From af253c980e69404a1b42ec8e5b80cb657ceb18fc Mon Sep 17 00:00:00 2001 From: MTG2000 Date: Wed, 27 Apr 2022 14:35:49 +0300 Subject: [PATCH] feat: Story form, Post content editor, File Input Component, File thumbnail component, change from css to scss --- .storybook/preview.js | 1 - .../Inputs/FilesInput/FileInput.stories.tsx | 24 ++++ .../Inputs/FilesInput/FileThumbnail.tsx | 75 ++++++++++ .../Inputs/FilesInput/FilesInput.tsx | 104 ++++++++++++++ .../Inputs/FilesInput/FilesThumbnails.tsx | 31 +++++ .../Inputs/TextEditor/SaveModule.tsx | 26 ++++ .../Inputs/TextEditor/TextEditor.stories.tsx | 11 +- .../Inputs/TextEditor/TextEditor.tsx | 13 +- .../Inputs/TextEditor/Toolbar/ToolButton.tsx | 19 ++- src/Components/Inputs/TextEditor/index.tsx | 5 + .../ContentEditor/ContentEditor.tsx | 103 ++++++++++++++ .../Components/ContentEditor/Toolbar.tsx | 34 +++++ .../ContentEditor/styles.module.scss | 18 +++ .../StoryForm/StoryForm.stories.tsx | 20 +++ .../Components/StoryForm/StoryForm.tsx | 131 ++++++++++++++++++ .../pages/CreatePostPage/CreatePostPage.tsx | 9 ++ .../SubmitBountyPlanModal.tsx | 2 +- src/{index.css => index.scss} | 6 +- src/index.tsx | 2 +- src/utils/storybook/decorators.tsx | 2 +- 20 files changed, 616 insertions(+), 20 deletions(-) create mode 100644 src/Components/Inputs/FilesInput/FileInput.stories.tsx create mode 100644 src/Components/Inputs/FilesInput/FileThumbnail.tsx create mode 100644 src/Components/Inputs/FilesInput/FilesInput.tsx create mode 100644 src/Components/Inputs/FilesInput/FilesThumbnails.tsx create mode 100644 src/Components/Inputs/TextEditor/SaveModule.tsx create mode 100644 src/Components/Inputs/TextEditor/index.tsx create mode 100644 src/features/Posts/pages/CreatePostPage/Components/ContentEditor/ContentEditor.tsx create mode 100644 src/features/Posts/pages/CreatePostPage/Components/ContentEditor/Toolbar.tsx create mode 100644 src/features/Posts/pages/CreatePostPage/Components/ContentEditor/styles.module.scss create mode 100644 src/features/Posts/pages/CreatePostPage/Components/StoryForm/StoryForm.stories.tsx create mode 100644 src/features/Posts/pages/CreatePostPage/Components/StoryForm/StoryForm.tsx create mode 100644 src/features/Posts/pages/CreatePostPage/CreatePostPage.tsx rename src/{index.css => index.scss} (94%) diff --git a/.storybook/preview.js b/.storybook/preview.js index 745c2b3..2b855fc 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,4 +1,3 @@ -import "../src/index.css"; import { configure, addDecorator, addParameters } from "@storybook/react"; import { WrapperDecorator, AppDecorator } from 'src/utils/storybook/decorators' diff --git a/src/Components/Inputs/FilesInput/FileInput.stories.tsx b/src/Components/Inputs/FilesInput/FileInput.stories.tsx new file mode 100644 index 0000000..97ad927 --- /dev/null +++ b/src/Components/Inputs/FilesInput/FileInput.stories.tsx @@ -0,0 +1,24 @@ +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import { BsImages } from 'react-icons/bs'; +import Button from 'src/Components/Button/Button'; + +import FilesInput from './FilesInput'; + +export default { + title: 'Shared/Files Input', + component: FilesInput, + +} as ComponentMeta; + +const Template: ComponentStory = (args) => + + +export const Default = Template.bind({}); +Default.args = { +} + +export const CustomButton = Template.bind({}); +CustomButton.args = { + multiple: true, + uploadBtn: +} \ No newline at end of file diff --git a/src/Components/Inputs/FilesInput/FileThumbnail.tsx b/src/Components/Inputs/FilesInput/FileThumbnail.tsx new file mode 100644 index 0000000..b03fb5b --- /dev/null +++ b/src/Components/Inputs/FilesInput/FileThumbnail.tsx @@ -0,0 +1,75 @@ +import { useMemo } from "react"; +import { MdClose } from "react-icons/md"; +import IconButton from "src/Components/IconButton/IconButton"; + +interface Props { + file: File | string, + onRemove?: () => void +} + +function getFileType(file: File | string) { + if (typeof file === 'string') { + if (/^http[^?]*.(jpg|jpeg|gif|png|tiff|bmp)(\?(.*))?$/gmi.test(file)) + return 'image' + if (/\.(pdf|doc|docx)$/.test(file)) + return 'document'; + + return 'unknown' + } + else { + if (file['type'].split('/')[0] === 'image') + return 'image' + + return 'unknown' + } +} + +type ThumbnailFile = { + name: string; + src: string; + type: ReturnType +} + +function processFile(file: Props['file']): ThumbnailFile { + + const fileType = getFileType(file); + + if (typeof file === 'string') return { name: file, src: file, type: fileType }; + + return { + name: file.name, + src: URL.createObjectURL(file), + type: fileType + }; + +} + + +export default function FileThumbnail({ file: f, onRemove }: Props) { + + const file = useMemo(() => processFile(f), [f]) + + return ( +
+ +
+ + + +
+
+ ) +} diff --git a/src/Components/Inputs/FilesInput/FilesInput.tsx b/src/Components/Inputs/FilesInput/FilesInput.tsx new file mode 100644 index 0000000..a71c1f3 --- /dev/null +++ b/src/Components/Inputs/FilesInput/FilesInput.tsx @@ -0,0 +1,104 @@ +import React, { ChangeEvent, useRef } from "react" +import { BsUpload } from "react-icons/bs"; +import Button from "src/Components/Button/Button" +import { UnionToObjectKeys } from "src/utils/types/utils"; +import FilesThumbnails from "./FilesThumbnails"; + + +type Props = { + multiple?: boolean; + value?: File[] | string[] | string; + max?: number; + onBlur?: () => void; + onChange?: (files: (File | string)[] | null) => void + uploadBtn?: JSX.Element + uploadText?: string; + allowedType?: 'images'; +} + +const fileAccept: UnionToObjectKeys = { + images: ".png, .jpg, .jpeg" +} as const; + +const fileUrlToObject = async (url: string, fileName: string = 'filename') => { + const res = await fetch(url); + const contentType = res.headers.get('content-type') as string; + const blob = await res.blob() + const file = new File([blob], fileName, { contentType } as any) + return file +} + +export default function FilesInput({ + multiple, + value, + max = 3, + onBlur, + onChange, + allowedType = 'images', + uploadText = 'Upload files', + ...props +}: Props) { + + const ref = useRef(null!) + + const handleClick = () => { + ref.current.click(); + } + + const handleChange = (e: ChangeEvent) => { + const files = e.target.files && Array.from(e.target.files).slice(0, max); + if (typeof value === 'string') + onChange?.([value, ...(files ?? [])]); + else + onChange?.([...(value ?? []), ...(files ?? [])]); + } + + const handleRemove = async (idx: number) => { + if (!value) return onChange?.([]); + if (typeof value === 'string') + onChange?.([]); + else { + let files = [...value] + files.splice(idx, 1); + + //change all files urls to file objects + const filesConverted = await Promise.all(files.map(async file => { + if (typeof file === 'string') return await fileUrlToObject(file, "") + else return file; + })) + + onChange?.(filesConverted); + } + } + + const canUploadMore = multiple ? + !value || (value && value.length < max) + : + !value || value.length === 0 + + + const uploadBtn = props.uploadBtn ? + React.cloneElement(props.uploadBtn, { onClick: handleClick }) + : + + + return ( + <> + + { + canUploadMore && + <> + {uploadBtn} + + + } + + ) +} diff --git a/src/Components/Inputs/FilesInput/FilesThumbnails.tsx b/src/Components/Inputs/FilesInput/FilesThumbnails.tsx new file mode 100644 index 0000000..72cdcd5 --- /dev/null +++ b/src/Components/Inputs/FilesInput/FilesThumbnails.tsx @@ -0,0 +1,31 @@ +import React, { useMemo } from 'react' +import { MdClose } from 'react-icons/md'; +import IconButton from 'src/Components/IconButton/IconButton'; +import FileThumbnail from './FileThumbnail'; + +interface Props { + files?: (File | string)[] | string; + onRemove?: (idx: number) => void +} + +function processFiles(files: Props['files']) { + + if (!files) return []; + if (typeof files === 'string') return [files]; + return files; +} + +export default function FilesThumbnails({ files, onRemove }: Props) { + const filesConverted = useMemo(() => processFiles(files), [files]) + + return ( +
+ { + filesConverted.map((file, idx) => onRemove?.(idx)} />) + } +
+ ) +} diff --git a/src/Components/Inputs/TextEditor/SaveModule.tsx b/src/Components/Inputs/TextEditor/SaveModule.tsx new file mode 100644 index 0000000..be9633e --- /dev/null +++ b/src/Components/Inputs/TextEditor/SaveModule.tsx @@ -0,0 +1,26 @@ +import React from 'react' + +import { EditorComponent, Remirror, useHelpers, useRemirror, useEvent, useEditorState } from '@remirror/react'; +import { Control, useController } from 'react-hook-form'; + +interface Props { + control?: Control, + name?: string +} + +export default function SaveModule(props: Props) { + + const state = useEditorState() + const { getMarkdown } = useHelpers(); + const { field: { onChange, onBlur } } = useController({ + control: props.control, + name: props.name ?? 'content' + }) + + useEvent('blur', () => { + onChange(getMarkdown(state)); + onBlur() + }) + + return <> +} diff --git a/src/Components/Inputs/TextEditor/TextEditor.stories.tsx b/src/Components/Inputs/TextEditor/TextEditor.stories.tsx index bbea3ff..fe44c41 100644 --- a/src/Components/Inputs/TextEditor/TextEditor.stories.tsx +++ b/src/Components/Inputs/TextEditor/TextEditor.stories.tsx @@ -1,4 +1,5 @@ import { ComponentStory, ComponentMeta } from '@storybook/react'; +import { FormProvider, useForm } from 'react-hook-form'; import TextEditor from './TextEditor'; @@ -8,8 +9,16 @@ export default { } as ComponentMeta; -const Template: ComponentStory = (args) => +const Template: ComponentStory = (args) => { + const methods = useForm(); + + console.log(methods.watch('content')) + + return + + +} export const Default = Template.bind({}); Default.args = { diff --git a/src/Components/Inputs/TextEditor/TextEditor.tsx b/src/Components/Inputs/TextEditor/TextEditor.tsx index 532a63c..d461bb1 100644 --- a/src/Components/Inputs/TextEditor/TextEditor.tsx +++ b/src/Components/Inputs/TextEditor/TextEditor.tsx @@ -25,9 +25,10 @@ import { UnderlineExtension, } from 'remirror/extensions'; import { ExtensionPriority } from 'remirror'; -import { EditorComponent, Remirror, useRemirror } from '@remirror/react'; +import { EditorComponent, Remirror, useHelpers, useRemirror } from '@remirror/react'; import { useCallback, useMemo } from 'react'; import Toolbar from './Toolbar/Toolbar'; +import SaveModule from './SaveModule'; interface Props { @@ -46,6 +47,7 @@ export default function TextEditor({ placeholder, initialContent }: Props) { return extension; }, []); + const extensions = useCallback( () => [ new PlaceholderExtension({ placeholder }), @@ -76,7 +78,7 @@ export default function TextEditor({ placeholder, initialContent }: Props) { */ new HardBreakExtension(), ], - [placeholder], + [linkExtension, placeholder], ); const { manager, } = useRemirror({ @@ -85,7 +87,12 @@ export default function TextEditor({ placeholder, initialContent }: Props) { }); return (
- + + diff --git a/src/Components/Inputs/TextEditor/Toolbar/ToolButton.tsx b/src/Components/Inputs/TextEditor/Toolbar/ToolButton.tsx index 5a60ff5..36e4b26 100644 --- a/src/Components/Inputs/TextEditor/Toolbar/ToolButton.tsx +++ b/src/Components/Inputs/TextEditor/Toolbar/ToolButton.tsx @@ -34,24 +34,21 @@ export default function ToolButton({ cmd: _cmd }: Props) { if (_cmd === 'heading') { return - + `}> + } transition> {Array(6).fill(0).map((_, idx) => { + const extension = new LinkExtension({ autoLink: true }); + extension.addHandler('onClick', (_, data) => { + alert(`You clicked link: ${JSON.stringify(data)}`); + return true; + }); + return extension; + }, []); + + + const extensions = useCallback( + () => [ + new PlaceholderExtension({ placeholder }), + linkExtension, + new BoldExtension(), + // new StrikeExtension(), + new UnderlineExtension(), + new ItalicExtension(), + new HeadingExtension(), + new LinkExtension(), + new BlockquoteExtension(), + new BulletListExtension(), + new OrderedListExtension(), + new ListItemExtension({ priority: ExtensionPriority.High, enableCollapsible: true }), + // new TaskListExtension(), + new CodeExtension(), + new CodeBlockExtension({ + supportedLanguages: [javascript, typescript] + }), + new ImageExtension({ enableResizing: true }), + // new TrailingNodeExtension(), + // new TableExtension(), + new MarkdownExtension({ copyAsMarkdown: false }), + new NodeFormattingExtension(), + /** + * `HardBreakExtension` allows us to create a newline inside paragraphs. + * e.g. in a list item + */ + new HardBreakExtension(), + ], + [linkExtension, placeholder], + ); + + const { manager, } = useRemirror({ + extensions, + stringHandler: 'markdown', + }); + return ( +
+ + + + + +
+ ); +}; + + diff --git a/src/features/Posts/pages/CreatePostPage/Components/ContentEditor/Toolbar.tsx b/src/features/Posts/pages/CreatePostPage/Components/ContentEditor/Toolbar.tsx new file mode 100644 index 0000000..e6d693f --- /dev/null +++ b/src/features/Posts/pages/CreatePostPage/Components/ContentEditor/Toolbar.tsx @@ -0,0 +1,34 @@ + +import TextEditorComponents from 'src/Components/Inputs/TextEditor'; + +interface Props { +} + +export default function Toolbar() { + + return ( +
+
+ + + + + +
+
+ + + + + +
+ + +
+ + +
+ +
+ ) +} diff --git a/src/features/Posts/pages/CreatePostPage/Components/ContentEditor/styles.module.scss b/src/features/Posts/pages/CreatePostPage/Components/ContentEditor/styles.module.scss new file mode 100644 index 0000000..99a7830 --- /dev/null +++ b/src/features/Posts/pages/CreatePostPage/Components/ContentEditor/styles.module.scss @@ -0,0 +1,18 @@ +.wrapper { + + :global{ + + .ProseMirror { + overflow: hidden; + border-bottom-left-radius: inherit; + border-bottom-right-radius: inherit; + } + + .ProseMirror, + .ProseMirror:active, + .ProseMirror:focus{ + box-shadow: none; + } + } + +} \ No newline at end of file diff --git a/src/features/Posts/pages/CreatePostPage/Components/StoryForm/StoryForm.stories.tsx b/src/features/Posts/pages/CreatePostPage/Components/StoryForm/StoryForm.stories.tsx new file mode 100644 index 0000000..53770fb --- /dev/null +++ b/src/features/Posts/pages/CreatePostPage/Components/StoryForm/StoryForm.stories.tsx @@ -0,0 +1,20 @@ +import { ComponentStory, ComponentMeta } from '@storybook/react'; + +import StoryForm from './StoryForm'; + +export default { + title: 'Posts/Create Post Page/Story Form', + component: StoryForm, + argTypes: { + backgroundColor: { control: 'color' }, + }, +} as ComponentMeta; + + +const Template: ComponentStory = (args) => + +export const Default = Template.bind({}); +Default.args = { +} + + diff --git a/src/features/Posts/pages/CreatePostPage/Components/StoryForm/StoryForm.tsx b/src/features/Posts/pages/CreatePostPage/Components/StoryForm/StoryForm.tsx new file mode 100644 index 0000000..3bea8c1 --- /dev/null +++ b/src/features/Posts/pages/CreatePostPage/Components/StoryForm/StoryForm.tsx @@ -0,0 +1,131 @@ +import { yupResolver } from "@hookform/resolvers/yup"; +import { Controller, FormProvider, NestedValue, Resolver, SubmitHandler, useForm } from "react-hook-form"; +import Button from "src/Components/Button/Button"; +import FilesInput from "src/Components/Inputs/FilesInput/FilesInput"; +import * as yup from "yup"; +import ContentEditor from "../ContentEditor/ContentEditor"; + + +const schema = yup.object({ + title: yup.string().required().min(10), + tags: yup.string().required().min(10), + body: yup.string().required().min(50, 'you have to write at least 10 words'), + cover_image: yup.lazy((value: string | File[]) => { + switch (typeof value) { + case 'object': + return yup.array() + .test("fileSize", "File Size is too large", (files) => (files as File[]).every(file => file.size <= 5242880)) + .test("fileType", "Unsupported File Format, only png/jpg/jpeg images are allowed", + (files) => (files as File[]).every((file: File) => + ["image/jpeg", "image/png", "image/jpg"].includes(file.type))) + case 'string': + return yup.string().url(); + default: + return yup.mixed() + } + }) +}).required(); + +interface IFormInputs { + title: string + tags: string + cover_image: NestedValue | string + body: string +} + + + +export default function StoryForm() { + + + const formMethods = useForm({ + resolver: yupResolver(schema) as Resolver, + defaultValues: { + body: '', + cover_image: 'https://i.picsum.photos/id/10/1600/900.jpg?hmac=9R7fIkKwC5JxHx8ayZAKNMt6FvJXqKKyiv8MClikgDo' + } + + }); + const { handleSubmit, control, register, formState: { isValid, errors }, watch, } = formMethods; + + console.log(errors); + + const onSubmit: SubmitHandler = data => console.log(data); + + return ( + +
+
+
+ + + ( + + )} + /> +

{errors.cover_image?.message}

+ + +

+ Title +

+
+ +
+ {errors.title &&

+ {errors.title.message} +

} + + +

+ Tags +

+
+ +
+ {errors.tags &&

+ {errors.tags.message} +

} +
+ + + {errors.body &&

+ {errors.body.message} +

} +
+
+ + +
+
+
+ ) +} diff --git a/src/features/Posts/pages/CreatePostPage/CreatePostPage.tsx b/src/features/Posts/pages/CreatePostPage/CreatePostPage.tsx new file mode 100644 index 0000000..ff6fda8 --- /dev/null +++ b/src/features/Posts/pages/CreatePostPage/CreatePostPage.tsx @@ -0,0 +1,9 @@ +interface Props { + +} + +export default function CreatePostPage() { + return ( +
CreatePostPage
+ ) +} diff --git a/src/features/Posts/pages/PostDetailsPage/Components/SubmitBountyPlanModal/SubmitBountyPlanModal.tsx b/src/features/Posts/pages/PostDetailsPage/Components/SubmitBountyPlanModal/SubmitBountyPlanModal.tsx index a8f6e81..1e3c29f 100644 --- a/src/features/Posts/pages/PostDetailsPage/Components/SubmitBountyPlanModal/SubmitBountyPlanModal.tsx +++ b/src/features/Posts/pages/PostDetailsPage/Components/SubmitBountyPlanModal/SubmitBountyPlanModal.tsx @@ -54,7 +54,7 @@ export default function Login_SuccessCard({ onClose, direction, ...props }: Moda rows={6} className="input-text !p-20" placeholder='What steps will you take to complete this task? ' - {...register("workplan", { required: true, minLength: 20 })} + {...register("workplan")} />
diff --git a/src/index.css b/src/index.scss similarity index 94% rename from src/index.css rename to src/index.scss index 04a2631..6c7e3c4 100644 --- a/src/index.css +++ b/src/index.scss @@ -22,7 +22,7 @@ } .input-text { - @apply flex-grow border-none focus:border-0 focus:!ring-0 bg-transparent min-w-0 text-primary-500; + @apply flex-grow border-none focus:border-0 focus:ring-0 bg-transparent min-w-0 text-primary-500; } .input-checkbox { @@ -34,6 +34,10 @@ @apply h-full text-primary-500 flex-shrink-0 w-42 px-12 self-center; } + .input-error{ + @apply text-body6 text-red-500 mt-4; + } + .chip { @apply bg-gray-100 text-body4 px-16 py-8 rounded-24 font-regular; } diff --git a/src/index.tsx b/src/index.tsx index bf7dfc6..60e6ec1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,7 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import Wrapper from './utils/Wrapper'; -import './index.css'; +import './index.scss'; import App from './App'; diff --git a/src/utils/storybook/decorators.tsx b/src/utils/storybook/decorators.tsx index 3185791..c341385 100644 --- a/src/utils/storybook/decorators.tsx +++ b/src/utils/storybook/decorators.tsx @@ -11,7 +11,7 @@ import { AnimatePresence, motion } from 'framer-motion'; // Add the global stuff first (index.ts) // ------------------------------------------- - +import "src/index.scss"; import "react-multi-carousel/lib/styles.css"; import 'react-loading-skeleton/dist/skeleton.css' import { ApolloProvider } from '@apollo/client';