mirror of
https://github.com/muerwre/vault-frontend.git
synced 2025-04-24 20:36:40 +07:00
Merge remote-tracking branch 'origin/master'
This commit is contained in:
commit
167c1a8aad
102 changed files with 1560 additions and 385 deletions
|
@ -7,6 +7,8 @@
|
|||
"@testing-library/jest-dom": "^5.11.4",
|
||||
"@testing-library/react": "^11.1.0",
|
||||
"@testing-library/user-event": "^12.1.10",
|
||||
"@tippy.js/react": "^3.1.1",
|
||||
"@types/react-router-dom": "^5.1.7",
|
||||
"autosize": "^4.0.2",
|
||||
"axios": "^0.21.1",
|
||||
"body-scroll-lock": "^2.6.4",
|
||||
|
@ -29,6 +31,7 @@
|
|||
"react-router-dom": "^5.1.2",
|
||||
"react-scripts": "3.4.4",
|
||||
"react-sortable-hoc": "^1.11",
|
||||
"react-sticky-box": "^0.9.3",
|
||||
"redux": "^4.0.1",
|
||||
"redux-persist": "^5.10.0",
|
||||
"redux-saga": "^1.1.1",
|
||||
|
@ -71,8 +74,8 @@
|
|||
"@types/node": "^11.13.22",
|
||||
"@types/ramda": "^0.26.33",
|
||||
"@types/react-redux": "^7.1.11",
|
||||
"@types/yup": "^0.29.11",
|
||||
"@types/swiper": "^5.4.2",
|
||||
"@types/yup": "^0.29.11",
|
||||
"craco-alias": "^2.1.1",
|
||||
"craco-fast-refresh": "^1.0.2",
|
||||
"prettier": "^1.18.2"
|
||||
|
|
40
src/components/boris/BorisComments/index.tsx
Normal file
40
src/components/boris/BorisComments/index.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
import React, { FC } from 'react';
|
||||
import styles from './styles.module.scss';
|
||||
import { Group } from '~/components/containers/Group';
|
||||
import { NodeCommentForm } from '~/components/node/NodeCommentForm';
|
||||
import { NodeNoComments } from '~/components/node/NodeNoComments';
|
||||
import { NodeComments } from '~/components/node/NodeComments';
|
||||
import { Footer } from '~/components/main/Footer';
|
||||
import { Card } from '~/components/containers/Card';
|
||||
import { useShallowSelect } from '~/utils/hooks/useShallowSelect';
|
||||
import { selectAuthUser } from '~/redux/auth/selectors';
|
||||
import { IComment, INode } from '~/redux/types';
|
||||
|
||||
interface IProps {
|
||||
isLoadingComments: boolean;
|
||||
commentCount: number;
|
||||
node: INode;
|
||||
comments: IComment[];
|
||||
}
|
||||
|
||||
const BorisComments: FC<IProps> = ({ isLoadingComments, node, commentCount, comments }) => {
|
||||
const user = useShallowSelect(selectAuthUser);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group className={styles.grid}>
|
||||
{user.is_user && <NodeCommentForm isBefore nodeId={node.id} />}
|
||||
|
||||
{isLoadingComments ? (
|
||||
<NodeNoComments is_loading count={7} />
|
||||
) : (
|
||||
<NodeComments comments={comments} count={commentCount} user={user} order="ASC" />
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { BorisComments };
|
18
src/components/boris/BorisComments/styles.module.scss
Normal file
18
src/components/boris/BorisComments/styles.module.scss
Normal file
|
@ -0,0 +1,18 @@
|
|||
@import "~/styles/variables.scss";
|
||||
|
||||
.content {
|
||||
flex: 4;
|
||||
z-index: 2;
|
||||
border-radius: $radius;
|
||||
padding: $gap;
|
||||
background: $node_bg;
|
||||
box-shadow: inset transparentize(mix($wisegreen, white, 60%), 0.6) 0 1px;
|
||||
|
||||
@include desktop {
|
||||
flex: 2.5;
|
||||
}
|
||||
|
||||
@media(max-width: 1024px) {
|
||||
flex: 2;
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import React, { FC } from 'react';
|
||||
import React, { FC, useMemo } from 'react';
|
||||
import { IBorisState } from '~/redux/boris/reducer';
|
||||
import styles from './styles.module.scss';
|
||||
import { Placeholder } from '~/components/placeholders/Placeholder';
|
||||
|
@ -9,7 +9,17 @@ interface IProps {
|
|||
}
|
||||
|
||||
const BorisStatsGit: FC<IProps> = ({ stats }) => {
|
||||
if (!stats.git.length) return null;
|
||||
if (!stats.issues.length) return null;
|
||||
|
||||
const open = useMemo(
|
||||
() => stats.issues.filter(el => !el.pull_request && el.state === 'open').slice(0, 5),
|
||||
[stats.issues]
|
||||
);
|
||||
|
||||
const closed = useMemo(
|
||||
() => stats.issues.filter(el => !el.pull_request && el.state === 'closed').slice(0, 5),
|
||||
[stats.issues]
|
||||
);
|
||||
|
||||
if (stats.is_loading) {
|
||||
return (
|
||||
|
@ -35,12 +45,13 @@ const BorisStatsGit: FC<IProps> = ({ stats }) => {
|
|||
<img src="https://jenkins.vault48.org/api/badges/muerwre/vault-golang/status.svg" />
|
||||
</div>
|
||||
|
||||
{stats.git
|
||||
.filter(data => data.commit && data.timestamp && data.subject)
|
||||
.slice(0, 5)
|
||||
.map(data => (
|
||||
<BorisStatsGitCard data={data} key={data.commit} />
|
||||
))}
|
||||
{open.map(data => (
|
||||
<BorisStatsGitCard data={data} key={data.id} />
|
||||
))}
|
||||
|
||||
{closed.map(data => (
|
||||
<BorisStatsGitCard data={data} key={data.id} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,22 +1,33 @@
|
|||
import React, { FC } from 'react';
|
||||
import { IStatGitRow } from '~/redux/boris/reducer';
|
||||
import React, { FC, useMemo } from 'react';
|
||||
import styles from './styles.module.scss';
|
||||
import { getPrettyDate } from '~/utils/dom';
|
||||
import { IGithubIssue } from '~/redux/boris/types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface IProps {
|
||||
data: Partial<IStatGitRow>;
|
||||
data: IGithubIssue;
|
||||
}
|
||||
|
||||
const BorisStatsGitCard: FC<IProps> = ({ data: { timestamp, subject } }) => {
|
||||
if (!subject || !timestamp) return null;
|
||||
const stateLabels: Record<IGithubIssue['state'], string> = {
|
||||
open: 'Ожидает',
|
||||
closed: 'Сделано',
|
||||
};
|
||||
|
||||
const BorisStatsGitCard: FC<IProps> = ({ data: { created_at, title, html_url, state } }) => {
|
||||
if (!title || !created_at) return null;
|
||||
|
||||
const date = useMemo(() => getPrettyDate(created_at), [created_at]);
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<div className={styles.time}>
|
||||
{getPrettyDate(new Date(parseInt(`${timestamp}000`)).toISOString())}
|
||||
<span className={classNames(styles.icon, styles[state])}>{stateLabels[state]}</span>
|
||||
{date}
|
||||
</div>
|
||||
|
||||
<div className={styles.subject}>{subject}</div>
|
||||
<a className={styles.subject} href={html_url} target="_blank">
|
||||
{title}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -12,10 +12,28 @@
|
|||
.time {
|
||||
font: $font_12_regular;
|
||||
line-height: 17px;
|
||||
opacity: 0.3;
|
||||
color: transparentize(white, 0.7)
|
||||
}
|
||||
|
||||
.subject {
|
||||
font: $font_14_regular;
|
||||
word-break: break-word;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font: $font_10_semibold;
|
||||
margin-right: 5px;
|
||||
border-radius: 2px;
|
||||
padding: 2px 0;
|
||||
text-transform: uppercase;
|
||||
|
||||
&.open {
|
||||
color: $red;
|
||||
}
|
||||
|
||||
&.closed {
|
||||
color: $green;
|
||||
}
|
||||
}
|
||||
|
|
37
src/components/boris/BorisSuperpowers/index.tsx
Normal file
37
src/components/boris/BorisSuperpowers/index.tsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
import React, { FC, useCallback } from 'react';
|
||||
import styles from './styles.module.scss';
|
||||
import { Toggle } from '~/components/input/Toggle';
|
||||
|
||||
interface IProps {
|
||||
active?: boolean;
|
||||
onChange?: (val: boolean) => void;
|
||||
}
|
||||
|
||||
const BorisSuperpowers: FC<IProps> = ({ active, onChange }) => {
|
||||
const onToggle = useCallback(() => {
|
||||
if (!onChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(!active);
|
||||
}, [onChange, active]);
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<div className={styles.toggle}>
|
||||
<Toggle value={active} handler={onChange} color="primary" />
|
||||
</div>
|
||||
|
||||
<div className={styles.left} onClick={onToggle}>
|
||||
<div className={styles.title}>Суперспособности</div>
|
||||
{active ? (
|
||||
<div className={styles.subtitle}>Ты видишь всё, что скрыто</div>
|
||||
) : (
|
||||
<div className={styles.subtitle}>Включи, чтобы видеть будущее</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { BorisSuperpowers };
|
20
src/components/boris/BorisSuperpowers/styles.module.scss
Normal file
20
src/components/boris/BorisSuperpowers/styles.module.scss
Normal file
|
@ -0,0 +1,20 @@
|
|||
@import "~/styles/variables";
|
||||
|
||||
.wrap {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
column-gap: $gap;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.title {
|
||||
font: $font_14_semibold;
|
||||
color: white;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font: $font_12_regular;
|
||||
color: transparentize(white, 0.5);
|
||||
}
|
51
src/components/boris/BorisUIDemo/index.tsx
Normal file
51
src/components/boris/BorisUIDemo/index.tsx
Normal file
|
@ -0,0 +1,51 @@
|
|||
import React, { FC } from 'react';
|
||||
import { Card } from '~/components/containers/Card';
|
||||
import styles from './styles.module.scss';
|
||||
import markdown from '~/styles/common/markdown.module.scss';
|
||||
import { Group } from '~/components/containers/Group';
|
||||
import { Button } from '~/components/input/Button';
|
||||
|
||||
interface IProps {}
|
||||
|
||||
const BorisUIDemo: FC<IProps> = () => (
|
||||
<Card className={styles.card}>
|
||||
<div className={markdown.wrapper}>
|
||||
<h1>UI</h1>
|
||||
<p>
|
||||
Простая демонстрация элементов интерфейса. Используется, в основном, как подсказка при
|
||||
разработке
|
||||
</p>
|
||||
|
||||
<h2>Кнопки</h2>
|
||||
|
||||
<h4>Цвета</h4>
|
||||
|
||||
<Group horizontal className={styles.sample}>
|
||||
<Button>Primary</Button>
|
||||
<Button color="secondary">Secondary</Button>
|
||||
<Button color="outline">Outline</Button>
|
||||
<Button color="gray">Gray</Button>
|
||||
<Button color="link">Link</Button>
|
||||
</Group>
|
||||
|
||||
<h4>Размеры</h4>
|
||||
|
||||
<Group horizontal className={styles.sample}>
|
||||
<Button size="micro">Micro</Button>
|
||||
<Button size="mini">Mini</Button>
|
||||
<Button size="normal">Normal</Button>
|
||||
<Button size="big">Big</Button>
|
||||
<Button size="giant">Giant</Button>
|
||||
</Group>
|
||||
|
||||
<h4>Варианты</h4>
|
||||
<Group horizontal className={styles.sample}>
|
||||
<Button iconRight="check">iconRight</Button>
|
||||
<Button iconLeft="send">iconLeft</Button>
|
||||
<Button round>Round</Button>
|
||||
</Group>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export { BorisUIDemo };
|
14
src/components/boris/BorisUIDemo/styles.module.scss
Normal file
14
src/components/boris/BorisUIDemo/styles.module.scss
Normal file
|
@ -0,0 +1,14 @@
|
|||
@import "~/styles/variables.scss";
|
||||
|
||||
.card {
|
||||
flex: 3;
|
||||
align-self: stretch;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 20px 30px;
|
||||
background-color: lighten($content_bg, 4%);
|
||||
}
|
||||
|
||||
.sample {
|
||||
flex-wrap: wrap;
|
||||
}
|
16
src/components/boris/Superpower/index.tsx
Normal file
16
src/components/boris/Superpower/index.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import React, { FC, memo } from 'react';
|
||||
import { useShallowSelect } from '~/utils/hooks/useShallowSelect';
|
||||
import { selectAuthIsTester, selectUser } from '~/redux/auth/selectors';
|
||||
|
||||
interface IProps {}
|
||||
|
||||
const Superpower: FC<IProps> = ({ children }) => {
|
||||
const user = useShallowSelect(selectUser);
|
||||
const is_tester = useShallowSelect(selectAuthIsTester);
|
||||
|
||||
if (!user.is_user || !is_tester) return null;
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export { Superpower };
|
|
@ -30,6 +30,8 @@ const CommentEmbedBlockUnconnected: FC<Props> = memo(
|
|||
return (match && match[1]) || '';
|
||||
}, [block.content]);
|
||||
|
||||
const url = useMemo(() => `https://youtube.com/watch?v=${id}`, [id]);
|
||||
|
||||
const preview = useMemo(() => getYoutubeThumb(block.content), [block.content]);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -47,7 +49,7 @@ const CommentEmbedBlockUnconnected: FC<Props> = memo(
|
|||
|
||||
return (
|
||||
<div className={styles.embed}>
|
||||
<a href={id[0]} target="_blank" />
|
||||
<a href={url} target="_blank" />
|
||||
|
||||
<div className={styles.preview}>
|
||||
<div style={{ backgroundImage: `url("${preview}")` }}>
|
||||
|
|
|
@ -14,7 +14,7 @@ import { EMPTY_COMMENT } from '~/redux/node/constants';
|
|||
import { CommentFormDropzone } from '~/components/comment/CommentFormDropzone';
|
||||
import styles from './styles.module.scss';
|
||||
import { ERROR_LITERAL } from '~/constants/errors';
|
||||
import { Group } from '~/components/containers/Group';
|
||||
import { useInputPasteUpload } from '~/utils/hooks/useInputPasteUpload';
|
||||
|
||||
interface IProps {
|
||||
comment?: IComment;
|
||||
|
@ -47,6 +47,7 @@ const CommentForm: FC<IProps> = ({ comment, nodeId, onCancelEdit }) => {
|
|||
}, [formik]);
|
||||
|
||||
const error = formik.status || formik.errors.text;
|
||||
useInputPasteUpload(textarea, uploader.uploadFiles);
|
||||
|
||||
return (
|
||||
<CommentFormDropzone onUpload={uploader.uploadFiles}>
|
||||
|
@ -65,34 +66,40 @@ const CommentForm: FC<IProps> = ({ comment, nodeId, onCancelEdit }) => {
|
|||
|
||||
<CommentFormAttaches />
|
||||
|
||||
<Group horizontal className={styles.buttons}>
|
||||
<CommentFormAttachButtons onUpload={uploader.uploadFiles} />
|
||||
<div className={styles.buttons}>
|
||||
<div className={styles.buttons_attach}>
|
||||
<CommentFormAttachButtons onUpload={uploader.uploadFiles} />
|
||||
</div>
|
||||
|
||||
{!!textarea && (
|
||||
<CommentFormFormatButtons
|
||||
element={textarea}
|
||||
handler={formik.handleChange('text')}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.buttons_format}>
|
||||
{!!textarea && (
|
||||
<CommentFormFormatButtons
|
||||
element={textarea}
|
||||
handler={formik.handleChange('text')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && <LoaderCircle size={20} />}
|
||||
<div className={styles.buttons_submit}>
|
||||
{isLoading && <LoaderCircle size={20} />}
|
||||
|
||||
{isEditing && (
|
||||
<Button size="small" color="link" type="button" onClick={onCancelEdit}>
|
||||
Отмена
|
||||
{isEditing && (
|
||||
<Button size="small" color="link" type="button" onClick={onCancelEdit}>
|
||||
Отмена
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="small"
|
||||
color="gray"
|
||||
iconRight={!isEditing ? 'enter' : 'check'}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{!isEditing ? 'Сказать' : 'Сохранить'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="small"
|
||||
color="gray"
|
||||
iconRight={!isEditing ? 'enter' : 'check'}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{!isEditing ? 'Сказать' : 'Сохранить'}
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
</FileUploaderProvider>
|
||||
</FormikProvider>
|
||||
</form>
|
||||
|
|
|
@ -21,13 +21,42 @@
|
|||
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
display: grid;
|
||||
background: transparentize(black, 0.8);
|
||||
padding: $gap / 2;
|
||||
border-radius: 0 0 $radius $radius;
|
||||
flex-wrap: wrap;
|
||||
column-gap: $gap;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-areas: "attach format submit";
|
||||
|
||||
@media(max-width: 470px) {
|
||||
padding: $gap;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
grid-template-areas:
|
||||
"attach format"
|
||||
"submit submit";
|
||||
row-gap: $gap;
|
||||
}
|
||||
|
||||
&_attach {
|
||||
grid-area: attach;
|
||||
}
|
||||
|
||||
&_format {
|
||||
grid-area: format;
|
||||
}
|
||||
|
||||
&_submit {
|
||||
grid-area: submit;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
column-gap: $gap / 2;
|
||||
}
|
||||
}
|
||||
|
||||
.uploads {
|
||||
|
|
|
@ -2,11 +2,8 @@
|
|||
|
||||
.wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
height: 32px;
|
||||
flex: 1;
|
||||
|
||||
@media(max-width: 480px) {
|
||||
display: none;
|
||||
}
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
@ -33,7 +33,6 @@
|
|||
@include tablet {
|
||||
:global(.comment-author) {
|
||||
display: none !important;
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,40 +1,15 @@
|
|||
import React, { DetailsHTMLAttributes, FC, useEffect, useRef } from 'react';
|
||||
import styles from './styles.module.scss';
|
||||
import StickySidebar from 'sticky-sidebar';
|
||||
import classnames from 'classnames';
|
||||
import ResizeSensor from 'resize-sensor';
|
||||
import React, { DetailsHTMLAttributes, FC } from 'react';
|
||||
import StickyBox from 'react-sticky-box/dist/esnext';
|
||||
|
||||
interface IProps extends DetailsHTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
(window as any).StickySidebar = StickySidebar;
|
||||
(window as any).ResizeSensor = ResizeSensor;
|
||||
|
||||
const Sticky: FC<IProps> = ({ children }) => {
|
||||
const ref = useRef(null);
|
||||
const sb = useRef<StickySidebar>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
sb.current = new StickySidebar(ref.current, {
|
||||
resizeSensor: true,
|
||||
topSpacing: 72,
|
||||
bottomSpacing: 10,
|
||||
});
|
||||
|
||||
return () => sb.current?.destroy();
|
||||
}, [ref.current, sb.current, children]);
|
||||
|
||||
if (sb) {
|
||||
sb.current?.updateSticky();
|
||||
}
|
||||
interface IProps extends DetailsHTMLAttributes<HTMLDivElement> {
|
||||
offsetTop?: number;
|
||||
}
|
||||
|
||||
const Sticky: FC<IProps> = ({ children, offsetTop = 65 }) => {
|
||||
return (
|
||||
<div className={classnames(styles.wrap, 'sidebar_container')}>
|
||||
<div className="sidebar" ref={ref}>
|
||||
<div className={classnames(styles.sticky, 'sidebar__inner')}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
<StickyBox offsetTop={offsetTop} offsetBottom={10}>
|
||||
{children}
|
||||
</StickyBox>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,17 +0,0 @@
|
|||
@import "src/styles/variables";
|
||||
|
||||
.wrap {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
:global(.sidebar) {
|
||||
will-change: min-height;
|
||||
}
|
||||
|
||||
:global(.sidebar__inner) {
|
||||
transform: translate(0, 0); /* For browsers don't support translate3d. */
|
||||
transform: translate3d(0, 0, 0);
|
||||
will-change: position, transform;
|
||||
}
|
||||
}
|
16
src/components/dialogs/Tab/index.tsx
Normal file
16
src/components/dialogs/Tab/index.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import React, { FC, MouseEventHandler } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
interface IProps {
|
||||
active?: boolean;
|
||||
onClick?: MouseEventHandler<any>;
|
||||
}
|
||||
|
||||
const Tab: FC<IProps> = ({ active, onClick, children }) => (
|
||||
<div className={classNames(styles.tab, { [styles.active]: active })} onClick={onClick}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export { Tab };
|
20
src/components/dialogs/Tab/styles.module.scss
Normal file
20
src/components/dialogs/Tab/styles.module.scss
Normal file
|
@ -0,0 +1,20 @@
|
|||
@import "src/styles/variables";
|
||||
|
||||
.tab {
|
||||
@include outer_shadow();
|
||||
|
||||
padding: $gap;
|
||||
margin-right: $gap;
|
||||
border-radius: $radius $radius 0 0;
|
||||
font: $font_14_semibold;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
background-color: $content_bg;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
|
||||
&.active {
|
||||
background: lighten($content_bg, 4%);
|
||||
}
|
||||
}
|
12
src/components/dialogs/Tabs/index.tsx
Normal file
12
src/components/dialogs/Tabs/index.tsx
Normal file
|
@ -0,0 +1,12 @@
|
|||
import React, { FC, useCallback } from 'react';
|
||||
import styles from './styles.module.scss';
|
||||
import classNames from 'classnames';
|
||||
import { IAuthState } from '~/redux/auth/types';
|
||||
|
||||
interface IProps {}
|
||||
|
||||
const Tabs: FC<IProps> = ({ children }) => {
|
||||
return <div className={styles.wrap}>{children}</div>;
|
||||
};
|
||||
|
||||
export { Tabs };
|
8
src/components/dialogs/Tabs/styles.module.scss
Normal file
8
src/components/dialogs/Tabs/styles.module.scss
Normal file
|
@ -0,0 +1,8 @@
|
|||
@import "src/styles/variables";
|
||||
|
||||
.wrap {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
padding: 0 $gap / 2;
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
import React, { FC } from 'react';
|
||||
import { EditorUploadButton } from '~/components/editors/EditorUploadButton';
|
||||
import { INode } from '~/redux/types';
|
||||
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
||||
import { IEditorComponentProps } from '~/redux/node/types';
|
||||
|
||||
|
|
|
@ -13,11 +13,12 @@
|
|||
flex-direction: row;
|
||||
|
||||
& > * {
|
||||
margin: 0 $gap;
|
||||
margin: 0 $gap / 2;
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
|
43
src/components/editors/EditorPublicSwitch/index.tsx
Normal file
43
src/components/editors/EditorPublicSwitch/index.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import React, { FC, useCallback } from 'react';
|
||||
import { IEditorComponentProps } from '~/redux/node/types';
|
||||
import { Button } from '~/components/input/Button';
|
||||
import { Icon } from '~/components/input/Icon';
|
||||
import styles from './styles.module.scss';
|
||||
import { Superpower } from '~/components/boris/Superpower';
|
||||
|
||||
interface IProps extends IEditorComponentProps {}
|
||||
|
||||
const EditorPublicSwitch: FC<IProps> = ({ data, setData }) => {
|
||||
const onChange = useCallback(() => setData({ ...data, is_promoted: !data.is_promoted }), [
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export { EditorPublicSwitch };
|
63
src/components/editors/EditorPublicSwitch/styles.module.scss
Normal file
63
src/components/editors/EditorPublicSwitch/styles.module.scss
Normal file
|
@ -0,0 +1,63 @@
|
|||
@import "src/styles/variables";
|
||||
|
||||
@keyframes move_1 {
|
||||
0% {
|
||||
transform: scale(0) translate(0, 0);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1) translate(5px, -5px);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1.2) translate(-5px, -10px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@keyframes move_2 {
|
||||
0% {
|
||||
transform: scale(0) translate(0, 0);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1) translate(-5px, -5px);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1.6) translate(5px, -10px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
|
||||
}
|
||||
|
||||
.lab_wrapper {
|
||||
position: relative;
|
||||
bottom: -2px;
|
||||
|
||||
.button:hover & {
|
||||
&:before,&:after {
|
||||
content: ' ';
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 10px;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
box-shadow: white 0 0 0 2px;
|
||||
border-radius: 4px;
|
||||
animation: move_1 0.5s infinite linear;
|
||||
}
|
||||
|
||||
&:after {
|
||||
animation: move_2 0.5s -0.25s infinite linear;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,15 +1,15 @@
|
|||
import React, { FC, useCallback, useEffect } from 'react';
|
||||
import styles from './styles.module.scss';
|
||||
import { Icon } from '~/components/input/Icon';
|
||||
import { IFileWithUUID, INode, IFile } from '~/redux/types';
|
||||
import { IFile, IFileWithUUID } from '~/redux/types';
|
||||
import uuid from 'uuid4';
|
||||
import { UPLOAD_SUBJECTS, UPLOAD_TARGETS, UPLOAD_TYPES } from '~/redux/uploads/constants';
|
||||
import * as UPLOAD_ACTIONS from '~/redux/uploads/actions';
|
||||
import { assocPath } from 'ramda';
|
||||
import { append } from 'ramda';
|
||||
import { append, assocPath } from 'ramda';
|
||||
import { selectUploads } from '~/redux/uploads/selectors';
|
||||
import { connect } from 'react-redux';
|
||||
import { NODE_SETTINGS } from '~/redux/node/constants';
|
||||
import { IEditorComponentProps } from '~/redux/node/types';
|
||||
|
||||
const mapStateToProps = state => {
|
||||
const { statuses, files } = selectUploads(state);
|
||||
|
@ -22,12 +22,7 @@ const mapDispatchToProps = {
|
|||
};
|
||||
|
||||
type IProps = ReturnType<typeof mapStateToProps> &
|
||||
typeof mapDispatchToProps & {
|
||||
data: INode;
|
||||
setData: (val: INode) => void;
|
||||
temp: string[];
|
||||
setTemp: (val: string[]) => void;
|
||||
|
||||
typeof mapDispatchToProps & IEditorComponentProps & {
|
||||
accept?: string;
|
||||
icon?: string;
|
||||
type?: typeof UPLOAD_TYPES[keyof typeof UPLOAD_TYPES];
|
||||
|
@ -82,18 +77,6 @@ const EditorUploadButtonUnconnected: FC<IProps> = ({
|
|||
[data, setData]
|
||||
);
|
||||
|
||||
// const onDrop = useCallback(
|
||||
// (event: React.DragEvent<HTMLDivElement>) => {
|
||||
// event.preventDefault();
|
||||
|
||||
// if (!event.dataTransfer || !event.dataTransfer.files || !event.dataTransfer.files.length)
|
||||
// return;
|
||||
|
||||
// onUpload(Array.from(event.dataTransfer.files));
|
||||
// },
|
||||
// [onUpload]
|
||||
// );
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('dragover', eventPreventer, false);
|
||||
window.addEventListener('drop', eventPreventer, false);
|
||||
|
|
|
@ -2,17 +2,10 @@
|
|||
|
||||
.wrap {
|
||||
@include outer_shadow();
|
||||
@include editor_round_button();
|
||||
|
||||
width: $upload_button_height;
|
||||
height: $upload_button_height;
|
||||
border-radius: ($upload_button_height / 2) !important;
|
||||
position: relative;
|
||||
border-radius: $radius;
|
||||
cursor: pointer;
|
||||
// opacity: 0.7;
|
||||
transition: opacity 0.5s;
|
||||
background: $red_gradient;
|
||||
// box-shadow: $content_bg 0 0 5px 10px;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
|
|
|
@ -11,7 +11,6 @@ const FlowRecent: FC<IProps> = ({ recent, updated }) => {
|
|||
return (
|
||||
<>
|
||||
{updated && updated.map(node => <FlowRecentItem node={node} key={node.id} has_new />)}
|
||||
|
||||
{recent && recent.map(node => <FlowRecentItem node={node} key={node.id} />)}
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -1,33 +1,24 @@
|
|||
import classnames from 'classnames';
|
||||
import React, {
|
||||
ButtonHTMLAttributes,
|
||||
DetailedHTMLProps,
|
||||
FC,
|
||||
createElement,
|
||||
memo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import React, { ButtonHTMLAttributes, DetailedHTMLProps, FC, memo, useMemo } from 'react';
|
||||
import styles from './styles.module.scss';
|
||||
import { Icon } from '~/components/input/Icon';
|
||||
import { IIcon } from '~/redux/types';
|
||||
import { usePopper } from 'react-popper';
|
||||
import Tippy from '@tippy.js/react';
|
||||
import 'tippy.js/dist/tippy.css';
|
||||
|
||||
type IButtonProps = DetailedHTMLProps<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
HTMLButtonElement
|
||||
> & {
|
||||
size?: 'mini' | 'normal' | 'big' | 'giant' | 'micro' | 'small';
|
||||
color?: 'primary' | 'secondary' | 'outline' | 'link' | 'gray';
|
||||
color?: 'primary' | 'secondary' | 'outline' | 'link' | 'gray' | 'lab';
|
||||
iconLeft?: IIcon;
|
||||
iconRight?: IIcon;
|
||||
seamless?: boolean;
|
||||
transparent?: boolean;
|
||||
title?: string;
|
||||
non_submitting?: boolean;
|
||||
is_loading?: boolean;
|
||||
stretchy?: boolean;
|
||||
iconOnly?: boolean;
|
||||
label?: string;
|
||||
round?: boolean;
|
||||
};
|
||||
|
||||
const Button: FC<IButtonProps> = memo(
|
||||
|
@ -38,56 +29,36 @@ const Button: FC<IButtonProps> = memo(
|
|||
iconLeft,
|
||||
iconRight,
|
||||
children,
|
||||
seamless = false,
|
||||
transparent = false,
|
||||
non_submitting = false,
|
||||
is_loading,
|
||||
title,
|
||||
stretchy,
|
||||
disabled,
|
||||
iconOnly,
|
||||
label,
|
||||
ref,
|
||||
round,
|
||||
...props
|
||||
}) => {
|
||||
const tooltip = useRef<HTMLSpanElement | null>(null);
|
||||
const pop = usePopper(tooltip?.current?.parentElement, tooltip.current, {
|
||||
placement: 'top',
|
||||
modifiers: [
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [0, 5],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return createElement(
|
||||
seamless || non_submitting ? 'div' : 'button',
|
||||
{
|
||||
className: classnames(styles.button, className, styles[size], styles[color], {
|
||||
seamless,
|
||||
transparent,
|
||||
const computedClassName = useMemo(
|
||||
() =>
|
||||
classnames(styles.button, className, styles[size], styles[color], {
|
||||
disabled,
|
||||
is_loading,
|
||||
stretchy,
|
||||
icon: ((iconLeft || iconRight) && !title && !children) || iconOnly,
|
||||
has_icon_left: !!iconLeft,
|
||||
has_icon_right: !!iconRight,
|
||||
round,
|
||||
}),
|
||||
...props,
|
||||
},
|
||||
[
|
||||
iconLeft && <Icon icon={iconLeft} size={20} key={0} className={styles.icon_left} />,
|
||||
title ? <span>{title}</span> : children || null,
|
||||
iconRight && <Icon icon={iconRight} size={20} key={2} className={styles.icon_right} />,
|
||||
!!label && (
|
||||
<span ref={tooltip} className={styles.tooltip} style={pop.styles.popper} key="tooltip">
|
||||
{label}
|
||||
</span>
|
||||
),
|
||||
]
|
||||
[round, disabled, className, stretchy, iconLeft, iconRight, size, color]
|
||||
);
|
||||
|
||||
return (
|
||||
<Tippy content={label || ''} enabled={!!label}>
|
||||
<button className={computedClassName} {...props}>
|
||||
{iconLeft && <Icon icon={iconLeft} size={20} key={0} className={styles.icon_left} />}
|
||||
{!!title ? <span>{title}</span> : children}
|
||||
{iconRight && <Icon icon={iconRight} size={20} key={2} className={styles.icon_right} />}
|
||||
</button>
|
||||
</Tippy>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -34,17 +34,14 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
position: relative;
|
||||
|
||||
filter: grayscale(0);
|
||||
|
||||
transition: opacity 0.25s, filter 0.25s, box-shadow 0.25s;
|
||||
transition: opacity 0.25s, filter 0.25s, box-shadow 0.25s, background-color 0.5s;
|
||||
opacity: 0.8;
|
||||
|
||||
@include outer_shadow();
|
||||
|
||||
input {
|
||||
color: red;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
@ -80,30 +77,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
&:global(.seamless) {
|
||||
background: transparent;
|
||||
color: black;
|
||||
box-shadow: none;
|
||||
fill: black;
|
||||
stroke: black;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&:global(.transparent) {
|
||||
background: transparent;
|
||||
color: white;
|
||||
box-shadow: transparentize(black, 0.5) 0 0 4px;
|
||||
padding: 0;
|
||||
fill: black;
|
||||
stroke: black;
|
||||
}
|
||||
|
||||
&:global(.red) {
|
||||
fill: $red;
|
||||
stroke: $red;
|
||||
color: $red;
|
||||
}
|
||||
|
||||
&:global(.stretchy) {
|
||||
flex: 1;
|
||||
}
|
||||
|
@ -112,8 +85,6 @@
|
|||
&:global(.grey) {
|
||||
background: transparentize(white, 0.9);
|
||||
color: white;
|
||||
// background: lighten(white, 0.5);
|
||||
// filter: grayscale(100%);
|
||||
}
|
||||
|
||||
&:global(.disabled) {
|
||||
|
@ -146,14 +117,6 @@
|
|||
padding-right: $gap;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: $red_gradient;
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
background: $green_gradient;
|
||||
}
|
||||
|
||||
&.outline {
|
||||
background: transparent;
|
||||
box-shadow: inset transparentize(white, 0.8) 0 0 0 2px;
|
||||
|
@ -185,31 +148,60 @@
|
|||
font: $font_12_semibold;
|
||||
padding: 0 15px;
|
||||
border-radius: $radius / 2;
|
||||
|
||||
&:global(.round) {
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.mini {
|
||||
height: 28px;
|
||||
border-radius: $radius / 2;
|
||||
|
||||
&:global(.round) {
|
||||
border-radius: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.small {
|
||||
height: 32px;
|
||||
// border-radius: $radius / 2;
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
&:global(.round) {
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.normal {
|
||||
height: 38px;
|
||||
|
||||
&:global(.round) {
|
||||
border-radius: 19px;
|
||||
}
|
||||
}
|
||||
|
||||
.big {
|
||||
height: 40px;
|
||||
|
||||
&:global(.round) {
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.giant {
|
||||
height: 50px;
|
||||
padding: 0 15px;
|
||||
min-width: 50px;
|
||||
|
||||
&:global(.round) {
|
||||
border-radius: 25px;
|
||||
}
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
@ -226,20 +218,14 @@
|
|||
height: 20px;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
padding: 5px 10px;
|
||||
background-color: darken($content_bg, 4%);
|
||||
z-index: 2;
|
||||
border-radius: $input_radius;
|
||||
text-transform: none;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
touch-action: none;
|
||||
transition: opacity 0.1s;
|
||||
border: 1px solid transparentize(white, 0.9);
|
||||
|
||||
.button:hover & {
|
||||
opacity: 1;
|
||||
font: $font_14_semibold;
|
||||
}
|
||||
.primary {
|
||||
background: $red;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: $wisegreen;
|
||||
}
|
||||
|
||||
.lab {
|
||||
background: $blue;
|
||||
}
|
||||
|
|
31
src/components/input/Toggle/index.tsx
Normal file
31
src/components/input/Toggle/index.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
import React, { FC, useCallback } from 'react';
|
||||
import styles from './styles.module.scss';
|
||||
import classNames from 'classnames';
|
||||
|
||||
type ToggleColor = 'primary' | 'secondary' | 'lab' | 'danger';
|
||||
|
||||
interface IProps {
|
||||
value?: boolean;
|
||||
handler?: (val: boolean) => void;
|
||||
color?: ToggleColor;
|
||||
}
|
||||
|
||||
const Toggle: FC<IProps> = ({ value, handler, color = 'primary' }) => {
|
||||
const onClick = useCallback(() => {
|
||||
if (!handler) {
|
||||
return;
|
||||
}
|
||||
|
||||
handler(!value);
|
||||
}, [value, handler]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(styles.toggle, { [styles.active]: value }, styles[color])}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toggle };
|
51
src/components/input/Toggle/styles.module.scss
Normal file
51
src/components/input/Toggle/styles.module.scss
Normal file
|
@ -0,0 +1,51 @@
|
|||
@import "~/styles/variables.scss";
|
||||
|
||||
.toggle {
|
||||
height: 24px;
|
||||
width: 48px;
|
||||
flex: 0 0 48px;
|
||||
border-radius: 12px;
|
||||
background-color: transparentize(white, 0.9);
|
||||
display: flex;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: ' ';
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 3px;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
border-radius: 11px;
|
||||
background-color: darken(white, 50%);
|
||||
transform: translate(0, 0);
|
||||
transition: transform 0.25s, color 0.25s, background-color;
|
||||
}
|
||||
|
||||
&.active {
|
||||
|
||||
&::after {
|
||||
transform: translate(24px, 0);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background-color: $wisegreen;
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
background-color: transparentize(white, 0.85);
|
||||
}
|
||||
|
||||
&.lab {
|
||||
background-color: $blue;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background-color: $red;
|
||||
}
|
||||
}
|
||||
}
|
22
src/components/lab/LabBanner/index.tsx
Normal file
22
src/components/lab/LabBanner/index.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import React, { FC } from 'react';
|
||||
import styles from './styles.module.scss';
|
||||
import { Card } from '~/components/containers/Card';
|
||||
import { Placeholder } from '~/components/placeholders/Placeholder';
|
||||
import { Group } from '~/components/containers/Group';
|
||||
|
||||
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" />
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export { LabBanner };
|
5
src/components/lab/LabBanner/styles.module.scss
Normal file
5
src/components/lab/LabBanner/styles.module.scss
Normal file
|
@ -0,0 +1,5 @@
|
|||
@import "~/styles/variables.scss";
|
||||
|
||||
.wrap {
|
||||
background: $red_gradient_alt;
|
||||
}
|
32
src/components/lab/LabHead/index.tsx
Normal file
32
src/components/lab/LabHead/index.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
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';
|
||||
|
||||
interface IProps {}
|
||||
|
||||
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>
|
||||
|
||||
<Group horizontal style={{ flex: '0 0 auto' }}>
|
||||
<Placeholder width="32px" height={32} />
|
||||
<Placeholder width="126px" height={18} />
|
||||
</Group>
|
||||
|
||||
<Group horizontal style={{ flex: '0 0 auto' }}>
|
||||
<Placeholder width="32px" height={32} />
|
||||
<Placeholder width="96px" height={18} />
|
||||
</Group>
|
||||
|
||||
<Filler />
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export { LabHead };
|
22
src/components/lab/LabHero/index.tsx
Normal file
22
src/components/lab/LabHero/index.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import React, { FC } 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';
|
||||
|
||||
interface IProps {}
|
||||
|
||||
const LabHero: FC<IProps> = () => (
|
||||
<Group horizontal className={styles.wrap1}>
|
||||
<div className={styles.star}>
|
||||
<Icon icon="star_full" size={32} />
|
||||
</div>
|
||||
|
||||
<Group>
|
||||
<Placeholder height={20} />
|
||||
<Placeholder height={12} width="100px" />
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
|
||||
export { LabHero };
|
10
src/components/lab/LabHero/styles.module.scss
Normal file
10
src/components/lab/LabHero/styles.module.scss
Normal file
|
@ -0,0 +1,10 @@
|
|||
@import "~/styles/variables.scss";
|
||||
|
||||
.wrap {
|
||||
margin-bottom: $gap;
|
||||
}
|
||||
|
||||
.star {
|
||||
fill: #2c2c2c;
|
||||
}
|
||||
|
31
src/components/lab/LabNode/index.tsx
Normal file
31
src/components/lab/LabNode/index.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
import React, { FC } 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';
|
||||
|
||||
interface IProps {
|
||||
node: INode;
|
||||
}
|
||||
|
||||
const LabNode: FC<IProps> = ({ node }) => {
|
||||
const { inline, block, head } = useNodeBlocks(node, false);
|
||||
|
||||
console.log(node.id, { inline, block, head });
|
||||
|
||||
return (
|
||||
<Card seamless className={styles.wrap}>
|
||||
<div className={styles.head}>
|
||||
<NodePanelLab node={node} />
|
||||
</div>
|
||||
|
||||
{head}
|
||||
{block}
|
||||
{inline}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export { LabNode };
|
11
src/components/lab/LabNode/styles.module.scss
Normal file
11
src/components/lab/LabNode/styles.module.scss
Normal file
|
@ -0,0 +1,11 @@
|
|||
@import "~/styles/variables.scss";
|
||||
|
||||
.wrap {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.head {
|
||||
background-color: transparentize(black, 0.9);
|
||||
border-radius: $radius $radius 0 0;
|
||||
}
|
||||
|
|
@ -21,6 +21,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';
|
||||
|
||||
const mapStateToProps = (state: IState) => ({
|
||||
user: pick(['username', 'is_user', 'photo', 'last_seen_boris'])(selectUser(state)),
|
||||
|
@ -89,6 +90,15 @@ const HeaderUnconnected: FC<IProps> = memo(
|
|||
ФЛОУ
|
||||
</Link>
|
||||
|
||||
<Superpower>
|
||||
<Link
|
||||
className={classNames(styles.item, { [styles.is_active]: pathname === URLS.BASE })}
|
||||
to={URLS.LAB}
|
||||
>
|
||||
ЛАБ
|
||||
</Link>
|
||||
</Superpower>
|
||||
|
||||
<Link
|
||||
className={classNames(styles.item, {
|
||||
[styles.is_active]: pathname === URLS.BORIS,
|
||||
|
@ -122,9 +132,6 @@ const HeaderUnconnected: FC<IProps> = memo(
|
|||
}
|
||||
);
|
||||
|
||||
const Header = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(HeaderUnconnected);
|
||||
const Header = connect(mapStateToProps, mapDispatchToProps)(HeaderUnconnected);
|
||||
|
||||
export { Header };
|
||||
|
|
|
@ -4,14 +4,12 @@ import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
|||
import { AudioPlayer } from '~/components/media/AudioPlayer';
|
||||
import styles from './styles.module.scss';
|
||||
import { INodeComponentProps } from '~/redux/node/constants';
|
||||
import { useNodeAudios } from '~/utils/hooks/node/useNodeAudios';
|
||||
|
||||
interface IProps extends INodeComponentProps {}
|
||||
|
||||
const NodeAudioBlock: FC<IProps> = ({ node }) => {
|
||||
const audios = useMemo(
|
||||
() => node.files.filter(file => file && file.type === UPLOAD_TYPES.AUDIO),
|
||||
[node.files]
|
||||
);
|
||||
const audios = useNodeAudios(node);
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
|
|
|
@ -6,14 +6,12 @@ import { path } from 'ramda';
|
|||
import { getURL } from '~/utils/dom';
|
||||
import { PRESETS } from '~/constants/urls';
|
||||
import { INodeComponentProps } from '~/redux/node/constants';
|
||||
import { useNodeImages } from '~/utils/hooks/node/useNodeImages';
|
||||
|
||||
interface IProps extends INodeComponentProps {}
|
||||
|
||||
const NodeAudioImageBlock: FC<IProps> = ({ node }) => {
|
||||
const images = useMemo(
|
||||
() => node.files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE),
|
||||
[node.files]
|
||||
);
|
||||
const images = useNodeImages(node);
|
||||
|
||||
if (images.length === 0) return null;
|
||||
|
||||
|
|
|
@ -2,16 +2,16 @@ import React, { FC } from 'react';
|
|||
import { NodeDeletedBadge } from '~/components/node/NodeDeletedBadge';
|
||||
import { Group } from '~/components/containers/Group';
|
||||
import { Padder } from '~/components/containers/Padder';
|
||||
import styles from '~/containers/node/NodeLayout/styles.module.scss';
|
||||
import { NodeCommentsBlock } from '~/components/node/NodeCommentsBlock';
|
||||
import { NodeCommentForm } from '~/components/node/NodeCommentForm';
|
||||
import { Sticky } from '~/components/containers/Sticky';
|
||||
import { NodeRelatedBlock } from '~/components/node/NodeRelatedBlock';
|
||||
import { useNodeBlocks } from '~/utils/hooks/node/useNodeBlocks';
|
||||
import { IComment, INode } from '~/redux/types';
|
||||
import { useUser } from '~/utils/hooks/user/userUser';
|
||||
import { NodeTagsBlock } from '~/components/node/NodeTagsBlock';
|
||||
import { INodeRelated } from '~/redux/node/types';
|
||||
import StickyBox from 'react-sticky-box/dist/esnext';
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
interface IProps {
|
||||
node: INode;
|
||||
|
@ -59,12 +59,12 @@ const NodeBottomBlock: FC<IProps> = ({
|
|||
</Group>
|
||||
|
||||
<div className={styles.panel}>
|
||||
<Sticky>
|
||||
<StickyBox className={styles.sticky} offsetTop={72}>
|
||||
<Group style={{ flex: 1, minWidth: 0 }}>
|
||||
<NodeTagsBlock node={node} isLoading={isLoading} />
|
||||
<NodeRelatedBlock isLoading={isLoading} node={node} related={related} />
|
||||
</Group>
|
||||
</Sticky>
|
||||
</StickyBox>
|
||||
</div>
|
||||
</Group>
|
||||
</Padder>
|
||||
|
|
48
src/components/node/NodeBottomBlock/styles.module.scss
Normal file
48
src/components/node/NodeBottomBlock/styles.module.scss
Normal file
|
@ -0,0 +1,48 @@
|
|||
@import "~/styles/variables.scss";
|
||||
|
||||
.sticky {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
align-items: stretch !important;
|
||||
@include vertical_at_tablet;
|
||||
}
|
||||
|
||||
.comments {
|
||||
flex: 3 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
flex-direction: column;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex: 2 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.panel {
|
||||
flex: 1 3;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
padding-left: $gap / 2;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding-left: 0;
|
||||
padding-top: $comment_height / 2;
|
||||
flex: 1 2;
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
background: $node_buttons_bg;
|
||||
flex: 1;
|
||||
border-radius: $panel_radius;
|
||||
box-shadow: $comment_shadow;
|
||||
}
|
|
@ -1,12 +1,13 @@
|
|||
import React, { FC, useCallback, useEffect, useState } from 'react';
|
||||
import { INodeComponentProps } from '~/redux/node/constants';
|
||||
import SwiperCore, { A11y, Pagination, SwiperOptions } from 'swiper';
|
||||
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';
|
||||
|
@ -16,13 +17,14 @@ import SwiperClass from 'swiper/types/swiper-class';
|
|||
import { modalShowPhotoswipe } from '~/redux/modal/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
SwiperCore.use([Pagination, A11y]);
|
||||
SwiperCore.use([Navigation, Pagination, A11y]);
|
||||
|
||||
interface IProps extends INodeComponentProps {}
|
||||
|
||||
const breakpoints: SwiperOptions['breakpoints'] = {
|
||||
599: {
|
||||
spaceBetween: 20,
|
||||
navigation: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -43,6 +45,7 @@ const NodeImageSwiperBlock: FC<IProps> = ({ node }) => {
|
|||
const resetSwiper = useCallback(() => {
|
||||
if (!controlledSwiper) return;
|
||||
controlledSwiper.slideTo(0, 0);
|
||||
setTimeout(() => controlledSwiper.slideTo(0, 0), 300);
|
||||
}, [controlledSwiper]);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -74,7 +77,12 @@ const NodeImageSwiperBlock: FC<IProps> = ({ node }) => {
|
|||
observeParents
|
||||
resizeObserver
|
||||
watchOverflow
|
||||
updateOnImagesReady
|
||||
onInit={resetSwiper}
|
||||
keyboard={{
|
||||
enabled: true,
|
||||
onlyInViewport: false,
|
||||
}}
|
||||
zoom
|
||||
>
|
||||
{images.map(file => (
|
||||
|
|
|
@ -20,6 +20,17 @@
|
|||
:global(.swiper-container) {
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
:global(.swiper-button-next),
|
||||
:global(.swiper-button-prev) {
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
|
||||
&::after {
|
||||
font-size: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.slide {
|
||||
|
|
|
@ -31,8 +31,6 @@
|
|||
|
||||
.wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: stretch;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
|
@ -88,7 +86,7 @@
|
|||
@include tablet {
|
||||
white-space: nowrap;
|
||||
padding-bottom: 0;
|
||||
font: $font_20_semibold;
|
||||
font: $font_16_semibold;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
19
src/components/node/NodePanelLab/index.tsx
Normal file
19
src/components/node/NodePanelLab/index.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
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 };
|
24
src/components/node/NodePanelLab/styles.module.scss
Normal file
24
src/components/node/NodePanelLab/styles.module.scss
Normal file
|
@ -0,0 +1,24 @@
|
|||
@import "~/styles/variables.scss";
|
||||
|
||||
.wrap {
|
||||
padding: $gap;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-transform: uppercase;
|
||||
font: $font_24_semibold;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@include tablet {
|
||||
white-space: nowrap;
|
||||
padding-bottom: 0;
|
||||
font: $font_16_semibold;
|
||||
}
|
||||
}
|
|
@ -12,7 +12,7 @@ type IProps = RouteComponentProps & {
|
|||
|
||||
type CellSize = 'small' | 'medium' | 'large';
|
||||
|
||||
const getTitleLetters = (title: string): string => {
|
||||
const getTitleLetters = (title?: string): string => {
|
||||
const words = (title && title.split(' ')) || [];
|
||||
|
||||
if (!words.length) return '';
|
||||
|
|
|
@ -50,4 +50,7 @@ export const API = {
|
|||
NODES: `/tag/nodes`,
|
||||
AUTOCOMPLETE: `/tag/autocomplete`,
|
||||
},
|
||||
LAB: {
|
||||
NODES: `/lab/`,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -2,6 +2,7 @@ import { INode } from '~/redux/types';
|
|||
|
||||
export const URLS = {
|
||||
BASE: '/',
|
||||
LAB: '/lab',
|
||||
BORIS: '/boris',
|
||||
AUTH: {
|
||||
LOGIN: '/auth/login',
|
||||
|
|
|
@ -95,7 +95,7 @@ const EditorDialogUnconnected: FC<IProps> = ({
|
|||
maxLength={256}
|
||||
/>
|
||||
|
||||
<Button title="Сохранить" iconRight="check" />
|
||||
<Button title="Сохранить" iconRight="check" color={data.is_promoted ? 'primary' : 'lab'} />
|
||||
</Group>
|
||||
</Padder>
|
||||
);
|
||||
|
|
|
@ -11,6 +11,7 @@ import { selectAuthRegisterSocial } from '~/redux/auth/selectors';
|
|||
import * as AUTH_ACTIONS from '~/redux/auth/actions';
|
||||
import { useCloseOnEscape } from '~/utils/hooks';
|
||||
import { LoginSocialRegisterButtons } from '~/containers/dialogs/LoginSocialRegisterButtons';
|
||||
import { Toggle } from '~/components/input/Toggle';
|
||||
|
||||
const mapStateToProps = selectAuthRegisterSocial;
|
||||
const mapDispatchToProps = {
|
||||
|
@ -21,6 +22,12 @@ const mapDispatchToProps = {
|
|||
|
||||
type Props = ReturnType<typeof mapStateToProps> & typeof mapDispatchToProps & IDialogProps & {};
|
||||
|
||||
const phrase = [
|
||||
'Сушёный кабачок особенно хорош в это время года, знаете ли.',
|
||||
'Бывало, стреляешь по кабачку, или он стреляет в тебя.',
|
||||
'Он всегда рядом, кабачок -- первый сорт! Надежда империи.',
|
||||
];
|
||||
|
||||
const LoginSocialRegisterDialogUnconnected: FC<Props> = ({
|
||||
onRequestClose,
|
||||
errors,
|
||||
|
@ -32,6 +39,7 @@ const LoginSocialRegisterDialogUnconnected: FC<Props> = ({
|
|||
}) => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isDryingPants, setIsDryingPants] = useState(false);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(event: FormEvent) => {
|
||||
|
@ -56,7 +64,7 @@ const LoginSocialRegisterDialogUnconnected: FC<Props> = ({
|
|||
useCloseOnEscape(onRequestClose);
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<form onSubmit={onSubmit} autoComplete="new-password">
|
||||
<BetterScrollDialog
|
||||
onClose={onRequestClose}
|
||||
width={300}
|
||||
|
@ -73,6 +81,7 @@ const LoginSocialRegisterDialogUnconnected: FC<Props> = ({
|
|||
value={username}
|
||||
title="Юзернэйм"
|
||||
error={errors.username}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
<InputText
|
||||
|
@ -81,12 +90,18 @@ const LoginSocialRegisterDialogUnconnected: FC<Props> = ({
|
|||
title="Пароль"
|
||||
type="password"
|
||||
error={errors.password}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
<label className={styles.check}>
|
||||
<input type="checkbox" />
|
||||
<div className={styles.check} onClick={() => setIsDryingPants(!isDryingPants)}>
|
||||
<Toggle value={isDryingPants} color="primary" />
|
||||
<span>Это не мои штаны сушатся на радиаторе в третьей лаборатории</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={styles.check} onClick={() => setIsDryingPants(!isDryingPants)}>
|
||||
<Toggle value={!isDryingPants} color="primary" />
|
||||
<span>{phrase[Math.floor(Math.random() * phrase.length)]}</span>
|
||||
</div>
|
||||
</Group>
|
||||
</div>
|
||||
</Padder>
|
||||
|
|
21
src/containers/lab/LabGrid/index.tsx
Normal file
21
src/containers/lab/LabGrid/index.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import React, { FC } from 'react';
|
||||
import { useShallowSelect } from '~/utils/hooks/useShallowSelect';
|
||||
import styles from './styles.module.scss';
|
||||
import { LabNode } from '~/components/lab/LabNode';
|
||||
import { selectLabListNodes } from '~/redux/lab/selectors';
|
||||
|
||||
interface IProps {}
|
||||
|
||||
const LabGrid: FC<IProps> = () => {
|
||||
const nodes = useShallowSelect(selectLabListNodes);
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
{nodes.map(node => (
|
||||
<LabNode node={node} key={node.id} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { LabGrid };
|
8
src/containers/lab/LabGrid/styles.module.scss
Normal file
8
src/containers/lab/LabGrid/styles.module.scss
Normal file
|
@ -0,0 +1,8 @@
|
|||
@import "~/styles/variables.scss";
|
||||
|
||||
.wrap {
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
grid-auto-rows: auto;
|
||||
grid-row-gap: $gap;
|
||||
}
|
112
src/containers/lab/LabLayout/index.tsx
Normal file
112
src/containers/lab/LabLayout/index.tsx
Normal file
|
@ -0,0 +1,112 @@
|
|||
import React, { FC, useEffect } from 'react';
|
||||
import styles from './styles.module.scss';
|
||||
import { Card } from '~/components/containers/Card';
|
||||
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 { Placeholder } from '~/components/placeholders/Placeholder';
|
||||
import { Grid } from '~/components/containers/Grid';
|
||||
import { Group } from '~/components/containers/Group';
|
||||
import { LabHero } from '~/components/lab/LabHero';
|
||||
import { LabBanner } from '~/components/lab/LabBanner';
|
||||
import { LabHead } from '~/components/lab/LabHead';
|
||||
import { Filler } from '~/components/containers/Filler';
|
||||
|
||||
interface IProps {}
|
||||
|
||||
const LabLayout: FC<IProps> = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(labGetList());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Container>
|
||||
<div className={styles.wrap}>
|
||||
<Group className={styles.content}>
|
||||
<LabHead />
|
||||
<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>
|
||||
</Sticky>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { LabLayout };
|
20
src/containers/lab/LabLayout/styles.module.scss
Normal file
20
src/containers/lab/LabLayout/styles.module.scss
Normal file
|
@ -0,0 +1,20 @@
|
|||
@import "~/styles/variables.scss";
|
||||
|
||||
.wrap {
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 1fr;
|
||||
column-gap: $gap;
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-top: -7px;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
& > * {
|
||||
margin: 0 $gap $gap 0;
|
||||
}
|
||||
}
|
|
@ -8,4 +8,8 @@
|
|||
@include tablet {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-width: $content_width + $gap * 4) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,10 +6,14 @@ import { BorisLayout } from '~/containers/node/BorisLayout';
|
|||
import { ErrorNotFound } from '~/containers/pages/ErrorNotFound';
|
||||
import { ProfilePage } from '~/containers/profile/ProfilePage';
|
||||
import { Redirect, Route, Switch, useLocation } from 'react-router';
|
||||
import { LabLayout } from '~/containers/lab/LabLayout';
|
||||
import { useShallowSelect } from '~/utils/hooks/useShallowSelect';
|
||||
import { selectAuthUser } from '~/redux/auth/selectors';
|
||||
|
||||
interface IProps {}
|
||||
|
||||
const MainRouter: FC<IProps> = () => {
|
||||
const { is_user } = useShallowSelect(selectAuthUser);
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
|
@ -20,6 +24,12 @@ const MainRouter: FC<IProps> = () => {
|
|||
<Route path={URLS.ERRORS.NOT_FOUND} component={ErrorNotFound} />
|
||||
<Route path={URLS.PROFILE_PAGE(':username')} component={ProfilePage} />
|
||||
|
||||
{is_user && (
|
||||
<>
|
||||
<Route exact path={URLS.LAB} component={LabLayout} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Redirect to="/" />
|
||||
</Switch>
|
||||
);
|
||||
|
|
|
@ -1,25 +1,30 @@
|
|||
import React, { FC, useEffect } from 'react';
|
||||
import React, { FC, useCallback, useEffect } from 'react';
|
||||
import { selectNode, selectNodeComments } from '~/redux/node/selectors';
|
||||
import { selectUser } from '~/redux/auth/selectors';
|
||||
import { selectAuthIsTester, selectUser } from '~/redux/auth/selectors';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { NodeComments } from '~/components/node/NodeComments';
|
||||
import styles from './styles.module.scss';
|
||||
import { Group } from '~/components/containers/Group';
|
||||
import boris from '~/sprites/boris_robot.svg';
|
||||
import { NodeNoComments } from '~/components/node/NodeNoComments';
|
||||
import { useRandomPhrase } from '~/constants/phrases';
|
||||
import { NodeCommentForm } from '~/components/node/NodeCommentForm';
|
||||
import isBefore from 'date-fns/isBefore';
|
||||
import { Card } from '~/components/containers/Card';
|
||||
import { Footer } from '~/components/main/Footer';
|
||||
import { Sticky } from '~/components/containers/Sticky';
|
||||
import { BorisStats } from '~/components/boris/BorisStats';
|
||||
import { useShallowSelect } from '~/utils/hooks/useShallowSelect';
|
||||
import { selectBorisStats } from '~/redux/boris/selectors';
|
||||
import { authSetUser } from '~/redux/auth/actions';
|
||||
import { authSetState, authSetUser } from '~/redux/auth/actions';
|
||||
import { nodeLoadNode } from '~/redux/node/actions';
|
||||
import { borisLoadStats } from '~/redux/boris/actions';
|
||||
import { Container } from '~/containers/main/Container';
|
||||
import StickyBox from 'react-sticky-box/dist/esnext';
|
||||
import { BorisComments } from '~/components/boris/BorisComments';
|
||||
import { URLS } from '~/constants/urls';
|
||||
import { Route, Switch, Link } from 'react-router-dom';
|
||||
import { BorisUIDemo } from '~/components/boris/BorisUIDemo';
|
||||
import { BorisSuperpowers } from '~/components/boris/BorisSuperpowers';
|
||||
import { Superpower } from '~/components/boris/Superpower';
|
||||
import { Tabs } from '~/components/dialogs/Tabs';
|
||||
import { Tab } from '~/components/dialogs/Tab';
|
||||
import { useHistory, useLocation } from 'react-router';
|
||||
import { Card } from '~/components/containers/Card';
|
||||
|
||||
type IProps = {};
|
||||
|
||||
|
@ -30,6 +35,7 @@ const BorisLayout: FC<IProps> = () => {
|
|||
const user = useShallowSelect(selectUser);
|
||||
const stats = useShallowSelect(selectBorisStats);
|
||||
const comments = useShallowSelect(selectNodeComments);
|
||||
const is_tester = useShallowSelect(selectAuthIsTester);
|
||||
|
||||
useEffect(() => {
|
||||
const last_comment = comments[0];
|
||||
|
@ -55,6 +61,16 @@ const BorisLayout: FC<IProps> = () => {
|
|||
dispatch(borisLoadStats());
|
||||
}, [dispatch]);
|
||||
|
||||
const setBetaTester = useCallback(
|
||||
(is_tester: boolean) => {
|
||||
dispatch(authSetState({ is_tester }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className={styles.wrap}>
|
||||
|
@ -70,26 +86,40 @@ const BorisLayout: FC<IProps> = () => {
|
|||
|
||||
<div className={styles.container}>
|
||||
<Card className={styles.content}>
|
||||
<Group className={styles.grid}>
|
||||
{user.is_user && <NodeCommentForm isBefore nodeId={node.current.id} />}
|
||||
<Superpower>
|
||||
<Tabs>
|
||||
<Tab
|
||||
active={location.pathname === URLS.BORIS}
|
||||
onClick={() => history.push(URLS.BORIS)}
|
||||
>
|
||||
Комментарии
|
||||
</Tab>
|
||||
|
||||
{node.is_loading_comments ? (
|
||||
<NodeNoComments is_loading count={7} />
|
||||
) : (
|
||||
<NodeComments
|
||||
comments={comments}
|
||||
count={node.comment_count}
|
||||
user={user}
|
||||
order="ASC"
|
||||
<Tab
|
||||
active={location.pathname === `${URLS.BORIS}/ui`}
|
||||
onClick={() => history.push(`${URLS.BORIS}/ui`)}
|
||||
>
|
||||
UI Demo
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Superpower>
|
||||
|
||||
{
|
||||
<Switch>
|
||||
<Route path={`${URLS.BORIS}/ui`} component={BorisUIDemo} />
|
||||
|
||||
<BorisComments
|
||||
isLoadingComments={node.is_loading_comments}
|
||||
commentCount={node.comment_count}
|
||||
node={node.current}
|
||||
comments={node.comments}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Footer />
|
||||
</Switch>
|
||||
}
|
||||
</Card>
|
||||
|
||||
<Group className={styles.stats}>
|
||||
<Sticky>
|
||||
<StickyBox className={styles.sticky} offsetTop={72} offsetBottom={10}>
|
||||
<Group className={styles.stats__container}>
|
||||
<div className={styles.stats__about}>
|
||||
<h4>Господи-боженьки, где это я?</h4>
|
||||
|
@ -102,11 +132,15 @@ const BorisLayout: FC<IProps> = () => {
|
|||
<p className="grey">// Такова жизнь.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{user.is_user && <BorisSuperpowers active={is_tester} onChange={setBetaTester} />}
|
||||
</div>
|
||||
|
||||
<div className={styles.stats__wrap}>
|
||||
<BorisStats stats={stats} />
|
||||
</div>
|
||||
</Group>
|
||||
</Sticky>
|
||||
</StickyBox>
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -7,22 +7,6 @@
|
|||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 4;
|
||||
z-index: 2;
|
||||
border-radius: $radius;
|
||||
padding: 0;
|
||||
background: $node_bg;
|
||||
box-shadow: inset transparentize(mix($wisegreen, white, 60%), 0.6) 0 1px;
|
||||
|
||||
@include desktop {
|
||||
flex: 2.5;
|
||||
}
|
||||
|
||||
@media(max-width: 1024px) {
|
||||
flex: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.grid {
|
||||
padding: $gap;
|
||||
|
@ -36,7 +20,7 @@
|
|||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: 50% 0% no-repeat url('../../../sprites/boris_bg.svg');
|
||||
background: 50% 0 no-repeat url('../../../sprites/boris_bg.svg');
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
|
@ -167,3 +151,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 3;
|
||||
}
|
||||
|
|
|
@ -40,7 +40,7 @@ const NodeLayout: FC<IProps> = memo(
|
|||
const { head, block } = useNodeBlocks(current, is_loading);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.wrap}>
|
||||
{head}
|
||||
|
||||
<Container>
|
||||
|
@ -64,7 +64,7 @@ const NodeLayout: FC<IProps> = memo(
|
|||
</Container>
|
||||
|
||||
<SidebarRouter prefix="/post:id" />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
.content {
|
||||
align-items: stretch !important;
|
||||
|
||||
@include vertical_at_tablet;
|
||||
}
|
||||
|
||||
|
|
|
@ -29,7 +29,9 @@ const ProfileLayoutUnconnected: FC<IProps> = ({ history, nodeSetCoverImage }) =>
|
|||
useEffect(() => {
|
||||
if (user && user.id && user.cover) {
|
||||
nodeSetCoverImage(user.cover);
|
||||
return () => nodeSetCoverImage(null);
|
||||
return () => {
|
||||
nodeSetCoverImage(undefined);
|
||||
};
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
|
|
|
@ -2,6 +2,8 @@ import React, { FC, useCallback } from 'react';
|
|||
import styles from './styles.module.scss';
|
||||
import classNames from 'classnames';
|
||||
import { IAuthState } from '~/redux/auth/types';
|
||||
import { Tabs } from '~/components/dialogs/Tabs';
|
||||
import { Tab } from '~/components/dialogs/Tab';
|
||||
|
||||
interface IProps {
|
||||
tab: string;
|
||||
|
@ -20,28 +22,20 @@ const ProfileTabs: FC<IProps> = ({ tab, is_own, setTab }) => {
|
|||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<div
|
||||
className={classNames(styles.tab, { [styles.active]: tab === 'profile' })}
|
||||
onClick={changeTab('profile')}
|
||||
>
|
||||
Профиль
|
||||
</div>
|
||||
<div
|
||||
className={classNames(styles.tab, { [styles.active]: tab === 'messages' })}
|
||||
onClick={changeTab('messages')}
|
||||
>
|
||||
Сообщения
|
||||
</div>
|
||||
{is_own && (
|
||||
<>
|
||||
<div
|
||||
className={classNames(styles.tab, { [styles.active]: tab === 'settings' })}
|
||||
onClick={changeTab('settings')}
|
||||
>
|
||||
<Tabs>
|
||||
<Tab active={tab === 'profile'} onClick={changeTab('profile')}>
|
||||
Профиль
|
||||
</Tab>
|
||||
|
||||
<Tab active={tab === 'messages'} onClick={changeTab('messages')}>
|
||||
Сообщения
|
||||
</Tab>
|
||||
{is_own && (
|
||||
<Tab active={tab === 'settings'} onClick={changeTab('settings')}>
|
||||
Настройки
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Tab>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,24 +1,6 @@
|
|||
@import "src/styles/variables";
|
||||
|
||||
.wrap {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
margin: $gap * 2 0 0 0;
|
||||
padding: 0 $gap;
|
||||
}
|
||||
|
||||
.tab {
|
||||
@include outer_shadow();
|
||||
|
||||
padding: $gap;
|
||||
margin-right: $gap;
|
||||
border-radius: $radius $radius 0 0;
|
||||
font: $font_14_semibold;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
|
||||
&.active {
|
||||
background: lighten($content_bg, 4%);
|
||||
}
|
||||
}
|
||||
|
|
1
src/react-app-env.d.ts
vendored
1
src/react-app-env.d.ts
vendored
|
@ -3,5 +3,6 @@ declare namespace NodeJS {
|
|||
interface ProcessEnv {
|
||||
readonly REACT_APP_API_URL: string;
|
||||
readonly REACT_APP_REMOTE_CURRENT: string;
|
||||
readonly REACT_APP_LAB_ENABLED: string;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,6 +20,11 @@ export const authSetToken = (token: IAuthState['token']) => ({
|
|||
token,
|
||||
});
|
||||
|
||||
export const authSetState = (payload: Partial<IAuthState>) => ({
|
||||
type: AUTH_USER_ACTIONS.SET_STATE,
|
||||
payload,
|
||||
});
|
||||
|
||||
export const gotAuthPostMessage = ({ token }: { token: string }) => ({
|
||||
type: AUTH_USER_ACTIONS.GOT_AUTH_POST_MESSAGE,
|
||||
token,
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { api, cleanResult, errorMiddleware, resultMiddleware } from '~/utils/api';
|
||||
import { api, cleanResult } from '~/utils/api';
|
||||
import { API } from '~/constants/api';
|
||||
import { IResultWithStatus } from '~/redux/types';
|
||||
import {
|
||||
ApiAttachSocialRequest,
|
||||
ApiAttachSocialResult,
|
||||
|
|
|
@ -3,6 +3,7 @@ import { IToken, IUser } from '~/redux/auth/types';
|
|||
export const AUTH_USER_ACTIONS = {
|
||||
SEND_LOGIN_REQUEST: 'SEND_LOGIN_REQUEST',
|
||||
SET_LOGIN_ERROR: 'SET_LOGIN_ERROR',
|
||||
SET_STATE: 'SET_STATE',
|
||||
SET_USER: 'SET_USER',
|
||||
SET_TOKEN: 'SET_TOKEN',
|
||||
|
||||
|
|
|
@ -25,6 +25,11 @@ const setUser: ActionHandler<typeof ActionCreators.authSetUser> = (state, { prof
|
|||
},
|
||||
});
|
||||
|
||||
const setState: ActionHandler<typeof ActionCreators.authSetState> = (state, { payload }) => ({
|
||||
...state,
|
||||
...payload,
|
||||
});
|
||||
|
||||
const setToken: ActionHandler<typeof ActionCreators.authSetToken> = (state, { token }) => ({
|
||||
...state,
|
||||
token,
|
||||
|
@ -104,6 +109,7 @@ const setRegisterSocialErrors: ActionHandler<typeof ActionCreators.authSetRegist
|
|||
export const AUTH_USER_HANDLERS = {
|
||||
[AUTH_USER_ACTIONS.SET_LOGIN_ERROR]: setLoginError,
|
||||
[AUTH_USER_ACTIONS.SET_USER]: setUser,
|
||||
[AUTH_USER_ACTIONS.SET_STATE]: setState,
|
||||
[AUTH_USER_ACTIONS.SET_TOKEN]: setToken,
|
||||
[AUTH_USER_ACTIONS.SET_PROFILE]: setProfile,
|
||||
[AUTH_USER_ACTIONS.SET_UPDATES]: setUpdates,
|
||||
|
|
|
@ -10,6 +10,7 @@ const HANDLERS = {
|
|||
const INITIAL_STATE: IAuthState = {
|
||||
token: '',
|
||||
user: { ...EMPTY_USER },
|
||||
is_tester: false,
|
||||
|
||||
updates: {
|
||||
last: '',
|
||||
|
|
|
@ -2,6 +2,7 @@ import { IState } from '~/redux/store';
|
|||
|
||||
export const selectAuth = (state: IState) => state.auth;
|
||||
export const selectUser = (state: IState) => state.auth.user;
|
||||
export const selectAuthIsTester = (state: IState) => state.auth.is_tester;
|
||||
export const selectToken = (state: IState) => state.auth.token;
|
||||
export const selectAuthLogin = (state: IState) => state.auth.login;
|
||||
export const selectAuthProfile = (state: IState) => state.auth.profile;
|
||||
|
|
|
@ -37,6 +37,8 @@ export type IAuthState = Readonly<{
|
|||
user: IUser;
|
||||
token: string;
|
||||
|
||||
is_tester: boolean;
|
||||
|
||||
updates: {
|
||||
last: string;
|
||||
notifications: INotification[];
|
||||
|
|
|
@ -1,10 +1,20 @@
|
|||
import git from '~/stats/git.json';
|
||||
import { API } from '~/constants/api';
|
||||
import { api, resultMiddleware, errorMiddleware, cleanResult } from '~/utils/api';
|
||||
import { api, cleanResult } from '~/utils/api';
|
||||
import { IBorisState, IStatBackend } from './reducer';
|
||||
import { IResultWithStatus } from '../types';
|
||||
import axios from 'axios';
|
||||
import { IGetGithubIssuesResult } from '~/redux/boris/types';
|
||||
|
||||
export const getBorisGitStats = () => Promise.resolve<IBorisState['stats']['git']>(git);
|
||||
|
||||
export const getBorisBackendStats = () =>
|
||||
api.get<IStatBackend>(API.BORIS.GET_BACKEND_STATS).then(cleanResult);
|
||||
|
||||
export const getGithubIssues = () => {
|
||||
return axios
|
||||
.get<IGetGithubIssuesResult>('https://api.github.com/repos/muerwre/vault-frontend/issues', {
|
||||
params: { state: 'all', sort: 'created' },
|
||||
})
|
||||
.then(result => result.data)
|
||||
.catch(() => []);
|
||||
};
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { createReducer } from '~/utils/reducer';
|
||||
import { BORIS_HANDLERS } from './handlers';
|
||||
import { IGithubIssue } from '~/redux/boris/types';
|
||||
|
||||
export type IStatGitRow = {
|
||||
commit: string;
|
||||
|
@ -31,6 +32,7 @@ export type IStatBackend = {
|
|||
export type IBorisState = Readonly<{
|
||||
stats: {
|
||||
git: Partial<IStatGitRow>[];
|
||||
issues: IGithubIssue[];
|
||||
backend?: IStatBackend;
|
||||
is_loading: boolean;
|
||||
};
|
||||
|
@ -39,6 +41,7 @@ export type IBorisState = Readonly<{
|
|||
const BORIS_INITIAL_STATE: IBorisState = {
|
||||
stats: {
|
||||
git: [],
|
||||
issues: [],
|
||||
backend: undefined,
|
||||
is_loading: false,
|
||||
},
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
import { takeLatest, put, call } from 'redux-saga/effects';
|
||||
import { call, put, takeLatest } from 'redux-saga/effects';
|
||||
import { BORIS_ACTIONS } from './constants';
|
||||
import { borisSetStats } from './actions';
|
||||
import { getBorisGitStats, getBorisBackendStats } from './api';
|
||||
import { getBorisBackendStats, getGithubIssues } from './api';
|
||||
import { Unwrap } from '../types';
|
||||
|
||||
function* loadStats() {
|
||||
try {
|
||||
yield put(borisSetStats({ is_loading: true }));
|
||||
|
||||
const git: Unwrap<typeof getBorisGitStats> = yield call(getBorisGitStats);
|
||||
const backend: Unwrap<typeof getBorisBackendStats> = yield call(getBorisBackendStats);
|
||||
const issues: Unwrap<typeof getGithubIssues> = yield call(getGithubIssues);
|
||||
|
||||
yield put(borisSetStats({ git, backend }));
|
||||
yield put(borisSetStats({ issues, backend }));
|
||||
} catch (e) {
|
||||
yield put(borisSetStats({ git: [], backend: undefined }));
|
||||
} finally {
|
||||
|
|
12
src/redux/boris/types.ts
Normal file
12
src/redux/boris/types.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
export interface IGithubIssue {
|
||||
id: string;
|
||||
url: string;
|
||||
html_url: string;
|
||||
body: string;
|
||||
title: string;
|
||||
state: 'open' | 'closed';
|
||||
created_at: string;
|
||||
pull_request?: unknown;
|
||||
}
|
||||
|
||||
export type IGetGithubIssuesResult = IGithubIssue[];
|
12
src/redux/lab/actions.ts
Normal file
12
src/redux/lab/actions.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { LAB_ACTIONS } from '~/redux/lab/constants';
|
||||
import { ILabState } from '~/redux/lab/types';
|
||||
|
||||
export const labGetList = (after?: string) => ({
|
||||
type: LAB_ACTIONS.GET_LIST,
|
||||
after,
|
||||
});
|
||||
|
||||
export const labSetList = (list: Partial<ILabState['list']>) => ({
|
||||
type: LAB_ACTIONS.SET_LIST,
|
||||
list,
|
||||
});
|
8
src/redux/lab/api.ts
Normal file
8
src/redux/lab/api.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { api, cleanResult } from '~/utils/api';
|
||||
import { API } from '~/constants/api';
|
||||
import { GetLabNodesRequest, GetLabNodesResult } from '~/redux/lab/types';
|
||||
|
||||
export const getLabNodes = ({ after }: GetLabNodesRequest) =>
|
||||
api
|
||||
.get<GetLabNodesResult>(API.LAB.NODES, { params: { after } })
|
||||
.then(cleanResult);
|
6
src/redux/lab/constants.ts
Normal file
6
src/redux/lab/constants.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
const prefix = 'LAB.';
|
||||
|
||||
export const LAB_ACTIONS = {
|
||||
GET_LIST: `${prefix}GET_LIST`,
|
||||
SET_LIST: `${prefix}SET_LIST`,
|
||||
};
|
20
src/redux/lab/handlers.ts
Normal file
20
src/redux/lab/handlers.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { LAB_ACTIONS } from '~/redux/lab/constants';
|
||||
import { labSetList } from '~/redux/lab/actions';
|
||||
import { ILabState } from '~/redux/lab/types';
|
||||
|
||||
type LabHandler<T extends (...args: any) => any> = (
|
||||
state: Readonly<ILabState>,
|
||||
payload: ReturnType<T>
|
||||
) => Readonly<ILabState>;
|
||||
|
||||
const setList: LabHandler<typeof labSetList> = (state, { list }) => ({
|
||||
...state,
|
||||
list: {
|
||||
...state.list,
|
||||
...list,
|
||||
},
|
||||
});
|
||||
|
||||
export const LAB_HANDLERS = {
|
||||
[LAB_ACTIONS.SET_LIST]: setList,
|
||||
};
|
14
src/redux/lab/index.ts
Normal file
14
src/redux/lab/index.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { createReducer } from '~/utils/reducer';
|
||||
import { LAB_HANDLERS } from '~/redux/lab/handlers';
|
||||
import { ILabState } from '~/redux/lab/types';
|
||||
|
||||
const INITIAL_STATE: ILabState = {
|
||||
list: {
|
||||
is_loading: false,
|
||||
nodes: [],
|
||||
count: 0,
|
||||
error: '',
|
||||
},
|
||||
};
|
||||
|
||||
export default createReducer(INITIAL_STATE, LAB_HANDLERS);
|
21
src/redux/lab/sagas.ts
Normal file
21
src/redux/lab/sagas.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { takeLeading, call, put } from 'redux-saga/effects';
|
||||
import { labGetList, labSetList } from '~/redux/lab/actions';
|
||||
import { LAB_ACTIONS } from '~/redux/lab/constants';
|
||||
import { Unwrap } from '~/redux/types';
|
||||
import { getLabNodes } from '~/redux/lab/api';
|
||||
|
||||
function* getList({ after = '' }: ReturnType<typeof labGetList>) {
|
||||
try {
|
||||
yield put(labSetList({ is_loading: true }));
|
||||
const { nodes, count }: Unwrap<typeof getLabNodes> = yield call(getLabNodes, { after });
|
||||
yield put(labSetList({ nodes, count }));
|
||||
} catch (error) {
|
||||
yield put(labSetList({ error: error.message }));
|
||||
} finally {
|
||||
yield put(labSetList({ is_loading: false }));
|
||||
}
|
||||
}
|
||||
|
||||
export default function* labSaga() {
|
||||
yield takeLeading(LAB_ACTIONS.GET_LIST, getList);
|
||||
}
|
4
src/redux/lab/selectors.ts
Normal file
4
src/redux/lab/selectors.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { IState } from '~/redux/store';
|
||||
|
||||
export const selectLab = (state: IState) => state.lab;
|
||||
export const selectLabListNodes = (state: IState) => state.lab.list.nodes;
|
19
src/redux/lab/types.ts
Normal file
19
src/redux/lab/types.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { IError, INode } from '~/redux/types';
|
||||
|
||||
export type ILabState = Readonly<{
|
||||
list: {
|
||||
is_loading: boolean;
|
||||
nodes: INode[];
|
||||
count: number;
|
||||
error: IError;
|
||||
};
|
||||
}>;
|
||||
|
||||
export type GetLabNodesRequest = {
|
||||
after?: string;
|
||||
};
|
||||
|
||||
export type GetLabNodesResult = {
|
||||
nodes: INode[];
|
||||
count: number;
|
||||
};
|
|
@ -10,13 +10,23 @@ function* onPathChange({
|
|||
},
|
||||
}: LocationChangeAction) {
|
||||
if (pathname.match(/^\/~([\wа-яА-Я]+)/)) {
|
||||
const [, username] = pathname.match(/^\/~([\wа-яА-Я]+)/);
|
||||
return yield put(authOpenProfile(username));
|
||||
const match = pathname.match(/^\/~([\wа-яА-Я]+)/);
|
||||
|
||||
if (!match || !match.length || !match[1]) {
|
||||
return;
|
||||
}
|
||||
|
||||
return yield put(authOpenProfile(match[1]));
|
||||
}
|
||||
|
||||
if (pathname.match(/^\/restore\/([\w\-]+)/)) {
|
||||
const [, code] = pathname.match(/^\/restore\/([\w\-]+)/);
|
||||
return yield put(authShowRestoreModal(code));
|
||||
const match = pathname.match(/^\/restore\/([\w\-]+)/);
|
||||
|
||||
if (!match || !match.length || !match[1]) {
|
||||
return;
|
||||
}
|
||||
|
||||
return yield put(authShowRestoreModal(match[1]));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ import { EditorAudioUploadButton } from '~/components/editors/EditorAudioUploadB
|
|||
import { EditorUploadCoverButton } from '~/components/editors/EditorUploadCoverButton';
|
||||
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';
|
||||
|
||||
const prefix = 'NODE.';
|
||||
|
@ -59,6 +60,8 @@ export const EMPTY_NODE: INode = {
|
|||
|
||||
blocks: [],
|
||||
tags: [],
|
||||
is_public: true,
|
||||
is_promoted: true,
|
||||
|
||||
flow: {
|
||||
display: 'single',
|
||||
|
@ -112,14 +115,20 @@ export const NODE_EDITORS: Record<
|
|||
};
|
||||
|
||||
export const NODE_PANEL_COMPONENTS: Record<string, FC<IEditorComponentProps>[]> = {
|
||||
[NODE_TYPES.TEXT]: [EditorFiller, EditorUploadCoverButton],
|
||||
[NODE_TYPES.VIDEO]: [EditorFiller, EditorUploadCoverButton],
|
||||
[NODE_TYPES.IMAGE]: [EditorImageUploadButton, EditorFiller, EditorUploadCoverButton],
|
||||
[NODE_TYPES.TEXT]: [EditorFiller, EditorUploadCoverButton, EditorPublicSwitch],
|
||||
[NODE_TYPES.VIDEO]: [EditorFiller, EditorUploadCoverButton, EditorPublicSwitch],
|
||||
[NODE_TYPES.IMAGE]: [
|
||||
EditorImageUploadButton,
|
||||
EditorFiller,
|
||||
EditorUploadCoverButton,
|
||||
EditorPublicSwitch,
|
||||
],
|
||||
[NODE_TYPES.AUDIO]: [
|
||||
EditorAudioUploadButton,
|
||||
EditorImageUploadButton,
|
||||
EditorFiller,
|
||||
EditorUploadCoverButton,
|
||||
EditorPublicSwitch,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
@ -17,6 +17,10 @@ import nodeSaga from '~/redux/node/sagas';
|
|||
import flow, { IFlowState } from '~/redux/flow/reducer';
|
||||
import flowSaga from '~/redux/flow/sagas';
|
||||
|
||||
import lab from '~/redux/lab';
|
||||
import labSaga from '~/redux/lab/sagas';
|
||||
import { ILabState } from '~/redux/lab/types';
|
||||
|
||||
import uploads, { IUploadState } from '~/redux/uploads/reducer';
|
||||
import uploadSaga from '~/redux/uploads/sagas';
|
||||
|
||||
|
@ -42,7 +46,7 @@ import { assocPath } from 'ramda';
|
|||
|
||||
const authPersistConfig: PersistConfig = {
|
||||
key: 'auth',
|
||||
whitelist: ['token', 'user', 'updates'],
|
||||
whitelist: ['token', 'user', 'updates', 'is_tester'],
|
||||
storage,
|
||||
};
|
||||
|
||||
|
@ -69,13 +73,16 @@ export interface IState {
|
|||
boris: IBorisState;
|
||||
messages: IMessagesState;
|
||||
tag: ITagState;
|
||||
lab: ILabState;
|
||||
}
|
||||
|
||||
export const sagaMiddleware = createSagaMiddleware();
|
||||
export const history = createBrowserHistory();
|
||||
|
||||
const composeEnhancers =
|
||||
typeof window === 'object' && (<any>window).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
|
||||
typeof window === 'object' &&
|
||||
(<any>window).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ &&
|
||||
process.env.NODE_ENV === 'development'
|
||||
? (<any>window).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({})
|
||||
: compose;
|
||||
|
||||
|
@ -91,6 +98,7 @@ export const store = createStore(
|
|||
player: persistReducer(playerPersistConfig, player),
|
||||
messages,
|
||||
tag: tag,
|
||||
lab: lab,
|
||||
}),
|
||||
composeEnhancers(applyMiddleware(routerMiddleware(history), sagaMiddleware))
|
||||
);
|
||||
|
@ -108,6 +116,7 @@ export function configureStore(): {
|
|||
sagaMiddleware.run(borisSaga);
|
||||
sagaMiddleware.run(messagesSaga);
|
||||
sagaMiddleware.run(tagSaga);
|
||||
sagaMiddleware.run(labSaga);
|
||||
|
||||
window.addEventListener('message', message => {
|
||||
if (message && message.data && message.data.type === 'oauth_login' && message.data.token)
|
||||
|
|
|
@ -11,7 +11,7 @@ import { apiGetTagSuggestions, apiGetNodesOfTag } from '~/redux/tag/api';
|
|||
import { Unwrap } from '~/redux/types';
|
||||
|
||||
function* loadTagNodes({ tag }: ReturnType<typeof tagLoadNodes>) {
|
||||
yield put(tagSetNodes({ isLoading: true, list: [] }));
|
||||
yield put(tagSetNodes({ isLoading: true }));
|
||||
|
||||
try {
|
||||
const { list }: ReturnType<typeof selectTagNodes> = yield select(selectTagNodes);
|
||||
|
|
|
@ -124,6 +124,8 @@ export interface INode {
|
|||
description?: string;
|
||||
is_liked?: boolean;
|
||||
is_heroic?: boolean;
|
||||
is_promoted?: boolean;
|
||||
is_public?: boolean;
|
||||
like_count?: number;
|
||||
|
||||
flow: {
|
||||
|
|
|
@ -255,6 +255,16 @@ const Sprites: FC<{}> = () => (
|
|||
<path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z" />
|
||||
</g>
|
||||
|
||||
<g id="waves" stroke="none">
|
||||
<path fill="none" d="M0 0h24v24H0V0z" />
|
||||
<path d="M17 16.99c-1.35 0-2.2.42-2.95.8-.65.33-1.18.6-2.05.6-.9 0-1.4-.25-2.05-.6-.75-.38-1.57-.8-2.95-.8s-2.2.42-2.95.8c-.65.33-1.17.6-2.05.6v1.95c1.35 0 2.2-.42 2.95-.8.65-.33 1.17-.6 2.05-.6s1.4.25 2.05.6c.75.38 1.57.8 2.95.8s2.2-.42 2.95-.8c.65-.33 1.18-.6 2.05-.6.9 0 1.4.25 2.05.6.75.38 1.58.8 2.95.8v-1.95c-.9 0-1.4-.25-2.05-.6-.75-.38-1.6-.8-2.95-.8zm0-4.45c-1.35 0-2.2.43-2.95.8-.65.32-1.18.6-2.05.6-.9 0-1.4-.25-2.05-.6-.75-.38-1.57-.8-2.95-.8s-2.2.43-2.95.8c-.65.32-1.17.6-2.05.6v1.95c1.35 0 2.2-.43 2.95-.8.65-.35 1.15-.6 2.05-.6s1.4.25 2.05.6c.75.38 1.57.8 2.95.8s2.2-.43 2.95-.8c.65-.35 1.15-.6 2.05-.6s1.4.25 2.05.6c.75.38 1.58.8 2.95.8v-1.95c-.9 0-1.4-.25-2.05-.6-.75-.38-1.6-.8-2.95-.8zm2.95-8.08c-.75-.38-1.58-.8-2.95-.8s-2.2.42-2.95.8c-.65.32-1.18.6-2.05.6-.9 0-1.4-.25-2.05-.6-.75-.37-1.57-.8-2.95-.8s-2.2.42-2.95.8c-.65.33-1.17.6-2.05.6v1.93c1.35 0 2.2-.43 2.95-.8.65-.33 1.17-.6 2.05-.6s1.4.25 2.05.6c.75.38 1.57.8 2.95.8s2.2-.43 2.95-.8c.65-.32 1.18-.6 2.05-.6.9 0 1.4.25 2.05.6.75.38 1.58.8 2.95.8V5.04c-.9 0-1.4-.25-2.05-.58zM17 8.09c-1.35 0-2.2.43-2.95.8-.65.35-1.15.6-2.05.6s-1.4-.25-2.05-.6c-.75-.38-1.57-.8-2.95-.8s-2.2.43-2.95.8c-.65.35-1.15.6-2.05.6v1.95c1.35 0 2.2-.43 2.95-.8.65-.32 1.18-.6 2.05-.6s1.4.25 2.05.6c.75.38 1.57.8 2.95.8s2.2-.43 2.95-.8c.65-.32 1.18-.6 2.05-.6.9 0 1.4.25 2.05.6.75.38 1.58.8 2.95.8V9.49c-.9 0-1.4-.25-2.05-.6-.75-.38-1.6-.8-2.95-.8z" />
|
||||
</g>
|
||||
|
||||
<g id="lab" stroke="none">
|
||||
<path fill="none" d="M0 0h24v24H0V0z" />
|
||||
<path d="M13,11.33L18,18H6l5-6.67V6h2 M15.96,4H8.04C7.62,4,7.39,4.48,7.65,4.81L9,6.5v4.17L3.2,18.4C2.71,19.06,3.18,20,4,20h16 c0.82,0,1.29-0.94,0.8-1.6L15,10.67V6.5l1.35-1.69C16.61,4.48,16.38,4,15.96,4L15.96,4z" />
|
||||
</g>
|
||||
|
||||
<g id="search">
|
||||
<path fill="none" d="M0 0h24v24H0V0z" stroke="none" />
|
||||
<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" />
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
$red: #ff3344;
|
||||
$yellow: #ffd60f;
|
||||
$dark_blue: #3c75ff;
|
||||
$blue: #3ca1ff;
|
||||
$blue: #582cd0;
|
||||
$green: #00d2b9;
|
||||
//$green: #00503c;
|
||||
$olive: #8bc12a;
|
||||
|
|
|
@ -55,6 +55,10 @@ $margin: 1em;
|
|||
|
||||
p {
|
||||
margin-bottom: $margin;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
h5, h4, h3, h2, h1 {
|
||||
|
|
|
@ -209,3 +209,13 @@ $sidebar_border: transparentize(white, 0.95);
|
|||
background: transparentize($content_bg, 0.4);
|
||||
box-shadow: transparentize(white, 0.95) -1px 0;
|
||||
}
|
||||
|
||||
@mixin editor_round_button {
|
||||
width: $upload_button_height;
|
||||
height: $upload_button_height;
|
||||
border-radius: ($upload_button_height / 2) !important;
|
||||
flex: 0 0 $upload_button_height;
|
||||
position: relative;
|
||||
border-radius: $radius;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
|
@ -3,6 +3,10 @@ import { useMemo } from 'react';
|
|||
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
||||
|
||||
export const useNodeAudios = (node: INode) => {
|
||||
if (!node?.files) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return useMemo(() => node.files.filter(file => file && file.type === UPLOAD_TYPES.AUDIO), [
|
||||
node.files,
|
||||
]);
|
||||
|
|
|
@ -3,6 +3,10 @@ import { useMemo } from 'react';
|
|||
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
||||
|
||||
export const useNodeImages = (node: INode) => {
|
||||
if (!node?.files) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return useMemo(() => node.files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE), [
|
||||
node.files,
|
||||
]);
|
||||
|
|
24
src/utils/hooks/useInputPasteUpload.ts
Normal file
24
src/utils/hooks/useInputPasteUpload.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { useCallback, useEffect } from 'react';
|
||||
import { getImageFromPaste } from '~/utils/uploader';
|
||||
|
||||
// useInputPasteUpload attaches event listener to input, that calls onUpload if user pasted any image
|
||||
export const useInputPasteUpload = (
|
||||
input: HTMLTextAreaElement | HTMLInputElement | undefined,
|
||||
onUpload: (files: File[]) => void
|
||||
) => {
|
||||
const onPaste = useCallback(async event => {
|
||||
const image = await getImageFromPaste(event);
|
||||
|
||||
if (!image) return;
|
||||
|
||||
onUpload([image]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!input) return;
|
||||
|
||||
input.addEventListener('paste', onPaste);
|
||||
|
||||
return () => input.removeEventListener('paste', onPaste);
|
||||
}, [input, onPaste]);
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue