1
0
Fork 0
mirror of https://github.com/muerwre/vault-frontend.git synced 2025-04-25 12:56:41 +07:00

Merge remote-tracking branch 'origin/master'

This commit is contained in:
Fedor Katurov 2021-03-24 21:10:49 +07:00
commit c2d71ae245
56 changed files with 985 additions and 206 deletions

View file

@ -78,7 +78,7 @@ steps:
format: markdown
message: >
{{#success build.status}}🤓{{else}}😨{{/success}}
{{ datetime build.finished "01.02.2006 15:04:05" "UTC" }} [{{repo.name}} / {{commit.branch}}]({{ build.link }})
[{{repo.name}} / {{commit.branch}}]({{ build.link }})
```
{{ commit.message }}
```

View file

@ -28,4 +28,12 @@
:global(.green) {
color: $wisegreen;
}
& > :last-child::after {
display: inline-block;
content: " ";
height: 1em;
width: 120px;
flex: 0 0 120px;
}
}

View file

@ -0,0 +1,15 @@
import React, { FC } from 'react';
import { useShallowSelect } from '~/utils/hooks/useShallowSelect';
import { selectUser } from '~/redux/auth/selectors';
interface IProps {}
const Authorized: FC<IProps> = ({ children }) => {
const user = useShallowSelect(selectUser);
if (!user.is_user) return null;
return <>{children}</>;
};
export { Authorized };

View file

@ -0,0 +1,11 @@
import React, { ButtonHTMLAttributes, DetailedHTMLProps, FC, HTMLAttributes } from 'react';
import styles from '~/styles/common/markdown.module.scss';
import classNames from 'classnames';
interface IProps extends DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> {}
const Markdown: FC<IProps> = ({ className, ...props }) => (
<div className={classNames(styles.wrapper, className)} {...props} />
);
export { Markdown };

View file

@ -14,7 +14,6 @@ const EditorPublicSwitch: FC<IProps> = ({ data, setData }) => {
]);
return (
<Superpower>
<Button
color={data.is_promoted ? 'primary' : 'lab'}
type="button"
@ -36,7 +35,6 @@ const EditorPublicSwitch: FC<IProps> = ({ data, setData }) => {
</div>
)}
</Button>
</Superpower>
);
};

View file

@ -9,12 +9,21 @@ interface IProps {}
const LabBanner: FC<IProps> = () => (
<Card className={styles.wrap}>
<Group>
<Placeholder height={32} />
<Placeholder height={18} width="120px" />
<Placeholder height={18} width="200px" />
<Placeholder height={18} width="60px" />
<Placeholder height={18} width="180px" />
<Placeholder height={18} width="230px" />
<div className={styles.title}>Лаборатория!</div>
<Group className={styles.content}>
<p>
<strong>
Всё, что происходит здесь &mdash; всего лишь эксперимент, о котором не узнает никто за
пределами Убежища.
</strong>
</p>
<p>
Ловим радиоактивных жуков, приручаем утконосов-вампиров, катаемся на младшем научном
сотруднике Егоре Порсифоровиче (у него как раз сейчас линька).
</p>
</Group>
</Group>
</Card>
);

View file

@ -1,5 +1,21 @@
@import "~/styles/variables.scss";
.wrap {
background: $red_gradient_alt;
@include lab_shadow;
background: linear-gradient(darken($dark_blue, 0%), darken($blue, 30%));
}
.title {
font: $font_24_bold;
text-transform: uppercase;
}
.content {
font: $font_14_regular;
line-height: 19px;
strong {
font-weight: bold;
}
}

View file

@ -0,0 +1,49 @@
import React, { FC, useCallback } from 'react';
import { Group } from '~/components/containers/Group';
import { Filler } from '~/components/containers/Filler';
import styles from './styles.module.scss';
import { getPrettyDate } from '~/utils/dom';
import { INode } from '~/redux/types';
import { Icon } from '~/components/input/Icon';
import classNames from 'classnames';
import { Grid } from '~/components/containers/Grid';
import { useHistory } from 'react-router';
import { URLS } from '~/constants/urls';
type Props = {
node: INode;
isLoading?: boolean;
hasNewComments: boolean;
commentCount: number;
};
const LabBottomPanel: FC<Props> = ({ node, hasNewComments, commentCount }) => {
const history = useHistory();
const onClick = useCallback(() => history.push(URLS.NODE_URL(node.id)), [node.id]);
return (
<Group horizontal className={styles.wrap} onClick={onClick}>
<div className={styles.timestamp}>{getPrettyDate(node.created_at)}</div>
<Filler />
{commentCount > 0 && (
<Grid
horizontal
className={classNames(styles.comments, { [styles.active]: hasNewComments })}
>
<Icon icon={hasNewComments ? 'comment_new' : 'comment'} size={16} />
<span>{commentCount}</span>
</Grid>
)}
{!!node.like_count && node.like_count > 0 && (
<Grid horizontal className={classNames(styles.like)}>
<Icon icon={node.is_liked ? 'heart_full' : 'heart'} size={16} />
<span>{node.like_count}</span>
</Grid>
)}
</Group>
);
};
export { LabBottomPanel };

View file

@ -0,0 +1,25 @@
@import "~/styles/variables.scss";
.wrap {
padding: 0 $gap $gap;
}
.timestamp {
font: $font_12_regular;
color: darken(white, 40%);
}
.comments, .like {
flex: 0;
font: $font_14_semibold;
color: darken(white, 50%);
fill: currentColor;
stroke: none;
column-gap: $gap / 2 !important;
align-items: center;
justify-content: center;
&.active {
color: $red;
}
}

