feat: Create story page

- topics input component
- refactor autocomplete component
- create staging slice
- fix post details username overflow
This commit is contained in:
MTG2000
2022-06-03 22:35:31 +03:00
parent 944661b842
commit 2f9d05b8cb
33 changed files with 538 additions and 99 deletions

View File

@@ -28,6 +28,14 @@ declare global {
}
export interface NexusGenInputs {
StoryInputType: { // input type
body: string; // String!
cover_image: string; // String!
id?: number | null; // Int
tags: string[]; // [String!]!
title: string; // String!
topicId: number; // Int!
}
}
export interface NexusGenEnums {
@@ -257,6 +265,7 @@ export interface NexusGenFieldTypes {
Mutation: { // field return type
confirmDonation: NexusGenRootTypes['Donation']; // Donation!
confirmVote: NexusGenRootTypes['Vote']; // Vote!
createStory: NexusGenRootTypes['Story'] | null; // Story
donate: NexusGenRootTypes['Donation']; // Donation!
vote: NexusGenRootTypes['Vote']; // Vote!
}
@@ -437,6 +446,7 @@ export interface NexusGenFieldTypeNames {
Mutation: { // field return type name
confirmDonation: 'Donation'
confirmVote: 'Vote'
createStory: 'Story'
donate: 'Donation'
vote: 'Vote'
}
@@ -553,6 +563,9 @@ export interface NexusGenArgTypes {
payment_request: string; // String!
preimage: string; // String!
}
createStory: { // args
data?: NexusGenInputs['StoryInputType'] | null; // StoryInputType
}
donate: { // args
amount_in_sat: number; // Int!
}
@@ -624,7 +637,7 @@ export interface NexusGenTypeInterfaces {
export type NexusGenObjectNames = keyof NexusGenObjects;
export type NexusGenInputNames = never;
export type NexusGenInputNames = keyof NexusGenInputs;
export type NexusGenEnumNames = keyof NexusGenEnums;

View File

@@ -86,6 +86,7 @@ type LnurlDetails {
type Mutation {
confirmDonation(payment_request: String!, preimage: String!): Donation!
confirmVote(payment_request: String!, preimage: String!): Vote!
createStory(data: StoryInputType): Story
donate(amount_in_sat: Int!): Donation!
vote(amount_in_sat: Int!, item_id: Int!, item_type: VOTE_ITEM_TYPE!): Vote!
}
@@ -182,6 +183,15 @@ type Story implements PostBase {
votes_count: Int!
}
input StoryInputType {
body: String!
cover_image: String!
id: Int
tags: [String!]!
title: String!
topicId: Int!
}
type Tag {
id: Int!
title: String!

View File

@@ -8,9 +8,11 @@ const {
stringArg,
enumType,
arg,
inputObjectType,
} = require('nexus');
const { paginationArgs } = require('./helpers');
const { prisma } = require('../../prisma')
const { prisma } = require('../../prisma');
const { getUserByPubKey } = require('../../auth/utils/helperFuncs');
const POST_TYPE = enumType({
@@ -136,6 +138,75 @@ const Story = objectType({
},
})
const StoryInputType = inputObjectType({
name: 'StoryInputType',
definition(t) {
t.int('id');
t.nonNull.string('title');
t.nonNull.string('body');
t.nonNull.string('cover_image');
t.nonNull.list.nonNull.string('tags');
t.nonNull.int('topicId');
}
})
const StoryMutation = extendType({
type: 'Mutation',
definition(t) {
t.field('createStory', {
type: 'Story',
args: { data: StoryInputType },
async resolve(_root, args, ctx) {
console.log(args.data);
const { id, title, body, cover_image, tags, topicId } = args.data;
const user = await getUserByPubKey(ctx.userPubKey);
// Do some validation
if (!user)
throw new Error("You have to login");
// TODO: validate post data
// Preprocess & insert
const excerpt = body.replace(/<[^>]+>/g, '').slice(0, 120);
return prisma.story.create({
data: {
title,
body,
cover_image,
excerpt,
tags: {
connectOrCreate:
tags.map(tag => {
tag = tag.toLowerCase().trim();
return {
where: {
title: tag,
},
create: {
title: tag
}
}
})
},
topic: {
connect: {
id: topicId
}
},
user: {
connect: {
id: user.id,
}
}
}
})
}
})
},
})
const BountyApplication = objectType({
name: 'BountyApplication',
definition(t) {
@@ -325,6 +396,7 @@ module.exports = {
BountyApplication,
Bounty,
Story,
StoryInputType,
Question,
PostComment,
Post,
@@ -333,5 +405,8 @@ module.exports = {
popularTopics,
getFeed,
getPostById,
getTrendingPosts
getTrendingPosts,
// Mutations
StoryMutation
}

View File

@@ -12,6 +12,7 @@ const FeedPage = React.lazy(() => import("./features/Posts/pages/FeedPage/FeedPa
const HackathonsPage = React.lazy(() => import("./features/Hackathons/pages/HackathonsPage/HackathonsPage"))
const HottestPage = React.lazy(() => import("src/features/Projects/pages/HottestPage/HottestPage"))
const PostDetailsPage = React.lazy(() => import("./features/Posts/pages/PostDetailsPage/PostDetailsPage"))
const CreatePostPage = React.lazy(() => import("./features/Posts/pages/CreatePostPage/CreatePostPage"))
const CategoryPage = React.lazy(() => import("src/features/Projects/pages/CategoryPage/CategoryPage"))
const ExplorePage = React.lazy(() => import("src/features/Projects/pages/ExplorePage"))
const DonatePage = React.lazy(() => import("./features/Donations/pages/DonatePage/DonatePage"))
@@ -52,6 +53,7 @@ function App() {
<Route path="/products" element={<ExplorePage />} />
<Route path="/blog/post/:type/:id" element={<PostDetailsPage />} />
<Route path="/blog/create-post" element={<CreatePostPage />} />
<Route path="/blog" element={<FeedPage />} />
<Route path="/hackathons" element={<HackathonsPage />} />

View File

@@ -1,8 +1,10 @@
import { useMemo } from "react";
import Select, { StylesConfig } from "react-select";
import { ControlledStateHandler } from "src/utils/interfaces";
type Props<T extends object | string> = {
type Props<T extends object | string, IsMulti extends boolean = false> = {
options: T[];
labelField?: keyof T
valueField?: keyof T
@@ -14,45 +16,13 @@ type Props<T extends object | string> = {
name?: string,
className?: string,
onBlur?: () => void;
} &
(
{
isMulti: true
onChange?: (values: T[] | null) => void
value?: T[] | null
}
|
{
isMulti?: false
onChange?: (values: T | null) => void
value?: T | null
}
)
size?: 'sm' | 'md' | 'lg'
} & ControlledStateHandler<T, IsMulti>
const colourStyles: StylesConfig = {
control: (styles, state) => ({
...styles,
padding: '9px 16px',
borderRadius: 12,
}),
indicatorSeparator: (styles, state) => ({
...styles,
display: "none"
}),
input: (styles, state) => ({
...styles,
" input": {
boxShadow: 'none !important'
},
}),
};
export default function AutoComplete<T extends object>({
export default function AutoComplete<T extends object, IsMulti extends boolean>({
options,
labelField,
valueField,
@@ -64,10 +34,28 @@ export default function AutoComplete<T extends object>({
value,
onChange,
onBlur,
size = 'md',
...props
}: Props<T, IsMulti>) {
}: Props<T>) {
const colourStyles: StylesConfig = useMemo(() => ({
control: (styles, state) => ({
...styles,
padding: size === 'md' ? '1px 4px' : '8px 12px',
borderRadius: size === 'md' ? 8 : 12,
}),
indicatorSeparator: (styles, state) => ({
...styles,
display: "none"
}),
input: (styles, state) => ({
...styles,
" input": {
boxShadow: 'none !important'
},
}),
}), [size])
return (
<div className='w-full'>

View File

@@ -1,6 +1,11 @@
import React, { ChangeEvent, useRef } from "react"
import { createAction } from "@reduxjs/toolkit";
import React, { ChangeEvent, useCallback, useRef } from "react"
import { BsUpload } from "react-icons/bs";
import { FaImage } from "react-icons/fa";
import Button from "src/Components/Button/Button"
import { openModal } from "src/redux/features/modals.slice";
import { useAppDispatch } from "src/utils/hooks";
import { useReduxEffect } from "src/utils/hooks/useReduxEffect";
import { UnionToObjectKeys } from "src/utils/types/utils";
import FilesThumbnails from "./FilesThumbnails";
@@ -28,6 +33,9 @@ const fileUrlToObject = async (url: string, fileName: string = 'filename') => {
return file
}
const INSERT_IMAGE_ACTION = createAction<{ src: string, alt?: string }>('COVER_IMAGE_INSERTED')({ src: '', alt: "" })
export default function FilesInput({
multiple,
value,
@@ -41,10 +49,33 @@ export default function FilesInput({
const ref = useRef<HTMLInputElement>(null!)
const dispatch = useAppDispatch();
const handleClick = () => {
ref.current.click();
// ref.current.click();
dispatch(openModal({
Modal: "InsertImageModal",
props: {
callbackAction: {
type: INSERT_IMAGE_ACTION.type,
payload: {
src: "",
alt: ""
}
}
}
}))
}
const onInsertImgUrl = useCallback(({ payload: { src, alt } }: typeof INSERT_IMAGE_ACTION) => {
if (typeof value === 'string')
onChange?.([value, src]);
else
onChange?.([...(value ?? []), src]);
}, [onChange, value])
useReduxEffect(onInsertImgUrl, INSERT_IMAGE_ACTION.type)
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const files = e.target.files && Array.from(e.target.files).slice(0, max);
if (typeof value === 'string')
@@ -80,7 +111,7 @@ export default function FilesInput({
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>
<Button type='button' onClick={handleClick} ><span className="align-middle">{uploadText}</span> <FaImage className="ml-12 scale-125" /></Button>
return (
<>

View File

@@ -23,7 +23,7 @@ export default function SaveModule(props: Props) {
useRemirrorContext(changeCallback)
useEvent('blur', () => onBlur())
// useEvent('focus', () => onBlur())
return <></>
}

View File

@@ -15,7 +15,6 @@ import {
} from '@szhsin/react-menu';
import '@szhsin/react-menu/dist/index.css';
import { FiAward, FiChevronDown, FiFeather, FiLogIn, FiMic } from "react-icons/fi";
import { useMeQuery } from "src/graphql";
import Avatar from "src/features/Profiles/Components/Avatar/Avatar";
@@ -24,10 +23,11 @@ export default function NavDesktop() {
const communityRef = useRef(null);
const [communitymenuProps, toggleCommunityMenu] = useMenuState({ transition: true });
const meQuery = useMeQuery();
const { isWalletConnected } = useAppSelector((state) => ({
const { isWalletConnected, curUser } = useAppSelector((state) => ({
isWalletConnected: state.wallet.isConnected,
curUser: state.user.me,
}));
@@ -165,9 +165,9 @@ export default function NavDesktop() {
<BsSearch className='scale-125 text-gray-400' />
</IconButton>}
</motion.div>
{!meQuery.loading &&
(meQuery.data?.me ?
<Menu menuButton={<MenuButton ><Avatar src={meQuery.data.me.avatar} width={40} /> </MenuButton>}>
{curUser !== undefined &&
(curUser ?
<Menu menuButton={<MenuButton ><Avatar src={curUser.avatar} width={40} /> </MenuButton>}>
<MenuItem
className='!p-16 font-medium flex gap-16 hover:bg-gray-100 !rounded-12 opacity-60'
>

View File

@@ -88,7 +88,7 @@ export default function NavMobile() {
></div>
)}
<motion.div
className="pointer-events-auto bg-white w-full sm:max-w-[400px] overflow-y-scroll absolute left-full border shadow-2xl px-16 flex flex-col"
className="pointer-events-auto bg-white w-full sm:max-w-[400px] overflow-y-scroll absolute left-full border px-16 flex flex-col"
variants={navListVariants}
style={{ height: 'calc(100vh - 67px)' }}
animate={drawerOpen ? "show" : "hide"}

View File

@@ -9,6 +9,8 @@ import NavDesktop from "./NavDesktop";
import { MEDIA_QUERIES } from "src/utils/theme/media_queries";
import { IoMdTrophy } from "react-icons/io";
import { useLocation } from "react-router-dom";
import { useMeQuery } from "src/graphql";
import { setUser } from "src/redux/features/user.slice";
export const navLinks = [
@@ -50,6 +52,11 @@ export default function Navbar() {
const isLargeScreen = useMediaQuery(MEDIA_QUERIES.isLarge)
useMeQuery({
onCompleted: (data) => {
dispatch(setUser(data.me))
}
});
useEffect(() => {
const nav = document.querySelector("nav");
@@ -63,7 +70,6 @@ export default function Navbar() {
document.body.style.paddingTop = `${nav.clientHeight}px`;
}
}
return () => {
document.body.style.paddingTop = oldPadding
}

View File

@@ -14,9 +14,9 @@ interface Props {
export default function HackathonCard({ hackathon }: Props) {
return (
<div className="rounded-16 bg-white overflow-hidden border-2">
<div className="rounded-16 bg-white overflow-hidden border-2 flex flex-col">
<img className="w-full h-[120px] object-cover" src={hackathon.cover_image} alt="" />
<div className="p-16">
<div className="p-16 grow flex flex-col">
<div className="flex flex-col gap-8">
<h3 className="text-body1 font-bold text-gray-900">
{hackathon.title}
@@ -34,6 +34,7 @@ export default function HackathonCard({ hackathon }: Props) {
<div className="mt-16 flex flex-wrap gap-8">
{hackathon.topics.map(topic => <div key={topic.id} className="p-8 bg-gray-50 rounded-8 text-body5">{topic.icon} {topic.title}</div>)}
</div>
<div className="mt-auto"></div>
<Button href={hackathon.website} newTab color="gray" fullWidth className="mt-16">
Learn more
</Button>

View File

@@ -56,10 +56,12 @@ export default function SortByFilter({ filterChanged }: Props) {
:
<AutoComplete
isClearable
isMulti={false}
placeholder='Sort By'
options={filters}
labelField='text'
valueField='value'
size='lg'
onChange={(o) => filterClicked(o ? o.value : null)} />
}</>

View File

@@ -11,7 +11,7 @@ import { Helmet } from 'react-helmet'
export default function HackathonsPage() {
const [sortByFilter, setSortByFilter] = useState<string | null>(null)
const [sortByFilter, setSortByFilter] = useState<string | null>('Upcoming')
const [topicsFilter, setTopicsFilter] = useState<number | null>(null)
const hackathonsQuery = useGetHackathonsQuery({

View File

@@ -1,6 +1,7 @@
import Avatar from 'src/features/Profiles/Components/Avatar/Avatar';
import dayjs from 'dayjs'
import { UnionToObjectKeys } from 'src/utils/types/utils';
import { trimText } from 'src/utils/helperFunctions';
interface Props {
author: {
@@ -43,8 +44,8 @@ export default function Header({
return (
<div className='flex gap-8'>
<Avatar width={avatarSize[size]} src={props.author.avatar} />
<div>
<p className={`${nameSize[size]} text-black font-medium`}>{props.author.name}</p>
<div className='overflow-hidden'>
<p className={`${nameSize[size]} text-black font-medium overflow-hidden text-ellipsis`}>{trimText(props.author.name, 30)}</p>
<p className={`text-body6 text-gray-600`}>{dateToShow()}</p>
</div>
{/* {showTimeAgo && <p className={`${nameSize[size]} text-gray-500 ml-auto `}>

View File

@@ -1,3 +1,4 @@
import { useState } from 'react'
import { yupResolver } from "@hookform/resolvers/yup";
import { Controller, FormProvider, NestedValue, Resolver, SubmitHandler, useForm } from "react-hook-form";
import Button from "src/Components/Button/Button";
@@ -5,52 +6,117 @@ import FilesInput from "src/Components/Inputs/FilesInput/FilesInput";
import TagsInput from "src/Components/Inputs/TagsInput/TagsInput";
import * as yup from "yup";
import ContentEditor from "../ContentEditor/ContentEditor";
import { Topic, useCreateStoryMutation } from 'src/graphql'
import { useNavigate } from 'react-router-dom'
import TopicsInput from '../TopicsInput/TopicsInput'
import { useAppDispatch, useAppSelector } from 'src/utils/hooks';
import { stageStory } from 'src/redux/features/staging.slice'
import { Override } from 'src/utils/interfaces';
const FileSchema = yup.lazy((value: string | File[]) => {
switch (typeof value) {
case 'object':
return yup.mixed()
.test("fileSize", "File Size is too large", file => file.size <= 5242880)
.test("fileType", "Unsupported File Format, only png/jpg/jpeg images are allowed",
(file: File) =>
["image/jpeg", "image/png", "image/jpg"].includes(file.type))
case 'string':
return yup.string().url();
default:
return yup.mixed()
}
})
const schema = yup.object({
title: yup.string().required().min(10),
topic: yup.object().nullable().required(),
tags: yup.array().required().min(1),
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()
}
})
cover_image: yup.array().of(FileSchema as any)
}).required();
interface IFormInputs {
id: number | null
title: string
tags: NestedValue<object[]>
cover_image: NestedValue<File[]> | string
topic: NestedValue<Topic> | null
tags: NestedValue<{ title: string }[]>
cover_image: NestedValue<File[]> | NestedValue<string[]>
body: string
}
export type CreateStoryType = Override<IFormInputs, {
topic: Topic | null
tags: { title: string }[]
cover_image: File[] | string[]
}>
export default function StoryForm() {
const dispatch = useAppDispatch();
const { story } = useAppSelector(state => ({
story: state.staging.story
}))
const formMethods = useForm<IFormInputs>({
resolver: yupResolver(schema) as Resolver<IFormInputs>,
defaultValues: {
title: '',
tags: [],
body: '',
cover_image: []
id: story?.id ?? null,
title: story?.title ?? '',
topic: story?.topic ?? null,
cover_image: story?.cover_image ?? [],
tags: story?.tags ?? [],
body: story?.body ?? '',
}
});
const { handleSubmit, control, register, formState: { errors }, } = formMethods;
const { handleSubmit, control, register, formState: { errors, }, trigger, getValues } = formMethods;
const [loading, setLoading] = useState(false)
const onSubmit: SubmitHandler<IFormInputs> = data => console.log(data);
const navigate = useNavigate()
const [createStory] = useCreateStoryMutation({
onCompleted: (data) => {
navigate(`/blog/post/Story/${data.createStory?.id}`)
setLoading(false)
},
onError: (error) => {
console.log(error)
alert('Unexpected error happened, please try again')
setLoading(false)
}
});
const clickPreview = async () => {
const isValid = await trigger();
const data = getValues()
console.log(data);
if (isValid)
dispatch(stageStory(data))
}
const onSubmit: SubmitHandler<IFormInputs> = data => {
setLoading(true);
createStory({
variables: {
data: {
id: null,
title: data.title,
body: data.body,
tags: data.tags.map(t => t.title),
cover_image: data.cover_image[0] as string,
topicId: 1,
},
}
})
}
return (
<FormProvider {...formMethods}>
@@ -91,7 +157,25 @@ export default function StoryForm() {
{errors.title.message}
</p>}
<p className="text-body5 mt-16">
Topic
</p>
<div className="mt-16">
<Controller
control={control}
name="topic"
render={({ field: { onChange, value, onBlur } }) => (
<TopicsInput
value={value}
onChange={onChange}
onBlur={onBlur}
/>
)}
/>
</div>
{errors.topic && <p className="input-error">
{errors.topic.message}
</p>}
<p className="text-body5 mt-16">
Tags
</p>
@@ -113,11 +197,18 @@ export default function StoryForm() {
</p>}
</div>
<div className="flex gap-16 mt-32">
<Button type='submit' color="primary">
<Button
type='submit'
color="primary"
disabled={loading}
>
Publish
</Button>
<Button color="gray">
Save Draft
<Button
color="gray"
onClick={clickPreview}
>
Preview
</Button>
</div>
</form>

View File

@@ -0,0 +1,5 @@
mutation createStory($data: StoryInputType) {
createStory(data: $data) {
id
}
}

View File

@@ -0,0 +1,31 @@
import React from 'react'
import AutoComplete from 'src/Components/Inputs/Autocomplete/Autocomplete'
import { Topic, useAllTopicsQuery } from 'src/graphql'
import { ControlledStateHandler } from 'src/utils/interfaces';
type Props<IsMulti extends boolean> = ControlledStateHandler<Topic, IsMulti>
export default function TopicsInput<IsMulti extends boolean = false>(props: Props<IsMulti>) {
const topicsQuery = useAllTopicsQuery();
return (
<AutoComplete
isClearable
placeholder='Choose a topic'
options={topicsQuery.data?.allTopics!}
labelField='title'
valueField='id'
isMulti={props.isMulti}
isLoading={topicsQuery.loading}
value={props.value}
onChange={props.onChange}
onBlur={props.onBlur}
/>
)
}

View File

@@ -1,15 +1,19 @@
import { spawn } from 'child_process';
import React, { useState } from 'react'
const types = [
{
text: "📜 Story",
value: 'story'
value: 'story',
disabled: false
}, {
text: "💰 Bounty",
value: 'bounty'
value: 'bounty',
disabled: true,
}, {
text: "❓ Question",
value: 'question'
value: 'question',
disabled: true,
},
] as const;
@@ -36,10 +40,14 @@ export default function PostTypeList({ selectionChanged }: Props) {
<ul>
{types.map((f, idx) => <li
key={f.value}
className={`p-12 rounded-8 cursor-pointer font-bold ${f.value === selected && 'bg-gray-100'}`}
onClick={() => handleClick(f.value)}
className={`
p-12 rounded-8 cursor-pointer font-bold
${f.value === selected && 'bg-gray-100'}
${f.disabled && 'opacity-40'}
`}
onClick={() => !f.disabled && handleClick(f.value)}
>
{f.text}
{f.text} {f.disabled && <span className="text-gray-400 text-body5">(WIP)</span>}
</li>)}
</ul>
</div>

View File

@@ -10,6 +10,7 @@ import PopularTopicsFilter from './PopularTopicsFilter/PopularTopicsFilter'
import SortBy from './SortBy/SortBy'
import styles from './styles.module.scss'
import { Helmet } from "react-helmet";
import Button from 'src/Components/Button/Button'
export default function FeedPage() {
@@ -48,6 +49,14 @@ export default function FeedPage() {
top: `${navHeight + 16}px`,
maxHeight: `calc(100vh - ${navHeight}px - 16px)`,
}}>
<Button
href='/blog/create-post'
color='primary'
fullWidth
>
Write a post
</Button>
<div className="my-24"></div>
<SortBy
filterChanged={setSortByFilter}
/>
@@ -55,6 +64,7 @@ export default function FeedPage() {
<PopularTopicsFilter
filterChanged={setTopicFilter}
/>
</div>
</aside>
<main>

View File

@@ -59,10 +59,12 @@ export default function SortBy({ filterChanged }: Props) {
:
<AutoComplete
isClearable
isMulti={false}
placeholder='Sort By'
options={filters}
labelField='text'
valueField='value'
size='lg'
onChange={(o) => filterClicked(o ? o.value : null)} />
}</>

View File

@@ -2,6 +2,7 @@ import dayjs from "dayjs";
import Button from "src/Components/Button/Button";
import { Author } from "src/features/Posts/types";
import Avatar from "src/features/Profiles/Components/Avatar/Avatar";
import { trimText } from "src/utils/helperFunctions";
interface Props {
author: Author
@@ -12,8 +13,8 @@ export default function AuthorCard({ author }: Props) {
<div className="bg-white p-16 border rounded-8">
<div className='flex gap-8'>
<Avatar width={48} src={author.avatar} />
<div>
<p className={`'text-body4' text-black font-medium`}>{author.name}</p>
<div className="overflow-hidden">
<p className={`'text-body4' text-black font-medium overflow-hidden text-ellipsis whitespace-nowrap`}>{trimText(author.name, 333)}</p>
<p className={`text-body6 text-gray-600`}>Joined on {dayjs(author.join_date).format('MMMM DD, YYYY')}</p>
</div>
</div>

View File

@@ -1,3 +1,7 @@
.body :where(h1, h2, h3, h4, h5, h6) {
font-weight: 700;
}
.body h1 {
font-size: 48px;
line-height: 54px;
@@ -30,3 +34,10 @@
line-height: 22px;
margin-bottom: 1.5em;
}
.body pre {
background-color: #2b2b2b;
padding: 16px;
border-radius: 12px;
color: whitesmoke;
}

View File

@@ -36,7 +36,7 @@ export default function PostActions({ post }: Props) {
<button className={`
hidden md:flex w-full aspect-square bg-white rounded-12 border justify-around items-center text-gray-500 hover:bg-gray-50 active:bg-gray-100
`}
onClick={() => navigate(-1)}
onClick={() => navigate('/blog')}
>
<FiArrowLeft className={"text-body1"} />
</button>

View File

@@ -55,7 +55,7 @@ export default function PostDetailsPage() {
<PageContent post={post} />
<aside id='author' className='no-scrollbar'>
<aside id='author' className='no-scrollbar min-w-0'>
<div className="flex flex-col gap-24"
style={{
top: `${navHeight + 16}px`,

View File

@@ -5,6 +5,10 @@
// grid-template-columns: 1fr;
gap: 32px;
& > * {
min-width: 0;
}
grid-template-areas:
"content"
"actions"

View File

@@ -107,6 +107,7 @@ export type Mutation = {
__typename?: 'Mutation';
confirmDonation: Donation;
confirmVote: Vote;
createStory: Maybe<Story>;
donate: Donation;
vote: Vote;
};
@@ -124,6 +125,11 @@ export type MutationConfirmVoteArgs = {
};
export type MutationCreateStoryArgs = {
data: InputMaybe<StoryInputType>;
};
export type MutationDonateArgs = {
amount_in_sat: Scalars['Int'];
};
@@ -299,6 +305,15 @@ export type Story = PostBase & {
votes_count: Scalars['Int'];
};
export type StoryInputType = {
body: Scalars['String'];
cover_image: Scalars['String'];
id: InputMaybe<Scalars['Int']>;
tags: Array<Scalars['String']>;
title: Scalars['String'];
topicId: Scalars['Int'];
};
export type Tag = {
__typename?: 'Tag';
id: Scalars['Int'];
@@ -394,6 +409,13 @@ export type TrendingPostsQueryVariables = Exact<{ [key: string]: never; }>;
export type TrendingPostsQuery = { __typename?: 'Query', getTrendingPosts: Array<{ __typename?: 'Bounty', id: number, title: string, author: { __typename?: 'User', id: number, avatar: string } } | { __typename?: 'Question', id: number, title: string, author: { __typename?: 'User', id: number, avatar: string } } | { __typename?: 'Story', id: number, title: string, author: { __typename?: 'User', id: number, avatar: string } }> };
export type CreateStoryMutationVariables = Exact<{
data: InputMaybe<StoryInputType>;
}>;
export type CreateStoryMutation = { __typename?: 'Mutation', createStory: { __typename?: 'Story', id: number } | null };
export type PopularTopicsQueryVariables = Exact<{ [key: string]: never; }>;
@@ -828,6 +850,39 @@ export function useTrendingPostsLazyQuery(baseOptions?: Apollo.LazyQueryHookOpti
export type TrendingPostsQueryHookResult = ReturnType<typeof useTrendingPostsQuery>;
export type TrendingPostsLazyQueryHookResult = ReturnType<typeof useTrendingPostsLazyQuery>;
export type TrendingPostsQueryResult = Apollo.QueryResult<TrendingPostsQuery, TrendingPostsQueryVariables>;
export const CreateStoryDocument = gql`
mutation createStory($data: StoryInputType) {
createStory(data: $data) {
id
}
}
`;
export type CreateStoryMutationFn = Apollo.MutationFunction<CreateStoryMutation, CreateStoryMutationVariables>;
/**
* __useCreateStoryMutation__
*
* To run a mutation, you first call `useCreateStoryMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useCreateStoryMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [createStoryMutation, { data, loading, error }] = useCreateStoryMutation({
* variables: {
* data: // value for 'data'
* },
* });
*/
export function useCreateStoryMutation(baseOptions?: Apollo.MutationHookOptions<CreateStoryMutation, CreateStoryMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CreateStoryMutation, CreateStoryMutationVariables>(CreateStoryDocument, options);
}
export type CreateStoryMutationHookResult = ReturnType<typeof useCreateStoryMutation>;
export type CreateStoryMutationResult = Apollo.MutationResult<CreateStoryMutation>;
export type CreateStoryMutationOptions = Apollo.BaseMutationOptions<CreateStoryMutation, CreateStoryMutationVariables>;
export const PopularTopicsDocument = gql`
query PopularTopics {
popularTopics {

View File

@@ -0,0 +1,36 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { CreateStoryType } from "src/features/Posts/pages/CreatePostPage/Components/StoryForm/StoryForm";
interface StoreState {
story: CreateStoryType | null
}
const initialState = {
story: null
} as StoreState;
export const stagingSlice = createSlice({
name: "user",
initialState,
reducers: {
stageStory(state, action: PayloadAction<StoreState['story']>) {
state.story = action.payload;
},
unstageStory(state) {
state.story = null;
},
unstageAll(state) {
state.story = null;
}
},
});
export const {
stageStory,
unstageStory,
unstageAll,
} = stagingSlice.actions;
export default stagingSlice.reducer;

View File

@@ -0,0 +1,30 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface StoreState {
me: {
id: number;
name: string;
avatar: string;
}
| undefined // fetching user data if exist
| null // user not logged in
}
const initialState = {
me: undefined
} as StoreState;
export const userSlice = createSlice({
name: "user",
initialState,
reducers: {
setUser(state, action: PayloadAction<StoreState['me']>) {
state.me = action.payload;
},
},
});
export const { setUser } = userSlice.actions;
export default userSlice.reducer;

View File

@@ -5,6 +5,8 @@ import walletSlice from "./features/wallet.slice";
import voteSlice from "./features/vote.slice";
import uiSlice from "./features/ui.slice";
import { actionReducer } from './features/action-reducer'
import userSlice from "./features/user.slice";
import stagingSlice from "./features/staging.slice";
const defaultStore = configureStore({
reducer: {
@@ -13,7 +15,9 @@ const defaultStore = configureStore({
wallet: walletSlice,
vote: voteSlice,
ui: uiSlice,
action: actionReducer
action: actionReducer,
user: userSlice,
staging: stagingSlice
},
});
@@ -27,7 +31,9 @@ export const createReduxStore = (initalState?: Partial<RootState>) => {
wallet: walletSlice,
vote: voteSlice,
ui: uiSlice,
action: actionReducer
action: actionReducer,
user: userSlice,
staging: stagingSlice
},
preloadedState: initalState
});

View File

@@ -13,6 +13,7 @@ body {
.page-container {
width: calc(min(100% - 32px, 1440px));
margin: 0 auto;
padding: 32px 0;
}
@media screen and (min-width: 780px) {

View File

@@ -8,7 +8,7 @@ let apiClientUri = CONSTS.apiEndpoint + '/graphql';
const httpLink = new HttpLink({
uri: apiClientUri,
credentials: 'same-origin'
credentials: process.env.REACT_APP_API_END_POINT?.includes('localhost') ? 'include' : "same-origin"
});
const errorLink = onError(({ graphQLErrors, networkError }) => {

View File

@@ -1,4 +1,5 @@
import { useDebouncedCallback, useMountEffect } from "@react-hookz/web";
import { useDebouncedCallback } from "@react-hookz/web";
import { useEffect } from "react";
export const useResizeListener = (
listener: () => void,
@@ -7,11 +8,11 @@ export const useResizeListener = (
options.debounce = options.debounce ?? 250;
const func = useDebouncedCallback(listener, [], options.debounce)
useMountEffect(() => {
useEffect(() => {
window.addEventListener("resize", func);
return () => {
window.removeEventListener("resize", func);
};
});
}, [func]);
};

View File

@@ -12,4 +12,22 @@ export type ListComponentProps<T> = {
onReachedBottom?: () => void
}
export type ControlledStateHandler<T, IsMulti extends boolean> = {
isMulti?: IsMulti;
value?:
| (true extends IsMulti ? T[] : never)
| (false extends IsMulti ? T : never)
| null
onChange?: (
nv: | (true extends IsMulti ? T[] : never)
| (false extends IsMulti ? T : never)
| null
) => void
onBlur?: () => void
}
export type Override<A, B> = Omit<A, keyof B> & B;
export type Image = string;