mirror of
https://github.com/aljazceru/landscape-template.git
synced 2026-02-18 13:04:37 +01:00
feat: Create story page
- topics input component - refactor autocomplete component - create staging slice - fix post details username overflow
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function SaveModule(props: Props) {
|
||||
|
||||
useRemirrorContext(changeCallback)
|
||||
|
||||
useEvent('blur', () => onBlur())
|
||||
// useEvent('focus', () => onBlur())
|
||||
|
||||
return <></>
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
>
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)} />
|
||||
}</>
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 `}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation createStory($data: StoryInputType) {
|
||||
createStory(data: $data) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)} />
|
||||
}</>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
// grid-template-columns: 1fr;
|
||||
gap: 32px;
|
||||
|
||||
& > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
grid-template-areas:
|
||||
"content"
|
||||
"actions"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
36
src/redux/features/staging.slice.ts
Normal file
36
src/redux/features/staging.slice.ts
Normal 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;
|
||||
30
src/redux/features/user.slice.ts
Normal file
30
src/redux/features/user.slice.ts
Normal 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;
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user