mirror of
https://github.com/aljazceru/landscape-template.git
synced 2026-02-19 21:44:20 +01:00
Merge branch 'dev'
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,6 +7,7 @@ environments/.dev.preview-server.env
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/node_modules_t
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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/,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,2 +1,3 @@
|
||||
|
||||
REACT_APP_ENABLE_MOCKS= true
|
||||
REACT_APP_ENABLE_MOCKS= true
|
||||
STORYBOOK_ENABLE_MOCKS= true
|
||||
@@ -1,2 +1,3 @@
|
||||
|
||||
REACT_APP_ENABLE_MOCKS= true
|
||||
REACT_APP_ENABLE_MOCKS= true
|
||||
STORYBOOK_ENABLE_MOCKS= true
|
||||
@@ -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: {
|
||||
|
||||
@@ -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!
|
||||
}
|
||||
24
functions/graphql/types/_scalars.js
Normal file
24
functions/graphql/types/_scalars.js
Normal 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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
79
functions/graphql/types/hackathon.js
Normal file
79
functions/graphql/types/hackathon.js
Normal 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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
337
functions/graphql/types/post.js
Normal file
337
functions/graphql/types/post.js
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
16
functions/graphql/types/users.js
Normal file
16
functions/graphql/types/users.js
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
73170
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
150
package.json
150
package.json
@@ -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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
31
src/App.tsx
31
src/App.tsx
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
171
src/Components/Inputs/Autocomplete/Autocomplete.stories.tsx
Normal file
171
src/Components/Inputs/Autocomplete/Autocomplete.stories.tsx
Normal 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
|
||||
}
|
||||
|
||||
97
src/Components/Inputs/Autocomplete/Autocomplete.tsx
Normal file
97
src/Components/Inputs/Autocomplete/Autocomplete.tsx
Normal 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>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
21
src/Components/Inputs/DatePicker/DatePicker.stories.tsx
Normal file
21
src/Components/Inputs/DatePicker/DatePicker.stories.tsx
Normal 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" }
|
||||
}
|
||||
|
||||
|
||||
|
||||
38
src/Components/Inputs/DatePicker/DatePicker.tsx
Normal file
38
src/Components/Inputs/DatePicker/DatePicker.tsx
Normal 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;
|
||||
66
src/Components/Inputs/FilesInput/DropInput.jsx
Normal file
66
src/Components/Inputs/FilesInput/DropInput.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/Components/Inputs/FilesInput/FileInput.stories.tsx
Normal file
31
src/Components/Inputs/FilesInput/FileInput.stories.tsx
Normal 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,
|
||||
}
|
||||
75
src/Components/Inputs/FilesInput/FileThumbnail.tsx
Normal file
75
src/Components/Inputs/FilesInput/FileThumbnail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
src/Components/Inputs/FilesInput/FilesDropInput.tsx
Normal file
88
src/Components/Inputs/FilesInput/FilesDropInput.tsx
Normal 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>
|
||||
);
|
||||
104
src/Components/Inputs/FilesInput/FilesInput.tsx
Normal file
104
src/Components/Inputs/FilesInput/FilesInput.tsx
Normal 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} />
|
||||
</>
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
31
src/Components/Inputs/FilesInput/FilesThumbnails.tsx
Normal file
31
src/Components/Inputs/FilesInput/FilesThumbnails.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
src/Components/Inputs/SelectInput/SelectInput.stories.tsx
Normal file
35
src/Components/Inputs/SelectInput/SelectInput.stories.tsx
Normal 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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
63
src/Components/Inputs/SelectInput/SelectInput.tsx
Normal file
63
src/Components/Inputs/SelectInput/SelectInput.tsx
Normal 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>
|
||||
|
||||
)
|
||||
}
|
||||
30
src/Components/Inputs/SelectInput/selectinput.style.css
Normal file
30
src/Components/Inputs/SelectInput/selectinput.style.css
Normal 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;
|
||||
}
|
||||
29
src/Components/Inputs/TagsInput/TagsInput.stories.tsx
Normal file
29
src/Components/Inputs/TagsInput/TagsInput.stories.tsx
Normal 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({});
|
||||
82
src/Components/Inputs/TagsInput/TagsInput.tsx
Normal file
82
src/Components/Inputs/TagsInput/TagsInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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({});
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
import { lazyModal } from 'src/utils/helperFunctions';
|
||||
|
||||
export const { LazyComponent: InsertImageModal } = lazyModal(() => import('./InsertImageModal'))
|
||||
@@ -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({});
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
import { lazyModal } from 'src/utils/helperFunctions';
|
||||
|
||||
export const { LazyComponent: InsertVideoModal } = lazyModal(() => import('./InsertVideoModal'))
|
||||
29
src/Components/Inputs/TextEditor/SaveModule.tsx
Normal file
29
src/Components/Inputs/TextEditor/SaveModule.tsx
Normal 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 <></>
|
||||
}
|
||||
111
src/Components/Inputs/TextEditor/TextEditor.stories.tsx
Normal file
111
src/Components/Inputs/TextEditor/TextEditor.stories.tsx
Normal 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>
|
||||
|
||||
`
|
||||
}
|
||||
113
src/Components/Inputs/TextEditor/TextEditor.tsx
Normal file
113
src/Components/Inputs/TextEditor/TextEditor.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
}
|
||||
73
src/Components/Inputs/TextEditor/ToolButton/ImageToolBtn.tsx
Normal file
73
src/Components/Inputs/TextEditor/ToolButton/ImageToolBtn.tsx
Normal 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>
|
||||
)
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
50
src/Components/Inputs/TextEditor/ToolButton/ToolBtn.tsx
Normal file
50
src/Components/Inputs/TextEditor/ToolButton/ToolBtn.tsx
Normal 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} />
|
||||
|
||||
|
||||
}
|
||||
|
||||
71
src/Components/Inputs/TextEditor/ToolButton/VideoToolBtn.tsx
Normal file
71
src/Components/Inputs/TextEditor/ToolButton/VideoToolBtn.tsx
Normal 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>
|
||||
)
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
110
src/Components/Inputs/TextEditor/ToolButton/helpers.ts
Normal file
110
src/Components/Inputs/TextEditor/ToolButton/helpers.ts
Normal 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;
|
||||
}
|
||||
3
src/Components/Inputs/TextEditor/ToolButton/index.tsx
Normal file
3
src/Components/Inputs/TextEditor/ToolButton/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import ToolButton from "./ToolBtn";
|
||||
|
||||
export default ToolButton;
|
||||
33
src/Components/Inputs/TextEditor/Toolbar/Toolbar.tsx
Normal file
33
src/Components/Inputs/TextEditor/Toolbar/Toolbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
src/Components/Inputs/TextEditor/index.tsx
Normal file
5
src/Components/Inputs/TextEditor/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import SaveModule from "./SaveModule";
|
||||
import ToolButton from "./ToolButton";
|
||||
|
||||
const TextEditorComponents = { SaveModule, ToolButton };
|
||||
export default TextEditorComponents;
|
||||
24
src/Components/Inputs/TextEditor/styles.module.scss
Normal file
24
src/Components/Inputs/TextEditor/styles.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
14
src/Components/LoadingPage/LoadingPage.jsx
Normal file
14
src/Components/LoadingPage/LoadingPage.jsx
Normal 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 />;
|
||||
}
|
||||
@@ -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' />
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
78
src/Components/VoteButton/VoteButton.stories.tsx
Normal file
78
src/Components/VoteButton/VoteButton.stories.tsx
Normal 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,
|
||||
}
|
||||
228
src/Components/VoteButton/VoteButton.tsx
Normal file
228
src/Components/VoteButton/VoteButton.tsx
Normal 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>
|
||||
|
||||
)
|
||||
}
|
||||
182
src/Components/VoteButton/styles.module.scss
Normal file
182
src/Components/VoteButton/styles.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
11
src/Components/VotesCount/VotesCount.tsx
Normal file
11
src/Components/VotesCount/VotesCount.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 = {
|
||||
}
|
||||
|
||||
|
||||
@@ -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)} />
|
||||
}</>
|
||||
|
||||
)
|
||||
}
|
||||
@@ -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 = {
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
</>
|
||||
}
|
||||
</>
|
||||
|
||||
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
query allTopics {
|
||||
allTopics {
|
||||
id
|
||||
title
|
||||
icon
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
3
src/features/Hackathons/types/hackathons.interface.ts
Normal file
3
src/features/Hackathons/types/hackathons.interface.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Hackathon as ApiHackathon, } from "src/graphql"
|
||||
|
||||
export type Hackathon = ApiHackathon
|
||||
1
src/features/Hackathons/types/index.ts
Normal file
1
src/features/Hackathons/types/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './hackathons.interface'
|
||||
@@ -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..."
|
||||
}
|
||||
|
||||
|
||||
119
src/features/Posts/Components/Comments/AddComment/AddComment.tsx
Normal file
119
src/features/Posts/Components/Comments/AddComment/AddComment.tsx
Normal 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
Reference in New Issue
Block a user