feat: Story form, Post content editor, File Input Component, File thumbnail component, change from css to scss

This commit is contained in:
MTG2000
2022-04-27 14:35:49 +03:00
parent 4957dad00b
commit af253c980e
20 changed files with 616 additions and 20 deletions

View File

@@ -1,4 +1,3 @@
import "../src/index.css";
import { configure, addDecorator, addParameters } from "@storybook/react";
import { WrapperDecorator, AppDecorator } from 'src/utils/storybook/decorators'

View File

@@ -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<typeof FilesInput>;
const Template: ComponentStory<typeof FilesInput> = (args) => <FilesInput {...args} />
export const Default = Template.bind({});
Default.args = {
}
export const CustomButton = Template.bind({});
CustomButton.args = {
multiple: true,
uploadBtn: <Button color='primary'><span className="align-middle">Drop Images</span> <BsImages className='ml-12 scale-125' /></Button>
}

View File

@@ -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<typeof getFileType>
}
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 (
<div className="bg-gray-100 rounded-8 p-12 shrink-0 flex gap-4 overflow-hidden">
<div className="w-[100px]">
<p className="text-body6 overflow-hidden overflow-ellipsis whitespace-nowrap">
{file.name}
</p>
<a
href={file.src}
target='_blank'
rel="noreferrer"
>
{
file.type === 'image' && <img src={file.src} alt={file.name} className="p-4 w-3/4 mx-auto max-h-full object-contain" />
}
</a>
</div>
<div className="w-32 shrink-0 self-start" >
<IconButton size="sm" className="hover:bg-gray-500" onClick={onRemove}>
<MdClose />
</IconButton>
</div>
</div>
)
}

View File

@@ -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<Props, 'allowedType'> = {
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<HTMLInputElement>(null!)
const handleClick = () => {
ref.current.click();
}
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
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 })
:
<Button type='button' onClick={handleClick} ><span className="align-middle">{uploadText}</span> <BsUpload className="ml-12 scale-125" /></Button>
return (
<>
<FilesThumbnails files={value} onRemove={handleRemove} />
{
canUploadMore &&
<>
{uploadBtn}
<input
ref={ref}
type="file"
onBlur={onBlur}
style={{ display: 'none' }}
multiple={multiple}
accept={fileAccept[allowedType]}
onChange={handleChange} />
</>
}
</>
)
}

View File

@@ -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 (
<div className="flex gap-12 mb-12">
{
filesConverted.map((file, idx) => <FileThumbnail
key={idx}
file={file}
onRemove={() => onRemove?.(idx)} />)
}
</div>
)
}

View File

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

View File

