Merge branch 'dev'

This commit is contained in:
MTG2000
2022-05-23 13:04:32 +03:00
274 changed files with 51870 additions and 33302 deletions

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@ environments/.dev.preview-server.env
# dependencies
/node_modules
/node_modules_t
/.pnp
.pnp.js

View File

@@ -1,41 +1,19 @@
const path = require("path");
module.exports = {
stories: ["../src/**/*.stories.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
addons: [
"stories": [
"../src/**/*.stories.mdx",
"../src/**/*.stories.@(js|jsx|ts|tsx)"
],
"addons": [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/preset-create-react-app",
"@storybook/addon-interactions",
"@storybook/preset-create-react-app"
],
webpackFinal: async (config) => {
config.module.rules.push({
test: /\.css$/,
use: [
{
loader: "postcss-loader",
options: {
ident: "postcss",
plugins: [require("tailwindcss"), require("autoprefixer")],
},
},
],
include: path.resolve(__dirname, "../"),
});
config.module.rules.push({
type: "javascript/auto",
test: /\.mjs$/,
include: /node_modules/,
});
"framework": "@storybook/react",
"core": {
"builder": "@storybook/builder-webpack5"
}
}
config.resolve.alias = {
...config.resolve.alias,
"@/SB": path.resolve(__dirname),
"@/Components": path.resolve(__dirname, "../src/Components"),
"@/Api": path.resolve(__dirname, "../src/api"),
"@/Utils": path.resolve(__dirname, "../src/utils"),
};
return config;
},
};

View File

@@ -1,5 +1,4 @@
import "../src/index.css";
import { configure, addDecorator } from "@storybook/react";
import { configure, addDecorator, addParameters } from "@storybook/react";
import { WrapperDecorator, AppDecorator } from 'src/utils/storybook/decorators'
@@ -11,15 +10,29 @@ export const parameters = {
date: /Date$/,
},
},
};
}
addDecorator(AppDecorator);
addDecorator(WrapperDecorator);
addParameters({
backgrounds: {
default: 'light',
values: [
{
name: 'light',
value: '#F8FAFC',
},
{
name: 'dark',
value: '#3f3f4c',
},
],
}
});
configure(require.context("../src", true, /\.stories\.ts$/), module);

View File

@@ -1,5 +1,5 @@
overwrite: true
schema: "https://makers-bolt-fun-preview.netlify.app/.netlify/functions/graphql"
schema: "http://localhost:8888/dev/graphql"
documents: "./src/**/*.{ts,graphql}"
generates:
src/graphql/index.tsx:

View File

@@ -1,20 +0,0 @@
module.exports = {
style: {
postcss: {
plugins: [require("tailwindcss"), require("autoprefixer")],
},
},
webpack: {
configure: {
module: {
rules: [
{
type: "javascript/auto",
test: /\.mjs$/,
include: /node_modules/,
},
],
},
},
},
};

View File

@@ -1,2 +1,3 @@
REACT_APP_ENABLE_MOCKS= true
REACT_APP_ENABLE_MOCKS= true
STORYBOOK_ENABLE_MOCKS= true

View File

@@ -1,2 +1,3 @@
REACT_APP_ENABLE_MOCKS= true
REACT_APP_ENABLE_MOCKS= true
STORYBOOK_ENABLE_MOCKS= true

View File

