mirror of
https://github.com/muerwre/vault-frontend.git
synced 2025-04-24 20:36:40 +07:00
#57 added block-based content editor
This commit is contained in:
parent
4b542e0291
commit
d8c379de6a
17 changed files with 302 additions and 13 deletions
|
@ -1,5 +1,5 @@
|
||||||
import React, { FC, useCallback } from 'react';
|
import React, { FC, useCallback } from 'react';
|
||||||
import { INode } from '~/redux/types';
|
import { BlockType } from '~/redux/types';
|
||||||
import styles from './styles.module.scss';
|
import styles from './styles.module.scss';
|
||||||
import { Textarea } from '~/components/input/Textarea';
|
import { Textarea } from '~/components/input/Textarea';
|
||||||
import { path } from 'ramda';
|
import { path } from 'ramda';
|
||||||
|
@ -9,7 +9,7 @@ type IProps = NodeEditorProps & {};
|
||||||
|
|
||||||
const TextEditor: FC<IProps> = ({ data, setData }) => {
|
const TextEditor: FC<IProps> = ({ data, setData }) => {
|
||||||
const setText = useCallback(
|
const setText = useCallback(
|
||||||
(text: string) => setData({ ...data, blocks: [{ type: 'text', text }] }),
|
(text: string) => setData({ ...data, blocks: [{ type: BlockType.text, text }] }),
|
||||||
[data, setData]
|
[data, setData]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { FC, useCallback, useMemo } from 'react';
|
import React, { FC, useCallback, useMemo } from 'react';
|
||||||
import { INode } from '~/redux/types';
|
import { BlockType } from '~/redux/types';
|
||||||
import styles from './styles.module.scss';
|
import styles from './styles.module.scss';
|
||||||
import { path } from 'ramda';
|
import { path } from 'ramda';
|
||||||
import { InputText } from '~/components/input/InputText';
|
import { InputText } from '~/components/input/InputText';
|
||||||
|
@ -11,7 +11,7 @@ type IProps = NodeEditorProps & {};
|
||||||
|
|
||||||
const VideoEditor: FC<IProps> = ({ data, setData }) => {
|
const VideoEditor: FC<IProps> = ({ data, setData }) => {
|
||||||
const setUrl = useCallback(
|
const setUrl = useCallback(
|
||||||
(url: string) => setData({ ...data, blocks: [{ type: 'video', url }] }),
|
(url: string) => setData({ ...data, blocks: [{ type: BlockType.video, url }] }),
|
||||||
[data, setData]
|
[data, setData]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
60
src/containers/dialogs/NewEditorDialog/index.tsx
Normal file
60
src/containers/dialogs/NewEditorDialog/index.tsx
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import { useRouteMatch } from 'react-router';
|
||||||
|
import { SidebarWrapper } from '~/containers/sidebars/SidebarWrapper';
|
||||||
|
import { FormikProvider } from 'formik';
|
||||||
|
import { useNodeFormFormik } from '~/utils/hooks/useNodeFormFormik';
|
||||||
|
import { EMPTY_NODE } from '~/redux/node/constants';
|
||||||
|
import { FileUploaderProvider, useFileUploader } from '~/utils/hooks/fileUploader';
|
||||||
|
import { UPLOAD_SUBJECTS, UPLOAD_TARGETS } from '~/redux/uploads/constants';
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
import { NewEditorPanel } from '~/containers/editors/NewEditorPanel';
|
||||||
|
import { NewEditorContent } from '~/containers/editors/NewEditorContent';
|
||||||
|
import { BlockType, INode } from '~/redux/types';
|
||||||
|
|
||||||
|
type RouteParams = { type: string };
|
||||||
|
|
||||||
|
const data: INode = {
|
||||||
|
...EMPTY_NODE,
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: BlockType.text,
|
||||||
|
text: 'test',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: BlockType.text,
|
||||||
|
text: 'test',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: BlockType.text,
|
||||||
|
text: 'test',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const NewEditorDialog: FC = ({}) => {
|
||||||
|
const {
|
||||||
|
params: { type },
|
||||||
|
} = useRouteMatch<RouteParams>();
|
||||||
|
|
||||||
|
const uploader = useFileUploader(UPLOAD_SUBJECTS.COMMENT, UPLOAD_TARGETS.COMMENTS, data?.files);
|
||||||
|
|
||||||
|
const formik = useNodeFormFormik({ ...data, type }, uploader, console.log);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FileUploaderProvider value={uploader}>
|
||||||
|
<FormikProvider value={formik}>
|
||||||
|
<SidebarWrapper>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<NewEditorContent />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.panel}>
|
||||||
|
<NewEditorPanel />
|
||||||
|
</div>
|
||||||
|
</SidebarWrapper>
|
||||||
|
</FormikProvider>
|
||||||
|
</FileUploaderProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { NewEditorDialog };
|
12
src/containers/dialogs/NewEditorDialog/styles.module.scss
Normal file
12
src/containers/dialogs/NewEditorDialog/styles.module.scss
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
@import "~/styles/variables.scss";
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
flex: 0 1 400px;
|
||||||
|
height: 100%;
|
||||||
|
background: transparentize($content_bg, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 0 3 $content_width - 400;
|
||||||
|
background-color: transparentize($content_bg, 0.5);
|
||||||
|
}
|
17
src/containers/editors/NewEditorBlockText/index.tsx
Normal file
17
src/containers/editors/NewEditorBlockText/index.tsx
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import React, { FC, useCallback } from 'react';
|
||||||
|
import { BlockType, IBlockComponentProps } from '~/redux/types';
|
||||||
|
import { InputText } from '~/components/input/InputText';
|
||||||
|
|
||||||
|
const NewEditorBlockText: FC<IBlockComponentProps> = ({ block, handler }) => {
|
||||||
|
const onChange = useCallback((text: string) => handler({ type: BlockType.text, text }), [
|
||||||
|
handler,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<InputText handler={onChange} value={block.text} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { NewEditorBlockText };
|
27
src/containers/editors/NewEditorBlockVideo/index.tsx
Normal file
27
src/containers/editors/NewEditorBlockVideo/index.tsx
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import React, { FC, useCallback, useMemo } from 'react';
|
||||||
|
import { getYoutubeThumb } from '~/utils/dom';
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
import classnames from 'classnames';
|
||||||
|
import { InputText } from '~/components/input/InputText';
|
||||||
|
import { BlockType, IBlockComponentProps } from '~/redux/types';
|
||||||
|
|
||||||
|
const NewEditorBlockVideo: FC<IBlockComponentProps> = ({ block, handler }) => {
|
||||||
|
const setUrl = useCallback((url: string) => handler({ type: BlockType.video, url }), [handler]);
|
||||||
|
|
||||||
|
const url = block.url || '';
|
||||||
|
const preview = useMemo(() => getYoutubeThumb(url), [url]);
|
||||||
|
const backgroundImage = (preview && `url("${preview}")`) || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<div className={styles.input_wrap}>
|
||||||
|
<div className={classnames(styles.input, { active: !!preview })}>
|
||||||
|
<InputText value={url} handler={setUrl} placeholder="Адрес видео" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.preview} style={{ backgroundImage }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export { NewEditorBlockVideo };
|
|
@ -0,0 +1,33 @@
|
||||||
|
@import "src/styles/variables";
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
background-color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
padding-top: 56.25%;
|
||||||
|
position: relative;
|
||||||
|
border-radius: $radius;
|
||||||
|
background: 50% 50% no-repeat;
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input_wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
flex: 1 0 50%;
|
||||||
|
padding: $gap;
|
||||||
|
background: lighten($content_bg, 4%);
|
||||||
|
border-radius: $radius $radius 0 0;
|
||||||
|
input {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:global(.active) {
|
||||||
|
background: $red;
|
||||||
|
}
|
||||||
|
}
|
34
src/containers/editors/NewEditorContent/index.tsx
Normal file
34
src/containers/editors/NewEditorContent/index.tsx
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import React, { createElement, FC, useCallback, useMemo } from 'react';
|
||||||
|
import { useNodeFormContext } from '~/utils/hooks/useNodeFormFormik';
|
||||||
|
import { NODE_EDITOR_BLOCKS } from '~/redux/node/constants';
|
||||||
|
import { has, prop } from 'ramda';
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
import { Group } from '~/components/containers/Group';
|
||||||
|
import { IBlock } from '~/redux/types';
|
||||||
|
|
||||||
|
interface IProps {}
|
||||||
|
|
||||||
|
const NewEditorContent: FC<IProps> = () => {
|
||||||
|
const { values, setFieldValue } = useNodeFormContext();
|
||||||
|
|
||||||
|
const onChange = useCallback(
|
||||||
|
(index: number) => (val: IBlock) =>
|
||||||
|
setFieldValue(
|
||||||
|
'blocks',
|
||||||
|
values.blocks.map((el, i) => (i === index ? val : el))
|
||||||
|
),
|
||||||
|
[setFieldValue, values.blocks]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group className={styles.wrap}>
|
||||||
|
{values.blocks.map((block, i) =>
|
||||||
|
prop(block.type, NODE_EDITOR_BLOCKS)
|
||||||
|
? createElement(prop(block.type, NODE_EDITOR_BLOCKS), { block, handler: onChange(i) })
|
||||||
|
: null
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { NewEditorContent };
|
|
@ -0,0 +1,5 @@
|
||||||
|
@import "~/styles/variables.scss";
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
padding: $gap;
|
||||||
|
}
|
29
src/containers/editors/NewEditorPanel/index.tsx
Normal file
29
src/containers/editors/NewEditorPanel/index.tsx
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import React, { FC } from 'react';
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
import { TextInput } from '~/components/input/TextInput';
|
||||||
|
import { Filler } from '~/components/containers/Filler';
|
||||||
|
import { Button } from '~/components/input/Button';
|
||||||
|
import { Placeholder } from '~/components/placeholders/Placeholder';
|
||||||
|
import { Group } from '~/components/containers/Group';
|
||||||
|
|
||||||
|
interface IProps {}
|
||||||
|
|
||||||
|
const NewEditorPanel: FC<IProps> = () => (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<Group>
|
||||||
|
<div className={styles.title}>
|
||||||
|
<TextInput onChange={console.log} label="Название" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Placeholder width="100%" height={60} />
|
||||||
|
|
||||||
|
<Placeholder width="100%" height={120} />
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Filler />
|
||||||
|
|
||||||
|
<Button color="primary">Сохранить</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { NewEditorPanel };
|
11
src/containers/editors/NewEditorPanel/styles.module.scss
Normal file
11
src/containers/editors/NewEditorPanel/styles.module.scss
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
@import "~/styles/variables.scss";
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
@include outer_shadow;
|
||||||
|
|
||||||
|
padding: $gap * 2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
|
@ -5,6 +5,7 @@ import { TagSidebar } from '~/containers/sidebars/TagSidebar';
|
||||||
import { ProfileSidebar } from '~/containers/sidebars/ProfileSidebar';
|
import { ProfileSidebar } from '~/containers/sidebars/ProfileSidebar';
|
||||||
import { Authorized } from '~/components/containers/Authorized';
|
import { Authorized } from '~/components/containers/Authorized';
|
||||||
import { SubmitBar } from '~/components/bars/SubmitBar';
|
import { SubmitBar } from '~/components/bars/SubmitBar';
|
||||||
|
import { NewEditorDialog } from '~/containers/dialogs/NewEditorDialog';
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
prefix?: string;
|
prefix?: string;
|
||||||
|
@ -17,6 +18,7 @@ const SidebarRouter: FC<IProps> = ({ prefix = '', isLab }) => {
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path={`${prefix}/tag/:tag`} component={TagSidebar} />
|
<Route path={`${prefix}/tag/:tag`} component={TagSidebar} />
|
||||||
<Route path={`${prefix}/~:username`} component={ProfileSidebar} />
|
<Route path={`${prefix}/~:username`} component={ProfileSidebar} />
|
||||||
|
<Route path={`${prefix}/create/:type`} component={NewEditorDialog} />
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|
||||||
<Authorized>
|
<Authorized>
|
||||||
|
|
|
@ -25,6 +25,7 @@ const LabLayout: FC<IProps> = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<div className={styles.blur} />
|
||||||
<Container>
|
<Container>
|
||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
<Group className={styles.content}>
|
<Group className={styles.content}>
|
||||||
|
|
|
@ -24,3 +24,14 @@
|
||||||
margin: 0 $gap $gap 0;
|
margin: 0 $gap $gap 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blur {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: $blue_gradient;
|
||||||
|
z-index: -1;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { IComment, INode, ValueOf } from '../types';
|
import { BlockType, IBlock, IBlockComponentProps, IComment, INode, ValueOf } from '../types';
|
||||||
import { NodeTextBlock } from '~/components/node/NodeTextBlock';
|
import { NodeTextBlock } from '~/components/node/NodeTextBlock';
|
||||||
import { NodeAudioBlock } from '~/components/node/NodeAudioBlock';
|
import { NodeAudioBlock } from '~/components/node/NodeAudioBlock';
|
||||||
import { NodeVideoBlock } from '~/components/node/NodeVideoBlock';
|
import { NodeVideoBlock } from '~/components/node/NodeVideoBlock';
|
||||||
|
@ -20,6 +20,8 @@ import { LabText } from '~/components/lab/LabText';
|
||||||
import { LabImage } from '~/components/lab/LabImage';
|
import { LabImage } from '~/components/lab/LabImage';
|
||||||
import { LabBottomPanel } from '~/components/lab/LabBottomPanel';
|
import { LabBottomPanel } from '~/components/lab/LabBottomPanel';
|
||||||
import { LabPad } from '~/components/lab/LabPad';
|
import { LabPad } from '~/components/lab/LabPad';
|
||||||
|
import { NewEditorBlockVideo } from '~/containers/editors/NewEditorBlockVideo';
|
||||||
|
import { NewEditorBlockText } from '~/containers/editors/NewEditorBlockText';
|
||||||
|
|
||||||
const prefix = 'NODE.';
|
const prefix = 'NODE.';
|
||||||
export const NODE_ACTIONS = {
|
export const NODE_ACTIONS = {
|
||||||
|
@ -126,6 +128,17 @@ export const NODE_EDITORS: Record<
|
||||||
[NODE_TYPES.AUDIO]: AudioEditor,
|
[NODE_TYPES.AUDIO]: AudioEditor,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface NodeEditorBlockConfig {
|
||||||
|
block: BlockType;
|
||||||
|
limit?: number;
|
||||||
|
initial?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NODE_EDITOR_BLOCKS: Record<BlockType, FC<IBlockComponentProps>> = {
|
||||||
|
[BlockType.video]: NewEditorBlockVideo,
|
||||||
|
[BlockType.text]: NewEditorBlockText,
|
||||||
|
};
|
||||||
|
|
||||||
export const NODE_PANEL_COMPONENTS: Record<string, FC<IEditorComponentProps>[]> = {
|
export const NODE_PANEL_COMPONENTS: Record<string, FC<IEditorComponentProps>[]> = {
|
||||||
[NODE_TYPES.TEXT]: [EditorFiller, EditorUploadCoverButton, EditorPublicSwitch],
|
[NODE_TYPES.TEXT]: [EditorFiller, EditorUploadCoverButton, EditorPublicSwitch],
|
||||||
[NODE_TYPES.VIDEO]: [EditorFiller, EditorUploadCoverButton, EditorPublicSwitch],
|
[NODE_TYPES.VIDEO]: [EditorFiller, EditorUploadCoverButton, EditorPublicSwitch],
|
||||||
|
@ -149,7 +162,7 @@ export const NODE_EDITOR_DATA: Record<
|
||||||
Partial<INode>
|
Partial<INode>
|
||||||
> = {
|
> = {
|
||||||
[NODE_TYPES.TEXT]: {
|
[NODE_TYPES.TEXT]: {
|
||||||
blocks: [{ text: '', type: 'text' }],
|
blocks: [{ text: '', type: BlockType.text }],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -97,17 +97,18 @@ export interface IFileWithUUID {
|
||||||
onFail?: () => void;
|
onFail?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IBlockText {
|
export enum BlockType {
|
||||||
type: 'text';
|
text = 'text',
|
||||||
text: string;
|
video = 'video',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IBlockEmbed {
|
export interface IBlock {
|
||||||
type: 'video';
|
type: BlockType;
|
||||||
url: string;
|
text?: string;
|
||||||
|
url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IBlock = IBlockText | IBlockEmbed;
|
export type IBlockComponentProps = { block: IBlock; handler: (val: IBlock) => void };
|
||||||
|
|
||||||
export interface INode {
|
export interface INode {
|
||||||
id?: number;
|
id?: number;
|
||||||
|
|
33
src/utils/hooks/useNodeFormFormik.ts
Normal file
33
src/utils/hooks/useNodeFormFormik.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import { IComment, INode } from '~/redux/types';
|
||||||
|
import { FileUploader } from '~/utils/hooks/fileUploader';
|
||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import { useFormik, useFormikContext } from 'formik';
|
||||||
|
import { object } from 'yup';
|
||||||
|
|
||||||
|
const validationSchema = object().shape({});
|
||||||
|
|
||||||
|
export const useNodeFormFormik = (
|
||||||
|
values: INode,
|
||||||
|
uploader: FileUploader,
|
||||||
|
stopEditing: () => void
|
||||||
|
) => {
|
||||||
|
const onSubmit = useCallback(console.log, []);
|
||||||
|
const { current: initialValues } = useRef(values);
|
||||||
|
|
||||||
|
const onReset = useCallback(() => {
|
||||||
|
uploader.setFiles([]);
|
||||||
|
|
||||||
|
if (stopEditing) stopEditing();
|
||||||
|
}, [uploader, stopEditing]);
|
||||||
|
|
||||||
|
return useFormik<INode>({
|
||||||
|
initialValues,
|
||||||
|
validationSchema,
|
||||||
|
onSubmit,
|
||||||
|
onReset,
|
||||||
|
initialStatus: '',
|
||||||
|
validateOnChange: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useNodeFormContext = () => useFormikContext<INode>();
|
Loading…
Add table
Add a link
Reference in a new issue