@@ -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<typeof TextEditor>;
const Template: ComponentStory<typeof TextEditor> = (args) => <TextEditor {...args as any} />
const Template: ComponentStory<typeof TextEditor> = (args) => {
const methods = useForm();
console.log(methods.watch('content'))
return <FormProvider {...methods}>
<TextEditor {...args} />
</FormProvider>
}
export const Default = Template.bind({});
Default.args = {

View File

@@ -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 (
<div className={`remirror-theme ${styles.wrapper} bg-white shadow-md`}>
<Remirror manager={manager} initialContent={initialContent}>
<Remirror
manager={manager}
initialContent={initialContent}
>
<SaveModule />
<Toolbar />
<EditorComponent />
</Remirror>

View File

@@ -34,24 +34,21 @@ export default function ToolButton({ cmd: _cmd }: Props) {
if (_cmd === 'heading') {
return <Menu menuButton={
<MenuButton>
<button
className={`
<MenuButton className={`
w-36 h-36 flex justify-center items-center
${active.heading({}) ?
'font-bold bg-gray-300 text-black'
:
'hover:bg-gray-200'
}
'font-bold bg-gray-300 text-black'
:
'hover:bg-gray-200'
}
${!commands.toggleHeading.enabled() && 'opacity-40 text-gray-600 pointer-events-none'}
`}
>
<FiType />
</button>
`}>
<FiType />
</MenuButton>
} transition>
{Array(6).fill(0).map((_, idx) => <MenuItem
key={idx}
className={`
py-8 px-16 hover:bg-gray-200
${active.heading({ level: idx + 1 }) && 'font-bold bg-gray-200'}

View File

@@ -0,0 +1,5 @@
import SaveModule from "./SaveModule";
import ToolButton from "./Toolbar/ToolButton";
const TextEditorComponents = { SaveModule, ToolButton };
export default TextEditorComponents;

View File

@@ -0,0 +1,103 @@
import 'remirror/styles/all.css';
import styles from './styles.module.scss'
import javascript from 'refractor/lang/javascript';
import typescript from 'refractor/lang/typescript';
import {
BlockquoteExtension,
BoldExtension,
BulletListExtension,
CodeBlockExtension,
CodeExtension,
HardBreakExtension,
HeadingExtension,
ImageExtension,
ItalicExtension,
LinkExtension,
ListItemExtension,
MarkdownExtension,
NodeFormattingExtension,
OrderedListExtension,
PlaceholderExtension,
StrikeExtension,
TableExtension,
TrailingNodeExtension,
UnderlineExtension,
} from 'remirror/extensions';
import { ExtensionPriority } from 'remirror';
import { EditorComponent, Remirror, useRemirror } from '@remirror/react';
import { useCallback, useMemo } from 'react';
import TextEditorComponents from 'src/Components/Inputs/TextEditor';
import Toolbar from './Toolbar';
interface Props {
placeholder?: string;
initialContent?: string;
name?: string;
}
export default function ContentEditor({ placeholder, initialContent, name }: Props) {
const linkExtension = useMemo(() => {
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 (
<div className={`remirror-theme ${styles.wrapper} bg-white`}>
<Remirror
manager={manager}
initialContent={initialContent}
>
<TextEditorComponents.SaveModule name={name} />
<Toolbar />
<EditorComponent />
</Remirror>
</div>
);
};

View File

@@ -0,0 +1,34 @@
import TextEditorComponents from 'src/Components/Inputs/TextEditor';
interface Props {
}
export default function Toolbar() {
return (
<div className='flex gap-36 bg-gray-100'>
<div className="flex">
<TextEditorComponents.ToolButton cmd='heading' />
<TextEditorComponents.ToolButton cmd='bold' />
<TextEditorComponents.ToolButton cmd='italic' />
<TextEditorComponents.ToolButton cmd='underline' />
<TextEditorComponents.ToolButton cmd='code' />
</div>
<div className="flex">
<TextEditorComponents.ToolButton cmd='leftAlign' />
<TextEditorComponents.ToolButton cmd='centerAlign' />
<TextEditorComponents.ToolButton cmd='rightAlign' />
<TextEditorComponents.ToolButton cmd='bulletList' />
<TextEditorComponents.ToolButton cmd='orderedList' />
</div>
<div className="flex ml-auto">
<TextEditorComponents.ToolButton cmd='undo' />
<TextEditorComponents.ToolButton cmd='redo' />
</div>
</div>
)
}

View File

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

View File

@@ -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<typeof StoryForm>;
const Template: ComponentStory<typeof StoryForm> = (args) => <StoryForm {...args as any} ></StoryForm>
export const Default = Template.bind({});
Default.args = {
}

View File

@@ -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<File[]> | string
body: string
}
export default function StoryForm() {
const formMethods = useForm<IFormInputs>({
resolver: yupResolver(schema) as Resolver<IFormInputs>,
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<IFormInputs> = data => console.log(data);
return (
<FormProvider {...formMethods}>
<form
onSubmit={handleSubmit(onSubmit)}
>
<div
className='bg-white shadow-lg rounded-8 overflow-hidden'>
<div className="p-32">
<Controller
control={control}
name="cover_image"
render={({ field: { onChange, value, onBlur } }) => (
<FilesInput
value={value}
onBlur={onBlur}
onChange={onChange}
uploadText='Add a cover image'
/>
)}
/>
<p className='input-error'>{errors.cover_image?.message}</p>
<p className="text-body5 mt-16">
Title
</p>
<div className="input-wrapper mt-8 relative">
<input
type='text'
className="input-text"
placeholder='Your Story Title'
{...register("title")}
/>
</div>
{errors.title && <p className="input-error">
{errors.title.message}
</p>}
<p className="text-body5 mt-16">
Tags
</p>
<div className="input-wrapper mt-8 relative">
<input
type='text'
className="input-text"
placeholder='WebLN, Design, ...'
{...register("tags")}
/>
</div>
{errors.tags && <p className="input-error">
{errors.tags.message}
</p>}
</div>
<ContentEditor
placeholder="Write your story content here..."
name="body"
/>
{errors.body && <p className="input-error py-8 px-16">
{errors.body.message}
</p>}
</div>
<div className="flex gap-16 mt-32">
<Button type='submit' color="primary">
Preview & Publish
</Button>
<Button color="gray">
Save Draft
</Button>
</div>
</form>
</FormProvider >
)
}

View File

@@ -0,0 +1,9 @@
interface Props {
}
export default function CreatePostPage() {
return (
<div>CreatePostPage</div>
)
}

View File

@@ -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")}
/>
</div>
<div className="grid sm:grid-cols-2 gap-24 mt-16">

View File

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

View File

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

View File

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