View file

@ -1,32 +1,31 @@
import React, { FC } from 'react';
import { Group } from '~/components/containers/Group';
import { Card } from '~/components/containers/Card';
import { Placeholder } from '~/components/placeholders/Placeholder';
import { Filler } from '~/components/containers/Filler';
import styles from './styles.module.scss';
import { LabHeadItem } from '~/components/lab/LabHeadItem';
interface IProps {}
interface IProps {
isLoading?: boolean;
}
const LabHead: FC<IProps> = () => (
<Card>
<Group horizontal>
<Group horizontal style={{ flex: '0 0 auto' }}>
<Placeholder width="32px" height={32} />
<Placeholder width="96px" height={18} />
</Group>
const LabHead: FC<IProps> = ({ isLoading }) => {
return null;
<Group horizontal style={{ flex: '0 0 auto' }}>
<Placeholder width="32px" height={32} />
<Placeholder width="126px" height={18} />
</Group>
return (
<div className={styles.wrap}>
<div className={styles.group}>
<LabHeadItem icon="recent" active isLoading={isLoading}>
Свежие
</LabHeadItem>
<Group horizontal style={{ flex: '0 0 auto' }}>
<Placeholder width="32px" height={32} />
<Placeholder width="96px" height={18} />
</Group>
<LabHeadItem icon="hot" isLoading={isLoading}>
Популярные
</LabHeadItem>
<Filler />
</Group>
</Card>
);
<LabHeadItem icon="star_full" isLoading={isLoading}>
Важные
</LabHeadItem>
</div>
</div>
);
};
export { LabHead };

View file

@ -0,0 +1,23 @@
@import "~/styles/variables.scss";
.wrap {
@include lab_shadow;
border-radius: $radius;
background-color: $content_bg;
padding: $gap / 2;
}
.group {
display: flex;
@include tablet {
flex-wrap: wrap;
align-items: center;
justify-content: center;
}
& > * {
margin: $gap / 2;
}
}

View file

@ -0,0 +1,32 @@
import React, { FC } from 'react';
import { Group } from '~/components/containers/Group';
import { Icon } from '~/components/input/Icon';
import { Placeholder } from '~/components/placeholders/Placeholder';
import styles from './styles.module.scss';
import classNames from 'classnames';
interface IProps {
icon: string;
isLoading?: boolean;
active?: boolean;
}
const LabHeadItem: FC<IProps> = ({ icon, children, isLoading, active }) => {
if (isLoading) {
return (
<Group horizontal className={styles.item}>
<Placeholder width="32px" height={32} />
<Placeholder width="96px" height={18} />
</Group>
);
}
return (
<Group horizontal className={classNames(styles.item, { [styles.active]: active })}>
<Icon icon={icon} size={24} />
<span className={styles.text}>{children}</span>
</Group>
);
};
export { LabHeadItem };

View file

@ -0,0 +1,25 @@
@import "~/styles/variables.scss";
.item {
flex: 0 0 auto;
padding: $gap / 2;
fill: currentColor;
color: darken(white, 50%);
transition: color 0.25s;
cursor: pointer;
&:hover {
color: white;
}
&.active {
color: $blue;
background-color: lighten($content_bg, 6%);
border-radius: $radius;
padding: 0 $gap;
}
}
.text {
font: $font_16_semibold;
}

View file

@ -1,22 +1,51 @@
import React, { FC } from 'react';
import React, { FC, useCallback } from 'react';
import { Placeholder } from '~/components/placeholders/Placeholder';
import { Group } from '~/components/containers/Group';
import { Icon } from '~/components/input/Icon';
import styles from './styles.module.scss';
import { INode } from '~/redux/types';
import { getPrettyDate } from '~/utils/dom';
import { URLS } from '~/constants/urls';
import { Link, useHistory } from 'react-router-dom';
interface IProps {}
interface IProps {
node?: Partial<INode>;
isLoading?: boolean;
}
const LabHero: FC<IProps> = () => (
const LabHero: FC<IProps> = ({ node, isLoading }) => {
const history = useHistory();
const onClick = useCallback(() => {
history.push(URLS.NODE_URL(node?.id));
}, [history, node]);
if (!node || isLoading) {
return (
<Group horizontal className={styles.wrap1}>
<div className={styles.star}>
<Icon icon="star_full" size={32} />
</div>
<Group>
<div className={styles.content}>
<Placeholder height={20} />
<Placeholder height={12} width="100px" />
</div>
</Group>
);
}
return (
<Group horizontal className={styles.wrap} onClick={onClick}>
<div className={styles.star}>
<Icon icon="star_full" size={32} />
</div>
<div className={styles.content}>
<div className={styles.title}>{node.title}</div>
<div className={styles.description}>{getPrettyDate(node.created_at)}</div>
</div>
</Group>
);
);
};
export { LabHero };

View file

@ -1,10 +1,34 @@
@import "~/styles/variables.scss";
.wrap {
margin-bottom: $gap;
min-width: 0;
text-decoration: none;
cursor: pointer;
}
.star {
fill: #2c2c2c;
fill: darken(white, 76%);
flex: 0 0 32px;
}
.title {
font: $font_18_semibold;
text-overflow: ellipsis;
line-height: 22px;
word-break: break-all;
color: darken(white, 40%);
@include clamp(2, 22px)
}
.description {
font: $font_10_regular;
color: darken(white, 50%);
padding-top: 4px;
}
.content {
padding: $gap / 2 0;
text-decoration: none;
}

View file

