1
0
Fork 0
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:
Fedor Katurov 2021-03-21 12:46:25 +07:00
commit 167c1a8aad
102 changed files with 1560 additions and 385 deletions

View file

@ -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"

View 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 };

View 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;
}
}

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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;
}
}

View 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 };

View 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);
}

View 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 };

View 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;
}

View 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 };

View file

@ -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}")` }}>

View file

@ -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>

View file

@ -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 {

View file

@ -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%;
}

View file

@ -33,7 +33,6 @@
@include tablet {
:global(.comment-author) {
display: none !important;
color: red;
}
}
}

View file

@ -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>
);
};

View file

@ -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;
}
}

View 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 };

View 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%);
}
}

View 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 };

View file

@ -0,0 +1,8 @@
@import "src/styles/variables";
.wrap {
display: flex;
align-items: flex-start;
justify-content: flex-start;
padding: 0 $gap / 2;
}

View file

@ -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';

View file

@ -13,11 +13,12 @@
flex-direction: row;
& > * {
margin: 0 $gap;
margin: 0 $gap / 2;
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}

View 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 };

View 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;
}
}
}

View file

@ -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);

View file

@ -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;

View file

@ -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} />)}
</>
);

View file

@ -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>
);
}
);

View file

@ -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;
}

View 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 };

View 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;
}
}
}

View 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 };

View file

@ -0,0 +1,5 @@
@import "~/styles/variables.scss";
.wrap {
background: $red_gradient_alt;
}

View 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 };

View 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 };

View file

@ -0,0 +1,10 @@
@import "~/styles/variables.scss";
.wrap {
margin-bottom: $gap;
}
.star {
fill: #2c2c2c;
}

View 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 };

View 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;
}

View file

@ -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 };

View file

@ -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}>

View file

@ -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;

View file

@ -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>

View 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;
}

View file

@ -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 => (

View 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 {

View file

@ -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;
}
}

View 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 };

View 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;
}
}

View file

@ -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 '';

View file

@ -50,4 +50,7 @@ export const API = {
NODES: `/tag/nodes`,
AUTOCOMPLETE: `/tag/autocomplete`,
},
LAB: {
NODES: `/lab/`,
},
};

View file

@ -2,6 +2,7 @@ import { INode } from '~/redux/types';
export const URLS = {
BASE: '/',
LAB: '/lab',
BORIS: '/boris',
AUTH: {
LOGIN: '/auth/login',

View file

@ -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>
);

View file

@ -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>

View 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 };

View file

@ -0,0 +1,8 @@
@import "~/styles/variables.scss";
.wrap {
display: grid;
grid-auto-flow: row;
grid-auto-rows: auto;
grid-row-gap: $gap;
}

View 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 };

View 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;
}
}

View file

@ -8,4 +8,8 @@
@include tablet {
padding: 0;
}
@media (max-width: $content_width + $gap * 4) {
padding: 0;
}
}

View file

@ -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>
);

View file

@ -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">//&nbsp;Такова&nbsp;жизнь.</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>

View file

@ -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;
}

View file

@ -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>
);
}
);

View file

@ -2,6 +2,7 @@
.content {
align-items: stretch !important;
@include vertical_at_tablet;
}

View file

@ -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]);

View file

@ -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>
);
};

View file

@ -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%);
}
}

View file

@ -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;
}
}

View file

@ -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,

View file

@ -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,

View file

@ -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',

View file

@ -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,

View file

@ -10,6 +10,7 @@ const HANDLERS = {
const INITIAL_STATE: IAuthState = {
token: '',
user: { ...EMPTY_USER },
is_tester: false,
updates: {
last: '',

View file

@ -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;

View file

@ -37,6 +37,8 @@ export type IAuthState = Readonly<{
user: IUser;
token: string;
is_tester: boolean;
updates: {
last: string;
notifications: INotification[];

View file

@ -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(() => []);
};

View file

@ -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,
},

View file

@ -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
View 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
View 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
View 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);

View 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
View 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
View 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
View 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);
}

View 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
View 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;
};

View file

@ -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]));
}
}

View file

@ -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,
],
};

View file

@ -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)

View file

@ -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);

View file

@ -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: {

View file

@ -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" />

View file

@ -3,7 +3,7 @@
$red: #ff3344;
$yellow: #ffd60f;
$dark_blue: #3c75ff;
$blue: #3ca1ff;
$blue: #582cd0;
$green: #00d2b9;
//$green: #00503c;
$olive: #8bc12a;

View file

@ -55,6 +55,10 @@ $margin: 1em;
p {
margin-bottom: $margin;
&:last-child {
margin-bottom: 0;
}
}
h5, h4, h3, h2, h1 {

View file

@ -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;
}

View file

@ -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,
]);

View file

@ -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,
]);

View 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