mirror of
https://github.com/aljazceru/landscape-template.git
synced 2026-01-30 03:34:23 +01:00
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:
@@ -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 />}>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
@@ -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'>
|
||||
@@ -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 >
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
20
src/services/storage.service.ts
Normal file
20
src/services/storage.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user