Merge branch 'dev'

This commit is contained in:
MTG2000
2022-07-14 08:35:35 +03:00
56 changed files with 964 additions and 385 deletions

2
.gitignore vendored
View File

@@ -30,4 +30,4 @@ yarn-debug.log*
yarn-error.log*
TODO
NOTES
NOTES

View File

@@ -30,8 +30,9 @@ declare global {
export interface NexusGenInputs {
StoryInputType: { // input type
body: string; // String!
cover_image: string; // String!
cover_image?: string | null; // String
id?: number | null; // Int
is_published?: boolean | null; // Boolean
tags: string[]; // [String!]!
title: string; // String!
}
@@ -81,13 +82,15 @@ export interface NexusGenObjects {
applicants_count: number; // Int!
applications: NexusGenRootTypes['BountyApplication'][]; // [BountyApplication!]!
body: string; // String!
cover_image: string; // String!
cover_image?: string | null; // String
createdAt: NexusGenScalars['Date']; // Date!
deadline: string; // String!
excerpt: string; // String!
id: number; // Int!
is_published?: boolean | null; // Boolean
reward_amount: number; // Int!
title: string; // String!
updatedAt: NexusGenScalars['Date']; // Date!
votes_count: number; // Int!
}
BountyApplication: { // root type
@@ -160,19 +163,24 @@ export interface NexusGenObjects {
createdAt: NexusGenScalars['Date']; // Date!
excerpt: string; // String!
id: number; // Int!
is_published?: boolean | null; // Boolean
title: string; // String!
updatedAt: NexusGenScalars['Date']; // Date!
votes_count: number; // Int!
}
Story: { // root type
body: string; // String!
cover_image: string; // String!
cover_image?: string | null; // String
createdAt: NexusGenScalars['Date']; // Date!
excerpt: string; // String!
id: number; // Int!
is_published?: boolean | null; // Boolean
title: string; // String!
updatedAt: NexusGenScalars['Date']; // Date!
votes_count: number; // Int!
}
Tag: { // root type
description?: string | null; // String
icon?: string | null; // String
id: number; // Int!
isOfficial?: boolean | null; // Boolean
@@ -236,15 +244,17 @@ export interface NexusGenFieldTypes {
applications: NexusGenRootTypes['BountyApplication'][]; // [BountyApplication!]!
author: NexusGenRootTypes['Author']; // Author!
body: string; // String!
cover_image: string; // String!
cover_image: string | null; // String
createdAt: NexusGenScalars['Date']; // Date!
deadline: string; // String!
excerpt: string; // String!
id: number; // Int!
is_published: boolean | null; // Boolean
reward_amount: number; // Int!
tags: NexusGenRootTypes['Tag'][]; // [Tag!]!
title: string; // String!
type: string; // String!
updatedAt: NexusGenScalars['Date']; // Date!
votes_count: number; // Int!
}
BountyApplication: { // field return type
@@ -334,6 +344,7 @@ export interface NexusGenFieldTypes {
getDonationsStats: NexusGenRootTypes['DonationsStats']; // DonationsStats!
getFeed: NexusGenRootTypes['Post'][]; // [Post!]!
getLnurlDetailsForProject: NexusGenRootTypes['LnurlDetails']; // LnurlDetails!
getMyDrafts: NexusGenRootTypes['Post'][]; // [Post!]!
getPostById: NexusGenRootTypes['Post']; // Post!
getProject: NexusGenRootTypes['Project']; // Project!
getTrendingPosts: NexusGenRootTypes['Post'][]; // [Post!]!
@@ -354,9 +365,11 @@ export interface NexusGenFieldTypes {
createdAt: NexusGenScalars['Date']; // Date!
excerpt: string; // String!
id: number; // Int!
is_published: boolean | null; // Boolean
tags: NexusGenRootTypes['Tag'][]; // [Tag!]!
title: string; // String!
type: string; // String!
updatedAt: NexusGenScalars['Date']; // Date!
votes_count: number; // Int!
}
Story: { // field return type
@@ -364,16 +377,19 @@ export interface NexusGenFieldTypes {
body: string; // String!
comments: NexusGenRootTypes['PostComment'][]; // [PostComment!]!
comments_count: number; // Int!
cover_image: string; // String!
cover_image: string | null; // String
createdAt: NexusGenScalars['Date']; // Date!
excerpt: string; // String!
id: number; // Int!
is_published: boolean | null; // Boolean
tags: NexusGenRootTypes['Tag'][]; // [Tag!]!
title: string; // String!
type: string; // String!
updatedAt: NexusGenScalars['Date']; // Date!
votes_count: number; // Int!
}
Tag: { // field return type
description: string | null; // String
icon: string | null; // String
id: number; // Int!
isOfficial: boolean | null; // Boolean
@@ -409,7 +425,9 @@ export interface NexusGenFieldTypes {
createdAt: NexusGenScalars['Date']; // Date!
excerpt: string; // String!
id: number; // Int!
is_published: boolean | null; // Boolean
title: string; // String!
updatedAt: NexusGenScalars['Date']; // Date!
votes_count: number; // Int!
}
}
@@ -438,10 +456,12 @@ export interface NexusGenFieldTypeNames {
deadline: 'String'
excerpt: 'String'
id: 'Int'
is_published: 'Boolean'
reward_amount: 'Int'
tags: 'Tag'
title: 'String'
type: 'String'
updatedAt: 'Date'
votes_count: 'Int'
}
BountyApplication: { // field return type name
@@ -531,6 +551,7 @@ export interface NexusGenFieldTypeNames {
getDonationsStats: 'DonationsStats'
getFeed: 'Post'
getLnurlDetailsForProject: 'LnurlDetails'
getMyDrafts: 'Post'
getPostById: 'Post'
getProject: 'Project'
getTrendingPosts: 'Post'
@@ -551,9 +572,11 @@ export interface NexusGenFieldTypeNames {
createdAt: 'Date'
excerpt: 'String'
id: 'Int'
is_published: 'Boolean'
tags: 'Tag'
title: 'String'
type: 'String'
updatedAt: 'Date'
votes_count: 'Int'
}
Story: { // field return type name
@@ -565,12 +588,15 @@ export interface NexusGenFieldTypeNames {
createdAt: 'Date'
excerpt: 'String'
id: 'Int'
is_published: 'Boolean'
tags: 'Tag'
title: 'String'
type: 'String'
updatedAt: 'Date'
votes_count: 'Int'
}
Tag: { // field return type name
description: 'String'
icon: 'String'
id: 'Int'
isOfficial: 'Boolean'
@@ -606,7 +632,9 @@ export interface NexusGenFieldTypeNames {
createdAt: 'Date'
excerpt: 'String'
id: 'Int'
is_published: 'Boolean'
title: 'String'
updatedAt: 'Date'
votes_count: 'Int'
}
}
@@ -660,6 +688,9 @@ export interface NexusGenArgTypes {
getLnurlDetailsForProject: { // args
project_id: number; // Int!
}
getMyDrafts: { // args
type: NexusGenEnums['POST_TYPE']; // POST_TYPE!
}
getPostById: { // args
id: number; // Int!
type: NexusGenEnums['POST_TYPE']; // POST_TYPE!

View File

@@ -22,15 +22,17 @@ type Bounty implements PostBase {
applications: [BountyApplication!]!
author: Author!
body: String!
cover_image: String!
cover_image: String
createdAt: Date!
deadline: String!
excerpt: String!
id: Int!
is_published: Boolean
reward_amount: Int!
tags: [Tag!]!
title: String!
type: String!
updatedAt: Date!
votes_count: Int!
}
@@ -113,7 +115,9 @@ interface PostBase {
createdAt: Date!
excerpt: String!
id: Int!
is_published: Boolean
title: String!
updatedAt: Date!
votes_count: Int!
}
@@ -150,6 +154,7 @@ type Query {
getDonationsStats: DonationsStats!
getFeed(skip: Int = 0, sortBy: String, tag: Int = 0, take: Int = 10): [Post!]!
getLnurlDetailsForProject(project_id: Int!): LnurlDetails!
getMyDrafts(type: POST_TYPE!): [Post!]!
getPostById(id: Int!, type: POST_TYPE!): Post!
getProject(id: Int!): Project!
getTrendingPosts: [Post!]!
@@ -171,9 +176,11 @@ type Question implements PostBase {
createdAt: Date!
excerpt: String!
id: Int!
is_published: Boolean
tags: [Tag!]!
title: String!
type: String!
updatedAt: Date!
votes_count: Int!
}
@@ -182,25 +189,29 @@ type Story implements PostBase {
body: String!
comments: [PostComment!]!
comments_count: Int!
cover_image: String!
cover_image: String
createdAt: Date!
excerpt: String!
id: Int!
is_published: Boolean
tags: [Tag!]!
title: String!
type: String!
updatedAt: Date!
votes_count: Int!
}
input StoryInputType {
body: String!
cover_image: String!
cover_image: String
id: Int
is_published: Boolean
tags: [String!]!
title: String!
}
type Tag {
description: String
icon: String
id: Int!
isOfficial: Boolean

View File

@@ -54,9 +54,11 @@ const PostBase = interfaceType({
t.nonNull.int('id');
t.nonNull.string('title');
t.nonNull.date('createdAt');
t.nonNull.date('updatedAt');
t.nonNull.string('body');
t.nonNull.string('excerpt');
t.nonNull.int('votes_count');
t.boolean('is_published');
},
})
@@ -67,7 +69,7 @@ const Story = objectType({
t.nonNull.string('type', {
resolve: () => t.typeName
});
t.nonNull.string('cover_image');
t.string('cover_image');
t.nonNull.list.nonNull.field('comments', {
type: "PostComment",
resolve: (parent) => prisma.story.findUnique({ where: { id: parent.id } }).comments()
@@ -107,8 +109,9 @@ const StoryInputType = inputObjectType({
t.int('id');
t.nonNull.string('title');
t.nonNull.string('body');
t.nonNull.string('cover_image');
t.string('cover_image');
t.nonNull.list.nonNull.string('tags');
t.boolean('is_published')
}
})
const createStory = extendType({
@@ -118,21 +121,24 @@ const createStory = extendType({
type: 'Story',
args: { data: StoryInputType },
async resolve(_root, args, ctx) {
const { id, title, body, cover_image, tags } = args.data;
const { id, title, body, cover_image, tags, is_published } = args.data;
const user = await getUserByPubKey(ctx.userPubKey);
// Do some validation
if (!user)
throw new ApolloError("Not Authenticated");
let was_published = false;
if (id) {
const oldPost = await prisma.story.findFirst({
where: { id },
select: {
user_id: true
user_id: true,
is_published: true
}
})
was_published = oldPost.is_published;
if (user.id !== oldPost.user_id)
throw new ApolloError("Not post author")
}
@@ -160,6 +166,7 @@ const createStory = extendType({
body,
cover_image,
excerpt,
is_published: was_published || is_published,
tags: {
connectOrCreate:
tags.map(tag => {
@@ -185,6 +192,7 @@ const createStory = extendType({
body,
cover_image,
excerpt,
is_published,
tags: {
connectOrCreate:
tags.map(tag => {
@@ -263,7 +271,7 @@ const Bounty = objectType({
t.nonNull.string('type', {
resolve: () => 'Bounty'
});
t.nonNull.string('cover_image');
t.string('cover_image');
t.nonNull.string('deadline');
t.nonNull.int('reward_amount');
t.nonNull.int('applicants_count');
@@ -371,7 +379,8 @@ const getFeed = extendType({
id: tag
}
},
})
}),
is_published: true,
},
skip,
take,
@@ -396,7 +405,8 @@ const getTrendingPosts = extendType({
where: {
createdAt: {
gte: lastWeekDate
}
},
is_published: true,
},
orderBy: { votes_count: 'desc' },
take: 5,
@@ -407,6 +417,37 @@ const getTrendingPosts = extendType({
})
const getMyDrafts = extendType({
type: "Query",
definition(t) {
t.nonNull.list.nonNull.field('getMyDrafts', {
type: "Post",
args: {
type: arg({
type: nonNull('POST_TYPE')
})
},
async resolve(parent, { type }, ctx) {
const user = await getUserByPubKey(ctx.userPubKey);
// Do some validation
if (!user)
throw new ApolloError("Not Authenticated");
if (type === 'Story')
return prisma.story.findMany({
where: {
is_published: false,
user_id: user.id
},
orderBy: { createdAt: 'desc' },
}).then(asStoryType)
return []
}
})
}
})
const getPostById = extendType({
type: "Query",
definition(t) {
@@ -453,6 +494,7 @@ module.exports = {
getFeed,
getPostById,
getTrendingPosts,
getMyDrafts,
// Mutations
createStory,

View File

@@ -7,6 +7,7 @@ const Tag = objectType({
t.nonNull.int('id');
t.nonNull.string('title');
t.string('icon');
t.string('description');
t.boolean('isOfficial');
}
});

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Story" ALTER COLUMN "cover_image" DROP NOT NULL;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Tag" ADD COLUMN "description" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Story" ADD COLUMN "is_published" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Story" ALTER COLUMN "is_published" SET DEFAULT true;

View File

@@ -12,10 +12,11 @@ generator client {
// -----------------
model Tag {
id Int @id @default(autoincrement())
title String @unique
icon String?
isOfficial Boolean @default(false)
id Int @id @default(autoincrement())
title String @unique
icon String?
description String?
isOfficial Boolean @default(false)
project Project[]
stories Story[]
@@ -113,14 +114,15 @@ model Award {
// -----------------
model Story {
id Int @id @default(autoincrement())
title String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
body String
excerpt String
cover_image String
votes_count Int @default(0)
id Int @id @default(autoincrement())
title String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
body String
excerpt String
cover_image String?
votes_count Int @default(0)
is_published Boolean @default(true)
tags Tag[]
@@ -139,6 +141,7 @@ model Question {
body String
excerpt String
votes_count Int @default(0)
is_published Boolean @default(true)
tags Tag[]

View File

@@ -1,5 +1,4 @@
import React, { Suspense, useEffect } from "react";
import Navbar from "src/Components/Navbar/Navbar";
import ModalsContainer from "src/Components/Modals/ModalsContainer/ModalsContainer";
import { useAppDispatch, useAppSelector } from './utils/hooks';
import { Wallet_Service } from "./services";
@@ -10,23 +9,29 @@ import { useMeQuery } from "./graphql";
import { setUser } from "./redux/features/user.slice";
import ProtectedRoute from "./Components/ProtectedRoute/ProtectedRoute";
import { Helmet } from "react-helmet";
import { NavbarLayout } from "./utils/routing/layouts";
import { Loadable } from "./utils/routing";
// Pages
const FeedPage = React.lazy(() => import("./features/Posts/pages/FeedPage/FeedPage"))
const PostDetailsPage = React.lazy(() => import("./features/Posts/pages/PostDetailsPage/PostDetailsPage"))
const CreatePostPage = React.lazy(() => import("./features/Posts/pages/CreatePostPage/CreatePostPage"))
const PreviewPostPage = React.lazy(() => import("./features/Posts/pages/PreviewPostPage/PreviewPostPage"))
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 HottestPage = Loadable(React.lazy(() => import("src/features/Projects/pages/HottestPage/HottestPage")))
const CategoryPage = Loadable(React.lazy(() => import("src/features/Projects/pages/CategoryPage/CategoryPage")))
const ExplorePage = Loadable(React.lazy(() => import("src/features/Projects/pages/ExplorePage")))
const HackathonsPage = Loadable(React.lazy(() => import("./features/Hackathons/pages/HackathonsPage/HackathonsPage")))
const DonatePage = Loadable(React.lazy(() => import("./features/Donations/pages/DonatePage/DonatePage")))
const LoginPage = Loadable(React.lazy(() => import("./features/Auth/pages/LoginPage/LoginPage")))
const LogoutPage = Loadable(React.lazy(() => import("./features/Auth/pages/LogoutPage/LogoutPage")))
const ProfilePage = Loadable(React.lazy(() => import("./features/Profiles/pages/ProfilePage/ProfilePage")))
const HottestPage = React.lazy(() => import("src/features/Projects/pages/HottestPage/HottestPage"))
const CategoryPage = React.lazy(() => import("src/features/Projects/pages/CategoryPage/CategoryPage"))
const ExplorePage = React.lazy(() => import("src/features/Projects/pages/ExplorePage"))
const HackathonsPage = React.lazy(() => import("./features/Hackathons/pages/HackathonsPage/HackathonsPage"))
const DonatePage = React.lazy(() => import("./features/Donations/pages/DonatePage/DonatePage"))
const LoginPage = React.lazy(() => import("./features/Auth/pages/LoginPage/LoginPage"))
const LogoutPage = React.lazy(() => import("./features/Auth/pages/LogoutPage/LogoutPage"))
const ProfilePage = React.lazy(() => import("./features/Profiles/pages/ProfilePage/ProfilePage"))
function App() {
const { isWalletConnected } = useAppSelector(state => ({
@@ -76,27 +81,29 @@ function App() {
/>
</Helmet>
<Navbar />
<Suspense fallback={<LoadingPage />}>
<Routes>
<Route path="/products/hottest" element={<HottestPage />} />
<Route path="/products/category/:id" element={<CategoryPage />} />
<Route path="/products" element={<ExplorePage />} />
<Route path="/blog/post/:type/:id/*" element={<PostDetailsPage />} />
<Route path="/blog/preview-post/:type" element={<PreviewPostPage />} />
<Route path="/blog/create-post" element={<ProtectedRoute><CreatePostPage /></ProtectedRoute>} />
<Route path="/blog" element={<FeedPage />} />
<Route path="/hackathons" element={<HackathonsPage />} />
<Route element={<NavbarLayout />}>
<Route path="/products/hottest" element={<HottestPage />} />
<Route path="/products/category/:id" element={<CategoryPage />} />
<Route path="/products" element={<ExplorePage />} />
<Route path="/donate" element={<DonatePage />} />
<Route path="/blog/post/:type/:id/*" element={<PostDetailsPage />} />
<Route path="/blog" element={<FeedPage />} />
<Route path="/profile/:id/*" element={<ProfilePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/logout" element={<LogoutPage />} />
<Route path="/hackathons" element={<HackathonsPage />} />
<Route path="/donate" element={<DonatePage />} />
<Route path="/profile/:id/*" element={<ProfilePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/logout" element={<LogoutPage />} />
<Route path="/" element={<Navigate to="/products" />} />
</Route>
<Route path="/" element={<Navigate to="/products" />} />
</Routes>
</Suspense>
<ModalsContainer />

View File

@@ -35,8 +35,7 @@ const fileUrlToObject = async (url: string, fileName: string = 'filename') => {
const INSERT_IMAGE_ACTION = createAction<{ src: string, alt?: string }>('COVER_IMAGE_INSERTED')({ src: '', alt: "" })
export default function FilesInput({
const FilesInput = React.forwardRef<any, Props>(({
multiple,
value,
max = 3,
@@ -45,9 +44,8 @@ export default function FilesInput({
allowedType = 'images',
uploadText = 'Upload files',
...props
}: Props) {
}, ref) => {
const ref = useRef<HTMLInputElement>(null!)
const dispatch = useAppDispatch();
@@ -132,4 +130,7 @@ export default function FilesInput({
}
</>
)
}
})
export default FilesInput;

View File

@@ -1,20 +1,19 @@
import { useController } from "react-hook-form";
import Badge from "src/Components/Badge/Badge";
import CreatableSelect from 'react-select/creatable';
// import CreatableSelect from 'react-select/creatable';
import Select from 'react-select'
import { OnChangeValue, StylesConfig, components, OptionProps } from "react-select";
import { useOfficialTagsQuery } from "src/graphql";
import { OfficialTagsQuery, useOfficialTagsQuery } from "src/graphql";
import React from "react";
interface Option {
readonly label: string;
readonly value: string;
readonly icon: string | null
readonly description: string | null
}
type Tag = {
title: string,
icon: string | null
}
type Tag = Omit<OfficialTagsQuery['officialTags'][number], 'id'>
interface Props {
classes?: {
@@ -28,43 +27,80 @@ interface Props {
const transformer = {
tagToOption: (tag: Tag): Option => ({ label: tag.title, value: tag.title, icon: tag.icon }),
optionToTag: (o: Option): Tag => ({ title: o.value, icon: null })
tagToOption: (tag: Tag): Option => ({ label: tag.title, value: tag.title, icon: tag.icon, description: tag.description }),
optionToTag: (o: Option): Tag => ({ title: o.value, icon: o.icon, description: o.description, })
}
const OptionComponent = (props: OptionProps<Option>) => {
return (
<div>
<components.Option {...props} className='flex items-start'>
<span className={`rounded-8 w-40 h-40 text-center py-8`}>
<components.Option {...props} className='!flex items-center gap-16 !py-16'>
<div className={`rounded-8 w-40 h-40 text-center py-8 shrink-0 bg-gray-100`}>
{props.data.icon}
</span>
<span className="self-center px-16">
{props.data.label}
</span>
</div>
<div>
<p className="font-medium self-center">
{props.data.label}
</p>
<p className="text-body5 text-gray-500">
{props.data.description}
</p>
</div>
</components.Option>
</div>
);
};
const { ValueContainer, Placeholder } = components;
const CustomValueContainer = ({ children, ...props }: any) => {
return (
<ValueContainer {...props}>
{React.Children.map(children, child =>
child && child.type !== Placeholder ? child : null
)}
<Placeholder {...props} isFocused={props.isFocused}>
{props.selectProps.placeholder}
</Placeholder>
</ValueContainer>
);
};
const colourStyles: StylesConfig = {
control: (styles, state) => ({
...styles,
padding: '1px 4px',
borderRadius: 8,
padding: '1px 0',
border: 'none',
boxShadow: 'none',
":hover": {
cursor: "pointer"
}
}),
indicatorSeparator: (styles, state) => ({
multiValueRemove: (styles) => ({
...styles,
display: "none"
":hover": {
background: 'none'
}
}),
indicatorsContainer: () => ({ display: 'none' }),
clearIndicator: () => ({ display: 'none' }),
indicatorSeparator: () => ({ display: "none" }),
input: (styles, state) => ({
...styles,
" input": {
boxShadow: 'none !important'
},
}),
multiValue: styles => ({
...styles,
padding: '4px 12px',
borderRadius: 48,
fontWeight: 500
})
}
@@ -83,7 +119,7 @@ export default function TagsInput({
const handleChange = (newValue: OnChangeValue<Option, true>,) => {
onChange([...value, ...newValue.map(transformer.optionToTag)]);
onChange([...newValue.map(transformer.optionToTag)]);
onBlur();
}
@@ -94,29 +130,34 @@ export default function TagsInput({
}
const maxReached = value.length >= max;
const tagsOptions = (officalTags.data?.officialTags ?? []).filter(t => !value.some((v: Tag) => v.title === t.title)).map(transformer.tagToOption);
const currentPlaceholder = maxReached ? '' : value.length > 0 ? "Add Another..." : placeholder;
const tagsOptions = !maxReached ? (officalTags.data?.officialTags ?? []).filter(t => !value.some((v: Tag) => v.title === t.title)).map(transformer.tagToOption) : [];
return (
<div className={`${classes?.container}`}>
<CreatableSelect
<Select
isLoading={officalTags.loading}
options={tagsOptions}
isMulti
isDisabled={maxReached}
placeholder={maxReached ? `Max. ${max} tags reached. Remove a tag to add another.` : placeholder}
isClearable
value={[]}
isOptionDisabled={() => maxReached}
placeholder={currentPlaceholder}
noOptionsMessage={() => {
return maxReached
? "You've reached the max number of tags."
: "No tags available";
}}
closeMenuOnSelect={false}
value={value.map(transformer.tagToOption)}
onChange={handleChange as any}
onBlur={onBlur}
components={{
Option: OptionComponent,
MultiValue: () => <></>
// ValueContainer: CustomValueContainer
}}
styles={colourStyles as any}
theme={(theme) => ({
...theme,
@@ -127,9 +168,9 @@ export default function TagsInput({
},
})}
/>
<div className="flex mt-16 gap-8 flex-wrap">
{/* <div className="flex mt-16 gap-8 flex-wrap">
{(value as Tag[]).map((tag, idx) => <Badge color="gray" key={tag.title} onRemove={() => handleRemove(idx)} >{tag.title}</Badge>)}
</div>
</div> */}
</div>
)
}

View File

@@ -3,5 +3,6 @@ query OfficialTags {
id
title
icon
description
}
}

View File

@@ -1,5 +1,5 @@
import { FiBold, FiItalic, FiType, FiUnderline, FiAlignCenter, FiAlignLeft, FiAlignRight, FiCode } from 'react-icons/fi'
import { FaListOl, FaListUl, FaUndo, FaRedo, FaImage, FaYoutube } from 'react-icons/fa'
import { FaListOl, FaListUl, FaUndo, FaRedo, FaImage, FaYoutube, FaQuoteLeft } from 'react-icons/fa'
import { BiCodeCurly } from 'react-icons/bi';
@@ -73,6 +73,12 @@ export const cmdToBtn = {
tip: "Redo",
Icon: FaRedo,
},
blockquote: {
cmd: 'toggleBlockquote',
activeCmd: 'blockquote',
tip: "Block Quote",
Icon: FaQuoteLeft,
},
code: {
cmd: 'toggleCode',
activeCmd: 'code',

View File

@@ -11,7 +11,7 @@ interface Props extends ModalCard {
message?: string,
actionName?: string,
color?: 'red' | 'yellow' | 'blue'
callbackAction: PayloadAction<{ confirmed?: boolean }>
callbackAction: PayloadAction<{ confirmed?: boolean, id?: string | number }>
}
@@ -39,8 +39,9 @@ export default function ConfirmModal({
const dispatch = useAppDispatch();
const handleConfirm = () => {
const action = Object.assign({}, callbackAction);
action.payload = { confirmed: true }
action.payload = { confirmed: true, id: callbackAction.payload.id }
dispatch(action)
onClose?.();
}

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,5 +1,5 @@
import { PropsWithChildren } from "react";
import { Navigate, } from "react-router-dom";
import { Navigate, useLocation } from "react-router-dom";
import { useAppSelector } from "src/utils/hooks";
interface Props {
@@ -17,10 +17,14 @@ export default function ProtectedRoute({
const user = useAppSelector(state => state.user.me);
const location = useLocation();
if (user === undefined) return <></>
if (user === null)
return <Navigate to={notAuthorizedRedirectPath} replace />;
return <Navigate to={notAuthorizedRedirectPath} replace state={{ from: location.pathname }} />;
if (!isAllowed) {

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from "react"
import { Helmet } from "react-helmet";
import { Grid } from "react-loader-spinner";
import { useNavigate } from "react-router-dom";
import { useNavigate, useLocation } from "react-router-dom";
import { useMeQuery } from "src/graphql"
import { CONSTS } from "src/utils";
import { QRCodeSVG } from 'qrcode.react';
@@ -9,6 +9,7 @@ import { IoRocketOutline } from "react-icons/io5";
import Button from "src/Components/Button/Button";
import { FiCopy } from "react-icons/fi";
import useCopyToClipboard from "src/utils/hooks/useCopyToClipboard";
import { getPropertyFromUnknown, } from "src/utils/helperFunctions";
@@ -58,6 +59,7 @@ const useLnurlQuery = () => {
export default function LoginPage() {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const [copied, setCopied] = useState(false);
const { loadingLnurl, data: { lnurl, session_token }, error } = useLnurlQuery();
@@ -75,7 +77,10 @@ export default function LoginPage() {
setIsLoggedIn(true);
meQuery.stopPolling();
setTimeout(() => {
navigate('/')
const cameFrom = getPropertyFromUnknown(location.state, 'from');
const navigateTo = cameFrom ? cameFrom : '/'
navigate(navigateTo)
}, 2000)
}

View File

@@ -33,7 +33,7 @@ export default function BountyCard({ bounty }: Props) {
return (
<div className="bg-white rounded-12 overflow-hidden border-2">
<img src={bounty.cover_image} className='h-[200px] w-full object-cover bg-gray-100' alt="" />
{bounty.cover_image && <img src={bounty.cover_image} className='h-[200px] w-full object-cover bg-gray-100' alt="" />}
<div className="p-24">
<Header author={bounty.author} date={bounty.createdAt} />
<div className="flex flex-col gap-8 md:gap-0 md:flex-row justify-between">

View File

@@ -25,7 +25,7 @@ import {
} from 'remirror/extensions';
import { ExtensionPriority, InvalidContentHandler } from 'remirror';
import { EditorComponent, Remirror, useRemirror } from '@remirror/react';
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import TextEditorComponents from 'src/Components/Inputs/TextEditor';
import Toolbar from './Toolbar';
@@ -35,7 +35,7 @@ turndownService.keep(['iframe']);
interface Props {
placeholder?: string;
initialContent?: string;
initialContent?: () => string;
name?: string;
}
@@ -100,7 +100,7 @@ export default function ContentEditor({ placeholder, initialContent, name }: Pro
<div className={`remirror-theme ${styles.wrapper} post-body bg-white`}>
<Remirror
manager={manager}
initialContent={initialContent}
initialContent={initialContent?.()}
>
<TextEditorComponents.SaveModule name={name} />
<Toolbar />

View File

@@ -17,6 +17,7 @@ export default function Toolbar() {
{/* <TextEditorComponents.ToolButton cmd='leftAlign' />
<TextEditorComponents.ToolButton cmd='centerAlign' />
<TextEditorComponents.ToolButton cmd='rightAlign' /> */}
<TextEditorComponents.ToolButton cmd='blockquote' />
<TextEditorComponents.ToolButton cmd='code' />
<TextEditorComponents.ToolButton cmd='codeBlock' />
<TextEditorComponents.ToolButton cmd='bulletList' />

View File

@@ -8,8 +8,6 @@
border-bottom-right-radius: inherit;
min-height: var(--rmr-space-7);
@include post-body;
a {
color: rgb(54, 139, 236);
@@ -18,6 +16,14 @@
cursor: pointer;
}
}
li {
& > label {
display: none;
}
}
@include post-body;
}
.remirror-editor-wrapper {
height: 60vh;

View File

@@ -0,0 +1,119 @@
import { createAction } from '@reduxjs/toolkit';
import React, { useCallback, useState } from 'react'
import { useFormContext } from 'react-hook-form';
import Button from 'src/Components/Button/Button';
import LoadingPage from 'src/Components/LoadingPage/LoadingPage';
import { isStory } from 'src/features/Posts/types';
import { Post_Type, useDeleteStoryMutation, useGetMyDraftsQuery, usePostDetailsLazyQuery } from 'src/graphql'
import { openModal } from 'src/redux/features/modals.slice';
import { NotificationsService } from 'src/services';
import { getDateDifference } from 'src/utils/helperFunctions';
import { useAppDispatch } from 'src/utils/hooks';
import { useReduxEffect } from 'src/utils/hooks/useReduxEffect';
import { IStoryFormInputs } from '../../CreateStoryPage/CreateStoryPage';
interface Props {
type: Post_Type,
onDraftLoad?: () => void,
}
const CONFIRM_DELETE_STORY = createAction<{ confirmed?: boolean, id: number }>('DELETE_STORY_CONFIRMED')({ id: -1 })
export default function DraftsContainer({ type, onDraftLoad }: Props) {
const myDraftsQuery = useGetMyDraftsQuery({ variables: { type } });
const [fetchDraft] = usePostDetailsLazyQuery();
const [deleteStory] = useDeleteStoryMutation({
refetchQueries: ['GetMyDrafts']
})
const { setValue } = useFormContext<IStoryFormInputs>()
const dispatch = useAppDispatch();
const [loading, setLoading] = useState(false)
const loadDraft = (id: number) => {
if (!loading)
setLoading(true);
fetchDraft({ variables: { type, id } })
.then(({ data }) => {
// data.data?.getPostById.
if (data?.getPostById) {
if (isStory(data.getPostById)) {
setValue('id', data.getPostById.id);
setValue('title', data.getPostById.title);
setValue('tags', data.getPostById.tags);
setValue('body', data.getPostById.body);
setValue('cover_image', data.getPostById.cover_image ? [data.getPostById.cover_image] : []);
setValue('is_published', data.getPostById.is_published);
}
}
onDraftLoad?.()
})
.catch(() => {
NotificationsService.error("Unexpected error happened...")
})
.finally(() => {
setLoading(false);
})
}
const onConfirmDelete = useCallback(({ payload: { confirmed, id } }: typeof CONFIRM_DELETE_STORY) => {
if (confirmed)
deleteStory({
variables: {
deleteStoryId: id
}
})
}, [deleteStory])
useReduxEffect(onConfirmDelete, CONFIRM_DELETE_STORY.type);
const deleteDraft = (id: number) => {
dispatch(openModal({
Modal: "ConfirmModal",
props: {
callbackAction: {
type: CONFIRM_DELETE_STORY.type,
payload: {
id
}
},
actionName: "Delete",
title: "Delete Draft",
message: "Are you sure you want to delete this draft ??",
color: "red"
}
}))
}
return (
<>
{(!myDraftsQuery.loading && myDraftsQuery.data?.getMyDrafts && myDraftsQuery.data.getMyDrafts.length > 0) &&
<div className="border-2 border-gray-200 rounded-16 p-16">
<p className="text-body2 font-bolder mb-16">Saved Drafts</p>
<ul className=''>
{myDraftsQuery.data.getMyDrafts.map(draft =>
<li key={draft.id} className='py-16 border-b-[1px] border-gray-200 last-of-type:border-b-0 ' >
<p
className="hover:underline"
role={'button'}
onClick={() => loadDraft(draft.id)}
>
{draft.title}
</p>
<div className="flex gap-4 text-body5">
<p className="text-gray-400">Last edited {getDateDifference(draft.updatedAt)} ago</p>
<Button size='sm' color='none' className='text-blue-500 !p-0' onClick={() => deleteDraft(draft.id)}>Delete draft</Button>
</div>
</li>)}
</ul>
</div>}
{loading && <LoadingPage />}
</>
)
}

View File

@@ -0,0 +1,19 @@
query GetMyDrafts($type: POST_TYPE!) {
getMyDrafts(type: $type) {
... on Story {
id
title
updatedAt
}
... on Bounty {
id
title
updatedAt
}
... on Question {
id
title
updatedAt
}
}
}

View File

@@ -0,0 +1,29 @@
import React, { forwardRef } from 'react'
import { useFormContext } from 'react-hook-form'
import { IStoryFormInputs } from '../../CreateStoryPage/CreateStoryPage';
const ErrorsContainer = forwardRef<HTMLDivElement>((props, ref) => {
const { formState: { isValid, isSubmitted, errors } } = useFormContext<IStoryFormInputs>();
return (
<div ref={ref}>
{(!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>
)
})
export default ErrorsContainer;

View File

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

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
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,8 +38,7 @@ 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-h2 font-bolder">{post.title}</h1>
<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'>
{tag.title}

View File

@@ -1,93 +1,47 @@
import { useState } from 'react'
import { yupResolver } from "@hookform/resolvers/yup";
import { Controller, FormProvider, NestedValue, Resolver, SubmitHandler, useForm } from "react-hook-form";
import { useEffect, useState } from 'react'
import { Controller, useFormContext } 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";
import * as yup from "yup";
import ContentEditor from "../ContentEditor/ContentEditor";
import { useCreateStoryMutation } from 'src/graphql'
import { useNavigate } from 'react-router-dom'
import { useAppDispatch, useAppSelector } from 'src/utils/hooks';
import { useAppDispatch, } from 'src/utils/hooks';
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';
import { CreateStoryType, IStoryFormInputs } from '../../CreateStoryPage/CreateStoryPage';
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().trim().required().min(10, 'the title is too short'),
tags: yup.array().required().min(1, 'please pick at least one relevant tag'),
body: yup.string().required().min(50, 'stories should have a minimum of 10 words'),
cover_image: yup.array().of(FileSchema as any).min(1, "You need to add a cover image")
}).required();
interface IFormInputs {
id: number | null
title: string
tags: NestedValue<{ title: string }[]>
cover_image: NestedValue<File[]> | NestedValue<string[]>
body: string
interface Props {
isUpdating?: boolean;
isPublished?: boolean;
onSuccess?: (isDraft: boolean) => void,
onValidationError?: () => void
}
const storageService = new StorageService<CreateStoryType>('story-edit');
export type CreateStoryType = Override<IFormInputs, {
tags: { title: string }[]
cover_image: File[] | string[]
}>
export default function StoryForm() {
export default function StoryForm(props: Props) {
const dispatch = useAppDispatch();
const { story } = useAppSelector(state => ({
story: state.staging.story
}))
const navigate = useNavigate();
const { handleSubmit, control, register, trigger, getValues, watch, reset } = useFormContext<IStoryFormInputs>();
const formMethods = useForm<IFormInputs>({
resolver: yupResolver(schema) as Resolver<IFormInputs>,
defaultValues: {
id: story?.id ?? null,
title: story?.title ?? '',
cover_image: story?.cover_image ?? [],
tags: story?.tags ?? [],
body: story?.body ?? '',
},
});
const { handleSubmit, control, register, formState: { errors, }, trigger, getValues, } = formMethods;
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const [editMode, setEditMode] = useState(true)
const [loading, setLoading] = useState(false);
const [createStory] = useCreateStoryMutation({
onCompleted: (data) => {
navigate(createRoute({ type: 'story', id: data.createStory?.id!, title: data.createStory?.title }))
setLoading(false)
},
onError: (error) => {
NotificationsService.error('Unexpected error happened, please try again', { error })
setLoading(false)
}
});
const presistPost = useThrottledCallback((value) => storageService.set(value), [], 1000)
useEffect(() => {
const subscription = watch(({ id, is_published, ...values }) => presistPost(values));
return () => subscription.unsubscribe();
}, [presistPost, watch]);
const clickPreview = async () => {
const isValid = await trigger();
@@ -95,13 +49,30 @@ export default function StoryForm() {
if (isValid) {
const data = getValues()
dispatch(stageStory(data))
navigate('/blog/preview-post/Story')
setEditMode(false);
} else {
clickSubmit(); // I'm doing this so that the react-hook-form attaches onChange listener to inputs validation
clickSubmit(false)(); // I'm doing this so that the react-hook-form attaches onChange listener to inputs validation
}
}
const clickSubmit = handleSubmit<IFormInputs>(data => {
const [createStory] = useCreateStoryMutation({
onCompleted: (data) => {
if (data.createStory?.is_published)
navigate(createRoute({ type: 'story', id: data.createStory?.id!, title: data.createStory?.title }))
else
reset()
props.onSuccess?.(!!data.createStory?.is_published);
setLoading(false)
},
onError: (error) => {
NotificationsService.error('Unexpected error happened, please try again', { error })
setLoading(false)
},
refetchQueries: ['GetMyDrafts']
});
const clickSubmit = (publish_now: boolean) => handleSubmit<IStoryFormInputs>(data => {
setLoading(true);
createStory({
variables: {
@@ -110,28 +81,38 @@ export default function StoryForm() {
title: data.title,
body: data.body,
tags: data.tags.map(t => t.title),
cover_image: (data.cover_image[0] ?? '') as string,
is_published: publish_now,
cover_image: (data.cover_image[0] ?? null) as string | null,
},
}
})
})
storageService.clear();
}, props.onValidationError);
const isUpdating = story?.id;
const postId = watch('id') ?? -1;
return (
<FormProvider {...formMethods}>
<form
onSubmit={clickSubmit}
>
<form
onSubmit={clickSubmit(true)}
>
<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>
{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 } }) => (
render={({ field: { onChange, value, onBlur, ref } }) => (
<FilesInput
ref={ref}
value={value}
onBlur={onBlur}
onChange={onChange}
@@ -139,65 +120,56 @@ export default function StoryForm() {
/>
)}
/>
<p className='input-error'>{errors.cover_image?.message}</p>
<p className="text-body5 mt-16">
Title
</p>
<div className="input-wrapper mt-8 relative">
<div className="mt-16 relative">
<input
autoFocus
type='text'
className="input-text"
placeholder='Your Story Title'
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>
{errors.title && <p className="input-error">
{errors.title.message}
</p>}
<p className="text-body5 mt-16">
Tags
</p>
<TagsInput
placeholder="Add up to 5 tags. Search popular ones or add your own"
classes={{ container: 'mt-8' }}
placeholder="Add up to 5 popular tags..."
classes={{ container: 'mt-16' }}
/>
{errors.tags && <p className="input-error">
{errors.tags.message}
</p>}
</div>
<ContentEditor
initialContent={story?.body}
key={postId}
initialContent={() => getValues().body}
placeholder="Write your story content here..."
name="body"
/>
{errors.body && <p className="input-error py-8 px-16">
{errors.body.message}
</p>}
</div>
<div className="flex gap-16 mt-32">
<Button
type='submit'
color="primary"
disabled={loading}
>
{isUpdating ?
loading ? "Updating..." : "Update" :
loading ? "Publishing..." : "Publish"
}
</Button>
</>}
{!editMode && <PreviewPostCard post={{ ...getValues(), cover_image: getValues().cover_image[0] }} />}
<div className="flex gap-16 mt-32">
<Button
type='submit'
color="primary"
disabled={loading}
>
{props.isUpdating ?
"Update" :
"Publish"
}
</Button>
{!props.isPublished &&
<Button
color="gray"
onClick={clickPreview}
disabled={loading}
onClick={clickSubmit(false)}
>
Preview
</Button>
</div>
</form>
</FormProvider >
Save as Draft
</Button>}
</div>
</form>
)
}

View File

@@ -9,6 +9,7 @@ mutation createStory($data: StoryInputType) {
title
}
votes_count
is_published
type
cover_image
comments_count

View File

@@ -4,26 +4,26 @@ import { FiArrowLeft } from "react-icons/fi";
import { useNavigate, useParams } from "react-router-dom";
import BountyForm from "./Components/BountyForm/BountyForm";
import QuestionForm from "./Components/QuestionForm/QuestionForm";
import StoryForm from "./Components/StoryForm/StoryForm";
import PostTypeList from "./PostTypeList";
import CreateStoryPage from "./CreateStoryPage/CreateStoryPage";
interface Props {
}
export default function CreatePostPage() {
const { type } = useParams()
const [postType, setPostType] = useState<'story' | 'bounty' | 'question'>((type as any) ?? 'story');
const [postType] = useState<'story' | 'bounty' | 'question'>((type as any) ?? 'story');
const navigate = useNavigate();
return (<>
<Helmet>
<title>Create Post</title>
{postType === 'story' && <title>Create Story</title>}
{postType === 'bounty' && <title>Create Bounty</title>}
{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="">
@@ -36,14 +36,12 @@ 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">
{/* <h2 className="text-h2 font-bolder text-gray-800 mb-32">
Write a Story
</h2>
<StoryForm />
</h2> */}
<CreateStoryPage />
</>}
{postType === 'bounty' && <>
<h2 className="text-h2 font-bolder text-gray-800 mb-32">

View File

@@ -0,0 +1,109 @@
import { yupResolver } from "@hookform/resolvers/yup";
import { useRef, useState } from "react";
import { FormProvider, NestedValue, Resolver, useForm } from "react-hook-form";
import { Post_Type } from "src/graphql";
import { StorageService } from "src/services";
import { useAppSelector } from "src/utils/hooks";
import { Override } from "src/utils/interfaces";
import * as yup from "yup";
import DraftsContainer from "../Components/DraftsContainer/DraftsContainer";
import ErrorsContainer from "../Components/ErrorsContainer/ErrorsContainer";
import StoryForm from "../Components/StoryForm/StoryForm";
import styles from './styles.module.scss'
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().trim().required().min(10, 'Story title must be 2+ words'),
tags: yup.array().required().min(1, 'Add at least one tag'),
body: yup.string().required().min(50, 'Post must contain at least 10+ words'),
cover_image: yup.array().of(FileSchema as any)
}).required();
export interface IStoryFormInputs {
id: number | null
title: string
tags: NestedValue<{ title: string }[]>
cover_image: NestedValue<File[]> | NestedValue<string[]>
body: string
is_published: boolean | null
}
export type CreateStoryType = Override<IStoryFormInputs, {
tags: { title: string }[]
cover_image: File[] | string[]
}>
const storageService = new StorageService<CreateStoryType>('story-edit');
export default function CreateStoryPage() {
const { story } = useAppSelector(state => ({
story: state.staging.story || storageService.get()
}))
const formMethods = useForm<IStoryFormInputs>({
resolver: yupResolver(schema) as Resolver<IStoryFormInputs>,
shouldFocusError: false,
defaultValues: {
id: story?.id ?? null,
title: story?.title ?? '',
cover_image: story?.cover_image ?? [],
tags: story?.tags ?? [],
body: story?.body ?? '',
is_published: story?.is_published ?? false,
},
});
const errorsContainerRef = useRef<HTMLDivElement>(null!);
const [formKey, setFormKey] = useState(1)
const resetForm = () => setFormKey(v => v + 1)
return (
<FormProvider {...formMethods}>
<div className={styles.grid}>
<div id="form">
<StoryForm
key={formKey}
isPublished={!!story?.is_published}
isUpdating={!!story?.id}
onSuccess={() => resetForm()}
onValidationError={() => errorsContainerRef.current.scrollIntoView({ behavior: 'smooth', block: "center" })}
/>
</div>
<div id="errors">
<ErrorsContainer ref={errorsContainerRef} />
</div>
<div id="drafts">
<DraftsContainer type={Post_Type.Story} onDraftLoad={resetForm} />
</div>
</div>
</FormProvider>
)
}

View File

@@ -0,0 +1,36 @@
@import "/src/styles/mixins/index.scss";
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 32px;
& > * {
min-width: 0;
}
grid-template-areas:
"errors"
"form"
"drafts";
:global {
#errors {
grid-area: errors;
}
#form {
grid-area: form;
}
#drafts {
grid-area: drafts;
}
}
@include gt-xl {
grid-template-columns: 1fr calc(min(326px, 25%));
grid-template-areas:
"form errors"
"form drafts"
"form .";
}
}

View File

@@ -2,7 +2,7 @@
import { useUpdateEffect } from '@react-hookz/web'
import { useState } from 'react'
import { useFeedQuery } from 'src/graphql'
import { useAppSelector, useInfiniteQuery } from 'src/utils/hooks'
import { useAppSelector, useInfiniteQuery, usePreload } from 'src/utils/hooks'
import PostsList from '../../Components/PostsList/PostsList'
import TrendingCard from '../../Components/TrendingCard/TrendingCard'
import PopularTagsFilter, { FilterTag } from './PopularTagsFilter/PopularTagsFilter'
@@ -32,6 +32,8 @@ export default function FeedPage() {
const { fetchMore, isFetchingMore, variablesChanged } = useInfiniteQuery(feedQuery, 'getFeed')
useUpdateEffect(variablesChanged, [sortByFilter, tagFilter]);
usePreload('PostPage');
const { navHeight, isLoggedIn } = useAppSelector((state) => ({
navHeight: state.ui.navHeight,
isLoggedIn: Boolean(state.user.me),
@@ -79,14 +81,13 @@ export default function FeedPage() {
top: `${navHeight + 16}px`,
maxHeight: `calc(100vh - ${navHeight}px - 16px)`,
}}>
{isLoggedIn &&
<Button
href='/blog/create-post'
color='primary'
fullWidth
>
Write a story
</Button>}
<Button
href='/blog/create-post'
color='primary'
fullWidth
>
Write a story
</Button>
<div className="my-24"></div>
<div className="my-24"></div>
<PopularTagsFilter

View File

@@ -28,10 +28,10 @@ export default function AuthorCard({ author }: Props) {
</div>
<Button
fullWidth
href={`/profile/${author.id}`}
href={createRoute({ type: 'profile', id: author.id, username: author.name })}
color="primary"
className="mt-16">
Follow
Maker's Profile
</Button>
</div>
)

View File

@@ -49,7 +49,7 @@ export default function StoryPageContent({ story }: Props) {
Delete
</MenuItem>
</Menu>}
<h1 className="text-h2 font-bolder">{story.title}</h1>
<h1 className="text-[42px] font-bolder">{story.title}</h1>
<Header size="lg" showTimeAgo={false} author={story.author} date={story.createdAt} />
{story.tags.length > 0 && <div className="flex gap-8">
{story.tags.map(tag => <Badge key={tag.id} size='sm'>

View File

@@ -28,18 +28,18 @@ export const useUpdateStory = (story: Story) => {
const handleEdit = () => {
dispatch(stageStory({
...story,
cover_image: [story.cover_image]
cover_image: story.cover_image ? [story.cover_image] : []
}))
navigate("/blog/create-post?type=story")
};
const onInsertImage = useCallback(({ payload: { confirmed } }: typeof CONFIRM_DELETE_STORY) => {
const onConfirmDelete = useCallback(({ payload: { confirmed } }: typeof CONFIRM_DELETE_STORY) => {
if (confirmed)
deleteMutation()
}, [deleteMutation])
useReduxEffect(onInsertImage, CONFIRM_DELETE_STORY.type);
useReduxEffect(onConfirmDelete, CONFIRM_DELETE_STORY.type);
const handleDelete = () => {
dispatch(openModal({

View File

@@ -18,6 +18,7 @@ query PostDetails($id: Int!, $type: POST_TYPE!) {
votes_count
type
cover_image
is_published
comments_count
comments {
id

View File

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

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

@@ -30,9 +30,10 @@ const schema: yup.SchemaOf<IFormInputs> = yup.object({
if (value) {
const [name, domain] = value.split("@");
const lnurl = `https://${domain}/.well-known/lnurlp/${name}`;
await fetch(lnurl);
const res = await fetch(lnurl);
if (res.status === 200) return true;
}
return true;
return false;
} catch (error) {
return false;
}
@@ -141,7 +142,7 @@ export default function UpdateAboutForm({ data, onClose }: Props) {
type='text'
className="input-text"
placeholder="UK, London"
placeholder="Back-end Developer"
{...register("jobTitle")}
/>
</div>

View File

@@ -40,15 +40,17 @@ export type Bounty = PostBase & {
applications: Array<BountyApplication>;
author: Author;
body: Scalars['String'];
cover_image: Scalars['String'];
cover_image: Maybe<Scalars['String']>;
createdAt: Scalars['Date'];
deadline: Scalars['String'];
excerpt: Scalars['String'];
id: Scalars['Int'];
is_published: Maybe<Scalars['Boolean']>;
reward_amount: Scalars['Int'];
tags: Array<Tag>;
title: Scalars['String'];
type: Scalars['String'];
updatedAt: Scalars['Date'];
votes_count: Scalars['Int'];
};
@@ -174,7 +176,9 @@ export type PostBase = {
createdAt: Scalars['Date'];
excerpt: Scalars['String'];
id: Scalars['Int'];
is_published: Maybe<Scalars['Boolean']>;
title: Scalars['String'];
updatedAt: Scalars['Date'];
votes_count: Scalars['Int'];
};
@@ -214,6 +218,7 @@ export type Query = {
getDonationsStats: DonationsStats;
getFeed: Array<Post>;
getLnurlDetailsForProject: LnurlDetails;
getMyDrafts: Array<Post>;
getPostById: Post;
getProject: Project;
getTrendingPosts: Array<Post>;
@@ -258,6 +263,11 @@ export type QueryGetLnurlDetailsForProjectArgs = {
};
export type QueryGetMyDraftsArgs = {
type: Post_Type;
};
export type QueryGetPostByIdArgs = {
id: Scalars['Int'];
type: Post_Type;
@@ -308,9 +318,11 @@ export type Question = PostBase & {
createdAt: Scalars['Date'];
excerpt: Scalars['String'];
id: Scalars['Int'];
is_published: Maybe<Scalars['Boolean']>;
tags: Array<Tag>;
title: Scalars['String'];
type: Scalars['String'];
updatedAt: Scalars['Date'];
votes_count: Scalars['Int'];
};
@@ -320,26 +332,30 @@ export type Story = PostBase & {
body: Scalars['String'];
comments: Array<PostComment>;
comments_count: Scalars['Int'];
cover_image: Scalars['String'];
cover_image: Maybe<Scalars['String']>;
createdAt: Scalars['Date'];
excerpt: Scalars['String'];
id: Scalars['Int'];
is_published: Maybe<Scalars['Boolean']>;
tags: Array<Tag>;
title: Scalars['String'];
type: Scalars['String'];
updatedAt: Scalars['Date'];
votes_count: Scalars['Int'];
};
export type StoryInputType = {
body: Scalars['String'];
cover_image: Scalars['String'];
cover_image: InputMaybe<Scalars['String']>;
id: InputMaybe<Scalars['Int']>;
is_published: InputMaybe<Scalars['Boolean']>;
tags: Array<Scalars['String']>;
title: Scalars['String'];
};
export type Tag = {
__typename?: 'Tag';
description: Maybe<Scalars['String']>;
icon: Maybe<Scalars['String']>;
id: Scalars['Int'];
isOfficial: Maybe<Scalars['Boolean']>;
@@ -401,7 +417,7 @@ export type Vote = {
export type OfficialTagsQueryVariables = Exact<{ [key: string]: never; }>;
export type OfficialTagsQuery = { __typename?: 'Query', officialTags: Array<{ __typename?: 'Tag', id: number, title: string, icon: string | null }> };
export type OfficialTagsQuery = { __typename?: 'Query', officialTags: Array<{ __typename?: 'Tag', id: number, title: string, icon: string | null, description: string | null }> };
export type NavCategoriesQueryVariables = Exact<{ [key: string]: never; }>;
@@ -453,12 +469,19 @@ export type TrendingPostsQueryVariables = Exact<{ [key: string]: never; }>;
export type TrendingPostsQuery = { __typename?: 'Query', getTrendingPosts: Array<{ __typename?: 'Bounty', id: number, title: string, author: { __typename?: 'Author', id: number, avatar: string } } | { __typename?: 'Question', id: number, title: string, author: { __typename?: 'Author', id: number, avatar: string } } | { __typename?: 'Story', id: number, title: string, author: { __typename?: 'Author', id: number, avatar: string } }> };
export type GetMyDraftsQueryVariables = Exact<{
type: Post_Type;
}>;
export type GetMyDraftsQuery = { __typename?: 'Query', getMyDrafts: Array<{ __typename?: 'Bounty', id: number, title: string, updatedAt: any } | { __typename?: 'Question', id: number, title: string, updatedAt: any } | { __typename?: 'Story', id: number, title: string, updatedAt: any }> };
export type CreateStoryMutationVariables = Exact<{
data: InputMaybe<StoryInputType>;
}>;
export type CreateStoryMutation = { __typename?: 'Mutation', createStory: { __typename?: 'Story', id: number, title: string, createdAt: any, body: string, votes_count: number, type: string, cover_image: string, comments_count: number, tags: Array<{ __typename?: 'Tag', id: number, title: string }> } | null };
export type CreateStoryMutation = { __typename?: 'Mutation', createStory: { __typename?: 'Story', id: number, title: string, createdAt: any, body: string, votes_count: number, is_published: boolean | null, type: string, cover_image: string | null, comments_count: number, tags: Array<{ __typename?: 'Tag', id: number, title: string }> } | null };
export type DeleteStoryMutationVariables = Exact<{
deleteStoryId: Scalars['Int'];
@@ -480,7 +503,7 @@ export type FeedQueryVariables = Exact<{
}>;
export type FeedQuery = { __typename?: 'Query', getFeed: Array<{ __typename?: 'Bounty', id: number, title: string, createdAt: any, excerpt: string, votes_count: number, type: string, cover_image: string, deadline: string, reward_amount: number, applicants_count: number, author: { __typename?: 'Author', id: number, name: string, avatar: string, join_date: any }, tags: Array<{ __typename?: 'Tag', id: number, title: string }> } | { __typename?: 'Question', id: number, title: string, createdAt: any, excerpt: string, votes_count: number, type: string, answers_count: number, author: { __typename?: 'Author', id: number, name: string, avatar: string, join_date: any }, tags: Array<{ __typename?: 'Tag', id: number, title: string }>, comments: Array<{ __typename?: 'PostComment', id: number, createdAt: any, body: string, author: { __typename?: 'Author', id: number, name: string, avatar: string, join_date: any } }> } | { __typename?: 'Story', id: number, title: string, createdAt: any, excerpt: string, votes_count: number, type: string, cover_image: string, comments_count: number, author: { __typename?: 'Author', id: number, name: string, avatar: string, join_date: any }, tags: Array<{ __typename?: 'Tag', id: number, title: string }> }> };
export type FeedQuery = { __typename?: 'Query', getFeed: Array<{ __typename?: 'Bounty', id: number, title: string, createdAt: any, excerpt: string, votes_count: number, type: string, cover_image: string | null, deadline: string, reward_amount: number, applicants_count: number, author: { __typename?: 'Author', id: number, name: string, avatar: string, join_date: any }, tags: Array<{ __typename?: 'Tag', id: number, title: string }> } | { __typename?: 'Question', id: number, title: string, createdAt: any, excerpt: string, votes_count: number, type: string, answers_count: number, author: { __typename?: 'Author', id: number, name: string, avatar: string, join_date: any }, tags: Array<{ __typename?: 'Tag', id: number, title: string }>, comments: Array<{ __typename?: 'PostComment', id: number, createdAt: any, body: string, author: { __typename?: 'Author', id: number, name: string, avatar: string, join_date: any } }> } | { __typename?: 'Story', id: number, title: string, createdAt: any, excerpt: string, votes_count: number, type: string, cover_image: string | null, comments_count: number, author: { __typename?: 'Author', id: number, name: string, avatar: string, join_date: any }, tags: Array<{ __typename?: 'Tag', id: number, title: string }> }> };
export type PostDetailsQueryVariables = Exact<{
id: Scalars['Int'];
@@ -488,7 +511,7 @@ export type PostDetailsQueryVariables = Exact<{
}>;
export type PostDetailsQuery = { __typename?: 'Query', getPostById: { __typename?: 'Bounty', id: number, title: string, createdAt: any, body: string, votes_count: number, type: string, cover_image: string, deadline: string, reward_amount: number, applicants_count: number, author: { __typename?: 'Author', id: number, name: string, avatar: string, join_date: any }, tags: Array<{ __typename?: 'Tag', id: number, title: string }>, applications: Array<{ __typename?: 'BountyApplication', id: number, date: string, workplan: string, author: { __typename?: 'Author', id: number, name: string, avatar: string } }> } | { __typename?: 'Question', id: number, title: string, createdAt: any, body: string, votes_count: number, type: string, answers_count: number, author: { __typename?: 'Author', id: number, name: string, avatar: string, join_date: any }, tags: Array<{ __typename?: 'Tag', id: number, title: string }>, comments: Array<{ __typename?: 'PostComment', id: number, createdAt: any, body: string, votes_count: number, parentId: number | null, author: { __typename?: 'Author', id: number, name: string, avatar: string } }> } | { __typename?: 'Story', id: number, title: string, createdAt: any, body: string, votes_count: number, type: string, cover_image: string, comments_count: number, author: { __typename?: 'Author', id: number, name: string, avatar: string, join_date: any }, tags: Array<{ __typename?: 'Tag', id: number, title: string }>, comments: Array<{ __typename?: 'PostComment', id: number, createdAt: any, body: string, votes_count: number, parentId: number | null, author: { __typename?: 'Author', id: number, name: string, avatar: string } }> } };
export type PostDetailsQuery = { __typename?: 'Query', getPostById: { __typename?: 'Bounty', id: number, title: string, createdAt: any, body: string, votes_count: number, type: string, cover_image: string | null, deadline: string, reward_amount: number, applicants_count: number, author: { __typename?: 'Author', id: number, name: string, avatar: string, join_date: any }, tags: Array<{ __typename?: 'Tag', id: number, title: string }>, applications: Array<{ __typename?: 'BountyApplication', id: number, date: string, workplan: string, author: { __typename?: 'Author', id: number, name: string, avatar: string } }> } | { __typename?: 'Question', id: number, title: string, createdAt: any, body: string, votes_count: number, type: string, answers_count: number, author: { __typename?: 'Author', id: number, name: string, avatar: string, join_date: any }, tags: Array<{ __typename?: 'Tag', id: number, title: string }>, comments: Array<{ __typename?: 'PostComment', id: number, createdAt: any, body: string, votes_count: number, parentId: number | null, author: { __typename?: 'Author', id: number, name: string, avatar: string } }> } | { __typename?: 'Story', id: number, title: string, createdAt: any, body: string, votes_count: number, type: string, cover_image: string | null, is_published: boolean | null, comments_count: number, author: { __typename?: 'Author', id: number, name: string, avatar: string, join_date: any }, tags: Array<{ __typename?: 'Tag', id: number, title: string }>, comments: Array<{ __typename?: 'PostComment', id: number, createdAt: any, body: string, votes_count: number, parentId: number | null, author: { __typename?: 'Author', id: number, name: string, avatar: string } }> } };
export type ProfileQueryVariables = Exact<{
profileId: Scalars['Int'];
@@ -557,6 +580,7 @@ export const OfficialTagsDocument = gql`
id
title
icon
description
}
}
`;
@@ -916,6 +940,55 @@ 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 GetMyDraftsDocument = gql`
query GetMyDrafts($type: POST_TYPE!) {
getMyDrafts(type: $type) {
... on Story {
id
title
updatedAt
}
... on Bounty {
id
title
updatedAt
}
... on Question {
id
title
updatedAt
}
}
}
`;
/**
* __useGetMyDraftsQuery__
*
* To run a query within a React component, call `useGetMyDraftsQuery` and pass it any options that fit your needs.
* When your component renders, `useGetMyDraftsQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useGetMyDraftsQuery({
* variables: {
* type: // value for 'type'
* },
* });
*/
export function useGetMyDraftsQuery(baseOptions: Apollo.QueryHookOptions<GetMyDraftsQuery, GetMyDraftsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetMyDraftsQuery, GetMyDraftsQueryVariables>(GetMyDraftsDocument, options);
}
export function useGetMyDraftsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetMyDraftsQuery, GetMyDraftsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetMyDraftsQuery, GetMyDraftsQueryVariables>(GetMyDraftsDocument, options);
}
export type GetMyDraftsQueryHookResult = ReturnType<typeof useGetMyDraftsQuery>;
export type GetMyDraftsLazyQueryHookResult = ReturnType<typeof useGetMyDraftsLazyQuery>;
export type GetMyDraftsQueryResult = Apollo.QueryResult<GetMyDraftsQuery, GetMyDraftsQueryVariables>;
export const CreateStoryDocument = gql`
mutation createStory($data: StoryInputType) {
createStory(data: $data) {
@@ -928,6 +1001,7 @@ export const CreateStoryDocument = gql`
title
}
votes_count
is_published
type
cover_image
comments_count
@@ -1159,6 +1233,7 @@ export const PostDetailsDocument = gql`
votes_count
type
cover_image
is_published
comments_count
comments {
id

View File

@@ -5,27 +5,37 @@ export const tags = [
{
id: 1,
title: 'Bitcoin',
description: 'Lorem ipsum dolor sit amort consectetur, adipisicing elit. Possimus officia sit numquam nobis iure atque ab sunt nihil voluptatibus',
icon: "🅱",
isOfficial: true,
},
{
id: 2,
title: 'Lightning',
description: 'Lorem ipsum dolor sit amort consectetur, adipisicing elit. Possimus officia sit numquam nobis iure atque ab sunt nihil voluptatibus',
icon: "⚡",
isOfficial: true,
},
{
id: 3,
title: 'Webln',
description: 'Lorem ipsum dolor sit amort consectetur, adipisicing elit. Possimus officia sit numquam nobis iure atque ab sunt nihil voluptatibus',
icon: "🔗",
isOfficial: true,
},
{
id: 4,
title: 'Gaming',
description: 'Lorem ipsum dolor sit amort consectetur, adipisicing elit. Possimus officia sit numquam nobis iure atque ab sunt nihil voluptatibus',
icon: "🎮",
isOfficial: true,
},
{
id: 5,
title: 'Design',
icon: '🎨'
description: 'Lorem ipsum dolor sit amort consectetur, adipisicing elit. Possimus officia sit numquam nobis iure atque ab sunt nihil voluptatibus',
icon: '🎨',
isOfficial: true,
}
].map(i => ({ __typename: "Tag", ...i })) as Tag[]

View File

@@ -1,5 +1,5 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { CreateStoryType } from "src/features/Posts/pages/CreatePostPage/Components/StoryForm/StoryForm";
import { CreateStoryType } from "src/features/Posts/pages/CreatePostPage/CreateStoryPage/CreateStoryPage";
interface StoreState {
story: CreateStoryType | null

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,24 @@
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;
}
clear() {
localStorage.removeItem(this.key)
}
}

View File

@@ -91,3 +91,9 @@ $screen-xl-max: 50000px;
@content;
}
}
@mixin gt-xl {
@media screen and (min-width: #{$screen-xl-min}) {
@content;
}
}

View File

@@ -8,6 +8,10 @@
}
}
:where(h1, h2, h3, h4, h5, h6, a, ul, ol, pre, a, p, iframe, ul, ol, blockquote) {
margin-bottom: 16px;
}
h1 {
font-size: 36px;
line-height: 50px;
@@ -66,4 +70,35 @@
aspect-ratio: 16/9;
margin: 36px auto;
}
ul,
ol {
margin-inline-start: 32px;
p {
margin-bottom: 0;
}
li {
margin-bottom: 8px;
}
}
ul li {
list-style: disc;
}
ol li {
list-style: decimal;
}
blockquote {
border-left: 3px solid #dee2e6;
margin-left: 0;
margin-right: 0;
padding-left: 10px;
font-style: italic;
p {
color: #777;
}
}
}

View File

@@ -1,3 +1,4 @@
import dayjs from "dayjs";
import React, { ComponentProps, ComponentType, Suspense } from "react";
import { RotatingLines } from "react-loader-spinner";
import { isNullOrUndefined } from "remirror";
@@ -102,4 +103,22 @@ export function capitalize(s?: string) {
return s && s[0].toUpperCase() + s.slice(1);
}
export const withHttp = (url: string) => !/^https?:\/\//i.test(url) ? `http://${url}` : url;
export const withHttp = (url: string) => !/^https?:\/\//i.test(url) ? `http://${url}` : url;
export function getPropertyFromUnknown<Value = string>(obj: unknown, prop: string | number | symbol): Value | null {
if (typeof obj === 'object' && obj !== null && prop in obj)
return (obj as any)[prop as any] as Value;
return null
}
export function getDateDifference(date: string) {
const now = dayjs();
const mins = now.diff(date, 'minute');
if (mins < 60) return mins + 'm';
const hrs = now.diff(date, 'hour');
if (hrs < 24) return hrs + 'h';
const days = now.diff(date, 'day');
if (days < 30) return days + 'd';
const months = now.diff(date, 'month');
return months + 'mo'
}

View File

@@ -9,3 +9,4 @@ export * from "./useVote";
export * from './useWindowSize'
export * from './useMediaQuery'
export * from './useCurrentSection'
export * from './usePreload'

View File

@@ -0,0 +1,13 @@
import { useEffect } from 'react';
const Components = {
PostPage: () => import('../../features/Posts/pages/PostDetailsPage/PostDetailsPage'),
}
type ComponentToLoad = keyof typeof Components;
export const usePreload = (componentToLoad: ComponentToLoad) => {
useEffect(() => {
Components[componentToLoad]()
}, [componentToLoad])
}

View File

@@ -1,8 +1,6 @@
import { Tag as ApiTag } from "src/graphql";
export type Tag = {
id: number
title: string
}
export type Tag = ApiTag;
export type ListComponentProps<T> = {

View File

@@ -1 +1,2 @@
export * from './routes'
export * from './routes'
export * from './loadable'

View File

@@ -0,0 +1,9 @@
import { Outlet, } from 'react-router-dom';
import Navbar from "src/Components/Navbar/Navbar";
export const NavbarLayout = () => {
return <>
<Navbar />
<Outlet />
</>
};

View File

@@ -0,0 +1,2 @@
export * from './NavbarLayout'

View File

@@ -0,0 +1,8 @@
import { Suspense } from "react";
import LoadingPage from "src/Components/LoadingPage/LoadingPage";
export const Loadable = (Component: any, Loading = LoadingPage) => (props: any) => (
<Suspense fallback={<Loading />}>
<Component {...props} />
</Suspense>
);