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:
commit
c2d71ae245
56 changed files with 985 additions and 206 deletions
|
@ -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 }}
|
||||
```
|
||||
|
|
|
@ -28,4 +28,12 @@
|
|||
:global(.green) {
|
||||
color: $wisegreen;
|
||||
}
|
||||
|
||||
& > :last-child::after {
|
||||
display: inline-block;
|
||||
content: " ";
|
||||
height: 1em;
|
||||
width: 120px;
|
||||
flex: 0 0 120px;
|
||||
}
|
||||
}
|
||||
|
|
15
src/components/containers/Authorized/index.tsx
Normal file
15
src/components/containers/Authorized/index.tsx
Normal 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 };
|
11
src/components/containers/Markdown/index.tsx
Normal file
11
src/components/containers/Markdown/index.tsx
Normal 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 };
|
|
@ -14,29 +14,27 @@ const EditorPublicSwitch: FC<IProps> = ({ data, setData }) => {
|
|||
]);
|
||||
|
||||
return (
|
||||
<Superpower>
|
||||
<Button
|
||||
color={data.is_promoted ? 'primary' : 'lab'}
|
||||
type="button"
|
||||
size="giant"
|
||||
label={
|
||||
data.is_promoted
|
||||
? 'Доступно всем на главной странице'
|
||||
: 'Видно только сотрудникам в лаборатории'
|
||||
}
|
||||
onClick={onChange}
|
||||
className={styles.button}
|
||||
round
|
||||
>
|
||||
{data.is_promoted ? (
|
||||
<Icon icon="waves" size={24} />
|
||||
) : (
|
||||
<div className={styles.lab_wrapper}>
|
||||
<Icon icon="lab" size={24} />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</Superpower>
|
||||
<Button
|
||||
color={data.is_promoted ? 'primary' : 'lab'}
|
||||
type="button"
|
||||
size="giant"
|
||||
label={
|
||||
data.is_promoted
|
||||
? 'Доступно всем на главной странице'
|
||||
: 'Видно только сотрудникам в лаборатории'
|
||||
}
|
||||
onClick={onChange}
|
||||
className={styles.button}
|
||||
round
|
||||
>
|
||||
{data.is_promoted ? (
|
||||
<Icon icon="waves" size={24} />
|
||||
) : (
|
||||
<div className={styles.lab_wrapper}>
|
||||
<Icon icon="lab" size={24} />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
Всё, что происходит здесь — всего лишь эксперимент, о котором не узнает никто за
|
||||
пределами Убежища.
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Ловим радиоактивных жуков, приручаем утконосов-вампиров, катаемся на младшем научном
|
||||
сотруднике Егоре Порсифоровиче (у него как раз сейчас линька).
|
||||
</p>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
49
src/components/lab/LabBottomPanel/index.tsx
Normal file
49
src/components/lab/LabBottomPanel/index.tsx
Normal 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 };
|
25
src/components/lab/LabBottomPanel/styles.module.scss
Normal file
25
src/components/lab/LabBottomPanel/styles.module.scss
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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 };
|
||||
|
|
23
src/components/lab/LabHead/styles.module.scss
Normal file
23
src/components/lab/LabHead/styles.module.scss
Normal 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;
|
||||
}
|
||||
}
|
32
src/components/lab/LabHeadItem/index.tsx
Normal file
32
src/components/lab/LabHeadItem/index.tsx
Normal 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 };
|
25
src/components/lab/LabHeadItem/styles.module.scss
Normal file
25
src/components/lab/LabHeadItem/styles.module.scss
Normal 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;
|
||||
}
|
|
@ -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> = () => (
|
||||
<Group horizontal className={styles.wrap1}>
|
||||
<div className={styles.star}>
|
||||
<Icon icon="star_full" size={32} />
|
||||
</div>
|
||||
const LabHero: FC<IProps> = ({ node, isLoading }) => {
|
||||
const history = useHistory();
|
||||
const onClick = useCallback(() => {
|
||||
history.push(URLS.NODE_URL(node?.id));
|
||||
}, [history, node]);
|
||||
|
||||
<Group>
|
||||
<Placeholder height={20} />
|
||||
<Placeholder height={12} width="100px" />
|
||||
if (!node || isLoading) {
|
||||
return (
|
||||
<Group horizontal className={styles.wrap1}>
|
||||
<div className={styles.star}>
|
||||
<Icon icon="star_full" size={32} />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</Group>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export { LabHero };
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
34
src/components/lab/LabHeroes/index.tsx
Normal file
34
src/components/lab/LabHeroes/index.tsx
Normal 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 };
|
100
src/components/lab/LabImage/index.tsx
Normal file
100
src/components/lab/LabImage/index.tsx
Normal 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 };
|
69
src/components/lab/LabImage/styles.module.scss
Normal file
69
src/components/lab/LabImage/styles.module.scss
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
||||
{head}
|
||||
{block}
|
||||
{inline}
|
||||
</Card>
|
||||
<div className={styles.wrap}>
|
||||
{lab}
|
||||
<LabBottomPanel
|
||||
node={node}
|
||||
isLoading={!!isLoading}
|
||||
hasNewComments={hasNewComments}
|
||||
commentCount={commentCount}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
34
src/components/lab/LabNodeTitle/index.tsx
Normal file
34
src/components/lab/LabNodeTitle/index.tsx
Normal 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 };
|
|
@ -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;
|
||||
}
|
14
src/components/lab/LabPad/index.tsx
Normal file
14
src/components/lab/LabPad/index.tsx
Normal 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 };
|
5
src/components/lab/LabPad/styles.module.scss
Normal file
5
src/components/lab/LabPad/styles.module.scss
Normal file
|
@ -0,0 +1,5 @@
|
|||
@import "~/styles/variables.scss";
|
||||
|
||||
.pad {
|
||||
height: $gap;
|
||||
}
|
36
src/components/lab/LabTags/index.tsx
Normal file
36
src/components/lab/LabTags/index.tsx
Normal 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 };
|
10
src/components/lab/LabTags/styles.module.scss
Normal file
10
src/components/lab/LabTags/styles.module.scss
Normal file
|
@ -0,0 +1,10 @@
|
|||
@import "~/styles/variables.scss";
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
& > * {
|
||||
margin: $gap / 2;
|
||||
}
|
||||
}
|
28
src/components/lab/LabText/index.tsx
Normal file
28
src/components/lab/LabText/index.tsx
Normal 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 };
|
21
src/components/lab/LabText/styles.module.scss
Normal file
21
src/components/lab/LabText/styles.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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, {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,4 +6,8 @@
|
|||
user-select: none;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
|
||||
@include tablet {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -215,6 +215,7 @@
|
|||
transition: fill, stroke 0.25s;
|
||||
will-change: transform;
|
||||
position: relative;
|
||||
flex: 0 0 32px;
|
||||
|
||||
&:global(.is_liked) {
|
||||
svg {
|
||||
|
|
|
@ -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 };
|
|
@ -52,5 +52,6 @@ export const API = {
|
|||
},
|
||||
LAB: {
|
||||
NODES: `/lab/`,
|
||||
STATS: '/lab/stats',
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -4,5 +4,5 @@
|
|||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
grid-auto-rows: auto;
|
||||
grid-row-gap: $gap;
|
||||
grid-row-gap: $gap * 2;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
60
src/containers/lab/LabStats/index.tsx
Normal file
60
src/containers/lab/LabStats/index.tsx
Normal 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 };
|
25
src/containers/lab/LabStats/styles.module.scss
Normal file
25
src/containers/lab/LabStats/styles.module.scss
Normal 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;
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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`,
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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[];
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
@ -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%);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
@ -10,7 +10,7 @@ export const useNodeCoverImage = (node: INode) => {
|
|||
dispatch(nodeSetCoverImage(node.cover));
|
||||
|
||||
return () => {
|
||||
nodeSetCoverImage(undefined);
|
||||
dispatch(nodeSetCoverImage(undefined));
|
||||
};
|
||||
}, [dispatch, node.cover, node.id]);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue