feat: build a new preview component, change the story form errors structure and display, store current draft in storage

Issues #67 #66
This commit is contained in:
MTG2000
2022-07-12 13:17:43 +03:00
parent 216133f893
commit 0b0e1b5bb2
10 changed files with 157 additions and 179 deletions

View File

@@ -18,7 +18,6 @@ import { Loadable } from "./utils/routing";
const FeedPage = Loadable(React.lazy(() => import("./features/Posts/pages/FeedPage/FeedPage")))
const PostDetailsPage = Loadable(React.lazy(() => import("./features/Posts/pages/PostDetailsPage/PostDetailsPage")))
const CreatePostPage = Loadable(React.lazy(() => import("./features/Posts/pages/CreatePostPage/CreatePostPage")))
const PreviewPostPage = Loadable(React.lazy(() => import("./features/Posts/pages/PreviewPostPage/PreviewPostPage")))
const HottestPage = Loadable(React.lazy(() => import("src/features/Projects/pages/HottestPage/HottestPage")))
const CategoryPage = Loadable(React.lazy(() => import("src/features/Projects/pages/CategoryPage/CategoryPage")))
@@ -84,7 +83,6 @@ function App() {
</Helmet>
<Suspense fallback={<LoadingPage />}>
<Routes>
<Route path="/blog/preview-post/:type" element={<PreviewPostPage />} />
<Route path="/blog/create-post" element={<ProtectedRoute><CreatePostPage /></ProtectedRoute>} />
<Route element={<NavbarLayout />}>

View File

@@ -21,8 +21,6 @@ import { createRoute } from "src/utils/routing";
export default function NavDesktop() {
const [searchOpen, setSearchOpen] = useState(false)
const communityRef = useRef(null);
const [communitymenuProps, toggleCommunityMenu] = useMenuState({ transition: true });

View File

@@ -1,18 +1,18 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { MOCK_DATA } from 'src/mocks/data';
import PreviewPostContent from './PreviewPostContent';
import PreviewPostCard from './PreviewPostCard';
export default {
title: 'Posts/Post Details Page/Components/Story Page Content',
component: PreviewPostContent,
component: PreviewPostCard,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof PreviewPostContent>;
} as ComponentMeta<typeof PreviewPostCard>;
const Template: ComponentStory<typeof PreviewPostContent> = (args) => <div className="max-w-[890px]"><PreviewPostContent {...args as any} ></PreviewPostContent></div>
const Template: ComponentStory<typeof PreviewPostCard> = (args) => <div className="max-w-[890px]"><PreviewPostCard {...args as any} ></PreviewPostCard></div>
export const Default = Template.bind({});
Default.args = {

View File

@@ -1,23 +1,25 @@
import Header from "src/features/Posts/Components/PostCard/Header/Header"
import { marked } from 'marked';
import styles from '../../PostDetailsPage/Components/PageContent/styles.module.scss'
import styles from 'src/features/Posts/pages/PostDetailsPage/Components/PageContent/styles.module.scss'
import Badge from "src/Components/Badge/Badge";
import { Post } from "src/graphql";
function isPost(type?: string): type is 'story' {
return type === 'story'
// || type === 'question' || type === 'bounty'
}
interface Props {
post: Pick<Post,
| 'title'
| 'createdAt'
| 'body'
| 'author'
> & {
tags: Array<{ title: string }>
cover_image?: string | File | null
}
}
export default function PreviewPostContent({ post }: Props) {
export default function PreviewPostContent({ post, }: Props) {
let coverImg: string;
if (!post.cover_image)
@@ -36,7 +38,6 @@ export default function PreviewPostContent({ post }: Props) {
className='w-full h-[120px] md:h-[240px] object-cover rounded-12 mb-16'
alt="" />}
<div className="flex flex-col gap-24">
<Header size="lg" showTimeAgo={false} author={post.author} date={post.createdAt} />
<h1 className="text-[42px] font-bolder">{post.title}</h1>
{post.tags.length > 0 && <div className="flex gap-8">
{post.tags.map((tag, idx) => <Badge key={idx} size='sm'>

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { yupResolver } from "@hookform/resolvers/yup";
import { Controller, FormProvider, NestedValue, Resolver, SubmitHandler, useForm } from "react-hook-form";
import { Controller, FormProvider, NestedValue, Resolver, useForm } from "react-hook-form";
import Button from "src/Components/Button/Button";
import FilesInput from "src/Components/Inputs/FilesInput/FilesInput";
import TagsInput from "src/Components/Inputs/TagsInput/TagsInput";
@@ -13,6 +13,9 @@ import { stageStory } from 'src/redux/features/staging.slice'
import { Override } from 'src/utils/interfaces';
import { NotificationsService } from "src/services/notifications.service";
import { createRoute } from 'src/utils/routing';
import PreviewPostCard from '../PreviewPostCard/PreviewPostCard'
import { StorageService } from 'src/services';
import { useThrottledCallback } from '@react-hookz/web';
const FileSchema = yup.lazy((value: string | File[]) => {
@@ -54,16 +57,25 @@ export type CreateStoryType = Override<IFormInputs, {
cover_image: File[] | string[]
}>
const storageService = new StorageService<CreateStoryType>('story-edit');
export default function StoryForm() {
const dispatch = useAppDispatch();
const { story } = useAppSelector(state => ({
story: state.staging.story
story: state.staging.story || storageService.get()
}))
const [editMode, setEditMode] = useState(true)
const navigate = useNavigate();
const errorsContainerRef = useRef<HTMLDivElement>(null!);
const formMethods = useForm<IFormInputs>({
resolver: yupResolver(schema) as Resolver<IFormInputs>,
shouldFocusError: false,
defaultValues: {
id: story?.id ?? null,
title: story?.title ?? '',
@@ -72,11 +84,19 @@ export default function StoryForm() {
body: story?.body ?? '',
},
});
const { handleSubmit, control, register, formState: { errors, }, trigger, getValues, } = formMethods;
const [loading, setLoading] = useState(false)
const { handleSubmit, control, register, formState: { errors, isValid, isSubmitted }, trigger, getValues, watch } = formMethods;
const navigate = useNavigate()
const presistPost = useThrottledCallback((value) => storageService.set(value), [], 1000)
useEffect(() => {
const subscription = watch((value) => presistPost(value));
return () => subscription.unsubscribe();
}, [presistPost, watch]);
const [loading, setLoading] = useState(false);
const [createStory] = useCreateStoryMutation({
onCompleted: (data) => {
navigate(createRoute({ type: 'story', id: data.createStory?.id!, title: data.createStory?.title }))
@@ -89,13 +109,15 @@ export default function StoryForm() {
}
});
const clickPreview = async () => {
const isValid = await trigger();
if (isValid) {
const data = getValues()
dispatch(stageStory(data))
navigate('/blog/preview-post/Story')
storageService.set(data)
setEditMode(false);
} else {
clickSubmit(); // I'm doing this so that the react-hook-form attaches onChange listener to inputs validation
}
@@ -114,86 +136,107 @@ export default function StoryForm() {
},
}
})
})
}, () => errorsContainerRef.current.scrollIntoView({ behavior: 'smooth', block: "center" }))
const isUpdating = story?.id;
return (
<FormProvider {...formMethods}>
<form
onSubmit={clickSubmit}
>
<div
className='bg-white border-2 border-gray-200 rounded-16 overflow-hidden'>
<div className="p-32">
<Controller
control={control}
name="cover_image"
render={({ field: { onChange, value, onBlur, ref } }) => (
<FilesInput
ref={ref}
value={value}
onBlur={onBlur}
onChange={onChange}
uploadText='Add a cover image'
/>
)}
/>
<p className='input-error'>{errors.cover_image?.message}</p>
<div className="mt-16 relative">
<input
autoFocus
type='text'
className="p-0 text-[42px] border-0 focus:border-0 focus:outline-none focus:ring-0 font-bolder placeholder:!text-gray-600"
placeholder='New story title here...'
{...register("title")}
/>
</div>
{errors.title && <p className="input-error">
{errors.title.message}
</p>}
<TagsInput
placeholder="Add up to 5 popular tags..."
classes={{ container: 'mt-16' }}
/>
{errors.tags && <p className="input-error">
{errors.tags.message}
</p>}
<div className="grid gap-24 grid-cols-1 xl:grid-cols-[1fr_min(326px,25%)]">
<form
className='order-2 xl:order-1'
onSubmit={clickSubmit}
>
<div className="flex gap-16 mb-24">
<button type='button' className={`rounded-8 px-16 py-8 ${editMode ? 'bg-primary-100 text-primary-700' : "text-gray-500"} active:scale-95 transition-transform`} onClick={() => setEditMode(true)}>Edit</button>
<button type='button' className={`rounded-8 px-16 py-8 ${!editMode ? 'bg-primary-100 text-primary-700' : "text-gray-500"} active:scale-95 transition-transform`} onClick={clickPreview}>Preview</button>
</div>
<ContentEditor
initialContent={story?.body}
placeholder="Write your story content here..."
name="body"
/>
{editMode && <>
<div
className='bg-white border-2 border-gray-200 rounded-16 overflow-hidden'>
<div className="p-32">
<Controller
control={control}
name="cover_image"
render={({ field: { onChange, value, onBlur, ref } }) => (
<FilesInput
ref={ref}
value={value}
onBlur={onBlur}
onChange={onChange}
uploadText='Add a cover image'
/>
)}
/>
{errors.body && <p className="input-error py-8 px-16">
{errors.body.message}
</p>}
<div className="mt-16 relative">
<input
autoFocus
type='text'
className="p-0 text-[42px] border-0 focus:border-0 focus:outline-none focus:ring-0 font-bolder placeholder:!text-gray-400"
placeholder='New story title here...'
{...register("title")}
/>
</div>
<TagsInput
placeholder="Add up to 5 popular tags..."
classes={{ container: 'mt-16' }}
/>
</div>
<ContentEditor
initialContent={story?.body}
placeholder="Write your story content here..."
name="body"
/>
</div>
</>}
{!editMode && <PreviewPostCard post={{ ...getValues(), cover_image: getValues().cover_image[0] }} />}
<div className="flex gap-16 mt-32">
<Button
type='submit'
color="primary"
disabled={loading}
>
{isUpdating ?
loading ? "Updating..." : "Update" :
loading ? "Publishing..." : "Publish"
}
</Button>
{/* <Button
color="gray"
// onClick={clickPreview}
>
Save Draft
</Button> */}
</div>
</form>
<div className="order-1 xl:sticky top-32 self-start">
<div ref={errorsContainerRef}>
{(!isValid && isSubmitted) && <ul className='bg-red-50 p-8 pl-24 border-l-4 rounded-8 border-red-600 list-disc text-body4 text-medium'>
{errors.title && <li className="input-error text-body5 text-medium">
{errors.title.message}
</li>}
{errors.cover_image && <li className="input-error text-body5 text-medium">
{errors.cover_image.message}
</li>}
{errors.tags && <li className="input-error text-body5 text-medium">
{errors.tags.message}
</li>}
{errors.body && <li className="input-error text-body5 text-medium">
{errors.body.message}
</li>}
</ul>}
</div>
</div>
<div className="flex gap-16 mt-32">
<Button
type='submit'
color="primary"
disabled={loading}
>
{isUpdating ?
loading ? "Updating..." : "Update" :
loading ? "Publishing..." : "Publish"
}
</Button>
<Button
color="gray"
onClick={clickPreview}
>
Preview
</Button>
</div>
</form>
</div>
</FormProvider >
)
}

View File

@@ -2,11 +2,9 @@ import { useState } from "react";
import { Helmet } from "react-helmet";
import { FiArrowLeft } from "react-icons/fi";
import { useNavigate, useParams } from "react-router-dom";
import { usePreload } from "src/utils/hooks";
import BountyForm from "./Components/BountyForm/BountyForm";
import QuestionForm from "./Components/QuestionForm/QuestionForm";
import StoryForm from "./Components/StoryForm/StoryForm";
import PostTypeList from "./PostTypeList";
interface Props {
@@ -18,7 +16,6 @@ export default function CreatePostPage() {
const [postType, setPostType] = useState<'story' | 'bounty' | 'question'>((type as any) ?? 'story');
usePreload('PreviewPostPage');
const navigate = useNavigate();
@@ -29,7 +26,7 @@ export default function CreatePostPage() {
{postType === 'question' && <title>Create Question</title>}
</Helmet>
<div
className="page-container grid gap-24 grid-cols-1 lg:grid-cols-[1fr_min(100%,910px)_1fr]"
className="page-container grid gap-24 grid-cols-1 lg:grid-cols-[1fr_4fr]"
// style={{ gridTemplateColumns: "326px 1fr" }}
>
<div className="">
@@ -42,9 +39,7 @@ export default function CreatePostPage() {
<FiArrowLeft className={"text-body3"} />
</button>
</div>
<div style={{
width: "min(100%,910px)"
}}>
<div >
{postType === 'story' && <>
{/* <h2 className="text-h2 font-bolder text-gray-800 mb-32">
Write a Story

View File

@@ -1,74 +0,0 @@
import { Helmet } from 'react-helmet'
import { useParams } from 'react-router-dom'
import NotFoundPage from 'src/features/Shared/pages/NotFoundPage/NotFoundPage'
import { useAppSelector, } from 'src/utils/hooks'
import TrendingCard from '../../Components/TrendingCard/TrendingCard'
import AuthorCard from '../PostDetailsPage/Components/AuthorCard/AuthorCard'
import PostActions from '../PostDetailsPage/Components/PostActions/PostActions'
import styles from '../PostDetailsPage/styles.module.scss';
import PreviewPostContent from './PreviewPostContent/PreviewPostContent'
function isPost(type?: string): type is 'story' {
return type === 'story'
// || type === 'question' || type === 'bounty'
}
export default function PreviewPostPage() {
const { type: _type } = useParams()
const type = _type?.toLowerCase();
const { post, author, navHeight } = useAppSelector(state => ({
post: isPost(type) ? state.staging[type] : null,
author: state.user.me,
navHeight: state.ui.navHeight
}))
if (!post)
return <NotFoundPage />
return (
<>
<Helmet>
<title>{post.title}</title>
<meta property="og:title" content={post.title} />
</Helmet>
<div
className={`page-container grid pt-16 w-full gap-32 ${styles.grid}`}
>
<aside id='actions' className='no-scrollbar'>
<div className="sticky"
style={{
top: `${navHeight + 16}px`,
maxHeight: `calc(100vh - ${navHeight}px - 16px)`,
}}>
<PostActions
post={{
id: 123,
votes_count: 123
}}
isPreview
/>
</div>
</aside>
<PreviewPostContent post={{ ...post, createdAt: new Date().toISOString(), author: author!, cover_image: post.cover_image[0] }} />
<aside id='author' className='no-scrollbar min-w-0'>
<div className="flex flex-col gap-24"
style={{
top: `${navHeight + 16}px`,
maxHeight: `calc(100vh - ${navHeight}px - 16px)`,
overflowY: "scroll",
}}>
<AuthorCard author={author!} />
<TrendingCard />
</div>
</aside>
</div>
</>
)
}

View File

@@ -1,5 +1,3 @@
import Wallet_Service from './wallet.service'
export {
Wallet_Service
}
export { default as Wallet_Service } from './wallet.service'
export * from './storage.service'
export * from './notifications.service'

View File

@@ -0,0 +1,20 @@
export class StorageService<T = any> {
key: string;
constructor(key: string) {
this.key = key;
}
set(newValue: T) {
localStorage.setItem(this.key, JSON.stringify(newValue));
}
get() {
const str = localStorage.getItem(this.key);
if (!str)
return null;
return JSON.parse(str) as T;
}
}

View File

@@ -2,7 +2,6 @@ import { useEffect } from 'react';
const Components = {
PostPage: () => import('../../features/Posts/pages/PostDetailsPage/PostDetailsPage'),
PreviewPostPage: () => import("../../features/Posts/pages/PreviewPostPage/PreviewPostPage")
}
type ComponentToLoad = keyof typeof Components;