@ -0,0 +1,34 @@
import React, { FC } from 'react';
import { INode } from '~/redux/types';
import styles from '~/containers/lab/LabStats/styles.module.scss';
import { LabHero } from '~/components/lab/LabHero';
import { Group } from '~/components/containers/Group';
interface IProps {
nodes: Partial<INode>[];
isLoading: boolean;
}
const empty = [...new Array(5)].map((_, i) => i);
const LabHeroes: FC<IProps> = ({ nodes, isLoading }) => {
if (isLoading) {
return (
<Group className={styles.heroes}>
{empty.map(i => (
<LabHero isLoading key={i} />
))}
</Group>
);
}
return (
<Group className={styles.heroes}>
{nodes.slice(0, 10).map(node => (
<LabHero node={node} key={node?.id} />
))}
</Group>
);
};
export { LabHeroes };

View file

@ -0,0 +1,100 @@
import React, { FC, useCallback, useEffect, useState } from 'react';
import { INodeComponentProps } from '~/redux/node/constants';
import SwiperCore, { A11y, Pagination, Navigation, SwiperOptions, Keyboard } from 'swiper';
import { Swiper, SwiperSlide } from 'swiper/react';
import 'swiper/swiper.scss';
import 'swiper/components/pagination/pagination.scss';
import 'swiper/components/scrollbar/scrollbar.scss';
import 'swiper/components/zoom/zoom.scss';
import 'swiper/components/navigation/navigation.scss';
import styles from './styles.module.scss';
import { useNodeImages } from '~/utils/hooks/node/useNodeImages';
import { getURL } from '~/utils/dom';
import { PRESETS, URLS } from '~/constants/urls';
import SwiperClass from 'swiper/types/swiper-class';
import { modalShowPhotoswipe } from '~/redux/modal/actions';
import { useDispatch } from 'react-redux';
import { useHistory } from 'react-router';
SwiperCore.use([Navigation, Pagination, A11y]);
interface IProps extends INodeComponentProps {}
const breakpoints: SwiperOptions['breakpoints'] = {
599: {
spaceBetween: 20,
navigation: true,
},
};
const LabImage: FC<IProps> = ({ node }) => {
const history = useHistory();
const [controlledSwiper, setControlledSwiper] = useState<SwiperClass | undefined>(undefined);
const images = useNodeImages(node);
const updateSwiper = useCallback(() => {
if (!controlledSwiper) return;
controlledSwiper.updateSlides();
controlledSwiper.updateSize();
controlledSwiper.update();
}, [controlledSwiper]);
const resetSwiper = useCallback(() => {
if (!controlledSwiper) return;
controlledSwiper.slideTo(0, 0);
}, [controlledSwiper]);
useEffect(() => {
updateSwiper();
resetSwiper();
}, [images, updateSwiper, resetSwiper]);
const onClick = useCallback(() => history.push(URLS.NODE_URL(node.id)), [history, node.id]);
if (!images?.length) {
return null;
}
return (
<div className={styles.wrapper}>
<Swiper
initialSlide={0}
slidesPerView={images.length > 1 ? 1.1 : 1}
onSwiper={setControlledSwiper}
spaceBetween={10}
grabCursor
autoHeight
breakpoints={breakpoints}
observeSlideChildren
observeParents
resizeObserver
watchOverflow
updateOnImagesReady
onInit={resetSwiper}
keyboard={{
enabled: true,
onlyInViewport: false,
}}
>
{images.map(file => (
<SwiperSlide className={styles.slide} key={file.id}>
<img
className={styles.image}
src={getURL(file, PRESETS['1600'])}
alt={node.title}
onLoad={updateSwiper}
onClick={onClick}
/>
</SwiperSlide>
))}
</Swiper>
</div>
);
};
export { LabImage };

View file

@ -0,0 +1,69 @@
@import "~/styles/variables.scss";
.wrapper {
border-radius: $radius;
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
:global(.swiper-container) {
width: 100%;
}
:global(.swiper-button-next),
:global(.swiper-button-prev) {
color: white;
font-size: 10px;
&::after {
font-size: 32px;
}
}
}
.slide {
text-align: center;
text-transform: uppercase;
font: $font_32_bold;
display: flex;
border-radius: $radius;
align-items: center;
justify-content: center;
width: auto;
max-width: 100%;
opacity: 1;
filter: brightness(50%) saturate(0.5);
transition: opacity 0.5s, filter 0.5s, transform 0.5s;
&:global(.swiper-slide-active) {
opacity: 1;
filter: brightness(100%);
}
@include tablet {
padding-bottom: 0;
padding-top: 0;
}
}
.image {
max-height: calc(100vh - 70px - 70px);
max-width: 100%;
border-radius: $radius;
transition: box-shadow 1s;
box-shadow: transparentize(black, 0.7) 0 3px 5px;
:global(.swiper-slide-active) & {
box-shadow: transparentize(black, 0.9) 0 10px 5px 4px,
transparentize(black, 0.7) 0 5px 5px,
transparentize(white, 0.95) 0 -1px 2px,
transparentize(white, 0.95) 0 -1px;
}
@include tablet {
padding-bottom: 0;
max-height: 100vh;
}
}

View file