@@ -4,9 +4,23 @@
*/
import type { core } from "nexus"
declare global {
interface NexusGenCustomInputMethods<TypeName extends string> {
/**
* Date custom scalar type
*/
date<FieldName extends string>(fieldName: FieldName, opts?: core.CommonInputFieldConfig<TypeName, FieldName>): void // "Date";
}
}
declare global {
interface NexusGenCustomOutputMethods<TypeName extends string> {
/**
* Date custom scalar type
*/
date<FieldName extends string>(fieldName: FieldName, ...opts: core.ScalarOutSpread<TypeName, FieldName>): void // "Date";
}
}
declare global {
@@ -17,6 +31,8 @@ export interface NexusGenInputs {
}
export interface NexusGenEnums {
POST_TYPE: "Bounty" | "Question" | "Story"
VOTE_ITEM_TYPE: "Bounty" | "PostComment" | "Project" | "Question" | "Story" | "User"
}
export interface NexusGenScalars {
@@ -25,6 +41,7 @@ export interface NexusGenScalars {
Float: number
Boolean: boolean
ID: string
Date: any
}
export interface NexusGenObjects {
@@ -34,12 +51,41 @@ export interface NexusGenObjects {
title: string; // String!
url: string; // String!
}
Bounty: { // root type
applicants_count: number; // Int!
applications: NexusGenRootTypes['BountyApplication'][]; // [BountyApplication!]!
body: string; // String!
cover_image: string; // String!
createdAt: NexusGenScalars['Date']; // Date!
deadline: string; // String!
excerpt: string; // String!
id: number; // Int!
reward_amount: number; // Int!
title: string; // String!
votes_count: number; // Int!
}
BountyApplication: { // root type
author: NexusGenRootTypes['User']; // User!
date: string; // String!
id: number; // Int!
workplan: string; // String!
}
Category: { // root type
cover_image?: string | null; // String
icon?: string | null; // String
id: number; // Int!
title: string; // String!
}
Hackathon: { // root type
cover_image: string; // String!
description: string; // String!
end_date: NexusGenScalars['Date']; // Date!
id: number; // Int!
location: string; // String!
start_date: NexusGenScalars['Date']; // Date!
title: string; // String!
website: string; // String!
}
LnurlDetails: { // root type
commentAllowed?: number | null; // Int
maxSendable?: number | null; // Int
@@ -47,6 +93,14 @@ export interface NexusGenObjects {
minSendable?: number | null; // Int
}
Mutation: {};
PostComment: { // root type
author: NexusGenRootTypes['User']; // User!
body: string; // String!
createdAt: NexusGenScalars['Date']; // Date!
id: number; // Int!
parentId?: number | null; // Int
votes_count: number; // Int!
}
Project: { // root type
cover_image: string; // String!
description: string; // String!
@@ -60,13 +114,43 @@ export interface NexusGenObjects {
website: string; // String!
}
Query: {};
Question: { // root type
answers_count: number; // Int!
body: string; // String!
createdAt: NexusGenScalars['Date']; // Date!
excerpt: string; // String!
id: number; // Int!
title: string; // String!
votes_count: number; // Int!
}
Story: { // root type
body: string; // String!
cover_image: string; // String!
createdAt: NexusGenScalars['Date']; // Date!
excerpt: string; // String!
id: number; // Int!
title: string; // String!
votes_count: number; // Int!
}
Tag: { // root type
id: number; // Int!
title: string; // String!
}
Topic: { // root type
icon: string; // String!
id: number; // Int!
title: string; // String!
}
User: { // root type
avatar: string; // String!
id: number; // Int!
name: string; // String!
}
Vote: { // root type
amount_in_sat: number; // Int!
id: number; // Int!
item_id: number; // Int!
item_type: NexusGenEnums['VOTE_ITEM_TYPE']; // VOTE_ITEM_TYPE!
paid: boolean; // Boolean!
payment_hash: string; // String!
payment_request: string; // String!
@@ -74,14 +158,16 @@ export interface NexusGenObjects {
}
export interface NexusGenInterfaces {
PostBase: NexusGenRootTypes['Bounty'] | NexusGenRootTypes['Question'] | NexusGenRootTypes['Story'];
}
export interface NexusGenUnions {
Post: NexusGenRootTypes['Bounty'] | NexusGenRootTypes['Question'] | NexusGenRootTypes['Story'];
}
export type NexusGenRootTypes = NexusGenObjects
export type NexusGenRootTypes = NexusGenInterfaces & NexusGenObjects & NexusGenUnions
export type NexusGenAllTypes = NexusGenRootTypes & NexusGenScalars
export type NexusGenAllTypes = NexusGenRootTypes & NexusGenScalars & NexusGenEnums
export interface NexusGenFieldTypes {
Award: { // field return type
@@ -91,6 +177,28 @@ export interface NexusGenFieldTypes {
title: string; // String!
url: string; // String!
}
Bounty: { // field return type
applicants_count: number; // Int!
applications: NexusGenRootTypes['BountyApplication'][]; // [BountyApplication!]!
author: NexusGenRootTypes['User']; // User!
body: string; // String!
cover_image: string; // String!
createdAt: NexusGenScalars['Date']; // Date!
deadline: string; // String!
excerpt: string; // String!
id: number; // Int!
reward_amount: number; // Int!
tags: NexusGenRootTypes['Tag'][]; // [Tag!]!
title: string; // String!
type: string; // String!
votes_count: number; // Int!
}
BountyApplication: { // field return type
author: NexusGenRootTypes['User']; // User!
date: string; // String!
id: number; // Int!
workplan: string; // String!
}
Category: { // field return type
apps_count: number; // Int!
cover_image: string | null; // String
@@ -100,6 +208,17 @@ export interface NexusGenFieldTypes {
title: string; // String!
votes_sum: number; // Int!
}
Hackathon: { // field return type
cover_image: string; // String!
description: string; // String!
end_date: NexusGenScalars['Date']; // Date!
id: number; // Int!
location: string; // String!
start_date: NexusGenScalars['Date']; // Date!
title: string; // String!
topics: NexusGenRootTypes['Topic'][]; // [Topic!]!
website: string; // String!
}
LnurlDetails: { // field return type
commentAllowed: number | null; // Int
maxSendable: number | null; // Int
@@ -110,6 +229,14 @@ export interface NexusGenFieldTypes {
confirmVote: NexusGenRootTypes['Vote']; // Vote!
vote: NexusGenRootTypes['Vote']; // Vote!
}
PostComment: { // field return type
author: NexusGenRootTypes['User']; // User!
body: string; // String!
createdAt: NexusGenScalars['Date']; // Date!
id: number; // Int!
parentId: number | null; // Int
votes_count: number; // Int!
}
Project: { // field return type
awards: NexusGenRootTypes['Award'][]; // [Award!]!
category: NexusGenRootTypes['Category']; // Category!
@@ -128,26 +255,78 @@ export interface NexusGenFieldTypes {
Query: { // field return type
allCategories: NexusGenRootTypes['Category'][]; // [Category!]!
allProjects: NexusGenRootTypes['Project'][]; // [Project!]!
allTopics: NexusGenRootTypes['Topic'][]; // [Topic!]!
getAllHackathons: NexusGenRootTypes['Hackathon'][]; // [Hackathon!]!
getCategory: NexusGenRootTypes['Category']; // Category!
getFeed: NexusGenRootTypes['Post'][]; // [Post!]!
getLnurlDetailsForProject: NexusGenRootTypes['LnurlDetails']; // LnurlDetails!
getPostById: NexusGenRootTypes['Post']; // Post!
getProject: NexusGenRootTypes['Project']; // Project!
getTrendingPosts: NexusGenRootTypes['Post'][]; // [Post!]!
hottestProjects: NexusGenRootTypes['Project'][]; // [Project!]!
newProjects: NexusGenRootTypes['Project'][]; // [Project!]!
popularTopics: NexusGenRootTypes['Topic'][]; // [Topic!]!
projectsByCategory: NexusGenRootTypes['Project'][]; // [Project!]!
searchProjects: NexusGenRootTypes['Project'][]; // [Project!]!
}
Question: { // field return type
answers_count: number; // Int!
author: NexusGenRootTypes['User']; // User!
body: string; // String!
comments: NexusGenRootTypes['PostComment'][]; // [PostComment!]!
createdAt: NexusGenScalars['Date']; // Date!
excerpt: string; // String!
id: number; // Int!
tags: NexusGenRootTypes['Tag'][]; // [Tag!]!
title: string; // String!
type: string; // String!
votes_count: number; // Int!
}
Story: { // field return type
author: NexusGenRootTypes['User']; // User!
body: string; // String!
comments: NexusGenRootTypes['PostComment'][]; // [PostComment!]!
comments_count: number; // Int!
cover_image: string; // String!
createdAt: NexusGenScalars['Date']; // Date!
excerpt: string; // String!
id: number; // Int!
tags: NexusGenRootTypes['Tag'][]; // [Tag!]!
title: string; // String!
topic: NexusGenRootTypes['Topic']; // Topic!
type: string; // String!
votes_count: number; // Int!
}
Tag: { // field return type
id: number; // Int!
project: NexusGenRootTypes['Project'][]; // [Project!]!
title: string; // String!
}
Topic: { // field return type
icon: string; // String!
id: number; // Int!
title: string; // String!
}
User: { // field return type
avatar: string; // String!
id: number; // Int!
name: string; // String!
}
Vote: { // field return type
amount_in_sat: number; // Int!
id: number; // Int!
item_id: number; // Int!
item_type: NexusGenEnums['VOTE_ITEM_TYPE']; // VOTE_ITEM_TYPE!
paid: boolean; // Boolean!
payment_hash: string; // String!
payment_request: string; // String!
project: NexusGenRootTypes['Project']; // Project!
}
PostBase: { // field return type
body: string; // String!
createdAt: NexusGenScalars['Date']; // Date!
excerpt: string; // String!
id: number; // Int!
title: string; // String!
votes_count: number; // Int!
}
}
@@ -159,6 +338,28 @@ export interface NexusGenFieldTypeNames {
title: 'String'
url: 'String'
}
Bounty: { // field return type name
applicants_count: 'Int'
applications: 'BountyApplication'
author: 'User'
body: 'String'
cover_image: 'String'
createdAt: 'Date'
deadline: 'String'
excerpt: 'String'
id: 'Int'
reward_amount: 'Int'
tags: 'Tag'
title: 'String'
type: 'String'
votes_count: 'Int'
}
BountyApplication: { // field return type name
author: 'User'
date: 'String'
id: 'Int'
workplan: 'String'
}
Category: { // field return type name
apps_count: 'Int'
cover_image: 'String'
@@ -168,6 +369,17 @@ export interface NexusGenFieldTypeNames {
title: 'String'
votes_sum: 'Int'
}
Hackathon: { // field return type name
cover_image: 'String'
description: 'String'
end_date: 'Date'
id: 'Int'
location: 'String'
start_date: 'Date'
title: 'String'
topics: 'Topic'
website: 'String'
}
LnurlDetails: { // field return type name
commentAllowed: 'Int'
maxSendable: 'Int'
@@ -178,6 +390,14 @@ export interface NexusGenFieldTypeNames {
confirmVote: 'Vote'
vote: 'Vote'
}
PostComment: { // field return type name
author: 'User'
body: 'String'
createdAt: 'Date'
id: 'Int'
parentId: 'Int'
votes_count: 'Int'
}
Project: { // field return type name
awards: 'Award'
category: 'Category'
@@ -196,26 +416,78 @@ export interface NexusGenFieldTypeNames {
Query: { // field return type name
allCategories: 'Category'
allProjects: 'Project'
allTopics: 'Topic'
getAllHackathons: 'Hackathon'
getCategory: 'Category'
getFeed: 'Post'
getLnurlDetailsForProject: 'LnurlDetails'
getPostById: 'Post'
getProject: 'Project'
getTrendingPosts: 'Post'
hottestProjects: 'Project'
newProjects: 'Project'
popularTopics: 'Topic'
projectsByCategory: 'Project'
searchProjects: 'Project'
}
Question: { // field return type name
answers_count: 'Int'
author: 'User'
body: 'String'
comments: 'PostComment'
createdAt: 'Date'
excerpt: 'String'
id: 'Int'
tags: 'Tag'
title: 'String'
type: 'String'
votes_count: 'Int'
}
Story: { // field return type name
author: 'User'
body: 'String'
comments: 'PostComment'
comments_count: 'Int'
cover_image: 'String'
createdAt: 'Date'
excerpt: 'String'
id: 'Int'
tags: 'Tag'
title: 'String'
topic: 'Topic'
type: 'String'
votes_count: 'Int'
}
Tag: { // field return type name
id: 'Int'
project: 'Project'
title: 'String'
}
Topic: { // field return type name
icon: 'String'
id: 'Int'
title: 'String'
}
User: { // field return type name
avatar: 'String'
id: 'Int'
name: 'String'
}
Vote: { // field return type name
amount_in_sat: 'Int'
id: 'Int'
item_id: 'Int'
item_type: 'VOTE_ITEM_TYPE'
paid: 'Boolean'
payment_hash: 'String'
payment_request: 'String'
project: 'Project'
}
PostBase: { // field return type name
body: 'String'
createdAt: 'Date'
excerpt: 'String'
id: 'Int'
title: 'String'
votes_count: 'Int'
}
}
@@ -227,7 +499,8 @@ export interface NexusGenArgTypes {
}
vote: { // args
amount_in_sat: number; // Int!
project_id: number; // Int!
item_id: number; // Int!
item_type: NexusGenEnums['VOTE_ITEM_TYPE']; // VOTE_ITEM_TYPE!
}
}
Query: {
@@ -235,12 +508,26 @@ export interface NexusGenArgTypes {
skip?: number | null; // Int
take: number | null; // Int
}
getAllHackathons: { // args
sortBy?: string | null; // String
topic?: number | null; // Int
}
getCategory: { // args
id: number; // Int!
}
getFeed: { // args
skip?: number | null; // Int
sortBy: string | null; // String
take: number | null; // Int
topic?: number | null; // Int
}
getLnurlDetailsForProject: { // args
project_id: number; // Int!
}
getPostById: { // args
id: number; // Int!
type: NexusGenEnums['POST_TYPE']; // POST_TYPE!
}
getProject: { // args
id: number; // Int!
}
@@ -266,26 +553,31 @@ export interface NexusGenArgTypes {
}
export interface NexusGenAbstractTypeMembers {
Post: "Bounty" | "Question" | "Story"
PostBase: "Bounty" | "Question" | "Story"
}
export interface NexusGenTypeInterfaces {
Bounty: "PostBase"
Question: "PostBase"
Story: "PostBase"
}
export type NexusGenObjectNames = keyof NexusGenObjects;
export type NexusGenInputNames = never;
export type NexusGenEnumNames = never;
export type NexusGenEnumNames = keyof NexusGenEnums;
export type NexusGenInterfaceNames = never;
export type NexusGenInterfaceNames = keyof NexusGenInterfaces;
export type NexusGenScalarNames = keyof NexusGenScalars;
export type NexusGenUnionNames = never;
export type NexusGenUnionNames = keyof NexusGenUnions;
export type NexusGenObjectsUsingAbstractStrategyIsTypeOf = never;
export type NexusGenAbstractsUsingStrategyResolveType = never;
export type NexusGenAbstractsUsingStrategyResolveType = "Post" | "PostBase";
export type NexusGenFeaturesConfig = {
abstractTypeStrategies: {

View File

@@ -10,6 +10,30 @@ type Award {
url: String!
}
type Bounty implements PostBase {
applicants_count: Int!
applications: [BountyApplication!]!
author: User!
body: String!
cover_image: String!
createdAt: Date!
deadline: String!
excerpt: String!
id: Int!
reward_amount: Int!
tags: [Tag!]!
title: String!
type: String!
votes_count: Int!
}
type BountyApplication {
author: User!
date: String!
id: Int!
workplan: String!
}
type Category {
apps_count: Int!
cover_image: String
@@ -20,6 +44,21 @@ type Category {
votes_sum: Int!
}
"""Date custom scalar type"""
scalar Date
type Hackathon {
cover_image: String!
description: String!
end_date: Date!
id: Int!
location: String!
start_date: Date!
title: String!
topics: [Topic!]!
website: String!
}
type LnurlDetails {
commentAllowed: Int
maxSendable: Int
@@ -29,7 +68,33 @@ type LnurlDetails {
type Mutation {
confirmVote(payment_request: String!, preimage: String!): Vote!
vote(amount_in_sat: Int!, project_id: Int!): Vote!
vote(amount_in_sat: Int!, item_id: Int!, item_type: VOTE_ITEM_TYPE!): Vote!
}
enum POST_TYPE {
Bounty
Question
Story
}
union Post = Bounty | Question | Story
interface PostBase {
body: String!
createdAt: Date!
excerpt: String!
id: Int!
title: String!
votes_count: Int!
}
type PostComment {
author: User!
body: String!
createdAt: Date!
id: Int!
parentId: Int
votes_count: Int!
}
type Project {
@@ -51,26 +116,83 @@ type Project {
type Query {
allCategories: [Category!]!
allProjects(skip: Int = 0, take: Int = 50): [Project!]!
allTopics: [Topic!]!
getAllHackathons(sortBy: String, topic: Int): [Hackathon!]!
getCategory(id: Int!): Category!
getFeed(skip: Int = 0, sortBy: String = "all", take: Int = 10, topic: Int = 0): [Post!]!
getLnurlDetailsForProject(project_id: Int!): LnurlDetails!
getPostById(id: Int!, type: POST_TYPE!): Post!
getProject(id: Int!): Project!
getTrendingPosts: [Post!]!
hottestProjects(skip: Int = 0, take: Int = 50): [Project!]!
newProjects(skip: Int = 0, take: Int = 50): [Project!]!
popularTopics: [Topic!]!
projectsByCategory(category_id: Int!, skip: Int = 0, take: Int = 10): [Project!]!
searchProjects(search: String!, skip: Int = 0, take: Int = 50): [Project!]!
}
type Question implements PostBase {
answers_count: Int!
author: User!
body: String!
comments: [PostComment!]!
createdAt: Date!
excerpt: String!
id: Int!
tags: [Tag!]!
title: String!
type: String!
votes_count: Int!
}
type Story implements PostBase {
author: User!
body: String!
comments: [PostComment!]!
comments_count: Int!
cover_image: String!
createdAt: Date!
excerpt: String!
id: Int!
tags: [Tag!]!
title: String!
topic: Topic!
type: String!
votes_count: Int!
}
type Tag {
id: Int!
project: [Project!]!
title: String!
}
type Topic {
icon: String!
id: Int!
title: String!
}
type User {
avatar: String!
id: Int!
name: String!
}
enum VOTE_ITEM_TYPE {
Bounty
PostComment
Project
Question
Story
User
}
type Vote {
amount_in_sat: Int!
id: Int!
item_id: Int!
item_type: VOTE_ITEM_TYPE!
paid: Boolean!
payment_hash: String!
payment_request: String!
project: Project!
}

View File

@@ -0,0 +1,24 @@
const { Kind } = require("graphql")
const { scalarType } = require("nexus")
const DateScalar = scalarType({
name: 'Date',
asNexusMethod: 'date',
description: 'Date custom scalar type',
parseValue(value) {
return new Date(value)
},
serialize(value) {
return value.toISOString()
},
parseLiteral(ast) {
if (ast.kind === Kind.INT) {
return new Date(ast.value)
}
return null
},
})
module.exports = {
DateScalar
}

View File

@@ -18,7 +18,7 @@ const Category = objectType({
t.nonNull.int('votes_sum', {
async resolve(parent) {
const projects = await prisma.category.findUnique({ where: { id: parent.id } }).project();
const projects = await prisma.category.findUnique({ where: { id: parent.id } }).project()
return projects.reduce((total, project) => total + project.votes_count, 0);
}
});

View File

@@ -0,0 +1,79 @@
const {
intArg,
objectType,
stringArg,
extendType,
nonNull,
} = require('nexus');
const { prisma } = require('../prisma')
const Hackathon = objectType({
name: 'Hackathon',
definition(t) {
t.nonNull.int('id');
t.nonNull.string('title');
t.nonNull.string('description');
t.nonNull.string('cover_image');
t.nonNull.date('start_date');
t.nonNull.date('end_date');
t.nonNull.string('location');
t.nonNull.string('website');
t.nonNull.list.nonNull.field('topics', {
type: "Topic",
resolve: (parent) => {
return prisma.hackathon.findUnique({ where: { id: parent.id } }).topics();
}
});
}
})
const getAllHackathons = extendType({
type: "Query",
definition(t) {
t.nonNull.list.nonNull.field('getAllHackathons', {
type: "Hackathon",
args: {
sortBy: stringArg(),
topic: intArg(),
},
resolve(_, args) {
const { sortBy, topic } = args;
return prisma.hackathon.findMany({
where: {
...(sortBy === 'Upcoming' && {
start_date: {
gte: new Date(),
}
}),
...(sortBy === 'Live' && {
start_date: { lte: new Date() },
end_date: { gte: new Date() }
}),
...(sortBy === 'Finished' && {
end_date: {
lt: new Date()
}
}),
...(topic && {
topics: {
some: {
id: topic
}
}
})
}
})
}
})
}
})
module.exports = {
// Types
Hackathon,
// Queries
getAllHackathons,
}

View File

@@ -34,7 +34,8 @@ async function getLnurlCallbackUrl(lightning_address) {
);
}
async function getPaymetRequestForProject(project, amount_in_sat) {
async function getPaymetRequestForItem(lightning_address, amount_in_sat) {
// # NOTE: CACHING LNURL CALLBACK URLS + PARAMETERS
// LNURL flows have a lot of back and forth and can impact
// the load time for your application users.
@@ -45,11 +46,9 @@ async function getPaymetRequestForProject(project, amount_in_sat) {
// careful when trying to optimise the amount of
// requests so be mindful of this when you are storing
// these items.
let lnurlCallbackUrl = project.lnurl_callback_url;
const amount = amount_in_sat * 1000; // msats
if (!lnurlCallbackUrl) {
lnurlCallbackUrl = await getLnurlCallbackUrl(project.lightning_address);
}
let lnurlCallbackUrl = await getLnurlCallbackUrl(lightning_address);
return axios
.get(lnurlCallbackUrl, { params: { amount } })
.then((prResponse) => {
@@ -69,10 +68,9 @@ const paginationArgs = (args) => {
}
module.exports = {
getPaymetRequestForProject,
getPaymetRequestForItem,
hexToUint8Array,
lightningAddressToLnurl,
getLnurlDetails,
paginationArgs
}

View File

@@ -1,9 +1,17 @@
const scalars = require('./_scalars')
const category = require('./category')
const project = require('./project')
const vote = require('./vote')
const post = require('./post')
const users = require('./users')
const hackathon = require('./hackathon')
module.exports = {
...scalars,
...category,
...project,
...vote,
...post,
...users,
...hackathon
}

View File

@@ -0,0 +1,337 @@
const {
intArg,
objectType,
extendType,
nonNull,
interfaceType,
unionType,
stringArg,
enumType,
arg,
} = require('nexus');
const { paginationArgs } = require('./helpers');
const { prisma } = require('../prisma')
const POST_TYPE = enumType({
name: 'POST_TYPE',
members: ['Story', 'Bounty', 'Question'],
});
const asType = type => (obj) => {
if (Array.isArray(obj)) return obj.map(o => ({ ...o, type }))
return { ...obj, type }
}
const asStoryType = asType('Story')
const asQuestionType = asType('Question')
const asBountyType = asType('Bounty')
const Topic = objectType({
name: 'Topic',
definition(t) {
t.nonNull.int('id');
t.nonNull.string('title');
t.nonNull.string('icon');
}
})
const allTopics = extendType({
type: "Query",
definition(t) {
t.nonNull.list.nonNull.field('allTopics', {
type: "Topic",
resolve: () => {
return prisma.topic.findMany({
});
}
})
}
})
const popularTopics = extendType({
type: "Query",
definition(t) {
t.nonNull.list.nonNull.field('popularTopics', {
type: "Topic",
resolve: () => {
return prisma.topic.findMany({
take: 6,
orderBy: {
stories: {
_count: 'desc'
}
},
});
}
})
}
})
const PostBase = interfaceType({
name: 'PostBase',
resolveType(item) {
return item.type
},
definition(t) {
t.nonNull.int('id');
t.nonNull.string('title');
t.nonNull.date('createdAt');
t.nonNull.string('body');
t.nonNull.string('excerpt');
t.nonNull.int('votes_count');
},
})
const Story = objectType({
name: 'Story',
definition(t) {
t.implements('PostBase');
t.nonNull.string('type', {
resolve: () => t.typeName
});
t.nonNull.string('cover_image');
t.nonNull.list.nonNull.field('comments', {
type: "PostComment",
resolve: (parent) => prisma.story.findUnique({ where: { id: parent.id } }).comments()
});
t.nonNull.list.nonNull.field('tags', {
type: "Tag",
resolve: (parent) => prisma.story.findUnique({ where: { id: parent.id } }).tags()
});
t.nonNull.int('comments_count', {
resolve: async (parent) => {
const post = await prisma.story.findUnique({
where: { id: parent.id },
include: {
_count: {
select: {
comments: true
}
}
}
})
return post._count.comments;
}
});
t.nonNull.field('topic', {
type: "Topic",
resolve: parent => {
return prisma.story.findUnique({
where: { id: parent.id }
}).topic()
}
})
t.nonNull.field('author', {
type: "User",
resolve: (parent) =>
prisma.story.findUnique({ where: { id: parent.id } }).user()
});
},
})
const BountyApplication = objectType({
name: 'BountyApplication',
definition(t) {
t.nonNull.int('id');
t.nonNull.string('date');
t.nonNull.string('workplan');
t.nonNull.field('author', {
type: "User"
});
}
})
const Bounty = objectType({
name: 'Bounty',
definition(t) {
t.implements('PostBase');
t.nonNull.string('type', {
resolve: () => 'Bounty'
});
t.nonNull.string('cover_image');
t.nonNull.string('deadline');
t.nonNull.int('reward_amount');
t.nonNull.int('applicants_count');
t.nonNull.list.nonNull.field('applications', {
type: "BountyApplication"
});
t.nonNull.field('author', {
type: "User",
resolve: (parent) => {
return prisma.bounty.findUnique({ where: { id: parent.id } }).user();
}
});
t.nonNull.list.nonNull.field('tags', {
type: "Tag",
resolve: (parent) => []
});
},
})
const Question = objectType({
name: 'Question',
definition(t) {
t.implements('PostBase');
t.nonNull.string('type', {
resolve: () => 'Question',
});
t.nonNull.list.nonNull.field('tags', {
type: "Tag",
resolve: (parent) => prisma.question.findUnique({ where: { id: parent.id } }).tags()
});
t.nonNull.int('answers_count');
t.nonNull.list.nonNull.field('comments', {
type: "PostComment",
resolve: (parent) => {
return prisma.question.findUnique({ where: { id: parent.id } }).comments();
}
});
t.nonNull.field('author', {
type: "User",
resolve: (parent) => {
return prisma.question.findUnique({ where: { id: parent.id } }).user();
}
});
},
})
const PostComment = objectType({
name: 'PostComment',
definition(t) {
t.nonNull.int('id');
t.nonNull.date('createdAt');
t.nonNull.string('body');
t.nonNull.field('author', {
type: "User"
});
t.int('parentId');
t.nonNull.int('votes_count');
}
})
const Post = unionType({
name: 'Post',
definition(t) {
t.members('Story', 'Bounty', 'Question')
},
resolveType: (item) => {
return item.type
},
})
const getFeed = extendType({
type: "Query",
definition(t) {
t.nonNull.list.nonNull.field('getFeed', {
type: "Post",
args: {
...paginationArgs({ take: 10 }),
sortBy: stringArg({
default: "all"
}), // all, popular, trending, newest
topic: intArg({
default: 0
})
},
resolve(_, { take, skip, topic, sortBy, }) {
return prisma.story.findMany({
orderBy: { createdAt: "desc" },
where: {
topic_id: topic ? topic : undefined,
},
skip,
take,
}).then(asStoryType)
}
})
}
})
const getTrendingPosts = extendType({
type: "Query",
definition(t) {
t.nonNull.list.nonNull.field('getTrendingPosts', {
type: "Post",
args: {
},
resolve() {
const now = new Date();
const lastWeekDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
return prisma.story.findMany({
take: 5,
where: {
createdAt: {
gte: lastWeekDate
}
}
}).then(asStoryType)
}
})
}
})
const getPostById = extendType({
type: "Query",
definition(t) {
t.nonNull.field('getPostById', {
type: "Post",
args: {
id: nonNull(intArg()),
type: arg({
type: nonNull('POST_TYPE')
})
},
resolve(_, { id, type }) {
if (type === 'Story')
return prisma.story.findUnique({
where: { id }
}).then(asStoryType)
if (type === 'Question')
return prisma.question.findUnique({
where: { id }
}).then(asQuestionType)
return null
}
})
}
})
module.exports = {
// Types
POST_TYPE,
Topic,
PostBase,
BountyApplication,
Bounty,
Story,
Question,
PostComment,
Post,
// Queries
allTopics,
popularTopics,
getFeed,
getPostById,
getTrendingPosts
}

View File

@@ -69,12 +69,12 @@ const Tag = objectType({
definition(t) {
t.nonNull.int('id');
t.nonNull.string('title');
t.nonNull.list.nonNull.field('project', {
type: "Project",
resolve: (parent) => {
return prisma.tag.findUnique({ where: { id: parent.id } }).project();
}
})
// t.nonNull.list.nonNull.field('project', {
// type: "Project",
// resolve: (parent) => {
// return prisma.tag.findUnique({ where: { id: parent.id } }).project();
// }
// })
}
})
@@ -123,7 +123,7 @@ const newProjects = extendType({
const take = args.take || 50;
const skip = args.skip || 0;
return prisma.project.findMany({
orderBy: { created_at: "desc" },
orderBy: { createdAt: "desc" },
skip,
take,
});

View File

@@ -0,0 +1,16 @@
const { objectType } = require("nexus");
const User = objectType({
name: 'User',
definition(t) {
t.nonNull.int('id');
t.nonNull.string('name');
t.nonNull.string('avatar');
}
})
module.exports = {
// Types
User
}

View File

@@ -4,13 +4,22 @@ const {
extendType,
nonNull,
stringArg,
arg,
enumType,
} = require('nexus')
const { parsePaymentRequest } = require('invoices');
const { getPaymetRequestForProject, hexToUint8Array } = require('./helpers');
const { getPaymetRequestForItem, hexToUint8Array } = require('./helpers');
const { createHash } = require('crypto');
const { prisma } = require('../prisma')
// the types of items we can vote to
const VOTE_ITEM_TYPE = enumType({
name: 'VOTE_ITEM_TYPE',
members: ['Story', 'Bounty', 'Question', 'Project', 'User', 'PostComment'],
})
const BOLT_FUN_LIGHTNING_ADDRESS = 'johns@getalby.com'; // #TODO, replace it by bolt-fun lightning address if there exist one
const Vote = objectType({
name: 'Vote',
@@ -21,16 +30,11 @@ const Vote = objectType({
t.nonNull.string('payment_hash');
t.nonNull.boolean('paid');
t.nonNull.field('project', {
type: "Project",
resolve: (parent, args,) => {
return parent.project ?? prisma.vote.findUnique({
where: { id: parent.id }
}).project()
}
t.nonNull.field('item_type', {
type: "VOTE_ITEM_TYPE"
})
t.nonNull.int('item_id');
}
})
@@ -45,30 +49,103 @@ const LnurlDetails = objectType({
}
})
const getModalOfType = (type) => {
switch (type) {
case "Story":
return prisma.story;
case "Question":
return prisma.question;
case "Project":
return prisma.project;
case "Comment":
return prisma.postComment;
default:
return null;
}
}
const getLightningAddress = async (item_id, item_type) => {
switch (item_type) {
case "Story":
return prisma.story.findUnique({
where: { id: item_id }, include: {
user: {
select: {
lightning_address: true
}
}
}
}).then(data => data.user.lightning_address);
case "Question":
return prisma.question.findUnique({
where: { id: item_id }, include: {
user: {
select: {
lightning_address: true
}
}
}
}).then(data => data.user.lightning_address);
case "Project":
return prisma.project.findUnique({
where: { id: item_id },
select: {
lightning_address: true
}
}).then(data => data.lightning_address);
case "Comment":
return prisma.postComment.findUnique({
where: { id: item_id }, include: {
user: {
select: {
lightning_address: true
}
}
}
}).then(data => data.user.lightning_address);
default:
return null;
}
}
// type => modal
// type => lightning address (pr)
// This is the new voting mutation, it can vote for any type of item that we define in the VOTE_ITEM_TYPE enum
const voteMutation = extendType({
type: "Mutation",
definition(t) {
t.nonNull.field('vote', {
type: "Vote",
args: {
project_id: nonNull(intArg()),
item_type: arg({
type: nonNull("VOTE_ITEM_TYPE")
}),
item_id: nonNull(intArg()),
amount_in_sat: nonNull(intArg())
},
resolve: async (_, args) => {
const project = await prisma.project.findUnique({
where: { id: args.project_id },
});
const pr = await getPaymetRequestForProject(project, args.amount_in_sat);
const { item_id, item_type, amount_in_sat } = args;
const lightning_address = (await getLightningAddress(item_id, item_type)) ?? BOLT_FUN_LIGHTNING_ADDRESS;
const pr = await getPaymetRequestForItem(lightning_address, args.amount_in_sat);
console.log(pr);
const invoice = parsePaymentRequest({ request: pr });
// #TODO remove votes rows that get added but not confirmed after some time
// maybe using a scheduler, timeout, or whatever mean available
return prisma.vote.create({
data: {
project_id: project.id,
amount_in_sat: args.amount_in_sat,
item_type: item_type,
item_id: item_id,
amount_in_sat: amount_in_sat,
payment_request: pr,
payment_hash: invoice.id,
},
include: {
project: true
}
});
}
@@ -97,17 +174,19 @@ const confirmVoteMutation = extendType({
payment_hash: paymentHash,
},
});
// if we find a vote it means the preimage is correct and we update the vote and mark it as paid
// can we write this nicer?
if (vote) {
const project = await prisma.project.findUnique({
where: { id: vote.project_id },
const modal = getModalOfType(vote.item_type);
const item = await modal.findUnique({
where: { id: vote.item_id },
});
// count up votes cache
await prisma.project.update({
where: { id: project.id },
await modal.update({
where: { id: item.id },
data: {
votes_count: project.votes_count + vote.amount_in_sat,
votes_count: item.votes_count + vote.amount_in_sat,
},
});
// return the current vote
@@ -116,9 +195,6 @@ const confirmVoteMutation = extendType({
data: {
paid: true,
preimage: args.preimage,
},
include: {
project: true
}
});
} else {
@@ -130,6 +206,9 @@ const confirmVoteMutation = extendType({
})
module.exports = {
// Enums
VOTE_ITEM_TYPE,
// Types
Vote,
LnurlDetails,

View File

@@ -1,6 +1,5 @@
[build]
functions = "functions" # netlify-lambda builds to this folder AND Netlify reads functions from here
publish = "build" # create-react-app builds to this folder, Netlify should serve all these files statically
[dev]
framework = "#static"

73170
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,62 +1,79 @@
{
"name": "makers.bolt.fun",
"name": "my-app",
"version": "0.1.0",
"private": true,
"dependencies": {
"@apollo/client": "^3.5.5",
"@prisma/client": "3.5.0",
"@react-hookz/web": "^13.1.0",
"@react-spring/web": "^9.4.2",
"@reduxjs/toolkit": "^1.6.2",
"@testing-library/jest-dom": "^5.15.0",
"@testing-library/react": "^11.2.7",
"@testing-library/user-event": "^12.8.3",
"@types/jest": "^26.0.24",
"@types/node": "^12.20.36",
"@types/react": "^17.0.33",
"@types/react-dom": "^17.0.10",
"@use-gesture/react": "^10.2.5",
"apollo-server": "^3.5.0",
"apollo-server-lambda": "^3.5.0",
"axios": "^0.24.0",
"@apollo/client": "^3.5.10",
"@hookform/resolvers": "^2.8.8",
"@prisma/client": "^3.12.0",
"@react-hookz/web": "^13.2.1",
"@react-spring/web": "^9.4.4",
"@reduxjs/toolkit": "^1.8.1",
"@remirror/pm": "^1.0.16",
"@remirror/react": "^1.0.34",
"@szhsin/react-menu": "^3.0.0",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.1.1",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.4.1",
"@types/node": "^16.11.27",
"@types/react": "^18.0.5",
"@types/react-dom": "^18.0.1",
"@use-gesture/react": "^10.2.11",
"apollo-server": "^3.6.7",
"apollo-server-lambda": "^3.6.7",
"axios": "^0.26.1",
"chance": "^1.1.8",
"dayjs": "^1.11.1",
"env-cmd": "^10.1.0",
"framer-motion": "^5.3.0",
"graphql": "^16.0.1",
"invoices": "^2.0.2",
"framer-motion": "^6.3.0",
"fslightbox-react": "^1.6.2-2",
"graphql": "^16.3.0",
"invoices": "^2.0.6",
"linkify-html": "^3.0.5",
"linkifyjs": "^3.0.5",
"lodash.debounce": "^4.0.8",
"lodash.throttle": "^4.1.1",
"marked": "^4.0.14",
"nexus": "^1.3.0",
"prisma": "3.5.0",
"react": "^17.0.2",
"node-sass": "^7.0.1",
"prisma": "^3.12.0",
"react": "^18.0.0",
"react-confetti": "^6.0.1",
"react-copy-to-clipboard": "^5.0.4",
"react-dom": "^17.0.2",
"react-copy-to-clipboard": "^5.1.0",
"react-datepicker": "^4.7.0",
"react-dom": "^18.0.0",
"react-file-drop": "^3.1.4",
"react-hook-form": "^7.30.0",
"react-icons": "^4.3.1",
"react-image-lightbox": "^5.1.4",
"react-loader-spinner": "^6.0.0-0",
"react-loading-skeleton": "^3.0.2",
"react-multi-carousel": "^2.6.5",
"react-query": "^3.32.3",
"react-redux": "^7.2.6",
"react-responsive-carousel": "^3.2.22",
"react-router-dom": "^6.2.2",
"react-scripts": "4.0.3",
"typescript": "^4.4.4",
"web-vitals": "^1.1.2",
"webln": "^0.2.2"
"react-loading-skeleton": "^3.1.0",
"react-modal": "^3.15.1",
"react-multi-carousel": "^2.8.0",
"react-query": "^3.35.0",
"react-redux": "^8.0.0",
"react-responsive-carousel": "^3.2.23",
"react-router-dom": "^6.3.0",
"react-scripts": "5.0.1",
"react-select": "^5.3.2",
"react-tooltip": "^4.2.21",
"react-topbar-progress-indicator": "^4.1.1",
"remirror": "^1.0.77",
"typescript": "^4.6.3",
"web-vitals": "^2.1.4",
"webln": "^0.3.0",
"yup": "^0.32.11"
},
"scripts": {
"client:prod-server": "env-cmd -f ./environments/.dev.prod-server.env craco start",
"client:preview-server": "env-cmd -f ./environments/.dev.preview-server.env craco start",
"client:mocks": "env-cmd -f ./environments/.dev.mock-server.env craco start",
"client:dev-server": "env-cmd -f ./environments/.dev.server.env craco start",
"client:prod-server": "env-cmd -f ./environments/.dev.prod-server.env react-scripts start",
"client:preview-server": "env-cmd -f ./environments/.dev.preview-server.env react-scripts start",
"client:mocks": "env-cmd -f ./environments/.dev.mock-server.env react-scripts start",
"client:dev-server": "env-cmd -f ./environments/.dev.server.env react-scripts start",
"server:dev": "serverless offline",
"generate-graphql": "graphql-codegen",
"build": "craco build",
"build:mocks": "env-cmd -f ./environments/.prod.mock-server.env craco build",
"test": "craco test",
"build": "react-scripts build",
"build:mocks": "env-cmd -f ./environments/.prod.mock-server.env react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"predeploy": "env-cmd -f ./environments/.prod.github.env npm run build",
"deploy": "gh-pages -d build",
@@ -64,6 +81,7 @@
"storybook": "env-cmd -f ./environments/.dev.preview-server.env start-storybook -p 6006 -s public",
"storybook:mocks": "env-cmd -f ./environments/.dev.mock-server.env start-storybook -p 6006 -s public",
"build-storybook": "env-cmd -f ./environments/.prod.preview-server.env build-storybook -s public",
"build-storybook:mocks": "env-cmd -f ./environments/.prod.mock-server.env build-storybook -s public",
"db:migrate-dev": "prisma migrate dev",
"db:migrate-deploy": "prisma migrate deploy",
"db:reset": "prisma migrate reset",
@@ -103,28 +121,38 @@
]
},
"devDependencies": {
"@craco/craco": "^6.4.0",
"@graphql-codegen/cli": "2.3.0",
"@graphql-codegen/cli": "^2.6.2",
"@graphql-codegen/typed-document-node": "^2.2.8",
"@graphql-codegen/typescript": "^2.4.1",
"@graphql-codegen/typescript-operations": "^2.2.1",
"@graphql-codegen/typescript-react-apollo": "3.2.2",
"@storybook/addon-actions": "^6.3.12",
"@storybook/addon-essentials": "^6.3.12",
"@storybook/addon-links": "^6.3.12",
"@storybook/addon-postcss": "^2.0.0",
"@storybook/node-logger": "^6.3.12",
"@storybook/preset-create-react-app": "^3.2.0",
"@storybook/react": "^6.3.12",
"@types/lodash.throttle": "^4.1.6",
"@graphql-codegen/typescript": "^2.4.8",
"@graphql-codegen/typescript-operations": "^2.3.5",
"@graphql-codegen/typescript-react-apollo": "^3.2.11",
"@storybook/addon-actions": "^6.4.22",
"@storybook/addon-essentials": "^6.4.22",
"@storybook/addon-interactions": "^6.4.22",
"@storybook/addon-links": "^6.4.22",
"@storybook/builder-webpack5": "^6.4.22",
"@storybook/manager-webpack5": "^6.4.22",
"@storybook/node-logger": "^6.4.22",
"@storybook/preset-create-react-app": "^4.1.0",
"@storybook/react": "^6.4.22",
"@storybook/testing-library": "^0.0.10",
"@tailwindcss/forms": "^0.5.0",
"@types/chance": "^1.1.3",
"@types/fslightbox-react": "^1.4.2",
"@types/lodash.debounce": "^4.0.7",
"@types/lodash.throttle": "^4.1.7",
"@types/marked": "^4.0.3",
"@types/react-copy-to-clipboard": "^5.0.2",
"autoprefixer": "^9.8.8",
"@types/react-datepicker": "^4.4.0",
"@types/react-modal": "^3.13.1",
"autoprefixer": "^10.4.4",
"gh-pages": "^3.2.3",
"msw": "^0.39.1",
"netlify-cli": "^8.15.0",
"postcss": "^7.0.39",
"serverless-offline": "^8.5.0",
"tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.2.17"
"msw": "^0.39.2",
"netlify-cli": "^10.0.0",
"postcss": "^8.4.12",
"serverless-offline": "^8.7.0",
"tailwindcss": "^3.0.24",
"webpack": "^5.72.0"
},
"msw": {
"workerDirectory": "public"

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,159 @@
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"username" TEXT NOT NULL,
"lightning_address" TEXT,
"avatar" TEXT NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Story" (
"id" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"body" TEXT NOT NULL,
"thumbnail_image" TEXT NOT NULL,
"cover_image" TEXT NOT NULL,
"votes_count" INTEGER NOT NULL DEFAULT 0,
"topic_id" INTEGER NOT NULL,
"user_id" INTEGER,
CONSTRAINT "Story_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Question" (
"id" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"body" TEXT NOT NULL,
"thumbnail_image" TEXT NOT NULL,
"votes_count" INTEGER NOT NULL DEFAULT 0,
"topic_id" INTEGER NOT NULL,
"user_id" INTEGER,
CONSTRAINT "Question_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Topic" (
"id" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"icon" TEXT NOT NULL,
CONSTRAINT "Topic_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PostComment" (
"id" SERIAL NOT NULL,
"body" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"votes_count" INTEGER NOT NULL DEFAULT 0,
"parent_comment_id" INTEGER,
"user_id" INTEGER,
"story_id" INTEGER,
"question_id" INTEGER,
CONSTRAINT "PostComment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Hackathon" (
"id" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"date" TEXT NOT NULL,
"cover_image" TEXT NOT NULL,
"description" TEXT NOT NULL,
"location" TEXT NOT NULL,
"website" TEXT NOT NULL,
"votes_count" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "Hackathon_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_StoryToTag" (
"A" INTEGER NOT NULL,
"B" INTEGER NOT NULL
);
-- CreateTable
CREATE TABLE "_QuestionToTag" (
"A" INTEGER NOT NULL,
"B" INTEGER NOT NULL
);
-- CreateTable
CREATE TABLE "_HackathonToTopic" (
"A" INTEGER NOT NULL,
"B" INTEGER NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "Topic_title_key" ON "Topic"("title");
-- CreateIndex
CREATE UNIQUE INDEX "_StoryToTag_AB_unique" ON "_StoryToTag"("A", "B");
-- CreateIndex
CREATE INDEX "_StoryToTag_B_index" ON "_StoryToTag"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_QuestionToTag_AB_unique" ON "_QuestionToTag"("A", "B");
-- CreateIndex
CREATE INDEX "_QuestionToTag_B_index" ON "_QuestionToTag"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_HackathonToTopic_AB_unique" ON "_HackathonToTopic"("A", "B");
-- CreateIndex
CREATE INDEX "_HackathonToTopic_B_index" ON "_HackathonToTopic"("B");
-- AddForeignKey
ALTER TABLE "Story" ADD CONSTRAINT "Story_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Story" ADD CONSTRAINT "Story_topic_id_fkey" FOREIGN KEY ("topic_id") REFERENCES "Topic"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Question" ADD CONSTRAINT "Question_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Question" ADD CONSTRAINT "Question_topic_id_fkey" FOREIGN KEY ("topic_id") REFERENCES "Topic"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PostComment" ADD CONSTRAINT "PostComment_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PostComment" ADD CONSTRAINT "PostComment_story_id_fkey" FOREIGN KEY ("story_id") REFERENCES "Story"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PostComment" ADD CONSTRAINT "PostComment_question_id_fkey" FOREIGN KEY ("question_id") REFERENCES "Question"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PostComment" ADD CONSTRAINT "PostComment_parent_comment_id_fkey" FOREIGN KEY ("parent_comment_id") REFERENCES "PostComment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_StoryToTag" ADD FOREIGN KEY ("A") REFERENCES "Story"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_StoryToTag" ADD FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_QuestionToTag" ADD FOREIGN KEY ("A") REFERENCES "Question"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_QuestionToTag" ADD FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_HackathonToTopic" ADD FOREIGN KEY ("A") REFERENCES "Hackathon"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_HackathonToTopic" ADD FOREIGN KEY ("B") REFERENCES "Topic"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,23 @@
/*
Warnings:
- You are about to drop the column `created_at` on the `PostComment` table. All the data in the column will be lost.
- You are about to drop the column `date` on the `Question` table. All the data in the column will be lost.
- You are about to drop the column `date` on the `Story` table. All the data in the column will be lost.
- Added the required column `updatedAt` to the `Question` table without a default value. This is not possible if the table is not empty.
- Added the required column `updatedAt` to the `Story` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "PostComment" DROP COLUMN "created_at",
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- AlterTable
ALTER TABLE "Question" DROP COLUMN "date",
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
-- AlterTable
ALTER TABLE "Story" DROP COLUMN "date",
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;

View File

@@ -0,0 +1,20 @@
/*
Warnings:
- You are about to drop the column `date` on the `Hackathon` table. All the data in the column will be lost.
- You are about to drop the column `thumbnail_image` on the `Question` table. All the data in the column will be lost.
- You are about to drop the column `thumbnail_image` on the `Story` table. All the data in the column will be lost.
- Added the required column `end_date` to the `Hackathon` table without a default value. This is not possible if the table is not empty.
- Added the required column `start_date` to the `Hackathon` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Hackathon" DROP COLUMN "date",
ADD COLUMN "end_date" DATE NOT NULL,
ADD COLUMN "start_date" DATE NOT NULL;
-- AlterTable
ALTER TABLE "Question" DROP COLUMN "thumbnail_image";
-- AlterTable
ALTER TABLE "Story" DROP COLUMN "thumbnail_image";

View File

@@ -0,0 +1,9 @@
/*
Warnings:
- You are about to drop the column `created_at` on the `Project` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Project" DROP COLUMN "created_at",
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;

View File

@@ -0,0 +1,17 @@
/*
Warnings:
- You are about to drop the column `username` on the `User` table. All the data in the column will be lost.
- A unique constraint covering the columns `[name]` on the table `User` will be added. If there are existing duplicate values, this will fail.
- Added the required column `name` to the `User` table without a default value. This is not possible if the table is not empty.
*/
-- DropIndex
DROP INDEX "User_username_key";
-- AlterTable
ALTER TABLE "User" DROP COLUMN "username",
ADD COLUMN "name" TEXT NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "User_name_key" ON "User"("name");

View File

@@ -0,0 +1,12 @@
/*
Warnings:
- Added the required column `excerpt` to the `Question` table without a default value. This is not possible if the table is not empty.
- Added the required column `excerpt` to the `Story` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Question" ADD COLUMN "excerpt" TEXT NOT NULL;
-- AlterTable
ALTER TABLE "Story" ADD COLUMN "excerpt" TEXT NOT NULL;

View File

@@ -0,0 +1,15 @@
/*
Warnings:
- You are about to drop the column `project_id` on the `Vote` table. All the data in the column will be lost.
- Added the required column `item_id` to the `Vote` table without a default value. This is not possible if the table is not empty.
- Added the required column `item_type` to the `Vote` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "Vote" DROP CONSTRAINT "Vote_project_id_fkey";
-- AlterTable
ALTER TABLE "Vote" DROP COLUMN "project_id",
ADD COLUMN "item_id" INTEGER NOT NULL,
ADD COLUMN "item_type" TEXT NOT NULL;

View File

@@ -7,6 +7,49 @@ generator client {
provider = "prisma-client-js"
}
// -----------------
// Shared
// -----------------
model Tag {
id Int @id @default(autoincrement())
title String @unique
project Project[]
stories Story[]
questions Question[]
}
model Vote {
id Int @id @default(autoincrement())
item_id Int
item_type String
amount_in_sat Int
payment_request String?
payment_hash String?
preimage String?
paid Boolean @default(false)
}
// -----------------
// Users
// -----------------
model User {
id Int @id @default(autoincrement())
name String @unique
lightning_address String?
avatar String
stories Story[]
questions Question[]
posts_comments PostComment[]
}
// -----------------
// Projects
// -----------------
model Category {
id Int @id @default(autoincrement())
title String
@@ -16,16 +59,6 @@ model Category {
project Project[]
}
model Award {
id Int @id @default(autoincrement())
title String
image String
url String
project Project @relation(fields: [project_id], references: [id])
project_id Int
}
model Project {
id Int @id @default(autoincrement())
title String
@@ -40,27 +73,112 @@ model Project {
category Category @relation(fields: [category_id], references: [id])
category_id Int
votes_count Int @default(0)
vote Vote[]
created_at DateTime @default(now())
createdAt DateTime @default(now())
awards Award[]
tags Tag[]
}
model Vote {
id Int @id @default(autoincrement())
project Project @relation(fields: [project_id], references: [id])
project_id Int
amount_in_sat Int
payment_request String?
payment_hash String?
preimage String?
paid Boolean @default(false)
model Award {
id Int @id @default(autoincrement())
title String
image String
url String
project Project @relation(fields: [project_id], references: [id])
project_id Int
}
model Tag {
// -----------------
// Posts
// -----------------
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)
topic Topic @relation(fields: [topic_id], references: [id])
topic_id Int
tags Tag[]
user User? @relation(fields: [user_id], references: [id])
user_id Int?
comments PostComment[] @relation("StoryComment")
}
model Question {
id Int @id @default(autoincrement())
title String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
body String
excerpt String
votes_count Int @default(0)
topic Topic @relation(fields: [topic_id], references: [id])
topic_id Int
tags Tag[]
user User? @relation(fields: [user_id], references: [id])
user_id Int?
comments PostComment[] @relation("QuestionComment")
}
model Topic {
id Int @id @default(autoincrement())
title String @unique
icon String
project Project[]
stories Story[]
questions Question[]
hackathons Hackathon[]
}
model PostComment {
id Int @id @default(autoincrement())
body String
createdAt DateTime @default(now())
votes_count Int @default(0)
replies PostComment[] @relation("PostComment_Replies")
parent_comment_id Int?
parent_comment PostComment? @relation("PostComment_Replies", fields: [parent_comment_id], references: [id])
user User? @relation(fields: [user_id], references: [id])
user_id Int?
story Story? @relation("StoryComment", fields: [story_id], references: [id])
story_id Int?
question Question? @relation("QuestionComment", fields: [question_id], references: [id])
question_id Int?
}
// -----------------
// Hackathons
// -----------------
model Hackathon {
id Int @id @default(autoincrement())
title String
start_date DateTime @db.Date
end_date DateTime @db.Date
cover_image String
description String
location String
website String
votes_count Int @default(0)
topics Topic[]
}

View File

@@ -1,13 +1,19 @@
import { useEffect } from "react";
import React, { Suspense, useEffect } from "react";
import Navbar from "src/Components/Navbar/Navbar";
import ExplorePage from "src/pages/ExplorePage";
import ModalsContainer from "src/Components/Modals/ModalsContainer/ModalsContainer";
import { useAppSelector } from './utils/hooks';
import { Wallet_Service } from "./services";
import { Route, Routes } from "react-router-dom";
import CategoryPage from "./pages/CategoryPage/CategoryPage";
import { useWrapperSetup } from "./utils/Wrapper";
import HottestPage from "./pages/HottestPage/HottestPage";
import LoadingPage from "./Components/LoadingPage/LoadingPage";
// Pages
const FeedPage = React.lazy(() => import("./features/Posts/pages/FeedPage/FeedPage"))
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 CategoryPage = React.lazy(() => import("src/features/Projects/pages/CategoryPage/CategoryPage"))
const ExplorePage = React.lazy(() => import("src/features/Projects/pages/ExplorePage"))
function App() {
const { isWalletConnected } = useAppSelector(state => ({
@@ -34,13 +40,18 @@ function App() {
return <div id="app" className='w-screen overflow-hidden'>
return <div id="app" className='w-full'>
<Navbar />
<Routes>
<Route path="/hottest" element={<HottestPage />} />
<Route path="/category/:id" element={<CategoryPage />} />
<Route path="/" element={<ExplorePage />} />
</Routes>
<Suspense fallback={<LoadingPage />}>
<Routes>
<Route path="/hottest" element={<HottestPage />} />
<Route path="/category/:id" element={<CategoryPage />} />
<Route path="/blog/post/:type/:id" element={<PostDetailsPage />} />
<Route path="/blog" element={<FeedPage />} />
<Route path="/hackathons" element={<HackathonsPage />} />
<Route path="/" element={<ExplorePage />} />
</Routes>
</Suspense>
<ModalsContainer />
</div>;
}

View File

@@ -66,7 +66,7 @@ export default function Badge(
return (
wrapLink(
<span className={classes} onClick={onClick}>
<span>{children}</span>
<span className="font-medium">{children}</span>
{onRemove && <IoMdCloseCircle onClick={onRemove} className="ml-12 cursor-pointer" />}
</span>
, href)

View File

@@ -1,10 +1,10 @@
import { ComponentProps, ReactNode } from 'react';
import { wrapLink } from 'src/utils/hoc';
import { ReactNode } from 'react';
import { UnionToObjectKeys } from 'src/utils/types/utils';
import { Link } from 'react-router-dom'
// import Loading from '../Loading/Loading';
interface Props {
color?: 'primary' | 'red' | 'white' | 'gray' | 'none',
color?: 'primary' | 'red' | 'white' | 'gray' | "black" | 'none',
variant?: 'fill' | 'outline'
size?: 'sm' | 'md' | 'lg'
children: ReactNode;
@@ -15,15 +15,17 @@ interface Props {
className?: string
isLoading?: boolean;
disableOnLoading?: boolean;
disabled?: boolean;
[rest: string]: any;
}
const btnStylesFill: UnionToObjectKeys<Props, 'color'> = {
none: "",
primary: "bg-primary-500 border-0 hover:bg-primary-400 active:bg-primary-600 text-white",
primary: "bg-primary-500 hover:bg-primary-400 active:bg-primary-600 text-white",
gray: 'bg-gray-100 hover:bg-gray-200 text-gray-900 active:bg-gray-300',
white: 'text-gray-900 bg-gray-25 hover:bg-gray-50',
red: "bg-red-600 border-0 hover:bg-red-500 active:bg-red-700 text-white",
white: 'border border-gray-300 text-gray-900 bg-gray-25 hover:bg-gray-50',
black: 'text-white bg-black hover:bg-gray-900',
red: "bg-red-600 hover:bg-red-500 active:bg-red-700 text-white",
}
const btnStylesOutline: UnionToObjectKeys<Props, 'color'> = {
@@ -31,11 +33,12 @@ const btnStylesOutline: UnionToObjectKeys<Props, 'color'> = {
primary: "text-primary-600",
gray: 'text-gray-700',
white: 'text-gray-900',
black: 'text-black',
red: "text-red-500",
}
const baseBtnStyles: UnionToObjectKeys<Props, 'variant'> = {
fill: " shadow-sm active:scale-95",
fill: "active:scale-95",
outline: "bg-gray-900 bg-opacity-0 hover:bg-opacity-5 active:bg-opacity-10 border border-gray-200 active:scale-95 "
}
@@ -54,15 +57,28 @@ const btnPadding: UnionToObjectKeys<Props, 'size'> = {
lg: 'py-12 px-36 text-body4'
}
export default function Button({ color = 'white', variant = 'fill', isLoading, disableOnLoading = true, size = 'md', fullWidth, href, newTab, className, onClick, children, ...props }: Props) {
export default function Button({ color = 'white',
variant = 'fill',
isLoading,
disableOnLoading = true,
size = 'md',
fullWidth,
disabled,
href,
newTab,
className,
onClick,
children,
...props }: Props) {
let classes = `
inline-block font-sans rounded-lg font-regular border border-gray-300 hover:cursor-pointer
inline-block font-sans rounded-lg font-regular hover:cursor-pointer text-center
${baseBtnStyles[variant]}
${btnPadding[size]}
${variant === 'fill' ? btnStylesFill[color] : btnStylesOutline[color]}
${isLoading && disableOnLoading && 'bg-opacity-70 pointer-events-none'}
${fullWidth && 'w-full'}
${disabled && 'opacity-40 pointer-events-none'}
`;
@@ -70,22 +86,38 @@ export default function Button({ color = 'white', variant = 'fill', isLoading, d
const handleClick = () => {
if (isLoading && disableOnLoading) return;
if (onClick) onClick();
}
if (href)
if (newTab)
return <a
href={href}
className={`${classes} ${className}`}
target="_blank" rel="noopener noreferrer"
{...props}
>
{children}
</a>
else
return <Link
to={href}
className={`${classes} ${className}`} >
{children}
</Link>
return (
wrapLink(
<button
type='button'
className={`${classes} ${className}`}
onClick={() => handleClick()}
{...props}
>
{/* {isLoading ? <Loading color={loadingColor[color]} /> : children} */}
{children}
</button>
, href, {
newTab
})
<button
type='button'
className={`${classes} ${className}`}
onClick={() => handleClick()}
disabled={disabled}
{...props}
>
{/* {isLoading ? <Loading color={loadingColor[color]} /> : children} */}
{children}
</button>
)
}

View File

@@ -10,7 +10,8 @@ export default {
const Template: ComponentStory<typeof CopyToClipboard> = (args) => <div className="flex h-[400px] justify-center items-center"><div className="input-wrapper mt-32 max-w-[320px] mx-auto">
<input
className="input-field overflow-ellipsis"
type='text'
className="input-text overflow-ellipsis"
value={'Some Text To Copy'}
/>
<CopyToClipboard {...args} text="Some Text To Copy" />

View File

@@ -0,0 +1,171 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { useWatch } from 'react-hook-form';
import { WrapForm } from 'src/utils/storybook/decorators';
import Autocomplete from './Autocomplete';
export default {
title: 'Shared/Inputs/AutoComplete',
component: Autocomplete,
decorators: [WrapForm({
defaultValues: {
autocomplete: null
}
})],
} as ComponentMeta<typeof Autocomplete>;
const options = [
{
"id": "20f0eb8d-c0cd-4e12-8a08-0d9846fc8704",
"name": "Nichole Bailey",
"username": "Cassie14",
"email": "Daisy_Auer50@hotmail.com",
"address": {
"street": "Anastasia Tunnel",
"suite": 95587,
"city": "Port Casperview",
"zipcode": "04167-6996",
"geo": {
"lat": "-73.4727",
"lng": "-142.9435"
}
},
"phone": "324-615-9195 x5902",
"website": "ron.net",
"company": {
"name": "Roberts, Tremblay and Christiansen",
"catchPhrase": "Vision-oriented actuating access",
"bs": "bricks-and-clicks strategize portals"
}
},
{
"id": "62b70f76-85ba-4241-9ffd-07582008c497",
"name": "Robert Blick",
"username": "Madilyn93",
"email": "Ronaldo82@gmail.com",
"address": {
"street": "Charlie Plain",
"suite": 83070,
"city": "Lake Bonitaland",
"zipcode": "01109",
"geo": {
"lat": "50.0971",
"lng": "-2.3057"
}
},
"phone": "1-541-367-2047 x9006",
"website": "jovani.com",
"company": {
"name": "Parisian - Kling",
"catchPhrase": "Multi-tiered tertiary toolset",
"bs": "plug-and-play benchmark content"
}
},
{
"id": "d02f74d9-bf99-4e41-b678-15e903abc1b3",
"name": "Eli O'Kon",
"username": "Rosario.Davis",
"email": "Mckayla59@hotmail.com",
"address": {
"street": "Wilford Drive",
"suite": 69742,
"city": "North Dianna",
"zipcode": "80620",
"geo": {
"lat": "-61.4191",
"lng": "126.7878"
}
},
"phone": "(339) 709-4080",
"website": "clay.name",
"company": {
"name": "Gerlach - Metz",
"catchPhrase": "Pre-emptive user-facing service-desk",
"bs": "frictionless monetize markets"
}
},
{
"id": "21077fa6-6a53-4b84-8407-6cd949718945",
"name": "Marilie Feil",
"username": "Antwon.Carter92",
"email": "Demario.Hyatt20@yahoo.com",
"address": {
"street": "Kenton Spurs",
"suite": 20079,
"city": "Beahanberg",
"zipcode": "79385",
"geo": {
"lat": "-70.7199",
"lng": "4.6977"
}
},
"phone": "608.750.4947",
"website": "jacynthe.org",
"company": {
"name": "Kuhn and Sons",
"catchPhrase": "Total eco-centric matrices",
"bs": "out-of-the-box target communities"
}
},
{
"id": "e07cf1b4-ff43-4c4a-a670-fd7417d6bbaf",
"name": "Ella Pagac",
"username": "Damien.Jaskolski",
"email": "Delmer1@gmail.com",
"address": {
"street": "VonRueden Shoals",
"suite": 14035,
"city": "Starkmouth",
"zipcode": "72448-1915",
"geo": {
"lat": "55.2157",
"lng": "98.0822"
}
},
"phone": "(165) 247-5332 x71067",
"website": "chad.info",
"company": {
"name": "Nicolas, Doyle and Rempel",
"catchPhrase": "Adaptive real-time strategy",
"bs": "innovative whiteboard supply-chains"
}
}
]
const Template: ComponentStory<typeof Autocomplete> = (args) => {
const value = useWatch({ name: 'autocomplete' })
console.log(value);
return <Autocomplete
options={options}
labelField='name'
valueField='name'
{...args as any}
/>
}
export const Default = Template.bind({});
Default.args = {
onChange: console.log
}
export const Lodaing = Template.bind({});
Lodaing.args = {
isLoading: true
}
export const Clearable = Template.bind({});
Clearable.args = {
isClearable: true
}
export const MultipleAllowed = Template.bind({});
MultipleAllowed.args = {
isMulti: true
}

View File

@@ -0,0 +1,97 @@
import Select, { StylesConfig } from "react-select";
type Props<T extends object | string> = {
options: T[];
labelField?: keyof T
valueField?: keyof T
placeholder?: string
disabled?: boolean
isLoading?: boolean;
isClearable?: boolean;
control?: any,
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
}
)
const colourStyles: StylesConfig = {
input: (styles, state) => ({
...styles,
" input": {
boxShadow: 'none !important'
}
}),
};
export default function AutoComplete<T extends object>({
options,
labelField,
valueField,
placeholder = "Select Option...",
isMulti,
isClearable,
disabled,
className,
value,
onChange,
onBlur,
...props
}: Props<T>) {
return (
<div className='w-full'>
<Select
options={options}
placeholder={placeholder}
className={className}
isMulti={isMulti}
isClearable={isClearable}
isLoading={props.isLoading}
getOptionLabel={o => o[labelField]}
getOptionValue={o => o[valueField]}
value={value as any}
onChange={v => onChange?.(v as any)}
onBlur={onBlur}
styles={colourStyles}
theme={(theme) => ({
...theme,
borderRadius: 8,
colors: {
...theme.colors,
primary: 'var(--primary)',
},
})}
/>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import DatePicker from './DatePicker';
export default {
title: 'Shared/Inputs/Date Picker',
component: DatePicker,
} as ComponentMeta<typeof DatePicker>;
const Template: ComponentStory<typeof DatePicker> = (args) => <DatePicker {...args} />
export const Default = Template.bind({});
Default.args = {
value: new Date(),
classes: { containerClasses: "max-w-[360px] bg-white" }
}

View File

@@ -0,0 +1,38 @@
import ReactDatePicker from "react-datepicker";
import { MdCalendarToday } from "react-icons/md";
import "react-datepicker/dist/react-datepicker.css";
import React from "react";
interface Props {
value?: Date,
onChange?: (newValue: Date | null) => void
classes?: {
containerClasses?: string,
inputClasses?: string
};
className?: string
innerClassname?: string
}
const DatePicker = React.forwardRef<HTMLDivElement, Props>(({ value = new Date(), onChange = () => { }, classes, className }, ref) => {
return (
<div className={`input-wrapper !text-gray-800 px-16
${className} ${classes?.containerClasses}`}
ref={ref}
>
<MdCalendarToday className="flex-shrink-0 self-center text-gray-600" />
<ReactDatePicker
selected={value}
onChange={onChange}
className={`
input-text
text-gray-800
${classes?.inputClasses} `}
></ReactDatePicker>
</div>
)
})
export default DatePicker;

View File

@@ -0,0 +1,66 @@
import { useToggle } from "@react-hookz/web";
import React from "react";
import { FileDrop } from "react-file-drop";
export default function DropInput({
value: files,
onChange,
emptyContent,
draggingContent,
hasFilesContent,
height,
multiple = false,
allowedType = "*",
classes = {
base: "",
idle: "",
dragging: "",
},
}) {
const [isDragging, toggleDrag] = useToggle(false);
const fileInputRef = React.useRef(null);
const onAddFiles = (_files) => {
onChange(_files);
// do something with your files...
};
const uploadClick = () => {
fileInputRef.current.click();
};
const status = isDragging ? "dragging" : files ? "has-files" : "empty";
return (
<div
style={{
height: height + "px",
}}
>
<FileDrop
onDrop={(files) => onAddFiles(files)}
onTargetClick={uploadClick}
onFrameDragEnter={() => toggleDrag(true)}
onFrameDragLeave={() => toggleDrag(false)}
onFrameDrop={() => toggleDrag(false)}
className={`h-full cursor-pointer`}
targetClassName={`h-full ${classes.base} ${
status === "empty" && classes.idle
}`}
draggingOverFrameClassName={`${classes.dragging}`}
>
{status === "dragging" && draggingContent}
{status === "empty" && emptyContent}
{status === "has-files" && hasFilesContent}
</FileDrop>
<input
onChange={(e) => onAddFiles(e.target.files)}
ref={fileInputRef}
type="file"
className="hidden"
multiple={multiple}
accept={allowedType}
/>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { BsImages } from 'react-icons/bs';
import Button from 'src/Components/Button/Button';
import FilesInput from './FilesInput';
import FileDropInput from './FilesDropInput';
export default {
title: 'Shared/Inputs/Files Input',
component: FilesInput,
} as ComponentMeta<typeof FilesInput>;
const Template: ComponentStory<typeof FilesInput> = (args) => <FilesInput {...args} />
export const DefaultButton = Template.bind({});
DefaultButton.args = {
}
export const CustomizedButton = Template.bind({});
CustomizedButton.args = {
multiple: true,
uploadBtn: <Button color='primary'><span className="align-middle">Drop Images</span> <BsImages className='ml-12 scale-125' /></Button>
}
const DropTemplate: ComponentStory<typeof FileDropInput> = (args) => <div className="max-w-[500px]"><FileDropInput {...args as any} /></div>
export const DropZoneInput = DropTemplate.bind({});
DropZoneInput.args = {
onChange: console.log,
}

View File

@@ -0,0 +1,75 @@
import { useMemo } from "react";
import { MdClose } from "react-icons/md";
import IconButton from "src/Components/IconButton/IconButton";
interface Props {
file: File | string,
onRemove?: () => void
}
function getFileType(file: File | string) {
if (typeof file === 'string') {
if (/^http[^?]*.(jpg|jpeg|gif|png|tiff|bmp)(\?(.*))?$/gmi.test(file))
return 'image'
if (/\.(pdf|doc|docx)$/.test(file))
return 'document';
return 'unknown'
}
else {
if (file['type'].split('/')[0] === 'image')
return 'image'
return 'unknown'
}
}
type ThumbnailFile = {
name: string;
src: string;
type: ReturnType<typeof getFileType>
}
function processFile(file: Props['file']): ThumbnailFile {
const fileType = getFileType(file);
if (typeof file === 'string') return { name: file, src: file, type: fileType };
return {
name: file.name,
src: URL.createObjectURL(file),
type: fileType
};
}
export default function FileThumbnail({ file: f, onRemove }: Props) {
const file = useMemo(() => processFile(f), [f])
return (
<div className="bg-gray-100 rounded-8 p-12 shrink-0 flex gap-4 overflow-hidden">
<div className="w-[100px]">
<p className="text-body6 overflow-hidden overflow-ellipsis whitespace-nowrap">
{file.name}
</p>
<a
href={file.src}
target='_blank'
rel="noreferrer"
>
{
file.type === 'image' && <img src={file.src} alt={file.name} className="p-4 w-3/4 mx-auto max-h-full object-contain" />
}
</a>
</div>
<div className="w-32 shrink-0 self-start" >
<IconButton size="sm" className="hover:bg-gray-500" onClick={onRemove}>
<MdClose />
</IconButton>
</div>
</div>
)
}

View File

@@ -0,0 +1,88 @@
import { FaImage } from "react-icons/fa";
import { UnionToObjectKeys } from "src/utils/types/utils";
import DropInput from "./DropInput";
type Props = {
height?: number
multiple?: boolean;
value?: File[] | string[] | string;
max?: number;
onBlur?: () => void;
onChange?: (files: (File | string)[] | null) => void
uploadBtn?: JSX.Element
uploadText?: string;
allowedType?: 'images';
classes?: Partial<{
base: string,
idle: string,
dragging: string,
hasFiles: string
}>
}
const fileAccept: UnionToObjectKeys<Props, 'allowedType'> = {
images: ".png, .jpg, .jpeg"
} as const;
const fileUrlToObject = async (url: string, fileName: string = 'filename') => {
const res = await fetch(url);
const contentType = res.headers.get('content-type') as string;
const blob = await res.blob()
const file = new File([blob], fileName, { contentType } as any)
return file
}
export default function FilesInput({
height = 200,
multiple,
value,
max = 3,
onBlur,
onChange,
allowedType = 'images',
classes,
...props
}: Props) {
const baseClasses = classes?.base ?? 'p-32 rounded-8 text-center flex flex-col justify-center items-center'
const idleClasses = classes?.idle ?? 'bg-primary-50 hover:bg-primary-25 border border-dashed border-primary-500 text-gray-800'
const draggingClasses = classes?.dragging ?? 'bg-primary-500 text-white'
return (
<DropInput
height={height}
emptyContent={defaultEmptyContent}
draggingContent={defaultDraggingContent}
hasFilesContent={defaultHasFilesContent}
value={value}
onChange={onChange}
multiple={multiple}
allowedType={fileAccept[allowedType]}
classes={{
base: baseClasses,
idle: idleClasses,
dragging: draggingClasses
}}
/>
)
}
const defaultEmptyContent = (
<>
<div>
<FaImage className="scale-150 mr-8 text-gray-400" />{" "}
<span className="align-middle">Drop your files here</span>
</div>
<p className="mt-4">
or <button className="hover:underline font-bold">Click to Upload</button>{" "}
</p>
</>
);
const defaultDraggingContent = <p className="font-bold text-body2">Drop your files here </p>;
const defaultHasFilesContent = (
<p className="font-bolder">Files Uploaded Successfully!!</p>
);

View File

@@ -0,0 +1,104 @@
import React, { ChangeEvent, useRef } from "react"
import { BsUpload } from "react-icons/bs";
import Button from "src/Components/Button/Button"
import { UnionToObjectKeys } from "src/utils/types/utils";
import FilesThumbnails from "./FilesThumbnails";
type Props = {
multiple?: boolean;
value?: File[] | string[] | string;
max?: number;
onBlur?: () => void;
onChange?: (files: (File | string)[] | null) => void
uploadBtn?: JSX.Element
uploadText?: string;
allowedType?: 'images';
}
const fileAccept: UnionToObjectKeys<Props, 'allowedType'> = {
images: ".png, .jpg, .jpeg"
} as const;
const fileUrlToObject = async (url: string, fileName: string = 'filename') => {
const res = await fetch(url);
const contentType = res.headers.get('content-type') as string;
const blob = await res.blob()
const file = new File([blob], fileName, { contentType } as any)
return file
}
export default function FilesInput({
multiple,
value,
max = 3,
onBlur,
onChange,
allowedType = 'images',
uploadText = 'Upload files',
...props
}: Props) {
const ref = useRef<HTMLInputElement>(null!)
const handleClick = () => {
ref.current.click();
}
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const files = e.target.files && Array.from(e.target.files).slice(0, max);
if (typeof value === 'string')
onChange?.([value, ...(files ?? [])]);
else
onChange?.([...(value ?? []), ...(files ?? [])]);
}
const handleRemove = async (idx: number) => {
if (!value) return onChange?.([]);
if (typeof value === 'string')
onChange?.([]);
else {
let files = [...value]
files.splice(idx, 1);
//change all files urls to file objects
const filesConverted = await Promise.all(files.map(async file => {
if (typeof file === 'string') return await fileUrlToObject(file, "")
else return file;
}))
onChange?.(filesConverted);
}
}
const canUploadMore = multiple ?
!value || (value && value.length < max)
:
!value || value.length === 0
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>
return (
<>
<FilesThumbnails files={value} onRemove={handleRemove} />
{
canUploadMore &&
<>
{uploadBtn}
<input
ref={ref}
type="file"
onBlur={onBlur}
style={{ display: 'none' }}
multiple={multiple}
accept={fileAccept[allowedType]}
onChange={handleChange} />
</>
}
</>
)
}

View File

@@ -0,0 +1,31 @@
import React, { useMemo } from 'react'
import { MdClose } from 'react-icons/md';
import IconButton from 'src/Components/IconButton/IconButton';
import FileThumbnail from './FileThumbnail';
interface Props {
files?: (File | string)[] | string;
onRemove?: (idx: number) => void
}
function processFiles(files: Props['files']) {
if (!files) return [];
if (typeof files === 'string') return [files];
return files;
}
export default function FilesThumbnails({ files, onRemove }: Props) {
const filesConverted = useMemo(() => processFiles(files), [files])
return (
<div className="flex gap-12 mb-12">
{
filesConverted.map((file, idx) => <FileThumbnail
key={idx}
file={file}
onRemove={() => onRemove?.(idx)} />)
}
</div>
)
}

View File

@@ -0,0 +1,35 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { MdOutlineKingBed } from 'react-icons/md';
import SelectInput from './SelectInput';
export default {
title: 'Shared/SelectInput',
component: SelectInput,
} as ComponentMeta<typeof SelectInput>;
const Template: ComponentStory<typeof SelectInput> = (args) => <SelectInput {...args} />
export const Default = Template.bind({});
Default.args = {
// defaultValue: 4,
options: [
{ value: 1, label: "Option 1" },
{ value: 2, label: 'Option 2' },
{ value: 3, label: 'Option 3' },
{ value: 4, label: 'Option 4' },
],
onChange: (nv) => alert("New value is: " + nv)
}
export const Loading = Template.bind({});
Loading.args = {
isLoading: true,
onChange: (nv) => alert("New value is: " + nv)
}

View File

@@ -0,0 +1,63 @@
import { ChangeEvent } from "react";
import { ThreeDots } from "react-loader-spinner";
import './selectinput.style.css'
interface Props {
options?: {
value: number | string | undefined,
label: string
}[]
classes?: {
containerClasses?: string,
inputClasses?: string
};
valueAsNumber?: boolean;
defaultValue?: number | string,
placeholder?: string;
value?: number | string,
isLoading?: boolean;
onChange?: (newValue: string) => void
onBlur?: () => void
[key: string]: any
}
export default function SelectInput({ options = [], classes, defaultValue, value, isLoading, onChange, onBlur, placeholder, ...props }: Props) {
const handleChange = (e: ChangeEvent<HTMLSelectElement>) => {
let value = props.valueAsNumber ? Number(e.target.value) : e.target.value;
onChange?.(value as any);
}
return (
<div className={`selectdiv relative ${classes?.containerClasses}`}>
<select className={`
block
w-full
rounded-md
border-gray-300
shadow-sm
focus:border-primary-700 focus:ring focus:ring-primary-600 focus:ring-opacity-50
cursor-pointer
${classes?.inputClasses}
`}
disabled={isLoading}
value={value}
onChange={handleChange}
onBlur={onBlur}
defaultValue={defaultValue}
{...props}
>
{placeholder && <option value="" className="py-12">{placeholder}</option>}
{options.map(o => <option key={o.value} value={o.value} className="py-12">{o.label}</option>)}
</select>
{isLoading &&
<div className="absolute top-1/2 -translate-y-1/2 right-48">
<ThreeDots width={40} />
</div>
}
</div>
)
}

View File

@@ -0,0 +1,30 @@
.selectdiv:after {
content: "<>";
font: 15px "Consolas", monospace;
color: inherit;
-webkit-transform: translateY(-50%) rotate(90deg);
-moz-transform: translateY(-50%) rotate(90deg);
-ms-transform: translateY(-50%) rotate(90deg);
transform: translateY(-50%) rotate(90deg);
right: 11px;
/*Adjust for position however you want*/
top: 50%;
padding: 0 0 2px;
border-bottom: 1px solid inherit;
/*left line */
position: absolute;
pointer-events: none;
}
.selectdiv select {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
/* Add some styling */
background-image: none;
-ms-word-break: normal;
word-break: normal;
}

View File

@@ -0,0 +1,29 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { WrapForm } from 'src/utils/storybook/decorators';
import TagsInput from './TagsInput';
export default {
title: 'Shared/Inputs/Tags Input',
component: TagsInput,
argTypes: {
backgroundColor: { control: 'color' },
},
decorators: [WrapForm({
defaultValues: {
tags: [{
title: "Webln"
}]
}
})]
} as ComponentMeta<typeof TagsInput>;
const Template: ComponentStory<typeof TagsInput> = (args) => <div>
<p className="text-body4 mb-8 text-gray-700">
Enter Tags:
</p>
<TagsInput classes={{ input: "max-w-[320px]" }} {...args}></TagsInput>
</div>
export const Default = Template.bind({});

View File

@@ -0,0 +1,82 @@
import { motion } from "framer-motion";
import { useState } from "react";
import { useController } from "react-hook-form";
import Badge from "src/Components/Badge/Badge";
import { Tag as ApiTag } from "src/utils/interfaces";
type Tag = Pick<ApiTag, 'title'>
interface Props {
classes?: {
container?: string
input?: string
}
placeholder?: string
max?: number;
[k: string]: any
}
export default function TagsInput({
classes,
placeholder = 'Write some tags',
max = 5,
...props }: Props) {
const [inputText, setInputText] = useState("");
const { field: { value, onChange, onBlur } } = useController({
name: props.name ?? "tags",
control: props.control,
})
const handleSubmit = () => {
onChange([...value, { title: inputText }]);
setInputText('');
onBlur();
}
const handleRemove = (idx: number) => {
onChange((value as Tag[]).filter((_, i) => idx !== i))
onBlur();
}
const isDisabled = value.length >= max;
return (
<div className={`${classes?.container}`}>
<div className="input-wrapper relative">
<input
disabled={isDisabled}
type='text'
className={`input-text inline-block
${isDisabled && 'opacity-50'}
${classes?.input}`}
placeholder={placeholder}
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyPress={e => {
if (e.key === 'Enter' && inputText.trim().length > 1) { e.preventDefault(); handleSubmit() }
}}
/>
{inputText.length > 2 && <motion.span
initial={{ scale: 1, y: "-50%" }}
animate={{ scale: 1.05 }}
transition={{
repeat: Infinity,
repeatType: 'mirror',
duration: .9
}}
className="text-gray-500 absolute top-1/2 right-16">
Enter to Insert
</motion.span>}
</div>
<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>
)
}

View File

@@ -0,0 +1,17 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import InsertImageModal from './InsertImageModal';
import { ModalsDecorator } from 'src/utils/storybook/decorators';
export default {
title: 'Shared/Inputs/Text Editor/Insert Image Modal',
component: InsertImageModal,
decorators: [ModalsDecorator]
} as ComponentMeta<typeof InsertImageModal>;
const Template: ComponentStory<typeof InsertImageModal> = (args) => <InsertImageModal {...args} />;
export const Default = Template.bind({});

View File

@@ -0,0 +1,84 @@
import React, { FormEvent, useState } from 'react'
import { ModalCard, modalCardVariants } from 'src/Components/Modals/ModalsContainer/ModalsContainer'
import { motion } from 'framer-motion'
import { IoClose } from 'react-icons/io5'
import Button from 'src/Components/Button/Button'
import { useAppDispatch } from 'src/utils/hooks'
import { PayloadAction } from '@reduxjs/toolkit'
interface Props extends ModalCard {
callbackAction: PayloadAction<{ src: string, alt?: string }>
}
export default function InsertImageModal({ onClose, direction, callbackAction, ...props }: Props) {
const [urlInput, setUrlInput] = useState("")
const [altInput, setAltInput] = useState("")
const dispatch = useAppDispatch();
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
if (urlInput.length > 10) {
// onInsert({ src: urlInput, alt: altInput })
const action = Object.assign({}, callbackAction);
action.payload = { src: urlInput, alt: altInput }
dispatch(action)
onClose?.();
}
}
return (
<motion.div
custom={direction}
variants={modalCardVariants}
initial='initial'
animate="animate"
exit='exit'
className="modal-card max-w-[660px] p-24 rounded-xl relative"
>
<IoClose className='absolute text-body2 top-24 right-24 hover:cursor-pointer' onClick={onClose} />
<h2 className='text-h5 font-bold'>Add Image</h2>
<form onSubmit={handleSubmit}>
<div className="grid md:grid-cols-3 gap-16 mt-32">
<div className='md:col-span-2'>
<p className="text-body5">
Image URL
</p>
<div className="input-wrapper mt-8 relative">
<input
type='text'
className="input-text"
value={urlInput}
onChange={e => setUrlInput(e.target.value)}
placeholder='https://images.com/my-image'
/>
</div>
</div>
<div>
<p className="text-body5">
Alt Text
</p>
<div className="input-wrapper mt-8 relative">
<input
type='text'
className="input-text"
value={altInput}
onChange={e => setAltInput(e.target.value)}
placeholder=''
/>
</div>
</div>
</div>
<div className="flex gap-16 justify-end mt-32">
<Button onClick={onClose}>
Cancel
</Button>
<Button type='submit' color='primary' >
Add
</Button>
</div>
</form>
</motion.div>
)
}

View File

@@ -0,0 +1,4 @@
import { lazyModal } from 'src/utils/helperFunctions';
export const { LazyComponent: InsertImageModal } = lazyModal(() => import('./InsertImageModal'))

View File

@@ -0,0 +1,17 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import InsertVideoModal from './InsertVideoModal';
import { ModalsDecorator } from 'src/utils/storybook/decorators';
export default {
title: 'Shared/Inputs/Text Editor/Insert Video Modal',
component: InsertVideoModal,
decorators: [ModalsDecorator]
} as ComponentMeta<typeof InsertVideoModal>;
const Template: ComponentStory<typeof InsertVideoModal> = (args) => <InsertVideoModal {...args} />;
export const Default = Template.bind({});

View File

@@ -0,0 +1,76 @@
import React, { FormEvent, useState } from 'react'
import { ModalCard, modalCardVariants } from 'src/Components/Modals/ModalsContainer/ModalsContainer'
import { motion } from 'framer-motion'
import { IoClose } from 'react-icons/io5'
import Button from 'src/Components/Button/Button'
import { useAppDispatch } from 'src/utils/hooks'
import { PayloadAction } from '@reduxjs/toolkit'
interface Props extends ModalCard {
callbackAction: PayloadAction<{ src: string, alt?: string }>
}
export default function InsertVideoModal({ onClose, direction, callbackAction, ...props }: Props) {
const [urlInput, setUrlInput] = useState("")
const dispatch = useAppDispatch();
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
const id = extractId(urlInput);
if (id) {
const action = Object.assign({}, callbackAction);
action.payload = { src: id }
dispatch(action)
onClose?.();
}
}
return (
<motion.div
custom={direction}
variants={modalCardVariants}
initial='initial'
animate="animate"
exit='exit'
className="modal-card max-w-[660px] p-24 rounded-xl relative"
>
<IoClose className='absolute text-body2 top-24 right-24 hover:cursor-pointer' onClick={onClose} />
<h2 className='text-h5 font-bold'>Insert Youtube Video</h2>
<form onSubmit={handleSubmit}>
<div className="grid gap-16 mt-32">
<div className='md:col-span-2'>
<p className="text-body5">
Video URL
</p>
<div className="input-wrapper mt-8 relative">
<input
type='text'
className="input-text"
value={urlInput}
onChange={e => setUrlInput(e.target.value)}
placeholder='https://www.youtube.com/watch?v=****'
/>
</div>
</div>
</div>
<div className="flex gap-16 justify-end mt-32">
<Button onClick={onClose}>
Cancel
</Button>
<Button type='submit' color='primary' >
Insert
</Button>
</div>
</form>
</motion.div>
)
}
function extractId(url: string) {
const rgx = /^.*(?:(?:youtu\.be\/|v\/|vi\/|u\/\w\/|embed\/)|(?:(?:watch)?\?v(?:i)?=|\&v(?:i)?=))([^#\&\?]*).*/;
return url.match(rgx)?.[1]
}

View File

@@ -0,0 +1,4 @@
import { lazyModal } from 'src/utils/helperFunctions';
export const { LazyComponent: InsertVideoModal } = lazyModal(() => import('./InsertVideoModal'))

View File

@@ -0,0 +1,29 @@
import { useHelpers, useEvent, useRemirrorContext } from '@remirror/react';
import { Control, useController } from 'react-hook-form';
import { useDebouncedCallback } from '@react-hookz/web';
interface Props {
control?: Control,
name?: string,
}
export default function SaveModule(props: Props) {
const { field: { onChange, onBlur } } = useController({
control: props.control,
name: props.name ?? 'content'
})
const { getMarkdown, getHTML } = useHelpers();
const changeCallback = useDebouncedCallback(ctx => {
const { state } = ctx;
onChange(getHTML(state));
}, [], 500)
useRemirrorContext(changeCallback)
useEvent('blur', () => onBlur())
return <></>
}

View File

@@ -0,0 +1,111 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { useEffect, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { FaCopy } from 'react-icons/fa';
import Button from 'src/Components/Button/Button';
import useCopyToClipboard from 'src/utils/hooks/useCopyToClipboard';
import { WithModals } from 'src/utils/storybook/decorators';
import TextEditor from './TextEditor';
export default {
title: 'Shared/Inputs/Text Editor',
decorators: [WithModals],
component: TextEditor,
} as ComponentMeta<typeof TextEditor>;
const Template: ComponentStory<typeof TextEditor> = (args) => {
const methods = useForm();
console.log(methods.watch('content'))
return <FormProvider {...methods}>
<div className="max-w-[80ch]">
<TextEditor {...args} />
</div>
</FormProvider>
}
export const Default = Template.bind({});
Default.args = {
placeholder: "Start writing something in markdown",
initialContent: `
## heading2
#### heading4
###### heading6
some text with **bold**, _italic,_ underline, [www.link.com](//www.link.com)
\`code line goes here\`
`
}
const PreviewTemplate: ComponentStory<typeof TextEditor> = (args) => {
const methods = useForm({
defaultValues: {
content: ""
}
});
const [mode, setMode] = useState(1); // 1 = editing, 0 = preview
const [copied, setCopied] = useState(false)
const copy = useCopyToClipboard();
const copyToClipboard = () => {
copy(methods.getValues('content'));
setCopied(true);
}
useEffect(() => {
let timer: NodeJS.Timer;
if (copied) {
timer = setTimeout(() => setCopied(false), 1000)
}
return () => {
clearTimeout(timer)
}
}, [copied])
return <FormProvider {...methods}>
<div className="max-w-[80ch]">
<div className="flex gap-16 items-start">
<div className={`${mode === 0 && 'hidden'} grow`}>
<TextEditor {...args} />
</div>
<div className={`${mode === 1 && 'hidden'} grow`}>
<div className="remirror-theme p-16 border bg-white rounded-16">
<div dangerouslySetInnerHTML={{
__html: methods.getValues('content')
}}>
</div>
</div>
</div>
<Button onClick={() => setMode(v => 1 - v)}>
{mode === 1 ? "Preview" : "Edit"}
</Button>
</div>
<Button className='mt-36' onClick={copyToClipboard}>
<FaCopy /> Copy to clipboard
</Button>
</div>
</FormProvider>
}
export const CanCopy = PreviewTemplate.bind({});
CanCopy.args = {
placeholder: "Start writing something in markdown",
initialContent: `
<h2 style="">Hello there</h2><p style="">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.<br></p><h2 style="">How are you doing ??</h2><p style="">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <strong>Excepteur</strong> sint <strong>occaecat</strong> cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.<br><br><br></p><h3 style="">Subheading</h3><p style="">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.<br>Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
`
}

View File

@@ -0,0 +1,113 @@
import 'remirror/styles/all.css';
import styles from './styles.module.scss'
import javascript from 'refractor/lang/javascript';
import typescript from 'refractor/lang/typescript';
import {
BlockquoteExtension,
BoldExtension,
BulletListExtension,
CodeBlockExtension,
CodeExtension,
HardBreakExtension,
HeadingExtension,
ImageExtension,
ItalicExtension,
LinkExtension,
ListItemExtension,
MarkdownExtension,
NodeFormattingExtension,
OrderedListExtension,
PlaceholderExtension,
StrikeExtension,
TableExtension,
TrailingNodeExtension,
UnderlineExtension,
IframeExtension,
} from 'remirror/extensions';
import { ExtensionPriority, InvalidContentHandler } from 'remirror';
import { EditorComponent, Remirror, useHelpers, useRemirror } from '@remirror/react';
import { useCallback, useMemo } from 'react';
import Toolbar from './Toolbar/Toolbar';
import SaveModule from './SaveModule';
interface Props {
placeholder?: string;
initialContent?: string;
}
export default function TextEditor({ placeholder, initialContent }: Props) {
const onError: InvalidContentHandler = useCallback(({ json, invalidContent, transformers }) => {
// Automatically remove all invalid nodes and marks.
return transformers.remove(json, invalidContent);
}, []);
const linkExtension = useMemo(() => {
const extension = new LinkExtension({ autoLink: true });
extension.addHandler('onClick', (_, data) => {
window.open(data.href, '_blank')?.focus();
return true;
});
return extension;
}, []);
const extensions = useCallback(
() => [
new PlaceholderExtension({ placeholder }),
linkExtension,
new BoldExtension(),
// new StrikeExtension(),
new UnderlineExtension(),
new ItalicExtension(),
new HeadingExtension(),
new LinkExtension(),
new BlockquoteExtension(),
new BulletListExtension(),
new OrderedListExtension(),
new ListItemExtension({ priority: ExtensionPriority.High, enableCollapsible: true }),
// new TaskListExtension(),
new CodeExtension(),
new CodeBlockExtension({
supportedLanguages: [javascript, typescript]
}),
new ImageExtension(),
// new TrailingNodeExtension(),
// new TableExtension(),
new MarkdownExtension({ copyAsMarkdown: false }),
new NodeFormattingExtension(),
new IframeExtension(),
/**
* `HardBreakExtension` allows us to create a newline inside paragraphs.
* e.g. in a list item
*/
new HardBreakExtension(),
],
[linkExtension, placeholder],
);
const { manager } = useRemirror({
extensions,
stringHandler: 'markdown',
onError,
});
return (
<div className={`remirror-theme ${styles.wrapper} bg-white shadow-md`}>
<Remirror
manager={manager}
initialContent={initialContent}
>
<SaveModule />
<Toolbar />
<EditorComponent />
</Remirror>
</div>
);
};

View File

@@ -0,0 +1,48 @@
import { useActive, useCommands } from '@remirror/react';
import { cmdToBtn, Command } from './helpers';
interface Props {
cmd: Command
classes: {
button: string,
icon: string,
active: string,
enabled: string
disabled: string
}
}
export default function DefaultToolButton({ cmd: _cmd, classes }: Props) {
const commands = useCommands();
const active = useActive();
const runCommand = (cmd: string, attrs?: any) => {
if (commands[cmd]) {
commands[cmd](attrs);
commands.focus();
}
}
const { activeCmd, cmd, Icon, tip } = cmdToBtn[_cmd]
return (
<button
type='button'
data-tip={tip}
className={`
${classes.button}
${(activeCmd && active[activeCmd]()) && classes.active}
${commands[cmd].enabled() ? classes.enabled : classes.disabled}
`}
onClick={() => runCommand(cmd)}
>
<Icon className={classes.icon} />
</button>
)
}

View File

@@ -0,0 +1,62 @@
import { useActive, useCommands } from '@remirror/react';
import { FiType } from 'react-icons/fi'
import {
Menu,
MenuItem,
MenuButton
} from '@szhsin/react-menu';
import '@szhsin/react-menu/dist/index.css';
import '@szhsin/react-menu/dist/transitions/slide.css';
interface Props {
classes: {
button: string,
icon: string,
active: string,
enabled: string
disabled: string
}
}
export default function HeadingsToolButton({ classes }: Props) {
const commands = useCommands();
const active = useActive();
const runCommand = (cmd: string, attrs?: any) => {
if (commands[cmd]) {
commands[cmd](attrs);
commands.focus();
}
}
return <Menu menuButton={
<MenuButton
data-tip={'Headings'}
className={`
${classes.button}
${active.heading({}) && classes.active}
${commands.toggleHeading.enabled() ? classes.enabled : classes.disabled}
`}>
<FiType className={classes.icon} />
</MenuButton>
} transition>
{Array(6).fill(0).map((_, idx) => <MenuItem
key={idx}
className={`
py-8 px-16 hover:bg-gray-200
${active.heading({ level: idx + 1 }) && 'font-bold bg-gray-200'}
`}
onClick={() => runCommand('toggleHeading', { level: idx + 1 })}
>
Heading{idx + 1}
</MenuItem>)}
</Menu>
}

View File

@@ -0,0 +1,73 @@
import { useActive, useCommands } from '@remirror/react';
import { useAppDispatch } from 'src/utils/hooks';
import { openModal } from 'src/redux/features/modals.slice';
import { useReduxEffect } from 'src/utils/hooks/useReduxEffect';
import { useCallback } from 'react';
import { createAction } from '@reduxjs/toolkit';
import { cmdToBtn } from './helpers';
interface Props {
classes: {
button: string,
icon: string,
active: string,
enabled: string
disabled: string
}
}
const INSERT_IMAGE_ACTION = createAction<{ src: string, alt?: string }>('IMAGE_INSERTED_IN_EDITOR')({ src: '', alt: "" })
export default function ImageToolButton({ classes }: Props) {
const commands = useCommands();
const active = useActive();
const dispatch = useAppDispatch()
const onInsertImage = useCallback(({ payload: { src, alt } }: typeof INSERT_IMAGE_ACTION) => {
commands.insertImage({
src,
alt,
})
}, [commands])
useReduxEffect(onInsertImage, INSERT_IMAGE_ACTION.type)
const { activeCmd, cmd, tip, Icon } = cmdToBtn['img'];
const onClick = () => {
dispatch(openModal({
Modal: "InsertImageModal",
props: {
callbackAction: {
type: INSERT_IMAGE_ACTION.type,
payload: {
src: "",
alt: ""
}
}
}
}))
}
return (
<button
type='button'
data-tip={tip}
className={`
${classes.button}
${(activeCmd && active[activeCmd]()) && classes.active}
${commands[cmd].enabled({ src: "" }) ? classes.enabled : classes.disabled}
`}
onClick={onClick}
>
<Icon className={classes.icon} />
</button>
)
}

View File

@@ -0,0 +1,50 @@
import ImageToolButton from './ImageToolBtn';
import HeadingsToolButton from './HeadingsToolBtn';
import DefaultToolButton from './DefaultToolBtn';
import { Command, isCommand } from './helpers';
import VideoToolButton from './VideoToolBtn';
interface Props {
cmd: Command
classes?: Partial<{
button: string,
icon: string,
active: string,
enabled: string
disabled: string
}>
}
export default function ToolButton({ cmd,
classes: _classes
}: Props) {
const classes = {
button: _classes?.button ?? `w-36 h-36 flex justify-center items-center`,
active: _classes?.active ?? 'font-bold bg-gray-300 hover:bg-gray-300 text-black',
enabled: _classes?.enabled ?? 'hover:bg-gray-200',
disabled: _classes?.disabled ?? 'opacity-40 text-gray-600 pointer-events-none',
icon: _classes?.icon ?? ''
}
if (!isCommand(cmd))
return <></>
if (cmd === 'heading')
return <HeadingsToolButton classes={classes} />
if (cmd === 'youtube')
return <VideoToolButton classes={classes} />
if (cmd === 'img')
return <ImageToolButton classes={classes} />
return <DefaultToolButton classes={classes} cmd={cmd} />
}

View File

@@ -0,0 +1,71 @@
import { useActive, useCommands } from '@remirror/react';
import { useAppDispatch } from 'src/utils/hooks';
import { openModal } from 'src/redux/features/modals.slice';
import { useReduxEffect } from 'src/utils/hooks/useReduxEffect';
import { useCallback } from 'react';
import { createAction } from '@reduxjs/toolkit';
import { cmdToBtn } from './helpers';
interface Props {
classes: {
button: string,
icon: string,
active: string,
enabled: string
disabled: string
}
}
const INSERT_VIDEO_ACTION = createAction<{ src: string }>('VIDEO_INSERTED_IN_EDITOR')({ src: '' })
export default function VideoToolButton({ classes }: Props) {
const commands = useCommands();
const active = useActive();
const dispatch = useAppDispatch()
const onInsertVideo = useCallback(({ payload: { src } }: typeof INSERT_VIDEO_ACTION) => {
commands.addYouTubeVideo({
video: src,
})
}, [commands])
useReduxEffect(onInsertVideo, INSERT_VIDEO_ACTION.type)
const { activeCmd, cmd, tip, Icon } = cmdToBtn['youtube'];
const onClick = () => {
dispatch(openModal({
Modal: "InsertVideoModal",
props: {
callbackAction: {
type: INSERT_VIDEO_ACTION.type,
payload: {
src: "",
}
}
}
}))
}
return (
<button
type='button'
data-tip={tip}
className={`
${classes.button}
${(activeCmd && active[activeCmd]()) && classes.active}
${commands[cmd].enabled({ video: "" }) ? classes.enabled : classes.disabled}
`}
onClick={onClick}
>
<Icon className={classes.icon} />
</button>
)
}

View File

@@ -0,0 +1,110 @@
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 { BiCodeCurly } from 'react-icons/bi';
export const cmdToBtn = {
'bold': {
cmd: 'toggleBold',
activeCmd: 'bold',
tip: "Bold",
Icon: FiBold
},
'italic': {
cmd: 'toggleItalic',
activeCmd: 'italic',
tip: "Italic",
Icon: FiItalic
},
underline: {
cmd: 'toggleUnderline',
activeCmd: 'underline',
tip: "Underline",
Icon: FiUnderline
},
heading: {
cmd: 'toggleHeading',
activeCmd: 'heading',
tip: "Headings",
Icon: FiType,
},
leftAlign: {
cmd: 'leftAlign',
activeCmd: null,
tip: "Left Align",
Icon: FiAlignLeft,
},
centerAlign: {
cmd: 'centerAlign',
activeCmd: null,
tip: "Center Align",
Icon: FiAlignCenter,
},
rightAlign: {
cmd: 'rightAlign',
activeCmd: null,
tip: "Right Align",
Icon: FiAlignRight,
},
bulletList: {
cmd: 'toggleBulletList',
activeCmd: 'bulletList',
tip: "Bullets List",
Icon: FaListUl,
},
orderedList: {
cmd: 'toggleOrderedList',
activeCmd: 'orderedList',
tip: "Numbered List",
Icon: FaListOl,
},
undo: {
cmd: 'undo',
activeCmd: null,
tip: "Undo",
Icon: FaUndo,
},
redo: {
cmd: 'redo',
activeCmd: null,
tip: "Redo",
Icon: FaRedo,
},
code: {
cmd: 'toggleCode',
activeCmd: 'code',
tip: "Code",
Icon: FiCode,
},
codeBlock: {
cmd: 'toggleCodeBlock',
activeCmd: 'codeBlock',
tip: "Code Block",
Icon: BiCodeCurly,
},
img: {
cmd: 'insertImage',
activeCmd: 'image',
tip: "Insert Image",
Icon: FaImage,
},
youtube: {
cmd: 'addYouTubeVideo',
activeCmd: 'iframe',
tip: "Insert Video",
Icon: FaYoutube,
},
} as const
export type Command = keyof typeof cmdToBtn
export function isCommand(cmd: string): cmd is Command {
return cmd in cmdToBtn;
}

View File

@@ -0,0 +1,3 @@
import ToolButton from "./ToolBtn";
export default ToolButton;

View File

@@ -0,0 +1,33 @@
import ToolButton from '../ToolButton';
export default function Toolbar() {
return (
<div className='flex flex-wrap gap-24 bg-gray-100'>
<div className="flex">
<ToolButton cmd='heading' />
<ToolButton cmd='bold' />
<ToolButton cmd='italic' />
<ToolButton cmd='underline' />
<ToolButton cmd='code' />
<ToolButton cmd='codeBlock' />
</div>
<div className="flex">
<ToolButton cmd='leftAlign' />
<ToolButton cmd='centerAlign' />
<ToolButton cmd='rightAlign' />
<ToolButton cmd='bulletList' />
<ToolButton cmd='orderedList' />
<ToolButton cmd='img' />
<ToolButton cmd='youtube' />
</div>
<div className="flex ml-auto">
<ToolButton cmd='undo' />
<ToolButton cmd='redo' />
</div>
</div>
)
}

View File

@@ -0,0 +1,5 @@
import SaveModule from "./SaveModule";
import ToolButton from "./ToolButton";
const TextEditorComponents = { SaveModule, ToolButton };
export default TextEditorComponents;

View File

@@ -0,0 +1,24 @@
.wrapper {
:global {
.ProseMirror {
overflow: hidden;
a {
color: rgb(54, 139, 236);
&:hover {
text-decoration: underline;
cursor: pointer;
}
}
}
.ProseMirror,
.ProseMirror:active,
.ProseMirror:focus {
box-shadow: none;
}
}
}

View File

@@ -1,9 +1,8 @@
import { useEffect, useState } from 'react';
import LightboxComponent from 'react-image-lightbox';
import 'react-image-lightbox/style.css'; // This only needs to be imported once in your app
import './styles.css'
import { useUpdateEffect } from '@react-hookz/web';
import { useState } from 'react';
import FsLightbox from 'fslightbox-react';
interface Props {
images: string[];
@@ -13,38 +12,21 @@ interface Props {
}
export default function Lightbox(props: Props) {
const [toggler, setToggler] = useState(false);
const [photoIndex, setPhotoIndex] = useState(0);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
if (props.isOpen) {
setIsOpen(true);
setPhotoIndex(props.initOpenIndex ?? 0)
} else
setIsOpen(false);
}, [props.initOpenIndex, props.isOpen])
useUpdateEffect(() => {
if (props.isOpen)
setToggler(!toggler)
}, [props.isOpen])
return (
<>
{isOpen &&
<LightboxComponent
mainSrc={props.images[photoIndex]}
nextSrc={props.images[(photoIndex + 1) % props.images.length]}
prevSrc={props.images[(photoIndex + props.images.length - 1) % props.images.length]}
onCloseRequest={() => props.onClose?.()}
onMovePrevRequest={() =>
setPhotoIndex((photoIndex + props.images.length - 1) % props.images.length
)
}
onMoveNextRequest={() =>
setPhotoIndex((photoIndex + 1) % props.images.length)
}
imagePadding={48}
/>}
</>
<FsLightbox
toggler={toggler}
onClose={props.onClose}
sources={props.images}
sourceIndex={props.initOpenIndex}
/>
)
}

View File

@@ -0,0 +1,14 @@
import TopBarProgress from "react-topbar-progress-indicator";
import THEME from "src/utils/theme";
TopBarProgress.config({
barColors: {
0: THEME.colors.primary[400],
".5": THEME.colors.primary[500],
"1.0": THEME.colors.primary[700],
},
});
export default function LoadingPage() {
return <TopBarProgress />;
}

View File

@@ -50,7 +50,8 @@ export default function Login_ExternalWalletCard({ onClose, direction, ...props
</p>
<div className="input-wrapper mt-16 relative">
<input
className="input-field overflow-ellipsis"
className="input-text overflow-ellipsis"
type={'text'}
value={"Inurldp-3234234-ahhsdfm-dssdf-uooiRS-TTRASssa-334Qaas-UUI"}
/>
<CopyToClipboard text='Inurldp-3234234-ahhsdfm-dssdf-uooiRS-TTRASssa-334Qaas-UUI' direction='top' />

View File

@@ -1,34 +1,40 @@
import { motion } from "framer-motion";
import { ReactElement } from "react";
import ReactModal from 'react-modal';
import { removeClosedModal } from "src/redux/features/modals.slice";
import { useAppDispatch } from 'src/utils/hooks'
interface Props {
onClose: () => void;
id: string,
isOpen: boolean;
isPageModal?: boolean;
children: ReactElement
onClose: () => void;
[key: string]: any;
}
ReactModal.setAppElement('#root');
export default function Modal({ onClose, children, ...props }: Props) {
return (
<motion.div
initial={false}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className='fixed w-full h-full items-center overflow-x-hidden no-scrollbar'
{...props}
>
<div
className='w-screen min-h-screen relative flex flex-col justify-center items-center md:py-64 md:px-16 overflow-x-hidden no-scrollbar'
>
<div
className={`absolute w-full h-full top-0 left-0 bg-gray-300 bg-opacity-50 ${props.isPageModal && "hidden md:block"}`}
onClick={onClose}
></div>
{children}
</div>
</motion.div>
)
const dispatch = useAppDispatch()
return <ReactModal
isOpen={props.isOpen}
onRequestClose={onClose}
overlayClassName='fixed w-full inset-0 overflow-x-hidden z-[2020]'
className=' '
closeTimeoutMS={1000}
onAfterClose={() => dispatch(removeClosedModal(props.id))}
contentElement={(_props, children) => <div {..._props} className={`${_props.className} w-screen min-h-screen relative flex flex-col justify-center items-center md:py-64 md:px-16 inset-0`}>
<div
onClick={onClose}
className={`absolute w-full h-full top-0 left-0 bg-gray-300 bg-opacity-50 ${props.isPageModal && "hidden md:block"}`}
></div>
{children}
</div>}
>
{children}
</ReactModal>
}

View File

@@ -1,9 +1,7 @@
import { AnimatePresence, motion } from "framer-motion";
import { useEffect } from "react";
import { ALL_MODALS, closeModal, Direction, removeScheduledModal } from "src/redux/features/modals.slice";
import { useAppDispatch, useAppSelector } from "src/utils/hooks";
import Modal from "../Modal/Modal";
import { Portal } from "../../Portal/Portal";
export interface ModalCard {
onClose?: () => void;
@@ -52,36 +50,36 @@ export default function ModalsContainer() {
}
useEffect(() => {
if (isOpen) document.body.style.overflowY = "hidden";
else document.body.style.overflowY = "initial";
if (isOpen) {
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
document.documentElement.style.overflow = 'hidden';
if (scrollbarWidth) {
document.documentElement.style.paddingRight = `${scrollbarWidth}px`;
}
}
else {
document.documentElement.style.overflow = "";
document.documentElement.style.paddingRight = "";
}
}, [isOpen]);
return (
<Portal>
<div className="z-[2020]">
{openModals.map((modal, idx) => {
const Child = ALL_MODALS[modal.Modal];
<AnimatePresence exitBeforeEnter>
{isOpen &&
<motion.div
className="w-screen fixed inset-0 overflow-x-hidden z-[2020]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{
opacity: 0,
transition: { ease: "easeInOut" },
}}
return (
<Modal
key={idx}
isOpen={modal.isOpen}
onClose={onClose}
direction={direction}
id={modal.id}
isPageModal={modal.props?.isPageModal}
>
<AnimatePresence>
{openModals.map((modal, idx) => {
const Child = ALL_MODALS[modal.Modal];
return (
<Modal key={idx} onClose={onClose} direction={direction} isPageModal={modal.props?.isPageModal}>
<Child onClose={onClose} direction={direction} isPageModal={modal.props?.isPageModal} {...modal.props} />
</Modal>)
})}
</AnimatePresence>
</motion.div>}
</AnimatePresence>
</Portal>
<Child onClose={onClose} direction={direction} isPageModal={modal.props?.isPageModal} {...modal.props} />
</Modal>)
})}
</div>
)
}

View File

@@ -52,7 +52,6 @@ export default function NavDesktop() {
};
return (<nav className="bg-white w-full flex fixed h-[118px] top-0 left-0 py-36 px-32 items-center z-[2010]">
<a href="https://bolt.fun/">
<h2 className="text-h5 font-bold mr-40 lg:mr-64">
@@ -70,11 +69,16 @@ export default function NavDesktop() {
)}
<li
ref={categoriesRef}
className="relative cursor-pointer" onClick={() => toggleCategories(!categoriesOpen)}>
<p className='text-body4 hover:text-primary-600'>
className="relative"
>
<button
onClick={() => toggleCategories(!categoriesOpen)}
onKeyDown={e => (e.key !== 'Escape') || toggleCategories(false)}
className='text-body4 hover:text-primary-600 cursor-pointer'
>
<IoExtensionPuzzle className={`text-body2 inline-block mr-8 text-primary-600`} />
<span className="align-middle">Categories</span>
</p>
</button>
{<motion.div
initial={{ opacity: 0, y: 200, display: 'none' }}
animate={categoriesOpen ? {
@@ -94,7 +98,7 @@ export default function NavDesktop() {
}
}}
className="absolute top-full left-0 w-[256px] bg-white border border-primary-50 rounded-8 shadow-3xl">
<CategoriesList />
<CategoriesList onClick={() => toggleCategories(false)} />
</motion.div>}
</li>
</ul>

View File

@@ -1,17 +1,30 @@
import NavMobile from "./NavMobile";
import { MdHomeFilled, MdLocalFireDepartment } from "react-icons/md";
import { MdComment, MdHomeFilled, MdLocalFireDepartment } from "react-icons/md";
import { IoExtensionPuzzle } from "react-icons/io5";
import { useCallback, useEffect } from "react";
import { useAppDispatch, useAppSelector } from "src/utils/hooks";
import { openModal } from "src/redux/features/modals.slice";
import { setNavHeight } from "src/redux/features/ui.slice";
import { useResizeListener } from "src/utils/hooks";
import NavDesktop from "./NavDesktop";
import { useMediaQuery } from "@react-hookz/web";
import { MEDIA_QUERIES } from "src/utils/theme/media_queries";
import { IoMdTrophy } from "react-icons/io";
export const navLinks = [
{ text: "Explore", url: "/", icon: MdHomeFilled, color: "text-primary-600" },
{
text: "Blog",
url: "/blog",
icon: MdComment,
color: "text-primary-600",
},
{
text: "Hackathons",
url: "/hackathons",
icon: IoMdTrophy,
color: "text-primary-600",
},
{
text: "Hottest",
url: "/hottest",
@@ -57,14 +70,19 @@ export default function Navbar() {
useEffect(() => {
const nav = document.querySelector("nav");
let oldPadding = '';
if (nav) {
const navStyles = getComputedStyle(nav);
if (navStyles.display !== "none") {
dispatch(setNavHeight(nav.clientHeight));
oldPadding = document.body.style.paddingTop
document.body.style.paddingTop = `${nav.clientHeight}px`;
}
}
return () => {
document.body.style.paddingTop = oldPadding
}
}, [dispatch, isMobileScreen, isLargeScreen])

View File

@@ -112,8 +112,9 @@ export default function Search({
<div className="input-wrapper bg-white !rounded-12 ring-1 ring-gray-400">
<BsSearch className={`input-icon`} />
<input
type='text'
ref={inputRef}
className="input-field placeholder-black pl-0"
className="input-text placeholder-black pl-0"
placeholder='Search'
value={searchInput}
onChange={handleChange}

View File

@@ -18,8 +18,7 @@ export default function SearchResults({ projects, isLoading }: Props) {
const handleOpenProject = (projectId: number) => {
dispatch(toggleSearch(false))
dispatch(openProject(projectId));
dispatch(openModal({ Modal: "ProjectDetailsCard" }))
dispatch(openModal({ Modal: "ProjectDetailsCard", props: { projectId } }))
}
return (

View File

@@ -8,24 +8,24 @@ export default {
} as ComponentMeta<typeof Slider>;
const Template: ComponentStory<typeof Slider> = (args) => <div className="bg-blue-100 max-w-[600px]"><Slider {...args} >
<div className="px-64 py-16 bg-gray-300" onClick={() => { alert(1) }}>
1
const Template: ComponentStory<typeof Slider> = (args) => <div className="overflow-hidden max-w-[770px]"><Slider {...args} >
<div className="px-64 py-16 bg-gray-200 border shadow-sm rounded-10" onClick={() => { alert(1) }}>
Item 1
</div>
<div className="px-64 py-16 bg-gray-300" onClick={() => { alert(2) }}>
2
<div className="px-64 py-16 bg-gray-200 border shadow-sm rounded-10" onClick={() => { alert(2) }}>
Item 2
</div>
<div className="px-64 py-16 bg-gray-300" onClick={() => { alert(3) }}>
3
<div className="px-64 py-16 bg-gray-200 border shadow-sm rounded-10" onClick={() => { alert(3) }}>
Item 3
</div>
<div className="px-64 py-16 bg-gray-300" onClick={() => { alert(4) }}>
4
<div className="px-64 py-16 bg-gray-200 border shadow-sm rounded-10" onClick={() => { alert(4) }}>
Item 4
</div>
<div className="px-64 py-16 bg-gray-300" onClick={() => { alert(5) }}>
5
<div className="px-64 py-16 bg-gray-200 border shadow-sm rounded-10" onClick={() => { alert(5) }}>
Item 5
</div>
<div className="px-64 py-16 bg-gray-300" onClick={() => { alert(6) }}>
6
<div className="px-64 py-16 bg-gray-200 border shadow-sm rounded-10" onClick={() => { alert(6) }}>
Item 6
</div>
</Slider></div>;

View File

@@ -0,0 +1,78 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { centerDecorator } from 'src/utils/storybook/decorators';
import VoteButton from './VoteButton';
export default {
title: 'Shared/Vote Button',
component: VoteButton,
decorators: [
centerDecorator
]
} as ComponentMeta<typeof VoteButton>;
const Template: ComponentStory<typeof VoteButton> = (args) => <VoteButton {...args} />;
export const Default = Template.bind({});
Default.args = {
initVotes: 540,
onVote: () => { }
}
export const Vertical = Template.bind({});
Vertical.args = {
initVotes: 540,
onVote: () => { },
direction: 'vertical'
}
export const Dense = Template.bind({});
Dense.args = {
initVotes: 540,
onVote: () => { },
dense: true
}
export const FillTypeUpdown = Template.bind({});
FillTypeUpdown.args = {
initVotes: 540,
onVote: () => { },
fillType: 'upDown'
}
export const FillTypeBackground = Template.bind({});
FillTypeBackground.args = {
initVotes: 540,
onVote: () => { },
fillType: 'background'
}
export const FillTypeRadial = Template.bind({});
FillTypeRadial.args = {
initVotes: 540,
onVote: () => { },
fillType: 'radial'
}
export const NoCounter = Template.bind({});
NoCounter.args = {
initVotes: 540,
onVote: () => { },
disableCounter: true,
}
export const CounterReset = Template.bind({});
CounterReset.args = {
initVotes: 540,
onVote: () => { },
resetCounterOnRelease: true
}
export const NoShake = Template.bind({});
NoShake.args = {
initVotes: 540,
onVote: () => { },
disableShake: true,
}

View File

@@ -0,0 +1,228 @@
import { MdLocalFireDepartment } from 'react-icons/md'
import Button from 'src/Components/Button/Button'
import { useAppSelector, usePressHolder } from 'src/utils/hooks'
import { ComponentProps, useRef, useState } from 'react'
import styles from './styles.module.scss'
import { random, randomItem, numberFormatter } from 'src/utils/helperFunctions'
import { useDebouncedCallback, useThrottledCallback } from '@react-hookz/web'
interface Particle {
id: string,
offsetX: number,
offsetY: number,
color: string
animation: 'fly-spark-1' | 'fly-spark-2',
animationSpeed: 1 | 2 | 3,
scale: number
}
type Props = {
votes: number,
onVote?: (amount: number, config: Partial<{ onSetteled: () => void }>) => void,
fillType?: 'leftRight' | 'upDown' | "background" | 'radial',
direction?: 'horizontal' | 'vertical'
disableCounter?: boolean
disableShake?: boolean
dense?: boolean
resetCounterOnRelease?: boolean
} & Omit<ComponentProps<typeof Button>, 'children'>
export default function VoteButton({
votes,
onVote = () => { },
fillType = 'leftRight',
direction = 'horizontal',
disableCounter = false,
disableShake = false,
dense = false,
resetCounterOnRelease: resetCounterOnReleaseProp = false,
...props }: Props) {
const [voteCnt, setVoteCnt] = useState(0)
const voteCntRef = useRef(0);
const btnContainerRef = useRef<HTMLDivElement>(null!!)
const [btnShakeClass, setBtnShakeClass] = useState('')
const [sparks, setSparks] = useState<Particle[]>([]);
const [wasActive, setWasActive] = useState(false);
const [incrementsCount, setIncrementsCount] = useState(0);
const totalIncrementsCountRef = useRef(0)
const currentIncrementsCountRef = useRef(0);
const [increments, setIncrements] = useState<Array<{ id: string, value: number }>>([])
const isMobileScreen = useAppSelector(s => s.ui.isMobileScreen);
const resetCounterOnRelease = resetCounterOnReleaseProp;
const doVote = useDebouncedCallback(() => {
const amount = voteCntRef.current;
onVote(amount, { onSetteled: () => setVoteCnt(v => v - amount) });
voteCntRef.current = 0;
}, [], 2000)
const clickIncrement = () => {
if (!disableShake)
setBtnShakeClass(s => s === styles.clicked_2 ? styles.clicked_1 : styles.clicked_2)
const _incStep = Math.ceil((currentIncrementsCountRef.current + 1) / 5);
currentIncrementsCountRef.current += 1;
totalIncrementsCountRef.current += 1;
setIncrementsCount(v => totalIncrementsCountRef.current);
if (!disableCounter)
setIncrements(v => {
const genId = Math.random().toString();
setTimeout(() => {
setIncrements(v => v.filter(e => e.id !== genId))
}, 500)
return [...v, { id: genId, value: _incStep }]
});
setVoteCnt(s => {
const newValue = s + _incStep;
voteCntRef.current = newValue;
return newValue;
})
if (totalIncrementsCountRef.current && totalIncrementsCountRef.current % 5 === 0) {
const newSparks = Array(5).fill(0).map((_, idx) => ({
id: (Math.random() + 1).toString(),
offsetX: random(-10, 99),
offsetY: random(40, 90),
animation: randomItem(styles.fly_spark_1, styles.fly_spark_1),
animationSpeed: randomItem(1, 1.5, 2),
color: `hsl(0deg 86% ${random(50, 63)}%)`,
scale: random(1, 1.5)
}))
// if on mobile screen, reduce number of sparks particles to 60%
setSparks(oldSparks => [...oldSparks, ...newSparks])
setTimeout(() => {
setSparks(s => {
return s.filter(spark => !newSparks.some(newSpark => newSpark.id === spark.id))
})
}, 2 * 1000)
}
doVote();
}
const onHold = useThrottledCallback(clickIncrement, [], 150)
const { onPressDown, onPressUp, isHolding } = usePressHolder(onHold, 100);
const handlePressDown = () => {
setWasActive(true);
onPressDown();
}
const handlePressUp = (event?: any) => {
if (!wasActive) return;
setWasActive(false);
if (event?.preventDefault) event.preventDefault();
onPressUp();
onHold();
if (resetCounterOnRelease)
if (!isHolding) {
currentIncrementsCountRef.current = 0;
} else
setTimeout(() =>
currentIncrementsCountRef.current = 0, 150)
}
return (
<button
onMouseDown={handlePressDown}
onMouseUp={handlePressUp}
onMouseLeave={handlePressUp}
onTouchStart={handlePressDown}
onTouchEnd={handlePressUp}
className={`${styles.vote_button} relative noselect border-0`}
style={{
"--increments": incrementsCount,
"--offset": `${(incrementsCount ? (incrementsCount % 5 === 0 ? 5 : incrementsCount % 5) : 0) * 20}%`,
"--bg-color": fillType !== 'background' ?
'hsl(0deg 86% max(calc((93 - var(--increments) / 3) * 1%), 68%))'
:
"hsl(0deg 86% max(calc((100 - var(--increments) / 2) * 1%), 68%))",
} as any}
{...props}
>
<div
ref={btnContainerRef}
className={`
${styles.btn_content}
relative rounded-lg text-gray-600 ${!incrementsCount && 'bg-gray-50 hover:bg-gray-100'}
${direction === 'vertical' ?
dense ? "py-4 px-12" : "py-8 px-20"
:
dense ? "py-4 px-8" : "p-8"}
${incrementsCount && "outline"} active:outline outline-1 outline-red-500
${btnShakeClass}
`}
>
<div
className={`
${styles.color_overlay}
${fillType === 'upDown' && styles.color_overlay__upDown}
${fillType === 'leftRight' && styles.color_overlay__leftRight}
${fillType === 'background' && styles.color_overlay__background}
${fillType === 'radial' && styles.color_overlay__radial}
`}
>
<div></div>
</div>
<div className={`
relative z-10
${incrementsCount ? "text-red-800" : "text-gray-600"}
flex justify-center items-center gap-8 text-left ${direction === 'vertical' && "flex-col !text-center"}
`}>
<MdLocalFireDepartment
className={`text-body2 ${incrementsCount ? "text-red-600" : "text-red-600"}`}
/><span className="align-middle w-[4ch]"> {numberFormatter(votes + voteCnt)}</span>
</div>
</div>
{increments.map(increment => <span
key={increment.id}
className={styles.vote_counter}
>+{increment.value}</span>)}
<div className="relative z-50">
{sparks.map(spark =>
<div
key={spark.id}
className={styles.spark}
style={{
"--offsetX": spark.offsetX,
"--offsetY": spark.offsetY,
"--animationSpeed": spark.animationSpeed,
"--scale": spark.scale,
"animationName": spark.animation,
"color": spark.color
} as any}
><MdLocalFireDepartment className='' /></div>)
}
</div>
<div
className={styles.spark}
><MdLocalFireDepartment className='' /></div>
</button>
)
}

View File

@@ -0,0 +1,182 @@
.vote_button {
--scale: 0;
--increments: 0;
--offset: 0;
--bg-color: hsl(0deg 86% max(calc((93 - var(--increments) / 3) * 1%), 68%));
/* transition: background-color 1s; */
/* background-color: hsl(25, 100%, max(calc((95 - var(--scale) / 4) * 1%), 63%)); */
}
.btn_content.clicked_1 {
animation: shake_1 0.14s 1 ease-in-out;
}
/* Same animation, two classes so that the animation restarts between clicks */
.btn_content.clicked_2 {
animation: shake_2 0.14s 1 ease-in-out;
}
@keyframes shake_1 {
0% {
transform: rotate(0deg);
}
33% {
transform: rotate(calc(clamp(5, var(--increments) / 3, 20) * 1deg));
}
66% {
transform: rotate(calc(-1 * clamp(5, var(--increments) / 2, 20) * 1deg));
}
100% {
transform: rotate(0deg);
}
}
@keyframes shake_2 {
0% {
transform: rotate(0deg);
}
33% {
transform: rotate(calc(clamp(10, var(--increments) / 2, 30) * 1deg));
}
66% {
transform: rotate(calc(-1 * clamp(10, var(--increments) / 2, 30) * 1deg));
}
100% {
transform: rotate(0deg);
}
}
.vote_counter {
position: absolute;
left: 50%;
bottom: 100%;
color: #ff2727;
font-weight: bold;
font-size: 21px;
will-change: transform;
transform: translate(-50%, 0) scale(0.5);
animation: fly_value 0.5s 1 ease-out;
}
.color_overlay {
position: absolute;
border-radius: inherit;
inset: 0;
overflow: hidden;
transition: all 0.1s;
}
.color_overlay > div {
content: "";
background: var(--bg-color);
width: 100%;
height: 100%;
position: absolute;
}
.color_overlay__background > div {
top: 0;
right: 0;
}
.color_overlay__leftRight > div {
top: 0;
right: 100%;
transform: translateX(var(--offset));
}
.color_overlay__upDown > div {
top: 100%;
right: 0;
transform: translateY(calc(-1 * var(--offset)));
}
.color_overlay__radial > div {
top: 0;
right: 0;
background: radial-gradient(
circle at center,
var(--bg-color) var(--offset),
transparent calc(var(--offset) * 1.1)
);
}
@keyframes fly_value {
0% {
transform: translate(-50%, 0) scale(0.5);
opacity: 1;
}
66% {
transform: translate(-50%, -26px) scale(1.2);
opacity: 0.6;
}
100% {
transform: translate(-50%, -38px) scale(0.8);
opacity: 0;
}
}
.spark {
position: absolute;
bottom: calc(var(--offsetY) * 1%);
left: calc(var(--offsetX) * 1%);
transform: scale(var(--scale));
opacity: 0;
will-change: transform;
z-index: 3000;
animation-name: fly-spark-1;
animation-duration: calc(var(--animationSpeed) * 1s);
animation-timing-function: linear;
animation-iteration-count: 1;
animation-fill-mode: forwards;
}
@keyframes fly_spark_1 {
0% {
transform: translate(0, 0) scale(var(--scale));
opacity: 1;
}
33% {
transform: translate(12px, -70px) scale(var(--scale));
}
66% {
transform: translate(0, -140px) scale(var(--scale));
opacity: 0.6;
}
100% {
transform: translate(6px, -200px) scale(var(--scale));
opacity: 0;
}
}
@keyframes fly_spark_2 {
0% {
transform: translate(0, 0) scale(var(--scale));
opacity: 1;
}
50% {
transform: translate(-10px, -80px) scale(var(--scale));
}
80% {
transform: translate(-4px, -140px) scale(var(--scale));
opacity: 0.6;
}
100% {
transform: translate(-6px, -160px) scale(var(--scale));
opacity: 0;
}
}

View File

@@ -0,0 +1,11 @@
import React from 'react'
import { MdLocalFireDepartment } from 'react-icons/md'
export default function VotesCount({ count = 0 }: { count: number }) {
return (
<span className="chip-small bg-warning-50 text-gray-900 font-medium text-body5 py-4 px-16">
<MdLocalFireDepartment className='inline-block text-fire transform text-body4' />
<span className=' align-middle'> {count}</span>
</span>
)
}

View File

@@ -0,0 +1,38 @@
import { Hackathon } from "src/features/Hackathons/types"
import { IoLocationOutline } from 'react-icons/io5'
import Button from "src/Components/Button/Button"
import Skeleton from "react-loading-skeleton"
export default function HackathonCardSkeleton() {
return (
<div className="rounded-16 bg-white overflow-hidden">
<div className="w-full h-[120px] bg-gray-200" />
<div className="p-16">
<div className="flex flex-col gap-8">
<h3 className="text-body1 font-bold text-gray-900">
<Skeleton width={'100%'} />
</h3>
<p className="text-body3 font-medium text-gray-900">
<Skeleton width={'100%'} />
</p>
<p className="text-body4 font-medium text-gray-600">
<Skeleton width={'50%'} />
</p>
<p className="text-body4 text-gray-600">
<Skeleton width={'100%'} />
<Skeleton width={'40%'} />
</p>
</div>
<div className="mt-16 flex flex-wrap gap-8">
<div className="p-8 bg-gray-50 rounded-8 w-[92px] h-36">
</div>
<div className="p-8 bg-gray-50 rounded-8 w-[92px] h-36">
</div>
</div>
<div className="bg-gray-100 h-[56px] mt-16 rounded-lg">
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,31 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { MOCK_DATA } from 'src/mocks/data';
import HackathonCard from './HackathonCard';
import HackathonCardSkeleton from './HackathonCard.Skeleton';
export default {
title: 'Hackathons/Components/Hackathon Card',
component: HackathonCard,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof HackathonCard>;
const Template: ComponentStory<typeof HackathonCard> = (args) => <div className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,326px),1fr))]"><HackathonCard {...args} ></HackathonCard></div>
export const Default = Template.bind({});
Default.args = {
hackathon: MOCK_DATA['hackathons'][0]
}
const LoadingTemplate: ComponentStory<typeof HackathonCard> = (args) => <div className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,326px),1fr))]"><HackathonCardSkeleton></HackathonCardSkeleton></div>
export const Loading = LoadingTemplate.bind({});
Loading.args = {
}

View File

@@ -0,0 +1,43 @@
import { Hackathon } from "src/features/Hackathons/types"
import { IoLocationOutline } from 'react-icons/io5'
import Button from "src/Components/Button/Button"
import dayjs from "dayjs";
import advancedFormat from 'dayjs/plugin/advancedFormat'
import { trimText } from "src/utils/helperFunctions";
dayjs.extend(advancedFormat)
export type HackathonCardType = Hackathon;
interface Props {
hackathon: HackathonCardType
}
export default function HackathonCard({ hackathon }: Props) {
return (
<div className="rounded-16 bg-white overflow-hidden">
<img className="w-full h-[120px] object-cover" src={hackathon.cover_image} alt="" />
<div className="p-16">
<div className="flex flex-col gap-8">
<h3 className="text-body1 font-bold text-gray-900">
{hackathon.title}
</h3>
<p className="text-body3 font-medium text-gray-900">
{`${dayjs(hackathon.start_date).format('Do')} - ${dayjs(hackathon.end_date).format('Do MMMM, YYYY')}`}
</p>
<p className="text-body4 font-medium text-gray-600">
<IoLocationOutline className="mr-8" /> {hackathon.location}
</p>
<p className="text-body4 text-gray-600">
{trimText(hackathon.description, 110)}
</p>
</div>
<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>
<Button href={hackathon.website} newTab color="gray" fullWidth className="mt-16">
Learn more
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,22 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { MOCK_DATA } from 'src/mocks/data';
import HackathonsList from './HackathonsList';
export default {
title: 'Hackathons/Components/HackathonsList',
component: HackathonsList,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof HackathonsList>;
const Template: ComponentStory<typeof HackathonsList> = (args) => <HackathonsList {...args} ></HackathonsList>
export const Default = Template.bind({});
Default.args = {
items: MOCK_DATA['hackathons']
}

View File

@@ -0,0 +1,32 @@
import { useReachedBottom } from "src/utils/hooks/useReachedBottom"
import { ListComponentProps } from "src/utils/interfaces"
import HackathonCard, { HackathonCardType } from "../HackathonCard/HackathonCard"
import HackathonCardSkeleton from "../HackathonCard/HackathonCard.Skeleton"
type Props = ListComponentProps<HackathonCardType>
export default function HackathonsList(props: Props) {
const { ref } = useReachedBottom<HTMLDivElement>(props.onReachedBottom)
if (props.isLoading)
return <div className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,326px),1fr))] gap-24">
{<>
<HackathonCardSkeleton />
<HackathonCardSkeleton />
<HackathonCardSkeleton />
</>
}
</div>
return (
<div ref={ref} className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,326px),1fr))] gap-24">
{
props.items?.map(hackathon => <HackathonCard key={hackathon.id} hackathon={hackathon} />)
}
{props.isFetching && <HackathonCardSkeleton />}
</div>
)
}

View File

@@ -0,0 +1,20 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import SortBy from './SortByFilter';
export default {
title: 'Hackathons/Components/Filters/Sort By',
component: SortBy,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof SortBy>;
const Template: ComponentStory<typeof SortBy> = (args) => <div className="max-w-[326px]"><SortBy {...args as any} ></SortBy></div>
export const Default = Template.bind({});
Default.args = {
}

View File

@@ -0,0 +1,63 @@
import { useMediaQuery } from '@react-hookz/web';
import React, { useState } from 'react'
import AutoComplete from 'src/Components/Inputs/Autocomplete/Autocomplete';
import { MEDIA_QUERIES } from 'src/utils/theme';
const filters = [
{
text: "Upcoming",
value: 'Upcoming'
}, {
text: "Live",
value: 'Live'
}, {
text: "Finished",
value: 'Finished'
},
]
interface Props {
filterChanged?: (newFilter: string | null) => void
}
export default function SortByFilter({ filterChanged }: Props) {
const [selected, setSelected] = useState<string | null>(null);
const filterClicked = (_newValue: string | null) => {
const newValue = selected !== _newValue ? _newValue : null;
setSelected(newValue);
filterChanged?.(newValue);
}
const isMdScreen = useMediaQuery(MEDIA_QUERIES.isMedium)
return (
<>
{
isMdScreen ?
<div className='bg-white border rounded-12 p-16'>
< p className="text-body2 font-bolder text-black mb-16" > Sort By</p >
<ul>
{filters.map((f, idx) => <li
key={f.value}
className={`p-12 rounded-8 cursor-pointer font-bold ${f.value === selected && 'bg-gray-200'}`}
onClick={() => filterClicked(f.value)}
>
{f.text}
</li>)}
</ul>
</div >
:
<AutoComplete
isClearable
placeholder='Sort By'
options={filters}
labelField='text'
valueField='value'
onChange={(o) => filterClicked(o ? o.value : null)} />
}</>
)
}

View File

@@ -0,0 +1,20 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import TopicsFilter from './TopicsFilter';
export default {
title: 'Hackathons/Components/Filters/Topics',
component: TopicsFilter,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof TopicsFilter>;
const Template: ComponentStory<typeof TopicsFilter> = (args) => <div className="max-w-[326px]"><TopicsFilter {...args as any} ></TopicsFilter></div>
export const Default = Template.bind({});
Default.args = {
}

View File

@@ -0,0 +1,82 @@
import { useMediaQuery } from '@react-hookz/web';
import React, { useState } from 'react'
import Skeleton from 'react-loading-skeleton';
import Slider from 'src/Components/Slider/Slider';
import { useAllTopicsQuery, usePopularTopicsQuery } from 'src/graphql';
import { MEDIA_QUERIES } from 'src/utils/theme';
interface Props {
filterChanged?: (newFilter: number | null) => void
}
export default function PopularTopicsFilter({ filterChanged }: Props) {
const [selected, setSelected] = useState<number | null>(null);
const topicsQuery = useAllTopicsQuery();
const filterClicked = (_newValue: number) => {
const newValue = selected !== _newValue ? _newValue : null;
setSelected(newValue);
filterChanged?.(newValue);
}
const isMdScreen = useMediaQuery(MEDIA_QUERIES.isMedium)
return (
<>
{isMdScreen ?
<div className='bg-white border rounded-12 p-16'>
<p className="text-body2 font-bolder text-black mb-16">Topics</p>
<ul className=' flex flex-col gap-16'>
{topicsQuery.loading ?
Array(4).fill(0).map((_, idx) => <li
key={idx}
className={`flex items-start rounded-8 font-bold`}
>
<span className='bg-gray-50 rounded-8 w-40 h-40 text-center py-8'> </span>
<span className="self-center px-16"><Skeleton width={'7ch'} />
</span>
</li>
)
:
topicsQuery.data?.allTopics.map((topic, idx) => <li
key={topic.id}
className={`flex items-start rounded-8 cursor-pointer font-bold ${topic.id === selected && 'bg-gray-200'}`}
onClick={() => filterClicked(topic.id)}
>
<span className={`${topic.id !== selected && 'bg-gray-50'} rounded-8 w-40 h-40 text-center py-8`}>{topic.icon}</span>
<span className="self-center px-16">
{topic.title}
</span>
</li>)}
</ul>
</div>
:
<>
{
topicsQuery.loading ?
<ul className="flex gap-8 ">
{Array(4).fill(0).map((_, idx) => <div key={idx} className="py-12 px-16 bg-gray-100 rounded-8 text-body5"><span className="opacity-0">Category</span></div>)}
</ul>
:
<Slider>
{topicsQuery.data?.allTopics.map(topic =>
<div
key={topic.id}
onClick={() => filterClicked(topic.id)}
className={`${topic.id === selected ? 'bg-gray-200' : "bg-gray-100"} py-12 px-16 rounded-8 text-body5`}
>{topic.icon} {topic.title}</div>)}
</Slider>
}
</>
}
</>
)
}

View File

@@ -0,0 +1,7 @@
query allTopics {
allTopics {
id
title
icon
}
}

View File

@@ -0,0 +1,20 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import HackathonsPage from './HackathonsPage';
export default {
title: 'Hackathons/Hackathons Page/Page',
component: HackathonsPage,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof HackathonsPage>;
const Template: ComponentStory<typeof HackathonsPage> = (args) => <HackathonsPage {...args as any} ></HackathonsPage>
export const Default = Template.bind({});
Default.args = {
}

View File

@@ -0,0 +1,60 @@
import { useReducer, useState } from 'react'
import Button from 'src/Components/Button/Button'
import { useGetHackathonsQuery } from 'src/graphql'
import { useAppSelector, useInfiniteQuery } from 'src/utils/hooks'
import HackathonsList from '../../Components/HackathonsList/HackathonsList'
import SortByFilter from '../../Components/SortByFilter/SortByFilter'
import TopicsFilter from '../../Components/TopicsFilter/TopicsFilter'
import styles from './styles.module.scss'
export default function HackathonsPage() {
const [sortByFilter, setSortByFilter] = useState<string | null>(null)
const [topicsFilter, setTopicsFilter] = useState<number | null>(null)
const hackathonsQuery = useGetHackathonsQuery({
variables: {
sortBy: sortByFilter,
topic: Number(topicsFilter)
},
})
const { navHeight } = useAppSelector((state) => ({
navHeight: state.ui.navHeight
}));
return (
<div
className={`page-container pt-16 w-full ${styles.grid}`}
>
<aside className='no-scrollbar'>
<div className="sticky flex flex-col gap-24 md:overflow-y-scroll"
style={{
top: `${navHeight + 16}px`,
maxHeight: `calc(100vh - ${navHeight}px - 16px)`,
}}>
<SortByFilter
filterChanged={setSortByFilter}
/>
<TopicsFilter
filterChanged={setTopicsFilter}
/>
<Button
href='https://airtable.com/some-registration-form'
newTab
color='primary'
fullWidth
>
List Your Hackathon
</Button>
</div>
</aside>
<div className="self-start">
<HackathonsList
isLoading={hackathonsQuery.loading}
items={hackathonsQuery.data?.getAllHackathons} />
</div>
</div>
)
}

View File

@@ -0,0 +1,17 @@
query getHackathons($sortBy: String, $topic: Int) {
getAllHackathons(sortBy: $sortBy, topic: $topic) {
id
title
description
cover_image
start_date
end_date
location
website
topics {
id
title
icon
}
}
}

View File

@@ -0,0 +1,14 @@
.grid {
display: grid;
grid-template-columns: 100%;
gap: 32px;
@media screen and (min-width: 768px) {
grid-template-columns: 1fr 2fr 0;
gap: 32px;
}
@media screen and (min-width: 1024px) {
grid-template-columns: calc(min(30%, 326px)) 1fr;
}
}

View File

@@ -0,0 +1,3 @@
import { Hackathon as ApiHackathon, } from "src/graphql"
export type Hackathon = ApiHackathon

View File

@@ -0,0 +1 @@
export * from './hackathons.interface'

View File

@@ -0,0 +1,21 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import AddComment from './AddComment';
export default {
title: 'Posts/Components/Comments/Add Comment',
component: AddComment,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof AddComment>;
const Template: ComponentStory<typeof AddComment> = (args) => <div className="max-w-[70ch]"><AddComment {...args} ></AddComment></div>
export const Default = Template.bind({});
Default.args = {
placeholder: "Leave a comment..."
}

View File

@@ -0,0 +1,119 @@
import 'remirror/styles/all.css';
import styles from './styles.module.scss'
import javascript from 'refractor/lang/javascript';
import typescript from 'refractor/lang/typescript';
import {
BoldExtension,
CodeBlockExtension,
CodeExtension,
HardBreakExtension,
ImageExtension,
LinkExtension,
MarkdownExtension,
PlaceholderExtension,
} from 'remirror/extensions';
import { EditorComponent, Remirror, useRemirror } from '@remirror/react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import Avatar from 'src/features/Profiles/Components/Avatar/Avatar';
import Toolbar from './Toolbar';
import Button from 'src/Components/Button/Button';
import { InvalidContentHandler } from 'remirror';
interface Props {
initialContent?: string;
placeholder?: string;
name?: string;
autoFocus?: boolean
}
export default function AddComment({ initialContent, placeholder, name, autoFocus }: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const linkExtension = useMemo(() => {
const extension = new LinkExtension({ autoLink: true });
extension.addHandler('onClick', (_, data) => {
window.open(data.href, '_blank')?.focus();
return true;
});
return extension;
}, []);
const valueRef = useRef<string>("");
const extensions = useCallback(
() => [
new PlaceholderExtension({ placeholder }),
linkExtension,
new BoldExtension(),
new CodeExtension(),
new CodeBlockExtension({
supportedLanguages: [javascript, typescript]
}),
new ImageExtension({ enableResizing: true }),
new MarkdownExtension({ copyAsMarkdown: false }),
/**
* `HardBreakExtension` allows us to create a newline inside paragraphs.
* e.g. in a list item
*/
new HardBreakExtension(),
],
[linkExtension, placeholder],
);
const onError: InvalidContentHandler = useCallback(({ json, invalidContent, transformers }) => {
// Automatically remove all invalid nodes and marks.
return transformers.remove(json, invalidContent);
}, []);
const { manager, state, onChange, } = useRemirror({
extensions,
stringHandler: 'markdown',
content: initialContent ?? '',
onError,
});
useEffect(() => {
if (autoFocus)
containerRef.current?.scrollIntoView({ behavior: "smooth", block: "center", inline: "center" });
}, [autoFocus])
const submitComment = () => {
console.log(valueRef.current);
manager.view.updateState(manager.createState({ content: manager.createEmptyDoc() }))
}
return (
<div className={`remirror-theme ${styles.wrapper} p-24 border rounded-12`} ref={containerRef}>
<Remirror
manager={manager}
state={state}
onChange={e => {
const html = e.helpers.getHTML(e.state)
valueRef.current = html;
onChange(e);
}}
autoFocus={autoFocus}
>
<div className="flex gap-16 items-start pb-24 border-b border-gray-200 focus-within:border-primary-500">
<div className="hidden sm:block mt-16 shrink-0"><Avatar width={48} src='https://i.pravatar.cc/150?img=1' /></div>
<div className="flex-grow">
<EditorComponent
/>
</div>
</div>
<div className="flex flex-wrap gap-16 mt-16">
<Toolbar />
<Button onClick={submitComment} color='primary' className='ml-auto'>Submit</Button>
</div>
</Remirror>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More