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 { INode } from '~/redux/types';
|
||||
import { BlockType } from '~/redux/types';
|
||||
import styles from './styles.module.scss';
|
||||
import { Textarea } from '~/components/input/Textarea';
|
||||
import { path } from 'ramda';
|
||||
|
@ -9,7 +9,7 @@ type IProps = NodeEditorProps & {};
|
|||
|
||||
const TextEditor: FC<IProps> = ({ data, setData }) => {
|
||||
const setText = useCallback(
|
||||
(text: string) => setData({ ...data, blocks: [{ type: 'text', text }] }),
|
||||
(text: string) => setData({ ...data, blocks: [{ type: BlockType.text, text }] }),
|
||||
[data, setData]
|
||||
);
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React, { FC, useCallback, useMemo } from 'react';
|
||||
import { INode } from '~/redux/types';
|
||||
import { BlockType } from '~/redux/types';
|
||||
import styles from './styles.module.scss';
|
||||
import { path } from 'ramda';
|
||||
import { InputText } from '~/components/input/InputText';
|
||||
|
@ -11,7 +11,7 @@ type IProps = NodeEditorProps & {};
|
|||
|
||||
const VideoEditor: FC<IProps> = ({ data, setData }) => {
|
||||
const setUrl = useCallback(
|
||||
(url: string) => setData({ ...data, blocks: [{ type: 'video', url }] }),
|
||||
(url: string) => setData({ ...data, blocks: [{ type: BlockType.video, url }] }),
|
||||
[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 { Authorized } from '~/components/containers/Authorized';
|
||||
import { SubmitBar } from '~/components/bars/SubmitBar';
|
||||
import { NewEditorDialog } from '~/containers/dialogs/NewEditorDialog';
|
||||
|
||||
interface IProps {
|
||||
prefix?: string;
|
||||
|
@ -17,6 +18,7 @@ const SidebarRouter: FC<IProps> = ({ prefix = '', isLab }) => {
|
|||
<Switch>
|
||||
<Route path={`${prefix}/tag/:tag`} component={TagSidebar} />
|
||||
<Route path={`${prefix}/~:username`} component={ProfileSidebar} />
|
||||
<Route path={`${prefix}/create/:type`} component={NewEditorDialog} />
|
||||
</Switch>
|
||||
|
||||
<Authorized>
|
||||
|
|
|
@ -25,6 +25,7 @@ const LabLayout: FC<IProps> = () => {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.blur} />
|
||||
<Container>
|
||||
<div className={styles.wrap}>
|
||||
<Group className={styles.content}>
|
||||
|
|
|
@ -24,3 +24,14 @@
|
|||
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 { IComment, INode, ValueOf } from '../types';
|
||||
import { BlockType, IBlock, IBlockComponentProps, IComment, INode, ValueOf } from '../types';
|
||||
import { NodeTextBlock } from '~/components/node/NodeTextBlock';
|
||||
import { NodeAudioBlock } from '~/components/node/NodeAudioBlock';
|
||||
import { NodeVideoBlock } from '~/components/node/NodeVideoBlock';
|
||||
|
@ -20,6 +20,8 @@ import { LabText } from '~/components/lab/LabText';
|
|||
import { LabImage } from '~/components/lab/LabImage';
|
||||
import { LabBottomPanel } from '~/components/lab/LabBottomPanel';
|
||||
import { LabPad } from '~/components/lab/LabPad';
|
||||
import { NewEditorBlockVideo } from '~/containers/editors/NewEditorBlockVideo';
|
||||
import { NewEditorBlockText } from '~/containers/editors/NewEditorBlockText';
|
||||
|
||||
const prefix = 'NODE.';
|
||||
export const NODE_ACTIONS = {
|
||||
|
@ -126,6 +128,17 @@ export const NODE_EDITORS: Record<
|
|||
[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>[]> = {
|
||||
[NODE_TYPES.TEXT]: [EditorFiller, EditorUploadCoverButton, EditorPublicSwitch],
|
||||
[NODE_TYPES.VIDEO]: [EditorFiller, EditorUploadCoverButton, EditorPublicSwitch],
|
||||
|
@ -149,7 +162,7 @@ export const NODE_EDITOR_DATA: Record<
|
|||
Partial<INode>
|
||||
> = {
|
||||
[NODE_TYPES.TEXT]: {
|
||||
blocks: [{ text: '', type: 'text' }],
|
||||
blocks: [{ text: '', type: BlockType.text }],
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -97,17 +97,18 @@ export interface IFileWithUUID {
|
|||
onFail?: () => void;
|
||||
}
|
||||
|
||||
export interface IBlockText {
|
||||
type: 'text';
|
||||
text: string;
|
||||
export enum BlockType {
|
||||
text = 'text',
|
||||
video = 'video',
|
||||
}
|
||||
|
||||
export interface IBlockEmbed {
|
||||
type: 'video';
|
||||
url: string;
|
||||
export interface IBlock {
|
||||
type: BlockType;
|
||||
text?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export type IBlock = IBlockText | IBlockEmbed;
|
||||
export type IBlockComponentProps = { block: IBlock; handler: (val: IBlock) => void };
|
||||
|
||||
export interface INode {
|
||||
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