@ -1,30 +1,36 @@
import React, { FC } from 'react';
import React, { FC, useMemo } from 'react';
import { INode } from '~/redux/types';
import { NodePanelInner } from '~/components/node/NodePanelInner';
import { useNodeBlocks } from '~/utils/hooks/node/useNodeBlocks';
import styles from './styles.module.scss';
import { Card } from '~/components/containers/Card';
import { NodePanelLab } from '~/components/node/NodePanelLab';
import { LabBottomPanel } from '~/components/lab/LabBottomPanel';
import { isAfter, parseISO } from 'date-fns';
interface IProps {
node: INode;
lastSeen: string | null;
isLoading?: boolean;
commentCount: number;
}
const LabNode: FC<IProps> = ({ node }) => {
const { inline, block, head } = useNodeBlocks(node, false);
const LabNode: FC<IProps> = ({ node, isLoading, lastSeen, commentCount }) => {
const { lab } = useNodeBlocks(node, false);
console.log(node.id, { inline, block, head });
const hasNewComments = useMemo(
() =>
!!node.commented_at && !!lastSeen && isAfter(parseISO(node.commented_at), parseISO(lastSeen)),
[node.commented_at, lastSeen]
);
return (
<Card seamless className={styles.wrap}>
<div className={styles.head}>
<NodePanelLab node={node} />
<div className={styles.wrap}>
{lab}
<LabBottomPanel
node={node}
isLoading={!!isLoading}
hasNewComments={hasNewComments}
commentCount={commentCount}
/>
</div>
{head}
{block}
{inline}
</Card>
);
};

View file

@ -1,11 +1,12 @@
@import "~/styles/variables.scss";
.wrap {
@include lab_shadow;
background-color: $lab_post_bg;
cursor: pointer;
min-width: 0;
}
.head {
background-color: transparentize(black, 0.9);
border-radius: $radius $radius 0 0;
border-radius: $radius;
}

View file

@ -0,0 +1,34 @@
import React, { FC } from 'react';
import { INode } from '~/redux/types';
import styles from './styles.module.scss';
import { URLS } from '~/constants/urls';
import { Link } from 'react-router-dom';
import { Group } from '~/components/containers/Group';
import { Icon } from '~/components/input/Icon';
import Tippy from '@tippy.js/react';
interface IProps {
node: INode;
}
const LabNodeTitle: FC<IProps> = ({ node }) => {
if (!node.title) return null;
return (
<Group horizontal className={styles.wrap}>
<div className={styles.title}>
<Link to={URLS.NODE_URL(node.id)}>{node.title || '...'}</Link>
</div>
{node.is_heroic && (
<Tippy content="Важный пост">
<div className={styles.star}>
<Icon icon="star_full" />
</div>
</Tippy>
)}
</Group>
);
};
export { LabNodeTitle };

View file

@ -1,7 +1,7 @@
@import "~/styles/variables.scss";
.wrap {
padding: $gap;
padding: 0 $gap;
}
.title {
@ -19,6 +19,11 @@
@include tablet {
white-space: nowrap;
padding-bottom: 0;
font: $font_16_semibold;
font: $font_20_semibold;
}
}
.star {
fill: $yellow;
flex: 0 0 24px;
}

View file

@ -0,0 +1,14 @@
import React, { FC, useCallback } from 'react';
import styles from './styles.module.scss';
import { useHistory } from 'react-router';
import { URLS } from '~/constants/urls';
import { INodeComponentProps } from '~/redux/node/constants';
const LabPad: FC<INodeComponentProps> = ({ node }) => {
const history = useHistory();
const onClick = useCallback(() => history.push(URLS.NODE_URL(node.id)), [node.id]);
return <div className={styles.pad} onClick={onClick} />;
};
export { LabPad };

View file

@ -0,0 +1,5 @@
@import "~/styles/variables.scss";
.pad {
height: $gap;
}

View file

@ -0,0 +1,36 @@
import React, { FC } from 'react';
import styles from './/styles.module.scss';
import { Placeholder } from '~/components/placeholders/Placeholder';
import { ITag } from '~/redux/types';
import { Tag } from '~/components/tags/Tag';
import { Group } from '~/components/containers/Group';
interface IProps {
tags: ITag[];
isLoading: boolean;
}
const LabTags: FC<IProps> = ({ tags, isLoading }) => {
if (isLoading) {
return (
<div className={styles.tags}>
<Placeholder height={20} width="100px" />
<Placeholder height={20} width="64px" />
<Placeholder height={20} width="100%" />
<Placeholder height={20} width="100px" />
<Placeholder height={20} width="100px" />
<Placeholder height={20} width="64px" />
</div>
);
}
return (
<div className={styles.tags}>
{tags.slice(0, 10).map(tag => (
<Tag tag={tag} key={tag.id} />
))}
</div>
);
};
export { LabTags };

View file

@ -0,0 +1,10 @@
@import "~/styles/variables.scss";
.tags {
display: flex;
flex-wrap: wrap;
& > * {
margin: $gap / 2;
}
}

View file

@ -0,0 +1,28 @@
import React, { FC, useCallback, useMemo } from 'react';
import { Markdown } from '~/components/containers/Markdown';
import { INodeComponentProps } from '~/redux/node/constants';
import { formatTextParagraphs } from '~/utils/dom';
import { path } from 'ramda';
import styles from './styles.module.scss';
import { useHistory } from 'react-router';
import { URLS } from '~/constants/urls';
const LabText: FC<INodeComponentProps> = ({ node }) => {
const content = useMemo(() => formatTextParagraphs(path(['blocks', 0, 'text'], node) || ''), [
node.blocks,
]);
const history = useHistory();
const onClick = useCallback(() => history.push(URLS.NODE_URL(node.id)), [node.id]);
return (
<Markdown
dangerouslySetInnerHTML={{ __html: content }}
className={styles.wrap}
onClick={onClick}
/>
);
};
export { LabText };

View file

@ -0,0 +1,21 @@
@import "~/styles/variables.scss";
.wrap {
padding: 0 $gap;
@include tablet {
position: relative;
max-height: 50vh;
overflow: hidden;
&::after {
content: ' ';
position: absolute;
background: linear-gradient(transparentize($lab_post_bg, 1), $lab_post_bg 80%);
bottom: 0;
left: auto;
width: 100%;
height: 100px;
}
}
}

View file

@ -1,15 +1,14 @@
import React, { FC, useCallback, memo, useState, useEffect, useMemo } from 'react';
import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react';
import { connect } from 'react-redux';
import { push as historyPush } from 'connected-react-router';
import { Link } from 'react-router-dom';
import { Logo } from '~/components/main/Logo';
import { Filler } from '~/components/containers/Filler';
import { selectUser, selectAuthUpdates } from '~/redux/auth/selectors';
import { selectAuthUpdates, selectUser } from '~/redux/auth/selectors';
import { Group } from '~/components/containers/Group';
import { DIALOGS } from '~/redux/modal/constants';
import { pick } from 'ramda';
import { path } from 'ramda';
import { path, pick } from 'ramda';
import { UserButton } from '../UserButton';
import { Notifications } from '../Notifications';
import { URLS } from '~/constants/urls';
@ -21,7 +20,7 @@ import * as MODAL_ACTIONS from '~/redux/modal/actions';
import * as AUTH_ACTIONS from '~/redux/auth/actions';
import { IState } from '~/redux/store';
import isBefore from 'date-fns/isBefore';
import { Superpower } from '~/components/boris/Superpower';
import { Authorized } from '~/components/containers/Authorized';
const mapStateToProps = (state: IState) => ({
user: pick(['username', 'is_user', 'photo', 'last_seen_boris'])(selectUser(state)),
@ -80,7 +79,7 @@ const HeaderUnconnected: FC<IProps> = memo(
<div className={styles.container}>
<Logo />
<Filler />
<Filler className={styles.filler} />
<div className={styles.plugs}>
<Link
@ -90,14 +89,14 @@ const HeaderUnconnected: FC<IProps> = memo(
ФЛОУ
</Link>
<Superpower>
<Authorized>
<Link
className={classNames(styles.item, { [styles.is_active]: pathname === URLS.BASE })}
to={URLS.LAB}
>
ЛАБ
</Link>
</Superpower>
</Authorized>
<Link
className={classNames(styles.item, {

View file

@ -48,6 +48,11 @@
user-select: none;
text-transform: uppercase;
align-items: center;
@include tablet {
flex: 1;
justify-content: space-around;
}
}
.profile {
@ -119,3 +124,9 @@
}
}
}
.filler {
@include tablet {
display: none;
}
}

View file

@ -6,4 +6,8 @@
user-select: none;
color: white;
text-decoration: none;
@include tablet {
display: none;
}
}

View file

@ -45,7 +45,9 @@ const NodeImageSwiperBlock: FC<IProps> = ({ node }) => {
const resetSwiper = useCallback(() => {
if (!controlledSwiper) return;
controlledSwiper.slideTo(0, 0);
setTimeout(() => controlledSwiper.slideTo(0, 0), 300);
// TODO: replace with working one
// setTimeout(() => controlledSwiper.slideTo(0, 0), 300);
}, [controlledSwiper]);
useEffect(() => {
@ -63,7 +65,7 @@ const NodeImageSwiperBlock: FC<IProps> = ({ node }) => {
}
return (
<div className={styles.wrapper}>
<div className={styles.wrapper} key={node.id}>
<Swiper
initialSlide={0}
slidesPerView="auto"

View file

@ -215,6 +215,7 @@
transition: fill, stroke 0.25s;
will-change: transform;
position: relative;
flex: 0 0 32px;
&:global(.is_liked) {
svg {

View file

@ -1,19 +0,0 @@
import React, { FC } from 'react';
import { INode } from '~/redux/types';
import styles from './styles.module.scss';
import { URLS } from '~/constants/urls';
import { Link } from 'react-router-dom';
interface IProps {
node: INode;
}
const NodePanelLab: FC<IProps> = ({ node }) => (
<div className={styles.wrap}>
<div className={styles.title}>
<Link to={URLS.NODE_URL(node.id)}>{node.title || '...'}</Link>
</div>
</div>
);
export { NodePanelLab };

View file

@ -52,5 +52,6 @@ export const API = {
},
LAB: {
NODES: `/lab/`,
STATS: '/lab/stats',
},
};

View file

@ -12,7 +12,12 @@ const LabGrid: FC<IProps> = () => {
return (
<div className={styles.wrap}>
{nodes.map(node => (
<LabNode node={node} key={node.id} />
<LabNode
node={node.node}
key={node.node.id}
lastSeen={node.last_seen}
commentCount={node.comment_count}
/>
))}
</div>
);

View file

@ -4,5 +4,5 @@
display: grid;
grid-auto-flow: row;
grid-auto-rows: auto;
grid-row-gap: $gap;
grid-row-gap: $gap * 2;
}

View file

@ -5,7 +5,7 @@ import { Sticky } from '~/components/containers/Sticky';
import { Container } from '~/containers/main/Container';
import { LabGrid } from '~/containers/lab/LabGrid';
import { useDispatch } from 'react-redux';
import { labGetList } from '~/redux/lab/actions';
import { labGetList, labGetStats } from '~/redux/lab/actions';
import { Placeholder } from '~/components/placeholders/Placeholder';
import { Grid } from '~/components/containers/Grid';
import { Group } from '~/components/containers/Group';
@ -13,14 +13,19 @@ import { LabHero } from '~/components/lab/LabHero';
import { LabBanner } from '~/components/lab/LabBanner';
import { LabHead } from '~/components/lab/LabHead';
import { Filler } from '~/components/containers/Filler';
import { LabStats } from '~/containers/lab/LabStats';
import { useShallowSelect } from '~/utils/hooks/useShallowSelect';
import { selectLabList, selectLabListNodes, selectLabStatsLoading } from '~/redux/lab/selectors';
interface IProps {}
const LabLayout: FC<IProps> = () => {
const { is_loading } = useShallowSelect(selectLabList);
const dispatch = useDispatch();
useEffect(() => {
dispatch(labGetList());
dispatch(labGetStats());
}, [dispatch]);
return (
@ -28,79 +33,13 @@ const LabLayout: FC<IProps> = () => {
<Container>
<div className={styles.wrap}>
<Group className={styles.content}>
<LabHead />
<LabHead isLoading={is_loading} />
<LabGrid />
</Group>
<div className={styles.panel}>
<Sticky>
<Group>
<LabBanner />
<Card>
<Group>
<Placeholder height={36} width="100%" />
<Group horizontal>
<Filler />
<Placeholder height={32} width="120px" />
</Group>
<div />
<div />
<Placeholder height={14} width="100px" />
<div />
<div className={styles.tags}>
<Placeholder height={20} width="100px" />
<Placeholder height={20} width="64px" />
<Placeholder height={20} width="100%" />
<Placeholder height={20} width="100px" />
<Placeholder height={20} width="100px" />
<Placeholder height={20} width="64px" />
</div>
<div />
<div />
<Placeholder height={14} width="180px" />
<div />
<Group className={styles.heroes}>
<LabHero />
<div />
<LabHero />
<div />
<LabHero />
<div />
<LabHero />
<div />
<LabHero />
<div />
<LabHero />
<div />
<LabHero />
</Group>
<div />
<div />
<Group>
<Placeholder width="100%" height={100} />
<Placeholder width="120px" height={16} />
</Group>
<div />
<Group>
<Placeholder width="100%" height={100} />
<Placeholder width="120px" height={16} />
</Group>
</Group>
</Card>
</Group>
<LabStats />
</Sticky>
</div>
</div>

View file

@ -4,6 +4,12 @@
display: grid;
grid-template-columns: 3fr 1fr;
column-gap: $gap;
@include tablet {
grid-template-columns: 1fr;
grid-auto-flow: row;
padding: 0 $gap / 2;
}
}
.panel {

View file

@ -0,0 +1,60 @@
import React, { FC } from 'react';
import styles from './styles.module.scss';
import { LabBanner } from '~/components/lab/LabBanner';
import { Card } from '~/components/containers/Card';
import { Group } from '~/components/containers/Group';
import { Placeholder } from '~/components/placeholders/Placeholder';
import { Filler } from '~/components/containers/Filler';
import { LabHero } from '~/components/lab/LabHero';
import { useShallowSelect } from '~/utils/hooks/useShallowSelect';
import {
selectLabStatsHeroes,
selectLabStatsLoading,
selectLabStatsTags,
} from '~/redux/lab/selectors';
import { LabTags } from '~/components/lab/LabTags';
import { LabHeroes } from '~/components/lab/LabHeroes';
interface IProps {}
const LabStats: FC<IProps> = () => {
const tags = useShallowSelect(selectLabStatsTags);
const heroes = useShallowSelect(selectLabStatsHeroes);
const isLoading = useShallowSelect(selectLabStatsLoading);
return (
<Group>
<LabBanner />
<div className={styles.card}>
<Group>
{isLoading ? (
<Placeholder height={14} width="100px" />
) : (
tags.length && <div className={styles.title}>Тэги</div>
)}
<div className={styles.tags}>
<LabTags tags={tags} isLoading={isLoading} />
</div>
<div />
<div />
<div />
{isLoading ? (
<Placeholder height={14} width="100px" />
) : (
heroes.length > 0 && <div className={styles.title}>Важные</div>
)}
<div className={styles.heroes}>
<LabHeroes nodes={heroes} isLoading={isLoading} />
</div>
</Group>
</div>
</Group>
);
};
export { LabStats };

View file

@ -0,0 +1,25 @@
@import "~/styles/variables.scss";
.title {
font: $font_14_semibold;
color: darken(white, 50%);
text-transform: uppercase;
padding: 0 $gap / 2;
padding-bottom: $gap / 2;
}
.tags.tags {
margin: 0 -$gap / 2;
}
.heroes {
margin-top: -$gap;
}
.card {
@include lab_shadow;
border-radius: $radius;
background-color: $comment_bg;
padding: $gap;
}

View file

@ -10,3 +10,12 @@ export const labSetList = (list: Partial<ILabState['list']>) => ({
type: LAB_ACTIONS.SET_LIST,
list,
});
export const labGetStats = () => ({
type: LAB_ACTIONS.GET_STATS,
});
export const labSetStats = (stats: Partial<ILabState['stats']>) => ({
type: LAB_ACTIONS.SET_STATS,
stats,
});

View file

@ -1,8 +1,10 @@
import { api, cleanResult } from '~/utils/api';
import { API } from '~/constants/api';
import { GetLabNodesRequest, GetLabNodesResult } from '~/redux/lab/types';
import { GetLabNodesRequest, GetLabNodesResult, GetLabStatsResult } from '~/redux/lab/types';
export const getLabNodes = ({ after }: GetLabNodesRequest) =>
api
.get<GetLabNodesResult>(API.LAB.NODES, { params: { after } })
.then(cleanResult);
export const getLabStats = () => api.get<GetLabStatsResult>(API.LAB.STATS).then(cleanResult);

View file

@ -3,4 +3,7 @@ const prefix = 'LAB.';
export const LAB_ACTIONS = {
GET_LIST: `${prefix}GET_LIST`,
SET_LIST: `${prefix}SET_LIST`,
GET_STATS: `${prefix}GET_STATS`,
SET_STATS: `${prefix}SET_STATS`,
};

View file

@ -1,5 +1,5 @@
import { LAB_ACTIONS } from '~/redux/lab/constants';
import { labSetList } from '~/redux/lab/actions';
import { labSetList, labSetStats } from '~/redux/lab/actions';
import { ILabState } from '~/redux/lab/types';
type LabHandler<T extends (...args: any) => any> = (
@ -15,6 +15,15 @@ const setList: LabHandler<typeof labSetList> = (state, { list }) => ({
},
});
const setStats: LabHandler<typeof labSetStats> = (state, { stats }) => ({
...state,
stats: {
...state.stats,
...stats,
},
});
export const LAB_HANDLERS = {
[LAB_ACTIONS.SET_LIST]: setList,
[LAB_ACTIONS.SET_STATS]: setStats,
};

View file

@ -1,6 +1,7 @@
import { createReducer } from '~/utils/reducer';
import { LAB_HANDLERS } from '~/redux/lab/handlers';
import { ILabState } from '~/redux/lab/types';
import { INode, ITag } from '~/redux/types';
const INITIAL_STATE: ILabState = {
list: {
@ -9,6 +10,12 @@ const INITIAL_STATE: ILabState = {
count: 0,
error: '',
},
stats: {
is_loading: false,
heroes: [],
tags: [],
error: undefined,
},
};
export default createReducer(INITIAL_STATE, LAB_HANDLERS);

View file

@ -1,8 +1,8 @@
import { takeLeading, call, put } from 'redux-saga/effects';
import { labGetList, labSetList } from '~/redux/lab/actions';
import { labGetList, labSetList, labSetStats } from '~/redux/lab/actions';
import { LAB_ACTIONS } from '~/redux/lab/constants';
import { Unwrap } from '~/redux/types';
import { getLabNodes } from '~/redux/lab/api';
import { getLabNodes, getLabStats } from '~/redux/lab/api';
function* getList({ after = '' }: ReturnType<typeof labGetList>) {
try {
@ -16,6 +16,19 @@ function* getList({ after = '' }: ReturnType<typeof labGetList>) {
}
}
function* getStats() {
try {
yield put(labSetStats({ is_loading: true }));
const { heroes, tags }: Unwrap<typeof getLabStats> = yield call(getLabStats);
yield put(labSetStats({ heroes, tags }));
} catch (error) {
yield put(labSetStats({ error: error.message }));
} finally {
yield put(labSetStats({ is_loading: false }));
}
}
export default function* labSaga() {
yield takeLeading(LAB_ACTIONS.GET_LIST, getList);
yield takeLeading(LAB_ACTIONS.GET_STATS, getStats);
}

View file

@ -2,3 +2,7 @@ import { IState } from '~/redux/store';
export const selectLab = (state: IState) => state.lab;
export const selectLabListNodes = (state: IState) => state.lab.list.nodes;
export const selectLabList = (state: IState) => state.lab.list;
export const selectLabStatsHeroes = (state: IState) => state.lab.stats.heroes;
export const selectLabStatsTags = (state: IState) => state.lab.stats.tags;
export const selectLabStatsLoading = (state: IState) => state.lab.stats.is_loading;

View file

@ -1,19 +1,36 @@
import { IError, INode } from '~/redux/types';
import { IError, INode, ITag } from '~/redux/types';
export type ILabState = Readonly<{
list: {
is_loading: boolean;
nodes: INode[];
nodes: ILabNode[];
count: number;
error: IError;
};
stats: {
is_loading: boolean;
heroes: Partial<INode>[];
tags: ITag[];
error?: string;
};
}>;
export type GetLabNodesRequest = {
after?: string;
};
export interface ILabNode {
node: INode;
last_seen: string | null;
comment_count: number;
}
export type GetLabNodesResult = {
nodes: INode[];
nodes: ILabNode[];
count: number;
};
export type GetLabStatsResult = {
heroes: INode[];
tags: ITag[];
};

View file

@ -15,6 +15,11 @@ import { IEditorComponentProps, NodeEditorProps } from '~/redux/node/types';
import { EditorFiller } from '~/components/editors/EditorFiller';
import { EditorPublicSwitch } from '~/components/editors/EditorPublicSwitch';
import { NodeImageSwiperBlock } from '~/components/node/NodeImageSwiperBlock';
import { LabNodeTitle } from '~/components/lab/LabNodeTitle';
import { LabText } from '~/components/lab/LabText';
import { LabImage } from '~/components/lab/LabImage';
import { LabBottomPanel } from '~/components/lab/LabBottomPanel';
import { LabPad } from '~/components/lab/LabPad';
const prefix = 'NODE.';
export const NODE_ACTIONS = {
@ -83,6 +88,13 @@ export type INodeComponentProps = {
export type INodeComponents = Record<ValueOf<typeof NODE_TYPES>, FC<INodeComponentProps>>;
export const LAB_PREVIEW_LAYOUT: Record<string, FC<INodeComponentProps>[]> = {
[NODE_TYPES.IMAGE]: [LabImage, LabPad, LabNodeTitle],
[NODE_TYPES.VIDEO]: [NodeVideoBlock, LabPad, LabNodeTitle],
[NODE_TYPES.AUDIO]: [LabPad, LabNodeTitle, LabPad, NodeAudioImageBlock, NodeAudioBlock, LabPad],
[NODE_TYPES.TEXT]: [LabPad, LabNodeTitle, LabPad, LabText, LabPad],
};
export const NODE_HEADS: INodeComponents = {
[NODE_TYPES.IMAGE]: NodeImageSwiperBlock,
};

View file

@ -1,6 +1,6 @@
import React, { FC } from 'react';
const Sprites: FC<{}> = () => (
const Sprites: FC = () => (
<svg width={0} height={0} viewBox="0 0 24 24">
<defs>
<pattern
@ -270,6 +270,16 @@ const Sprites: FC<{}> = () => (
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
</g>
<g id="comment">
<path fill="none" d="M0 0h24v24H0V0z" stroke="none" />
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z" />
</g>
<g id="comment_new">
<path fill="none" d="M0 0h24v24H0V0z" stroke="none" />
<path d="M22,6.98V16c0,1.1-0.9,2-2,2H6l-4,4V4c0-1.1,0.9-2,2-2h10.1C14.04,2.32,14,2.66,14,3c0,2.76,2.24,5,5,5 C20.13,8,21.16,7.61,22,6.98z M16,3c0,1.66,1.34,3,3,3s3-1.34,3-3s-1.34-3-3-3S16,1.34,16,3z" />
</g>
<g id="youtube" stroke="none">
<path fill="none" d="M0 0h24v24H0V0z" />
<g transform="scale(0.1) translate(-30 -30)">
@ -283,6 +293,22 @@ const Sprites: FC<{}> = () => (
transform="scale(0.011) translate(120, 120)"
/>
</g>
<g id="recent">
<path fill="none" d="M0 0h24v24H0V0z" stroke="none" />
<path
stroke="none"
d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"
/>
</g>
<g id="hot">
<path fill="none" d="M0 0h24v24H0V0z" stroke="none" />
<path
stroke="none"
d="M13.5.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67zM11.71 19c-1.78 0-3.22-1.4-3.22-3.14 0-1.62 1.05-2.76 2.81-3.12 1.77-.36 3.6-1.21 4.62-2.58.39 1.29.59 2.65.59 4.04 0 2.65-2.15 4.8-4.8 4.8z"
/>
</g>
</svg>
);

View file

@ -2,7 +2,7 @@
// $red: #ff3344;
$red: #ff3344;
$yellow: #ffd60f;
$dark_blue: #3c75ff;
$dark_blue: #592071;
$blue: #582cd0;
$green: #00d2b9;
//$green: #00503c;
@ -16,7 +16,7 @@ $primary: $red;
$secondary: $wisegreen;
$red_gradient: linear-gradient(165deg, $orange -50%, $red 150%);
$blue_gradient: linear-gradient(170deg, $green, $dark_blue);
$blue_gradient: linear-gradient(170deg, $blue, $dark_blue);
$green_gradient: linear-gradient(
170deg,
lighten(adjust_hue($wisegreen, 15deg), 10%) 0%,
@ -36,6 +36,7 @@ $main_text_color: white;
$content_bg: darken($main_bg_color, 0%);
$content_bg_secondary: darken($content_bg, 2%);
$lab_post_bg: lighten($content_bg, 4%);
$cell_bg: lighten($main_bg_color, 0%);
$card_bg: lighten($main_bg_color, 0%);

View file

@ -219,3 +219,9 @@ $sidebar_border: transparentize(white, 0.95);
border-radius: $radius;
cursor: pointer;
}
@mixin lab_shadow {
box-shadow: transparentize(black, 0.5) 0 0 0 1px,
inset transparentize(white, 0.9) 0 1px,
lighten(black, 10%) 0 4px;
}

View file

@ -3,6 +3,7 @@ import { createElement, FC, useCallback, useMemo } from 'react';
import { isNil, prop } from 'ramda';
import {
INodeComponentProps,
LAB_PREVIEW_LAYOUT,
NODE_COMPONENTS,
NODE_HEADS,
NODE_INLINES,
@ -11,11 +12,12 @@ import {
// useNodeBlocks returns head, block and inline blocks of node
export const useNodeBlocks = (node: INode, isLoading: boolean) => {
const createNodeBlock = useCallback(
(block?: FC<INodeComponentProps>) =>
(block?: FC<INodeComponentProps>, key = 0) =>
!isNil(block) &&
createElement(block, {
node,
isLoading,
key,
}),
[node, isLoading]
);
@ -35,5 +37,13 @@ export const useNodeBlocks = (node: INode, isLoading: boolean) => {
[node, createNodeBlock]
);
return { head, block, inline };
const lab = useMemo(
() =>
node?.type && prop(node.type, LAB_PREVIEW_LAYOUT)
? prop(node.type, LAB_PREVIEW_LAYOUT).map((comp, i) => createNodeBlock(comp, i))
: undefined,
[node, createNodeBlock]
);
return { head, block, inline, lab };
};

View file

@ -10,7 +10,7 @@ export const useNodeCoverImage = (node: INode) => {
dispatch(nodeSetCoverImage(node.cover));
return () => {
nodeSetCoverImage(undefined);
dispatch(nodeSetCoverImage(undefined));
};
}, [dispatch, node.cover, node.id]);
};

View file

@ -16,6 +16,6 @@ export const canLikeNode = (node: Partial<INode>, user: Partial<IUser>): boolean
path(['role'], user) && path(['role'], user) !== USER_ROLES.GUEST;
export const canStarNode = (node: Partial<INode>, user: Partial<IUser>): boolean =>
node.type === NODE_TYPES.IMAGE &&
(node.type === NODE_TYPES.IMAGE || node.is_promoted === false) &&
path(['role'], user) &&
path(['role'], user) === USER_ROLES.ADMIN;