Merge pull request #182 from peakshift/dev

Release: Submit project, Project Page, Tag Project, Stories Templates
This commit is contained in:
Mohammed Taher Ghazal
2022-10-05 22:23:33 +03:00
committed by GitHub
117 changed files with 6264 additions and 344 deletions

View File

@@ -28,6 +28,29 @@ declare global {
}
export interface NexusGenInputs {
CreateProjectInput: { // input type
capabilities: number[]; // [Int!]!
category_id: number; // Int!
cover_image: NexusGenInputs['ImageInput']; // ImageInput!
description: string; // String!
discord?: string | null; // String
github?: string | null; // String
hashtag: string; // String!
id?: number | null; // Int
launch_status: NexusGenEnums['ProjectLaunchStatusEnum']; // ProjectLaunchStatusEnum!
lightning_address?: string | null; // String
members: NexusGenInputs['TeamMemberInput'][]; // [TeamMemberInput!]!
recruit_roles: number[]; // [Int!]!
screenshots: NexusGenInputs['ImageInput'][]; // [ImageInput!]!
slack?: string | null; // String
tagline: string; // String!
telegram?: string | null; // String
thumbnail_image: NexusGenInputs['ImageInput']; // ImageInput!
title: string; // String!
tournaments: number[]; // [Int!]!
twitter?: string | null; // String
website: string; // String!
}
ImageInput: { // input type
id?: string | null; // String
name?: string | null; // String
@@ -67,9 +90,37 @@ export interface NexusGenInputs {
cover_image?: NexusGenInputs['ImageInput'] | null; // ImageInput
id?: number | null; // Int
is_published?: boolean | null; // Boolean
project_id?: number | null; // Int
tags: string[]; // [String!]!
title: string; // String!
}
TeamMemberInput: { // input type
id: number; // Int!
role: NexusGenEnums['TEAM_MEMBER_ROLE']; // TEAM_MEMBER_ROLE!
}
UpdateProjectInput: { // input type
capabilities: number[]; // [Int!]!
category_id: number; // Int!
cover_image: NexusGenInputs['ImageInput']; // ImageInput!
description: string; // String!
discord?: string | null; // String
github?: string | null; // String
hashtag: string; // String!
id?: number | null; // Int
launch_status: NexusGenEnums['ProjectLaunchStatusEnum']; // ProjectLaunchStatusEnum!
lightning_address?: string | null; // String
members: NexusGenInputs['TeamMemberInput'][]; // [TeamMemberInput!]!
recruit_roles: number[]; // [Int!]!
screenshots: NexusGenInputs['ImageInput'][]; // [ImageInput!]!
slack?: string | null; // String
tagline: string; // String!
telegram?: string | null; // String
thumbnail_image: NexusGenInputs['ImageInput']; // ImageInput!
title: string; // String!
tournaments: number[]; // [Int!]!
twitter?: string | null; // String
website: string; // String!
}
UpdateTournamentRegistrationInput: { // input type
email?: string | null; // String
hacking_status?: NexusGenEnums['TournamentMakerHackingStatusEnum'] | null; // TournamentMakerHackingStatusEnum
@@ -82,7 +133,10 @@ export interface NexusGenInputs {
export interface NexusGenEnums {
POST_TYPE: "Bounty" | "Question" | "Story"
ProjectLaunchStatusEnum: "Launched" | "WIP"
ProjectPermissionEnum: "DeleteProject" | "UpdateAdmins" | "UpdateInfo" | "UpdateMembers"
RoleLevelEnum: 3 | 0 | 1 | 2 | 4
TEAM_MEMBER_ROLE: "Admin" | "Maker" | "Owner"
TournamentEventTypeEnum: 2 | 3 | 0 | 1
TournamentMakerHackingStatusEnum: 1 | 0
VOTE_ITEM_TYPE: "Bounty" | "PostComment" | "Project" | "Question" | "Story" | "User"
@@ -131,11 +185,19 @@ export interface NexusGenObjects {
id: number; // Int!
workplan: string; // String!
}
Capability: { // root type
icon: string; // String!
id: number; // Int!
title: string; // String!
}
Category: { // root type
icon?: string | null; // String
id: number; // Int!
title: string; // String!
}
CreateProjectResponse: { // root type
project: NexusGenRootTypes['Project']; // Project!
}
Donation: { // root type
amount: number; // Int!
createdAt: NexusGenScalars['Date']; // Date!
@@ -214,13 +276,25 @@ export interface NexusGenObjects {
}
Project: { // root type
description: string; // String!
discord?: string | null; // String
github?: string | null; // String
hashtag: string; // String!
id: number; // Int!
launch_status: NexusGenEnums['ProjectLaunchStatusEnum']; // ProjectLaunchStatusEnum!
lightning_address?: string | null; // String
lnurl_callback_url?: string | null; // String
slack?: string | null; // String
tagline: string; // String!
telegram?: string | null; // String
title: string; // String!
twitter?: string | null; // String
votes_count: number; // Int!
website: string; // String!
}
ProjectMember: { // root type
role: NexusGenEnums['TEAM_MEMBER_ROLE']; // TEAM_MEMBER_ROLE!
user: NexusGenRootTypes['User']; // User!
}
Query: {};
Question: { // root type
body: string; // String!
@@ -379,6 +453,11 @@ export interface NexusGenFieldTypes {
id: number; // Int!
workplan: string; // String!
}
Capability: { // field return type
icon: string; // String!
id: number; // Int!
title: string; // String!
}
Category: { // field return type
apps_count: number; // Int!
cover_image: string | null; // String
@@ -388,6 +467,9 @@ export interface NexusGenFieldTypes {
title: string; // String!
votes_sum: number; // Int!
}
CreateProjectResponse: { // field return type
project: NexusGenRootTypes['Project']; // Project!
}
Donation: { // field return type
amount: number; // Int!
by: NexusGenRootTypes['User'] | null; // User
@@ -438,12 +520,15 @@ export interface NexusGenFieldTypes {
Mutation: { // field return type
confirmDonation: NexusGenRootTypes['Donation']; // Donation!
confirmVote: NexusGenRootTypes['Vote']; // Vote!
createProject: NexusGenRootTypes['CreateProjectResponse'] | null; // CreateProjectResponse
createStory: NexusGenRootTypes['Story'] | null; // Story
deleteProject: NexusGenRootTypes['Project'] | null; // Project
deleteStory: NexusGenRootTypes['Story'] | null; // Story
donate: NexusGenRootTypes['Donation']; // Donation!
registerInTournament: NexusGenRootTypes['User'] | null; // User
updateProfileDetails: NexusGenRootTypes['MyProfile'] | null; // MyProfile
updateProfileRoles: NexusGenRootTypes['MyProfile'] | null; // MyProfile
updateProject: NexusGenRootTypes['CreateProjectResponse'] | null; // CreateProjectResponse
updateTournamentRegistration: NexusGenRootTypes['ParticipationInfo'] | null; // ParticipationInfo
updateUserPreferences: NexusGenRootTypes['MyProfile']; // MyProfile!
vote: NexusGenRootTypes['Vote']; // Vote!
@@ -464,6 +549,7 @@ export interface NexusGenFieldTypes {
name: string; // String!
nostr_prv_key: string | null; // String
nostr_pub_key: string | null; // String
projects: NexusGenRootTypes['Project'][]; // [Project!]!
role: string | null; // String
roles: NexusGenRootTypes['MakerRole'][]; // [MakerRole!]!
similar_makers: NexusGenRootTypes['User'][]; // [User!]!
@@ -489,23 +575,42 @@ export interface NexusGenFieldTypes {
}
Project: { // field return type
awards: NexusGenRootTypes['Award'][]; // [Award!]!
capabilities: NexusGenRootTypes['Capability'][]; // [Capability!]!
category: NexusGenRootTypes['Category']; // Category!
cover_image: string; // String!
description: string; // String!
discord: string | null; // String
github: string | null; // String
hashtag: string; // String!
id: number; // Int!
launch_status: NexusGenEnums['ProjectLaunchStatusEnum']; // ProjectLaunchStatusEnum!
lightning_address: string | null; // String
lnurl_callback_url: string | null; // String
members: NexusGenRootTypes['ProjectMember'][]; // [ProjectMember!]!
permissions: NexusGenEnums['ProjectPermissionEnum'][]; // [ProjectPermissionEnum!]!
recruit_roles: NexusGenRootTypes['MakerRole'][]; // [MakerRole!]!
screenshots: string[]; // [String!]!
slack: string | null; // String
stories: NexusGenRootTypes['Story'][]; // [Story!]!
tagline: string; // String!
tags: NexusGenRootTypes['Tag'][]; // [Tag!]!
telegram: string | null; // String
thumbnail_image: string; // String!
title: string; // String!
tournaments: NexusGenRootTypes['Tournament'][]; // [Tournament!]!
twitter: string | null; // String
votes_count: number; // Int!
website: string; // String!
}
ProjectMember: { // field return type
role: NexusGenEnums['TEAM_MEMBER_ROLE']; // TEAM_MEMBER_ROLE!
user: NexusGenRootTypes['User']; // User!
}
Query: { // field return type
allCategories: NexusGenRootTypes['Category'][]; // [Category!]!
allProjects: NexusGenRootTypes['Project'][]; // [Project!]!
checkValidProjectHashtag: boolean; // Boolean!
getAllCapabilities: NexusGenRootTypes['Capability'][]; // [Capability!]!
getAllHackathons: NexusGenRootTypes['Hackathon'][]; // [Hackathon!]!
getAllMakersRoles: NexusGenRootTypes['GenericMakerRole'][]; // [GenericMakerRole!]!
getAllMakersSkills: NexusGenRootTypes['MakerSkill'][]; // [MakerSkill!]!
@@ -519,6 +624,7 @@ export interface NexusGenFieldTypes {
getProject: NexusGenRootTypes['Project']; // Project!
getProjectsInTournament: NexusGenRootTypes['TournamentProjectsResponse']; // TournamentProjectsResponse!
getTournamentById: NexusGenRootTypes['Tournament']; // Tournament!
getTournamentToRegister: NexusGenRootTypes['Tournament'][]; // [Tournament!]!
getTrendingPosts: NexusGenRootTypes['Post'][]; // [Post!]!
hottestProjects: NexusGenRootTypes['Project'][]; // [Project!]!
me: NexusGenRootTypes['MyProfile'] | null; // MyProfile
@@ -528,7 +634,9 @@ export interface NexusGenFieldTypes {
profile: NexusGenRootTypes['User'] | null; // User
projectsByCategory: NexusGenRootTypes['Project'][]; // [Project!]!
searchProjects: NexusGenRootTypes['Project'][]; // [Project!]!
searchUsers: NexusGenRootTypes['User'][]; // [User!]!
similarMakers: NexusGenRootTypes['User'][]; // [User!]!
similarProjects: NexusGenRootTypes['Project'][]; // [Project!]!
tournamentParticipationInfo: NexusGenRootTypes['ParticipationInfo'] | null; // ParticipationInfo
}
Question: { // field return type
@@ -554,6 +662,7 @@ export interface NexusGenFieldTypes {
excerpt: string; // String!
id: number; // Int!
is_published: boolean | null; // Boolean
project: NexusGenRootTypes['Project'] | null; // Project
tags: NexusGenRootTypes['Tag'][]; // [Tag!]!
title: string; // String!
type: string; // String!
@@ -639,6 +748,7 @@ export interface NexusGenFieldTypes {
linkedin: string | null; // String
location: string | null; // String
name: string; // String!
projects: NexusGenRootTypes['Project'][]; // [Project!]!
role: string | null; // String
roles: NexusGenRootTypes['MakerRole'][]; // [MakerRole!]!
similar_makers: NexusGenRootTypes['User'][]; // [User!]!
@@ -676,6 +786,7 @@ export interface NexusGenFieldTypes {
linkedin: string | null; // String
location: string | null; // String
name: string; // String!
projects: NexusGenRootTypes['Project'][]; // [Project!]!
role: string | null; // String
roles: NexusGenRootTypes['MakerRole'][]; // [MakerRole!]!
similar_makers: NexusGenRootTypes['User'][]; // [User!]!
@@ -736,6 +847,11 @@ export interface NexusGenFieldTypeNames {
id: 'Int'
workplan: 'String'
}
Capability: { // field return type name
icon: 'String'
id: 'Int'
title: 'String'
}
Category: { // field return type name
apps_count: 'Int'
cover_image: 'String'
@@ -745,6 +861,9 @@ export interface NexusGenFieldTypeNames {
title: 'String'
votes_sum: 'Int'
}
CreateProjectResponse: { // field return type name
project: 'Project'
}
Donation: { // field return type name
amount: 'Int'
by: 'User'
@@ -795,12 +914,15 @@ export interface NexusGenFieldTypeNames {
Mutation: { // field return type name
confirmDonation: 'Donation'
confirmVote: 'Vote'
createProject: 'CreateProjectResponse'
createStory: 'Story'
deleteProject: 'Project'
deleteStory: 'Story'
donate: 'Donation'
registerInTournament: 'User'
updateProfileDetails: 'MyProfile'
updateProfileRoles: 'MyProfile'
updateProject: 'CreateProjectResponse'
updateTournamentRegistration: 'ParticipationInfo'
updateUserPreferences: 'MyProfile'
vote: 'Vote'
@@ -821,6 +943,7 @@ export interface NexusGenFieldTypeNames {
name: 'String'
nostr_prv_key: 'String'
nostr_pub_key: 'String'
projects: 'Project'
role: 'String'
roles: 'MakerRole'
similar_makers: 'User'
@@ -846,23 +969,42 @@ export interface NexusGenFieldTypeNames {
}
Project: { // field return type name
awards: 'Award'
capabilities: 'Capability'
category: 'Category'
cover_image: 'String'
description: 'String'
discord: 'String'
github: 'String'
hashtag: 'String'
id: 'Int'
launch_status: 'ProjectLaunchStatusEnum'
lightning_address: 'String'
lnurl_callback_url: 'String'
members: 'ProjectMember'
permissions: 'ProjectPermissionEnum'
recruit_roles: 'MakerRole'
screenshots: 'String'
slack: 'String'
stories: 'Story'
tagline: 'String'
tags: 'Tag'
telegram: 'String'
thumbnail_image: 'String'
title: 'String'
tournaments: 'Tournament'
twitter: 'String'
votes_count: 'Int'
website: 'String'
}
ProjectMember: { // field return type name
role: 'TEAM_MEMBER_ROLE'
user: 'User'
}
Query: { // field return type name
allCategories: 'Category'
allProjects: 'Project'
checkValidProjectHashtag: 'Boolean'
getAllCapabilities: 'Capability'
getAllHackathons: 'Hackathon'
getAllMakersRoles: 'GenericMakerRole'
getAllMakersSkills: 'MakerSkill'
@@ -876,6 +1018,7 @@ export interface NexusGenFieldTypeNames {
getProject: 'Project'
getProjectsInTournament: 'TournamentProjectsResponse'
getTournamentById: 'Tournament'
getTournamentToRegister: 'Tournament'
getTrendingPosts: 'Post'
hottestProjects: 'Project'
me: 'MyProfile'
@@ -885,7 +1028,9 @@ export interface NexusGenFieldTypeNames {
profile: 'User'
projectsByCategory: 'Project'
searchProjects: 'Project'
searchUsers: 'User'
similarMakers: 'User'
similarProjects: 'Project'
tournamentParticipationInfo: 'ParticipationInfo'
}
Question: { // field return type name
@@ -911,6 +1056,7 @@ export interface NexusGenFieldTypeNames {
excerpt: 'String'
id: 'Int'
is_published: 'Boolean'
project: 'Project'
tags: 'Tag'
title: 'String'
type: 'String'
@@ -996,6 +1142,7 @@ export interface NexusGenFieldTypeNames {
linkedin: 'String'
location: 'String'
name: 'String'
projects: 'Project'
role: 'String'
roles: 'MakerRole'
similar_makers: 'User'
@@ -1033,6 +1180,7 @@ export interface NexusGenFieldTypeNames {
linkedin: 'String'
location: 'String'
name: 'String'
projects: 'Project'
role: 'String'
roles: 'MakerRole'
similar_makers: 'User'
@@ -1064,9 +1212,15 @@ export interface NexusGenArgTypes {
payment_request: string; // String!
preimage: string; // String!
}
createProject: { // args
input?: NexusGenInputs['CreateProjectInput'] | null; // CreateProjectInput
}
createStory: { // args
data?: NexusGenInputs['StoryInputType'] | null; // StoryInputType
}
deleteProject: { // args
id: number; // Int!
}
deleteStory: { // args
id: number; // Int!
}
@@ -1083,6 +1237,9 @@ export interface NexusGenArgTypes {
updateProfileRoles: { // args
data?: NexusGenInputs['ProfileRolesInput'] | null; // ProfileRolesInput
}
updateProject: { // args
input?: NexusGenInputs['UpdateProjectInput'] | null; // UpdateProjectInput
}
updateTournamentRegistration: { // args
data?: NexusGenInputs['UpdateTournamentRegistrationInput'] | null; // UpdateTournamentRegistrationInput
tournament_id: number; // Int!
@@ -1106,6 +1263,10 @@ export interface NexusGenArgTypes {
skip?: number | null; // Int
take: number | null; // Int
}
checkValidProjectHashtag: { // args
hashtag: string; // String!
projectId?: number | null; // Int
}
getAllHackathons: { // args
sortBy?: string | null; // String
tag?: number | null; // Int
@@ -1138,7 +1299,8 @@ export interface NexusGenArgTypes {
type: NexusGenEnums['POST_TYPE']; // POST_TYPE!
}
getProject: { // args
id: number; // Int!
id?: number | null; // Int
tag?: string | null; // String
}
getProjectsInTournament: { // args
roleId?: number | null; // Int
@@ -1171,9 +1333,15 @@ export interface NexusGenArgTypes {
skip?: number | null; // Int
take: number | null; // Int
}
searchUsers: { // args
value: string; // String!
}
similarMakers: { // args
id: number; // Int!
}
similarProjects: { // args
id: number; // Int!
}
tournamentParticipationInfo: { // args
tournamentId: number; // Int!
}

View File

@@ -31,6 +31,7 @@ interface BaseUser {
linkedin: String
location: String
name: String!
projects: [Project!]!
role: String
roles: [MakerRole!]!
similar_makers: [User!]!
@@ -67,6 +68,12 @@ type BountyApplication {
workplan: String!
}
type Capability {
icon: String!
id: Int!
title: String!
}
type Category {
apps_count: Int!
cover_image: String
@@ -77,6 +84,34 @@ type Category {
votes_sum: Int!
}
input CreateProjectInput {
capabilities: [Int!]!
category_id: Int!
cover_image: ImageInput!
description: String!
discord: String
github: String
hashtag: String!
id: Int
launch_status: ProjectLaunchStatusEnum!
lightning_address: String
members: [TeamMemberInput!]!
recruit_roles: [Int!]!
screenshots: [ImageInput!]!
slack: String
tagline: String!
telegram: String
thumbnail_image: ImageInput!
title: String!
tournaments: [Int!]!
twitter: String
website: String!
}
type CreateProjectResponse {
project: Project!
}
"""Date custom scalar type"""
scalar Date
@@ -152,12 +187,15 @@ input MakerSkillInput {
type Mutation {
confirmDonation(payment_request: String!, preimage: String!): Donation!
confirmVote(payment_request: String!, preimage: String!): Vote!
createProject(input: CreateProjectInput): CreateProjectResponse
createStory(data: StoryInputType): Story
deleteProject(id: Int!): Project
deleteStory(id: Int!): Story
donate(amount_in_sat: Int!): Donation!
registerInTournament(data: RegisterInTournamentInput, tournament_id: Int!): User
updateProfileDetails(data: ProfileDetailsInput): MyProfile
updateProfileRoles(data: ProfileRolesInput): MyProfile
updateProject(input: UpdateProjectInput): CreateProjectResponse
updateTournamentRegistration(data: UpdateTournamentRegistrationInput, tournament_id: Int!): ParticipationInfo
updateUserPreferences(userKeys: [UserKeyInputType!]): MyProfile!
vote(amount_in_sat: Int!, item_id: Int!, item_type: VOTE_ITEM_TYPE!): Vote!
@@ -179,6 +217,7 @@ type MyProfile implements BaseUser {
name: String!
nostr_prv_key: String
nostr_pub_key: String
projects: [Project!]!
role: String
roles: [MakerRole!]!
similar_makers: [User!]!
@@ -246,24 +285,56 @@ input ProfileRolesInput {
type Project {
awards: [Award!]!
capabilities: [Capability!]!
category: Category!
cover_image: String!
description: String!
discord: String
github: String
hashtag: String!
id: Int!
launch_status: ProjectLaunchStatusEnum!
lightning_address: String
lnurl_callback_url: String
members: [ProjectMember!]!
permissions: [ProjectPermissionEnum!]!
recruit_roles: [MakerRole!]!
screenshots: [String!]!
slack: String
stories: [Story!]!
tagline: String!
tags: [Tag!]!
telegram: String
thumbnail_image: String!
title: String!
tournaments: [Tournament!]!
twitter: String
votes_count: Int!
website: String!
}
enum ProjectLaunchStatusEnum {
Launched
WIP
}
type ProjectMember {
role: TEAM_MEMBER_ROLE!
user: User!
}
enum ProjectPermissionEnum {
DeleteProject
UpdateAdmins
UpdateInfo
UpdateMembers
}
type Query {
allCategories: [Category!]!
allProjects(skip: Int = 0, take: Int = 50): [Project!]!
checkValidProjectHashtag(hashtag: String!, projectId: Int): Boolean!
getAllCapabilities: [Capability!]!
getAllHackathons(sortBy: String, tag: Int): [Hackathon!]!
getAllMakersRoles: [GenericMakerRole!]!
getAllMakersSkills: [MakerSkill!]!
@@ -274,9 +345,10 @@ type Query {
getMakersInTournament(openToConnect: Boolean, roleId: Int, search: String, skip: Int = 0, take: Int = 10, tournamentId: Int!): TournamentMakersResponse!
getMyDrafts(type: POST_TYPE!): [Post!]!
getPostById(id: Int!, type: POST_TYPE!): Post!
getProject(id: Int!): Project!
getProject(id: Int, tag: String): Project!
getProjectsInTournament(roleId: Int, search: String, skip: Int = 0, take: Int = 10, tournamentId: Int!): TournamentProjectsResponse!
getTournamentById(id: Int!): Tournament!
getTournamentToRegister: [Tournament!]!
getTrendingPosts: [Post!]!
hottestProjects(skip: Int = 0, take: Int = 50): [Project!]!
me: MyProfile
@@ -286,7 +358,9 @@ type Query {
profile(id: Int!): User
projectsByCategory(category_id: Int!, skip: Int = 0, take: Int = 10): [Project!]!
searchProjects(search: String!, skip: Int = 0, take: Int = 50): [Project!]!
searchUsers(value: String!): [User!]!
similarMakers(id: Int!): [User!]!
similarProjects(id: Int!): [Project!]!
tournamentParticipationInfo(tournamentId: Int!): ParticipationInfo
}
@@ -327,6 +401,7 @@ type Story implements PostBase {
excerpt: String!
id: Int!
is_published: Boolean
project: Project
tags: [Tag!]!
title: String!
type: String!
@@ -339,10 +414,17 @@ input StoryInputType {
cover_image: ImageInput
id: Int
is_published: Boolean
project_id: Int
tags: [String!]!
title: String!
}
enum TEAM_MEMBER_ROLE {
Admin
Maker
Owner
}
type Tag {
description: String
icon: String
@@ -351,6 +433,11 @@ type Tag {
title: String!
}
input TeamMemberInput {
id: Int!
role: TEAM_MEMBER_ROLE!
}
type Tournament {
cover_image: String!
description: String!
@@ -430,6 +517,30 @@ type TournamentProjectsResponse {
projects: [Project!]!
}
input UpdateProjectInput {
capabilities: [Int!]!
category_id: Int!
cover_image: ImageInput!
description: String!
discord: String
github: String
hashtag: String!
id: Int
launch_status: ProjectLaunchStatusEnum!
lightning_address: String
members: [TeamMemberInput!]!
recruit_roles: [Int!]!
screenshots: [ImageInput!]!
slack: String
tagline: String!
telegram: String
thumbnail_image: ImageInput!
title: String!
tournaments: [Int!]!
twitter: String
website: String!
}
input UpdateTournamentRegistrationInput {
email: String
hacking_status: TournamentMakerHackingStatusEnum
@@ -448,6 +559,7 @@ type User implements BaseUser {
linkedin: String
location: String
name: String!
projects: [Project!]!
role: String
roles: [MakerRole!]!
similar_makers: [User!]!

View File

@@ -114,6 +114,13 @@ const Story = objectType({
});
t.field('project', {
type: "Project",
resolve(parent) {
return prisma.story.findUnique({ where: { id: parent.id } }).project();
}
})
},
})
@@ -128,6 +135,7 @@ const StoryInputType = inputObjectType({
})
t.nonNull.list.nonNull.string('tags');
t.boolean('is_published')
t.int('project_id')
}
})
@@ -421,7 +429,7 @@ const createStory = extendType({
type: 'Story',
args: { data: StoryInputType },
async resolve(_root, args, ctx) {
const { id, title, body, cover_image, tags, is_published } = args.data;
const { id, title, body, project_id, cover_image, tags, is_published } = args.data;
const user = await getUserByPubKey(ctx.userPubKey);
// Do some validation
@@ -528,6 +536,13 @@ const createStory = extendType({
cover_image: '',
excerpt,
is_published: was_published || is_published,
project: project_id ? {
connect: {
id: project_id,
},
} : {
disconnect: true
},
tags: {
connectOrCreate:
tags.map(tag => {
@@ -573,6 +588,11 @@ const createStory = extendType({
}
})
},
project: {
connect: {
id: project_id,
}
},
user: {
connect: {
id: user.id,

View File

@@ -1,23 +1,34 @@
const { ApolloError } = require('apollo-server-lambda');
const {
intArg,
objectType,
stringArg,
extendType,
nonNull,
} = require('nexus')
enumType,
inputObjectType,
} = require('nexus');
const { getUserByPubKey } = require('../../../auth/utils/helperFuncs');
const { prisma } = require('../../../prisma');
const { deleteImage } = require('../../../services/imageUpload.service');
const { logError } = require('../../../utils/logger');
const { resolveImgObjectToUrl } = require('../../../utils/resolveImageUrl');
const { paginationArgs, getLnurlDetails, lightningAddressToLnurl } = require('./helpers');
const { ImageInput } = require('./misc');
const { Story } = require('./post');
const { MakerRole } = require('./users');
const Project = objectType({
name: 'Project',
definition(t) {
t.nonNull.int('id');
t.nonNull.string('title');
t.nonNull.string('tagline');
t.nonNull.string('website');
t.nonNull.string('description');
t.nonNull.string('hashtag');
t.nonNull.string('cover_image', {
async resolve(parent) {
return prisma.project.findUnique({ where: { id: parent.id } }).cover_image_rel().then(resolveImgObjectToUrl)
@@ -28,6 +39,14 @@ const Project = objectType({
return prisma.project.findUnique({ where: { id: parent.id } }).thumbnail_image_rel().then(resolveImgObjectToUrl)
}
});
t.nonNull.field('launch_status', {
type: ProjectLaunchStatusEnum
});
t.string('twitter');
t.string('discord');
t.string('github');
t.string('slack');
t.string('telegram');
t.nonNull.list.nonNull.string('screenshots', {
async resolve(parent) {
if (!parent.screenshots_ids) return null
@@ -42,7 +61,6 @@ const Project = objectType({
});
}
});
t.nonNull.string('website');
t.string('lightning_address');
t.string('lnurl_callback_url');
t.nonNull.int('votes_count');
@@ -68,6 +86,54 @@ const Project = objectType({
}
})
t.nonNull.list.nonNull.field('members', {
type: ProjectMember,
resolve: (parent) => {
return prisma.projectMember.findMany({
where: {
projectId: parent.id
},
include: {
user: true
}
})
}
})
t.nonNull.list.nonNull.field('tournaments', {
type: "Tournament",
resolve: (parent) => {
return prisma.tournamentProject.findMany({
where: { project_id: parent.id },
include: {
tournament: true
}
}).then(res => res.map(item => item.tournament))
}
})
t.nonNull.list.nonNull.field('stories', {
type: Story,
resolve: (parent) => {
return prisma.story.findMany({
where: {
project_id: parent.id,
},
orderBy: {
createdAt: "desc"
},
})
}
})
t.nonNull.list.nonNull.field('capabilities', {
type: Capability,
resolve: async (parent) => {
return prisma.project.findUnique({ where: { id: parent.id } }).capabilities()
}
})
t.nonNull.list.nonNull.field('recruit_roles', {
type: MakerRole,
resolve: async (parent) => {
@@ -89,6 +155,57 @@ const Project = objectType({
})
}
})
t.nonNull.list.nonNull.field('permissions', {
type: ProjectPermissionEnum,
resolve: async (parent, _, ctx) => {
const user = await getUserByPubKey(ctx.userPubKey)
if (!user) return [];
const role = (await prisma.projectMember.findUnique({ where: { projectId_userId: { projectId: parent.id, userId: user.id } } }))?.role;
if (!role) return [];
if (role === ROLE_ADMIN) return [PROJECT_PERMISSIONS.UpdateMembers, PROJECT_PERMISSIONS.UpdateInfo];
if (role === ROLE_OWNER) return Object.values(PROJECT_PERMISSIONS);
return []
}
})
}
})
const ROLE_OWNER = 'Owner'
const ROLE_ADMIN = 'Admin'
const ROLE_MAKER = 'Maker'
const TEAM_MEMBER_ROLE = enumType({
name: 'TEAM_MEMBER_ROLE',
members: [ROLE_OWNER, ROLE_ADMIN, ROLE_MAKER],
});
const PROJECT_PERMISSIONS = {
UpdateInfo: "UpdateInfo",
DeleteProject: "DeleteProject",
UpdateAdmins: "UpdateAdmins",
UpdateMembers: "UpdateMembers",
}
const ProjectPermissionEnum = enumType({
name: 'ProjectPermissionEnum',
members: PROJECT_PERMISSIONS,
});
const ProjectMember = objectType({
name: "ProjectMember",
definition(t) {
t.nonNull.field('user', {
type: "User"
})
t.nonNull.field("role", {
type: TEAM_MEMBER_ROLE
})
}
})
@@ -110,6 +227,59 @@ const Award = objectType({
})
const Capability = objectType({
name: 'Capability',
definition(t) {
t.nonNull.int('id');
t.nonNull.string('title');
t.nonNull.string('icon');
}
})
const checkValidProjectHashtag = extendType({
type: "Query",
definition(t) {
t.nonNull.boolean('checkValidProjectHashtag', {
args: {
hashtag: nonNull(stringArg()),
projectId: intArg(),
},
async resolve(parent, args, context) {
if (args.projectId) {
return !(await prisma.project.findFirst({
where: {
id: {
not: args.projectId,
},
hashtag: {
equals: args.hashtag
}
}
}))
}
return !(await prisma.project.findFirst({
where: {
hashtag: {
equals: args.hashtag
}
}
}))
}
})
}
})
const getAllCapabilities = extendType({
type: "Query",
definition(t) {
t.nonNull.list.nonNull.field('getAllCapabilities', {
type: Capability,
async resolve(parent, args, context) {
return prisma.capability.findMany();
}
})
}
})
const getProject = extendType({
@@ -118,9 +288,11 @@ const getProject = extendType({
t.nonNull.field('getProject', {
type: "Project",
args: {
id: nonNull(intArg())
id: intArg(),
tag: stringArg(),
},
resolve(_, { id }) {
resolve(_, { id, tag }) {
if (tag) return prisma.project.findFirst({ where: { hashtag: tag } })
return prisma.project.findUnique({
where: { id }
})
@@ -284,10 +456,667 @@ const getLnurlDetailsForProject = extendType({
}
})
const similarProjects = extendType({
type: "Query",
definition(t) {
t.nonNull.list.nonNull.field('similarProjects', {
type: "Project",
args: {
id: nonNull(intArg())
},
async resolve(parent, { id }, ctx) {
const currentProject = await prisma.project.findUnique({ where: { id }, select: { category_id: true } })
return prisma.project.findMany({
where: {
AND: {
id: {
not: id
},
category_id: {
equals: currentProject.category_id
}
}
},
take: 5,
})
}
})
}
})
const TeamMemberInput = inputObjectType({
name: 'TeamMemberInput',
definition(t) {
t.nonNull.int('id')
t.nonNull.field("role", {
type: TEAM_MEMBER_ROLE
})
}
})
const ProjectLaunchStatusEnum = enumType({
name: 'ProjectLaunchStatusEnum',
members: ['WIP', 'Launched'],
});
const CreateProjectInput = inputObjectType({
name: 'CreateProjectInput',
definition(t) {
t.int('id') // exists in update
t.nonNull.string('title');
t.nonNull.string('hashtag');
t.nonNull.string('website');
t.nonNull.string('tagline');
t.nonNull.string('description');
t.nonNull.field('thumbnail_image', {
type: ImageInput
})
t.nonNull.field('cover_image', {
type: ImageInput
})
t.string('twitter');
t.string('discord');
t.string('github');
t.string('slack');
t.string('telegram');
t.string('lightning_address');
t.nonNull.int('category_id');
t.nonNull.list.nonNull.int('capabilities'); // ids
t.nonNull.list.nonNull.field('screenshots', {
type: ImageInput
});
t.nonNull.list.nonNull.field('members', {
type: TeamMemberInput
});
t.nonNull.list.nonNull.int('recruit_roles'); // ids
t.nonNull.field('launch_status', {
type: ProjectLaunchStatusEnum
});
t.nonNull.list.nonNull.int('tournaments'); // ids
}
})
const CreateProjectResponse = objectType({
name: 'CreateProjectResponse',
definition(t) {
t.nonNull.field('project', { type: Project })
}
})
const createProject = extendType({
type: 'Mutation',
definition(t) {
t.field('createProject', {
type: CreateProjectResponse,
args: { input: CreateProjectInput },
async resolve(_root, args, ctx) {
let {
title,
tagline,
hashtag,
description,
lightning_address,
capabilities,
category_id,
cover_image,
discord,
github,
slack,
telegram,
twitter,
website,
launch_status,
members,
recruit_roles,
screenshots,
thumbnail_image,
tournaments,
} = args.input
const user = await getUserByPubKey(ctx.userPubKey)
// Do some validation
if (!user) throw new ApolloError('Not Authenticated')
// Many Owners found. Throw an error
if (members.filter((m) => m.role === ROLE_OWNER).length > 1) {
throw new ApolloError('Only 1 owner can be defined.')
}
// No owner found. Set the current user as Owner
if (!members.find((m) => m.role === ROLE_OWNER)) {
const currentUser = members.find((m) => m.id === user.id)
if (currentUser) {
currentUser.role = ROLE_OWNER
} else {
members = [{ id: user.id, role: ROLE_OWNER }, ...members]
}
}
const coverImage = await prisma.hostedImage.findFirst({
where: {
provider_image_id: cover_image.id,
},
})
const coverImageRel = coverImage
? {
cover_image_rel: {
connect: {
id: coverImage ? coverImage.id : null,
},
},
}
: {}
const thumbnailImage = await prisma.hostedImage.findFirst({
where: {
provider_image_id: thumbnail_image.id,
},
})
const thumbnailImageRel = thumbnailImage
? {
thumbnail_image_rel: {
connect: {
id: thumbnailImage ? thumbnailImage.id : null,
},
},
}
: {}
const screenshots_ids = await prisma.hostedImage.findMany({
where: {
provider_image_id: {
in: screenshots.map((s) => s.id),
},
},
select: {
id: true,
},
})
const project = await prisma.project.create({
data: {
title,
description,
lightning_address,
tagline,
hashtag,
website,
discord,
github,
twitter,
slack,
telegram,
launch_status,
...coverImageRel,
...thumbnailImageRel,
screenshots_ids: screenshots_ids.map((s) => s.id),
category: {
connect: {
id: category_id,
},
},
members: {
create: members.map((member) => {
return {
role: member.role,
user: {
connect: {
id: member.id,
},
},
}
}),
},
recruit_roles: {
create: recruit_roles.map((role) => {
return {
level: 0,
role: {
connect: {
id: role,
},
},
}
}),
},
tournaments: {
create: tournaments.map((tournament) => {
return {
tournament: {
connect: {
id: tournament,
},
},
}
}),
},
capabilities: {
connect: capabilities.map((c) => {
return {
id: c,
}
}),
},
},
})
await prisma.hostedImage
.updateMany({
where: {
id: {
in: [coverImage.id, thumbnailImage.id, ...screenshots_ids.map((s) => s.id)],
},
},
data: {
is_used: true,
},
})
.catch((error) => {
logError(error)
throw new ApolloError('Unexpected error happened...')
})
return { project }
},
})
},
})
const UpdateProjectInput = inputObjectType({
name: 'UpdateProjectInput',
definition(t) {
t.int('id')
t.nonNull.string('title')
t.nonNull.string('hashtag')
t.nonNull.string('website')
t.nonNull.string('tagline')
t.nonNull.string('description')
t.nonNull.field('thumbnail_image', {
type: ImageInput,
})
t.nonNull.field('cover_image', {
type: ImageInput,
})
t.string('twitter')
t.string('discord')
t.string('github')
t.string('slack')
t.string('telegram')
t.string('lightning_address');
t.nonNull.int('category_id')
t.nonNull.list.nonNull.int('capabilities')
t.nonNull.list.nonNull.field('screenshots', {
type: ImageInput,
})
t.nonNull.list.nonNull.field('members', {
type: TeamMemberInput,
})
t.nonNull.list.nonNull.int('recruit_roles') // ids
t.nonNull.field('launch_status', {
type: ProjectLaunchStatusEnum,
})
t.nonNull.list.nonNull.int('tournaments') // ids
},
})
const updateProject = extendType({
type: 'Mutation',
definition(t) {
t.field('updateProject', {
type: CreateProjectResponse,
args: { input: UpdateProjectInput },
async resolve(_root, args, ctx) {
const {
id,
title,
tagline,
hashtag,
description,
lightning_address,
capabilities,
category_id,
cover_image,
discord,
github,
slack,
telegram,
twitter,
website,
launch_status,
members,
recruit_roles,
screenshots,
thumbnail_image,
tournaments,
} = args.input
const user = await getUserByPubKey(ctx.userPubKey)
// Do some validation
if (!user) throw new ApolloError('Not Authenticated')
const project = await prisma.project.findFirst({
where: {
id,
},
include: {
members: true,
},
})
// Verifying current user is a member
if (!project.members.some((m) => m.userId === user.id)) {
throw new ApolloError("You don't have permission to update this project")
}
// Maker can't change project info
if (project.members.find((m) => m.userId === user.id)?.role === ROLE_MAKER) {
throw new ApolloError("Makers can't change project info")
}
let newMembers = []
// Admin can only change makers
if (project.members.find((m) => m.userId === user.id)?.role === ROLE_ADMIN) {
// Changing Makers
const newMakers = members.filter((m) => m.role === ROLE_MAKER)
// Set old Admins and Owner using current project.memebers because Admin can't change these Roles
const currentAdminsOwner = project.members
.filter((m) => m.role === ROLE_ADMIN || m.role === ROLE_OWNER)
.map((m) => ({ id: m.userId, role: m.role }))
newMembers = [...newMakers, ...currentAdminsOwner]
} else {
// Curent user is Owner. Can change all users roles
newMembers = members
}
let imagesToDelete = []
let imagesToAdd = []
let coverImageRel = {}
if (cover_image.id) {
const coverImage = await prisma.hostedImage.findFirst({
where: {
provider_image_id: cover_image.id,
},
})
coverImageRel = coverImage
? {
cover_image_rel: {
connect: {
id: coverImage ? coverImage.id : null,
},
},
}
: {}
if (coverImage) {
imagesToAdd.push(coverImage.id)
}
imagesToDelete.push(project.cover_image_id)
}
let thumbnailImageRel = {}
if (thumbnail_image.id) {
const thumbnailImage = await prisma.hostedImage.findFirst({
where: {
provider_image_id: thumbnail_image.id,
},
})
thumbnailImageRel = thumbnailImage
? {
thumbnail_image_rel: {
connect: {
id: thumbnailImage ? thumbnailImage.id : null,
},
},
}
: {}
if (thumbnailImage) {
imagesToAdd.push(thumbnailImage.id)
}
imagesToDelete.push(project.thumbnail_image_id)
}
let screenshots_ids = []
for (const screenshot of screenshots) {
if (screenshot.id) {
const newScreenshot = await prisma.hostedImage.findFirst({
where: {
provider_image_id: screenshot.id,
},
select: {
id: true,
},
})
if (newScreenshot) {
screenshots_ids.push(newScreenshot.id)
imagesToAdd.push(newScreenshot.id)
}
} else {
const newScreenshot = await prisma.hostedImage.findFirst({
where: {
url: screenshot.url,
},
select: {
id: true,
},
})
if (newScreenshot) {
screenshots_ids.push(newScreenshot.id)
}
}
}
const screenshotsIdsToDelete = project.screenshots_ids.filter((x) => !screenshots_ids.includes(x))
imagesToDelete = [...imagesToDelete, ...screenshotsIdsToDelete]
const updatedProject = await prisma.project
.update({
where: {
id,
},
data: {
title,
description,
lightning_address,
tagline,
hashtag,
website,
discord,
github,
twitter,
slack,
telegram,
launch_status,
...coverImageRel,
...thumbnailImageRel,
screenshots_ids,
category: {
connect: {
id: category_id,
},
},
members: {
deleteMany: {},
create: newMembers.map((member) => {
return {
role: member.role,
user: {
connect: {
id: member.id,
},
},
}
}),
},
recruit_roles: {
deleteMany: {},
create: recruit_roles.map((role) => {
return {
level: 0,
role: {
connect: {
id: role,
},
},
}
}),
},
tournaments: {
deleteMany: {},
create: tournaments.map((tournament) => {
return {
tournament: {
connect: {
id: tournament,
},
},
}
}),
},
capabilities: {
set: capabilities.map((c) => {
return {
id: c,
}
}),
},
},
})
.catch((error) => {
logError(error)
throw new ApolloError('Unexpected error happened...')
})
if (imagesToAdd.length > 0) {
await prisma.hostedImage
.updateMany({
where: {
id: {
in: imagesToAdd,
},
},
data: {
is_used: true,
},
})
.catch((error) => {
logError(error)
throw new ApolloError('Unexpected error happened...')
})
}
imagesToDelete.map(async (i) => await deleteImage(i))
return { project: updatedProject }
},
})
},
})
const deleteProject = extendType({
type: 'Mutation',
definition(t) {
t.field('deleteProject', {
type: 'Project',
args: { id: nonNull(intArg()) },
async resolve(_root, args, ctx) {
const { id } = args
const user = await getUserByPubKey(ctx.userPubKey)
// Do some validation
if (!user) throw new ApolloError('Not Authenticated')
const project = await prisma.project.findFirst({
where: { id },
include: {
members: true,
},
})
if (!project) throw new ApolloError('Project not found')
if (project.members.find((m) => m.userId === user.id)?.role !== ROLE_OWNER)
throw new ApolloError("You don't have the right to delete this project")
// Award is not implemented yet
// await prisma.award.deleteMany({
// where: {
// projectId: project.id
// }
// })
await prisma.projectRecruitRoles.deleteMany({
where: {
projectId: project.id,
},
})
await prisma.projectMember.deleteMany({
where: {
projectId: project.id,
},
})
await prisma.tournamentProject.deleteMany({
where: {
project_id: project.id,
},
})
const deletedProject = await prisma.project.delete({
where: {
id,
},
})
const imagesToDelete = await prisma.hostedImage.findMany({
where: {
OR: [
{ id: project.cover_image_id },
{ id: project.thumbnail_image_id },
{
id: {
in: project.screenshots_ids,
},
},
],
},
select: {
id: true,
},
})
imagesToDelete.map(async (i) => await deleteImage(i.id))
return deletedProject
},
})
},
})
module.exports = {
// Types
Project,
Award,
TEAM_MEMBER_ROLE,
// Queries
getProject,
allProjects,
@@ -295,5 +1124,13 @@ module.exports = {
hottestProjects,
searchProjects,
projectsByCategory,
getLnurlDetailsForProject
getLnurlDetailsForProject,
getAllCapabilities,
checkValidProjectHashtag,
similarProjects,
// Mutations
createProject,
updateProject,
deleteProject,
}

View File

@@ -76,8 +76,6 @@ const TournamentMakerHackingStatusEnum = enumType({
},
});
const TournamentEvent = objectType({
name: 'TournamentEvent',
definition(t) {
@@ -214,6 +212,28 @@ const getTournamentById = extendType({
}
})
const getTournamentToRegister = extendType({
type: "Query",
definition(t) {
t.nonNull.list.nonNull.field('getTournamentToRegister', {
type: Tournament,
args: {
},
resolve() {
return prisma.tournament.findMany({
where: {
end_date: {
gt: new Date()
},
}
})
}
})
}
})
const ParticipationInfo = objectType({
name: "ParticipationInfo",
definition(t) {
@@ -538,6 +558,7 @@ module.exports = {
getMakersInTournament,
getProjectsInTournament,
tournamentParticipationInfo,
getTournamentToRegister,
// Mutations
registerInTournament,

View File

@@ -1,6 +1,6 @@
const { prisma } = require('../../../prisma');
const { objectType, extendType, intArg, nonNull, inputObjectType, interfaceType, list, enumType } = require("nexus");
const { objectType, extendType, intArg, nonNull, inputObjectType, stringArg, interfaceType, list, enumType } = require("nexus");
const { getUserByPubKey } = require("../../../auth/utils/helperFuncs");
const { removeNulls } = require("./helpers");
const { ImageInput } = require('./misc');
@@ -72,6 +72,19 @@ const BaseUser = interfaceType({
}).then(d => d.map(item => item.tournament))
}
})
t.nonNull.list.nonNull.field('projects', {
type: "Project",
resolve: async (parent) => {
return prisma.projectMember.findMany({
where: {
userId: parent.id
},
include: {
project: true
}
}).then(d => d.map(item => item.project))
}
})
t.nonNull.list.nonNull.field('similar_makers', {
type: "User",
resolve(parent,) {
@@ -245,6 +258,29 @@ const profile = extendType({
}
})
const searchUsers = extendType({
type: "Query",
definition(t) {
t.nonNull.list.nonNull.field('searchUsers', {
type: "User",
args: {
value: nonNull(stringArg())
},
async resolve(_, { value }) {
return prisma.user.findMany({
where: {
name: {
contains: value,
mode: "insensitive"
}
},
})
}
})
}
})
const similarMakers = extendType({
type: "Query",
definition(t) {
@@ -530,6 +566,7 @@ module.exports = {
// Queries
me,
profile,
searchUsers,
similarMakers,
getAllMakersRoles,
getAllMakersSkills,

View File

@@ -1,15 +1,19 @@
const { PrismaClient } = require('@prisma/client/edge');
const { PrismaClient } = process.env.PRISMA_GENERATE_DATAPROXY ? require('@prisma/client/edge') : require('@prisma/client');
const createGlobalModule = require('../utils/createGlobalModule');
const createPrismaClient = () => {
console.log("New Prisma Client");
return new PrismaClient({
log: ["info"],
});
try {
return new PrismaClient();
} catch (error) {
console.log(error);
}
}
const prisma = createGlobalModule('prisma', createPrismaClient)
module.exports = {
prisma
}

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "contact_email" TEXT;

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Story" ADD COLUMN "project_id" INTEGER;
-- AddForeignKey
ALTER TABLE "Story" ADD CONSTRAINT "Story_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -137,6 +137,7 @@ model Project {
github String?
telegram String?
slack String?
contact_email String?
thumbnail_image String?
thumbnail_image_id Int? @unique
thumbnail_image_rel HostedImage? @relation("Project_Thumbnail", fields: [thumbnail_image_id], references: [id])
@@ -161,6 +162,7 @@ model Project {
members ProjectMember[]
recruit_roles ProjectRecruitRoles[]
tournaments TournamentProject[]
stories Story[]
}
model ProjectRecruitRoles {
@@ -221,6 +223,9 @@ model Story {
user_id Int?
comments PostComment[] @relation("StoryComment")
project Project? @relation(fields: [project_id], references: [id])
project_id Int?
}
model Question {

View File

@@ -69,7 +69,9 @@ async function main() {
// await migrateOldImages();
await createCapabilities();
// await createCapabilities();
// await createHashtags();
}
async function migrateOldImages() {
@@ -238,7 +240,7 @@ async function migrateOldImages() {
/**
* Tournament
**/
const tournaments = await prisma.tournament.findMany({
const tournaments = await prisma.tournament.findMany({
select: {
id: true,
thumbnail_image: true,
@@ -263,7 +265,7 @@ async function migrateOldImages() {
/**
* TournamentPrize
**/
const tournamentPrizes = await prisma.tournamentPrize.findMany({
const tournamentPrizes = await prisma.tournamentPrize.findMany({
select: {
id: true,
image: true,
@@ -508,6 +510,24 @@ async function createCapabilities() {
})
}
async function createHashtags() {
console.log("Creating Hashtags for projects");
const projects = await prisma.project.findMany({ select: { title: true, id: true } });
for (let i = 0; i < projects.length; i++) {
const project = projects[i];
await prisma.project.update({
where: { id: project.id },
data: {
hashtag: project.title.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '_')
.replace(/^-+|-+$/g, '')
}
})
}
}
main()
.catch((e) => {

View File

@@ -11,7 +11,7 @@ import ProtectedRoute from "./Components/ProtectedRoute/ProtectedRoute";
import { Helmet } from "react-helmet";
import { NavbarLayout } from "./utils/routing/layouts";
import { Loadable, PAGES_ROUTES } from "./utils/routing";
import ListProjectPage from "./features/Projects/pages/ListProjectPage/ListProjectPage";
// Pages
@@ -22,6 +22,7 @@ const CreatePostPage = Loadable(React.lazy(() => import( /* webpackChunkName: "
const HottestPage = Loadable(React.lazy(() => import( /* webpackChunkName: "hottest_page" */ "src/features/Projects/pages/HottestPage/HottestPage")))
const CategoryPage = Loadable(React.lazy(() => import( /* webpackChunkName: "category_page" */ "src/features/Projects/pages/CategoryPage/CategoryPage")))
const ExplorePage = Loadable(React.lazy(() => import( /* webpackChunkName: "explore_page" */ "src/features/Projects/pages/ExplorePage")))
const ProjectPage = Loadable(React.lazy(() => import( /* webpackChunkName: "explore_page" */ "src/features/Projects/pages/ProjectPage/ProjectPage")))
const HackathonsPage = Loadable(React.lazy(() => import( /* webpackChunkName: "hackathons_page" */ "./features/Hackathons/pages/HackathonsPage/HackathonsPage")))
@@ -98,6 +99,9 @@ function App() {
<Route path={PAGES_ROUTES.projects.hottest} element={<HottestPage />} />
<Route path={PAGES_ROUTES.projects.byCategoryId} element={<CategoryPage />} />
<Route path={PAGES_ROUTES.projects.default} element={<ExplorePage />} />
<Route path={PAGES_ROUTES.projects.listProject} element={<ListProjectPage />} />
<Route path={PAGES_ROUTES.projects.projectPage} element={<ProjectPage />} />
<Route path={PAGES_ROUTES.projects.catchProject} element={<Navigate replace to={PAGES_ROUTES.projects.default} />} />
<Route path={PAGES_ROUTES.blog.storyById} element={<PostDetailsPage postType='story' />} />
<Route path={PAGES_ROUTES.blog.feed} element={<FeedPage />} />

View File

@@ -23,7 +23,7 @@ const badgrColor: UnionToObjectKeys<Props, 'color'> = {
}
const badgeSize: UnionToObjectKeys<Props, 'size'> = {
sm: "px-8 py-4 text-body6",
sm: "px-12 py-4 text-body5",
md: "px-16 py-8 text-body4",
lg: "px-24 py-12 text-body3"
}

View File

@@ -34,10 +34,10 @@ export default function CoverImageInput(props: Props) {
wrapperClass='h-full'
render={({ img, isUploading, isDraggingOnWindow }) =>
<div className="w-full h-full group relative ">
{!img && <div className='w-full h-full flex flex-col justify-center items-center bg-gray-500 outline outline-2 outline-gray-200'>
<p className="text-center text-gray-100 text-body1 md:text-h1 mb-8"><FaImage /></p>
<div className={`text-gray-100 text-center text-body4`}>
Drop a <span className="font-bold">COVER IMAGE</span> here or <br /> <span className="text-blue-300 underline">Click to browse</span>
{!img && <div className={`w-full h-full flex flex-col justify-center items-center bg-gray-100 border-dashed border-2 border-gray-200 ${props.rounded ?? 'rounded-12'}`}>
<p className="text-center text-gray-800 text-body1 md:text-h1 mb-8"><FaImage /></p>
<div className={`text-gray-700 text-center text-body4`}>
Drop a <span className="font-bold">COVER IMAGE</span> here or <br /> <span className="text-blue-400 underline">Click to browse</span>
</div>
</div>}
{img && <>
@@ -45,10 +45,10 @@ export default function CoverImageInput(props: Props) {
{!isUploading &&
<div className="flex flex-wrap gap-16 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 ">
<button type='button' className='py-8 px-16 rounded-12 bg-black bg-opacity-70 opacity-0 group-hover:opacity-100 hover:bg-opacity-90 transition-opacity text-white text-h1'>
<button type='button' className='w-42 h-42 flex justify-center items-center rounded-full bg-gray-800 bg-opacity-60 opacity-0 group-hover:opacity-100 hover:bg-opacity-90 transition-opacity text-white text-body3'>
<CgArrowsExchangeV />
</button>
<button type='button' className='py-8 px-16 rounded-12 bg-black bg-opacity-70 opacity-0 group-hover:opacity-100 hover:bg-opacity-90 transition-opacity text-white text-h1' onClick={(e) => { e.stopPropagation(); props.onChange(null) }}>
<button type='button' className='w-42 h-42 flex justify-center items-center rounded-full bg-gray-800 bg-opacity-60 opacity-0 group-hover:opacity-100 hover:bg-opacity-90 transition-opacity text-white text-body3' onClick={(e) => { e.stopPropagation(); props.onChange(null) }}>
<IoMdClose />
</button>
</div>
@@ -56,7 +56,7 @@ export default function CoverImageInput(props: Props) {
</>}
{isUploading &&
<div
className="absolute inset-0 bg-gray-400 bg-opacity-60 flex flex-col justify-center items-center text-white font-bold transition-transform"
className={`absolute inset-0 bg-gray-400 ${props.rounded ?? 'rounded-12'} bg-opacity-60 flex flex-col justify-center items-center text-white font-bold transition-transform`}
>
<RotatingLines
strokeColor="#fff"

View File

@@ -1,5 +1,5 @@
import UploadPreview, { PreviewComponentProps, PreviewMethods } from '@rpldy/upload-preview'
import { useAbortItem, useItemAbortListener, useItemCancelListener, useItemErrorListener, useItemProgressListener } from '@rpldy/uploady';
import { useAbortItem, useItemAbortListener, useItemCancelListener, useItemErrorListener, useItemFinishListener, useItemProgressListener } from '@rpldy/uploady';
import { useState } from 'react'
import ScreenShotsThumbnail from './ScreenshotThumbnail'
@@ -20,15 +20,11 @@ function CustomImagePreview({ id, url }: PreviewComponentProps) {
useItemProgressListener(item => {
if (item.completed > progress) {
setProgress(() => item.completed);
if (item.completed === 100) {
setItemState(STATES.DONE)
} else {
setItemState(STATES.PROGRESS)
}
}
}, id);
useItemFinishListener(() => setItemState(STATES.DONE), id)
useItemAbortListener(item => {
@@ -41,7 +37,10 @@ function CustomImagePreview({ id, url }: PreviewComponentProps) {
}, id);
useItemErrorListener(item => {
console.log(item);
setItemState(STATES.ERROR);
setTimeout(() => setItemState(STATES.CANCELLED), 2000)
}, id);
if (itemState === STATES.DONE || itemState === STATES.CANCELLED)

View File

@@ -45,7 +45,7 @@ export default function ScreenshotThumbnail({ url, isLoading, isError, onCancel
Failed...
</div>}
{!isEmpty &&
<button className="absolute bg-gray-900 hover:bg-opacity-100 bg-opacity-60 text-white rounded-full w-32 h-32 top-8 right-8" onClick={() => onCancel?.()}><FaTimes /></button>
<button type='button' className="absolute bg-gray-900 hover:bg-opacity-100 bg-opacity-60 text-white rounded-full w-32 h-32 top-8 right-8 flex flex-col justify-center items-center" onClick={() => onCancel?.()}><FaTimes /></button>
}
</div>
)

View File

@@ -1,13 +1,14 @@
import React from 'react'
import { ComponentStory, ComponentMeta } from '@storybook/react';
import ScreenshotsInput, { ScreenshotType } from './ScreenshotsInput';
import ScreenshotsInput from './ScreenshotsInput';
import { WrapForm, WrapFormController } from 'src/utils/storybook/decorators';
import { ImageInput } from 'src/graphql';
export default {
title: 'Shared/Inputs/Files Inputs/Screenshots',
component: ScreenshotsInput,
decorators: [
WrapFormController<{ screenshots: Array<ScreenshotType> }>({
WrapFormController<{ screenshots: Array<ImageInput> }>({
logValues: true,
name: "screenshots",
defaultValues: {
@@ -29,7 +30,7 @@ Empty.args = {
export const WithValues = Template.bind({});
WithValues.decorators = [
WrapFormController<{ screenshots: Array<ScreenshotType> }>({
WrapFormController<{ screenshots: Array<ImageInput> }>({
logValues: true,
name: "screenshots",
defaultValues: {
@@ -51,7 +52,7 @@ WithValues.args = {
export const Full = Template.bind({});
Full.decorators = [
WrapFormController<{ screenshots: Array<ScreenshotType> }>({
WrapFormController<{ screenshots: Array<ImageInput> }>({
logValues: true,
name: "screenshots",
defaultValues: {

View File

@@ -11,6 +11,9 @@ import { getMockSenderEnhancer } from "@rpldy/mock-sender";
import ScreenshotThumbnail from "./ScreenshotThumbnail";
import { FiCamera } from "react-icons/fi";
import { Control, Path, useController } from "react-hook-form";
import { ImageInput } from "src/graphql";
import { fetchUploadImageUrl } from "src/api/uploading";
import { removeArrayItemAtIndex } from "src/utils/helperFunctions";
@@ -20,15 +23,14 @@ const mockSenderEnhancer = getMockSenderEnhancer({
const MAX_UPLOAD_COUNT = 4 as const;
export interface ScreenshotType {
id: string,
name: string,
url: string;
interface Image extends ImageInput {
local_id?: string
}
interface Props {
value: ScreenshotType[],
onChange: (new_value: ScreenshotType[]) => void
value: Image[],
onChange: (new_value: Image[]) => void
}
@@ -46,10 +48,10 @@ export default function ScreenshotsInput(props: Props) {
return (
<Uploady
accept="image/*"
multiple={true}
inputFieldName='file'
grouped={false}
enhancer={mockSenderEnhancer}
listeners={{
[UPLOADER_EVENTS.BATCH_ADD]: (batch) => {
setUploadingCount(v => v + batch.items.length)
@@ -57,31 +59,22 @@ export default function ScreenshotsInput(props: Props) {
[UPLOADER_EVENTS.ITEM_FINALIZE]: () => setUploadingCount(v => v - 1),
[UPLOADER_EVENTS.ITEM_FINISH]: (item) => {
// Just for mocking purposes
const dataUrl = URL.createObjectURL(item.file);
const { id, filename, variants } = item?.uploadResponse?.data?.result ?? {
id: Math.random().toString(),
filename: item.file.name,
variants: [
"",
dataUrl
]
}
if (id) {
onChange([...uploadedFiles, { id, name: filename, url: variants[1] }].slice(-MAX_UPLOAD_COUNT))
const { id, filename, variants } = item?.uploadResponse?.data?.result;
const url = (variants as string[]).find(v => v.includes('public'));
if (id && url) {
onChange([...uploadedFiles, { id, local_id: id, name: filename, url: url }].slice(-MAX_UPLOAD_COUNT))
}
}
}}
>
<div className="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-16 mt-24">
{canUploadMore && <DropZoneButton />}
{uploadedFiles.map(f => <ScreenshotThumbnail
key={f.id}
<DropZoneButton extraProps={{ canUploadMore }} />
{uploadedFiles.map((f, idx) => <ScreenshotThumbnail
key={f.local_id}
url={f.url}
onCancel={() => {
onChange(uploadedFiles.filter(file => file.id !== f.id))
onChange(removeArrayItemAtIndex(uploadedFiles, idx))
}} />)}
<ImagePreviews />
{(placeholdersCount > 0) &&
@@ -92,21 +85,22 @@ export default function ScreenshotsInput(props: Props) {
}
const DropZone = forwardRef<any, any>((props, ref) => {
const { onClick, ...buttonProps } = props;
const { canUploadMore, onClick, ...buttonProps } = props;
useRequestPreSend(async (data) => {
const filename = data.items?.[0].file.name ?? ''
// const url = await fetchUploadUrl({ filename });
const res = await fetchUploadImageUrl({ filename });
return {
options: {
destination: {
url: "URL"
}
url: res.uploadURL
},
}
}
})
const onZoneClick = useCallback(
@@ -118,6 +112,8 @@ const DropZone = forwardRef<any, any>((props, ref) => {
[onClick]
);
if (!canUploadMore) return null
return <UploadDropZone
{...buttonProps}
ref={ref}

View File

@@ -2,7 +2,7 @@
import { useController } from "react-hook-form";
// import CreatableSelect from 'react-select/creatable';
import Select from 'react-select'
import { OnChangeValue, StylesConfig, components, OptionProps } from "react-select";
import { OnChangeValue, StylesConfig, components, OptionProps, } from "react-select";
import { OfficialTagsQuery, useOfficialTagsQuery } from "src/graphql";
import React from "react";
@@ -52,8 +52,9 @@ export default function TagsInput({
const maxReached = value.length >= max;
const currentPlaceholder = props.placeholder ?? <div className="flex gap-8 items-center text-gray-500">
{maxReached ? '' : value.length > 0 ? "Add Another..." : placeholder} </div>
const currentPlaceholder = maxReached ? '' : value.length > 0 ? "Add Another..." : placeholder;
const tagsOptions = !maxReached ? (officalTags.data?.officialTags ?? []).filter(t => !value.some((v) => v.title === t.title)).map(transformer.tagToOption) : [];

View File

@@ -0,0 +1,32 @@
import React from "react";
type Props = {
isError?: boolean;
className?: string;
inputClass?: string;
renderBefore?: () => JSX.Element
renderAfter?: () => JSX.Element
} & React.ComponentPropsWithoutRef<'input'>
export default React.forwardRef<HTMLInputElement, Props>(function TextInput({ className, inputClass, isError, renderBefore, renderAfter, ...props }, ref) {
return (
<div className={`
relative w-full border bg-white rounded-12 flex
focus-within:ring focus-within:ring-opacity-50
${isError ?
"border-red-300 focus-within:border-red-300 focus-within:outline-red-400 focus-within:ring-red-200"
:
"border-gray-300 focus-within:border-primary-300 focus-within:outline-primary-400 focus-within:ring-primary-200"}
${className}`}>
{renderBefore?.()}
<input
type='text'
className={`input-text ${inputClass}`}
ref={ref}
{...props}
/>
{renderAfter?.()}
</div>
)
})

View File

@@ -0,0 +1,27 @@
import React from "react";
type Props = {
isError?: boolean;
className?: string;
inputClass?: string
} & React.ComponentPropsWithoutRef<'textarea'>
export default React.forwardRef<HTMLTextAreaElement, Props>(function TextareaInput({ className, inputClass, isError, ...props }, ref) {
return (
<div className={`
relative w-full border bg-white rounded-12 flex
focus-within:ring focus-within:ring-opacity-50
${isError ?
"border-red-300 focus-within:border-red-300 focus-within:outline-red-400 focus-within:ring-red-200"
:
"border-gray-300 focus-within:border-primary-300 focus-within:outline-primary-400 focus-within:ring-primary-200"}
${className}`}>
<textarea
className={`input-text ${inputClass}`}
ref={ref}
{...props}
/>
</div>
)
})

View File

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

View File

@@ -0,0 +1,164 @@
import AsyncSelect from 'react-select/async';
import { OnChangeValue, StylesConfig, components, OptionProps } from "react-select";
import { SearchUsersDocument, SearchUsersQuery, SearchUsersQueryResult } from "src/graphql";
import { apolloClient } from "src/utils/apollo";
import Avatar from "src/features/Profiles/Components/Avatar/Avatar";
import { FiSearch } from 'react-icons/fi';
import { useState } from 'react';
import debounce from 'lodash.debounce';
type User = SearchUsersQuery['searchUsers'][number]
interface Props {
classes?: {
container?: string
input?: string
}
placeholder?: string,
onSelect?: (selectedUser: User) => void
}
const fetchOptions = debounce((value, callback: any) => {
apolloClient.query({
query: SearchUsersDocument,
variables: {
value
}
})
.then((result) => callback((result as SearchUsersQueryResult).data?.searchUsers ?? []))
.catch((error: any) => callback(error, null));
}, 1000);
const OptionComponent = (props: OptionProps<User>) => {
return (
<div>
<components.Option {...props} className='!flex items-center gap-16 !py-16'>
<Avatar src={props.data.avatar} width={48} />
<div>
<p className="font-medium self-center">
{props.data.name}
</p>
<p className="text-body5 text-gray-500">
{props.data.jobTitle}
</p>
</div>
</components.Option>
</div>
);
};
const colourStyles: StylesConfig = {
control: (styles, state) => ({
...styles,
padding: '5px 16px',
borderRadius: 12,
// border: 'none',
// boxShadow: 'none',
":hover": {
cursor: "pointer"
},
":focus-within": {
'--tw-border-opacity': '1',
borderColor: 'rgb(179 160 255 / var(--tw-border-opacity))',
outlineColor: '#9E88FF',
'--tw-ring-offset-shadow': 'var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)',
'--tw-ring-shadow': 'var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)',
boxShadow: 'var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)',
'--tw-ring-color': 'rgb(179 160 255 / var(--tw-ring-opacity))',
'--tw-ring-opacity': '0.5'
}
}),
multiValueRemove: (styles) => ({
...styles,
":hover": {
background: 'none'
}
}),
indicatorsContainer: () => ({ display: 'none' }),
clearIndicator: () => ({ display: 'none' }),
indicatorSeparator: () => ({ display: "none" }),
input: (styles, state) => ({
...styles,
" input": {
boxShadow: 'none !important'
},
}),
multiValue: styles => ({
...styles,
padding: '4px 12px',
borderRadius: 48,
fontWeight: 500
}),
valueContainer: (styles) => ({
...styles,
paddingLeft: 0,
paddingRight: 0,
})
}
export default function UsersInput({
classes,
...props }: Props) {
const [inputValue, setInputValue] = useState("")
const placeholder = props.placeholder ?? <span className='text-gray-400'><FiSearch /> <span className='align-middle'>Search by username</span></span>
const handleChange = (newValue: OnChangeValue<User, false>,) => {
if (newValue)
props.onSelect?.(newValue);
}
let emptyMessage = "Type at least 2 characters";
if (inputValue.length >= 2)
emptyMessage = "Couldn't find any users..."
let loadingMessage = "Searching...";
if (inputValue.length < 2)
loadingMessage = "Type at least 2 characters"
return (
<div className={`${classes?.container}`}>
<AsyncSelect
value={null}
inputValue={inputValue}
onInputChange={setInputValue}
defaultOptions={false}
loadOptions={fetchOptions}
loadingMessage={() => loadingMessage}
placeholder={placeholder}
noOptionsMessage={() => emptyMessage}
onChange={handleChange as any}
components={{
Option: OptionComponent,
// ValueContainer: CustomValueContainer
}}
styles={colourStyles as any}
theme={(theme) => ({
...theme,
borderRadius: 8,
colors: {
...theme.colors,
primary: 'var(--primary)',
},
})}
/>
{/* <div className="flex mt-16 gap-8 flex-wrap">
{(value as Tag[]).map((tag, idx) => <Badge color="gray" key={tag.title} onRemove={() => handleRemove(idx)} >{tag.title}</Badge>)}
</div> */}
</div>
)
}

View File

@@ -0,0 +1,8 @@
query SearchUsers($value: String!) {
searchUsers(value: $value) {
id
name
avatar
jobTitle
}
}

View File

@@ -8,7 +8,7 @@ interface Props {
export default function HeaderSkeleton({ size = 'md', }: Props) {
return (
<div className='flex gap-8'>
<div className='flex gap-8 items-center'>
<Skeleton circle width={size === 'md' ? 40 : 32} height={size === 'md' ? 40 : 32} />
<div>
<p className={`${size === 'md' ? 'text-body4' : "text-body5"} text-black font-medium`}>

View File

@@ -1,14 +1,20 @@
import Skeleton from "react-loading-skeleton"
import HeaderSkeleton from "../Header/Header.Skeleton"
import Badge from 'src/Components/Badge/Badge'
import Card from "src/Components/Card/Card"
export default function PostCardSkeleton() {
return <div className="bg-white rounded-12 overflow-hidden border">
<div className="relative h-[200px]">
<Skeleton height='100%' className='!leading-inherit' />
return <div>
<div className="flex gap-8 items-center mb-8">
<Skeleton circle width={32} height={32} />
<span className='flex gap-4 mt-4'>
<p className="text-gray-900 text-body5 font-medium"><Skeleton width="12ch" /></p>
</span>
</div>
<div className="p-24">
<HeaderSkeleton />
<Card>
<div className="relative h-[200px]">
<Skeleton height='100%' className='!leading-inherit rounded-8' />
</div>
<h2 className="text-h4 font-bolder mt-16">
<Skeleton width={'70%'} />
</h2>
@@ -24,6 +30,8 @@ export default function PostCardSkeleton() {
<span className="align-middle text-body5"><Skeleton width={'10ch'} /></span>
</div>
</div>
</div>
</Card>
</div>
}

View File

@@ -0,0 +1,54 @@
import Avatar from 'src/features/Profiles/Components/Avatar/Avatar';
import dayjs from 'dayjs'
import { UnionToObjectKeys } from 'src/utils/types/utils';
import { trimText } from 'src/utils/helperFunctions';
import { Link } from 'react-router-dom';
import { createRoute } from 'src/utils/routing';
import { Project, User } from 'src/graphql';
interface Props {
author?: Pick<User, 'id' | 'name' | 'avatar'>
project?: Pick<Project, 'id' | 'title' | "thumbnail_image" | 'hashtag'> | null
date: string;
}
export default function PostCardHeader(props: Props) {
const dateToShow = () => {
const passedTimeHrs = dayjs().diff(props.date, 'hour');
const passedTimesDays = Math.ceil(passedTimeHrs / 24);
if (passedTimeHrs === 0) return 'now';
if (passedTimeHrs < 24) return `${dayjs().diff(props.date, 'hour')}h ago`
if (passedTimesDays < 29) return `${passedTimesDays} days`
return dayjs(props.date).format('DD MMM');
}
if (!props.author) return null
return (
<div className="flex gap-8 items-center mb-8">
<span className='flex'>
<Link to={createRoute({ type: 'profile', id: props.author.id, username: props.author.name })}>
<Avatar width={32} src={props.author.avatar} />
</Link>
{props.project && <Link className='-ml-12' to={createRoute({ type: "project", tag: props.project.hashtag })}>
<Avatar src={props.project.thumbnail_image} width={32} />
</Link>}
</span>
<span className='flex gap-4'>
<Link className='hover:underline' to={createRoute({ type: 'profile', id: props.author.id, username: props.author.name })}>
<p className="text-gray-900 text-body5 font-medium">{trimText(props.author.name, 20)}</p>
</Link>
{props.project && <>
<span className="text-body5 text-gray-500 font-medium">for</span>
<Link className='hover:underline' to={createRoute({ type: "project", tag: props.project.hashtag })}>
<p className="text-gray-900 text-body5 font-medium">{trimText(props.project.title, 15)}</p>
</Link>
</>}
</span>
<p className="text-body6 text-gray-500 font-medium">{dateToShow()}</p>
</div>
)
}

View File

@@ -1,5 +1,4 @@
import { Story } from "src/features/Posts/types"
import Header from "../Header/Header"
import { Link } from "react-router-dom"
import VoteButton from "src/Components/VoteButton/VoteButton"
import { useVote } from "src/utils/hooks"
@@ -8,6 +7,7 @@ import Badge from "src/Components/Badge/Badge"
import { createRoute } from "src/utils/routing"
import { BiComment } from "react-icons/bi"
import Card from "src/Components/Card/Card"
import PostCardHeader from "../PostCardHeader/PostCardHeader"
export type StoryCardType = Pick<Story,
@@ -19,6 +19,7 @@ export type StoryCardType = Pick<Story,
| 'excerpt'
| 'votes_count'
| 'comments_count'
| 'project'
> & {
tags: Array<Pick<Tag, 'id' | "title">>,
author: Pick<Author, 'id' | 'name' | 'avatar' | 'join_date'>
@@ -34,30 +35,37 @@ export default function StoryCard({ story }: Props) {
itemType: Vote_Item_Type.Story
});
return (
<Card className="overflow-hidden" defaultPadding={false}>
{story.cover_image && <img src={story.cover_image} className='h-[200px] w-full object-cover' alt="" />}
<div className="p-24">
<Header author={story.author} date={story.createdAt} />
<Link to={createRoute({ type: 'story', id: story.id, title: story.title, username: story.author.name })}>
<h2 className="text-h5 font-bolder mt-16">{story.title}</h2>
</Link>
<p className="text-body4 text-gray-600 mt-8">{story.excerpt}...</p>
<div className="flex gap-8 mt-8">
{story.tags.map(tag => <Badge key={tag.id} size='sm'>
{tag.title}
</Badge>)}
</div>
<hr className="my-16 bg-gray-200" />
<div className="flex gap-24 items-center">
<VoteButton votes={story.votes_count} dense onVote={vote} />
<div className="text-gray-600">
<BiComment /> <span className="align-middle text-body5">{story.comments_count} Comments</span>
return (
<div>
<PostCardHeader author={story.author} project={story.project} date={story.createdAt} />
<Card className="overflow-hidden mt-8" >
{story.cover_image &&
<Link className="mb-16 block" to={createRoute({ type: 'story', id: story.id, title: story.title, username: story.author.name })}>
<img src={story.cover_image} className='h-[200px] w-full object-cover rounded-8' alt="" />
</Link>
}
<div >
<Link to={createRoute({ type: 'story', id: story.id, title: story.title, username: story.author.name })}>
<h2 className="text-h5 font-bolder">{story.title}</h2>
</Link>
<p className="text-body4 text-gray-600 mt-8">{story.excerpt}...</p>
<div className="flex gap-8 mt-8">
{story.tags.map(tag => <Badge key={tag.id} size='sm'>
{tag.title}
</Badge>)}
</div>
<hr className="my-16 bg-gray-200" />
<div className="flex gap-24 items-center">
<VoteButton votes={story.votes_count} dense onVote={vote} />
<div className="text-gray-600">
<BiComment /> <span className="align-middle text-body5">{story.comments_count} Comments</span>
</div>
</div>
</div>
</div>
</Card>
</Card>
</div>
)
}

View File

@@ -2,6 +2,7 @@ import { createAction } from '@reduxjs/toolkit';
import React, { useCallback, useState } from 'react'
import { useFormContext } from 'react-hook-form';
import Button from 'src/Components/Button/Button';
import Card from 'src/Components/Card/Card';
import LoadingPage from 'src/Components/LoadingPage/LoadingPage';
import { isStory } from 'src/features/Posts/types';
import { Post_Type, useDeleteStoryMutation, useGetMyDraftsQuery, usePostDetailsLazyQuery } from 'src/graphql'
@@ -95,11 +96,11 @@ export default function DraftsContainer({ id, type, onDraftLoad }: Props) {
<div id={id}>
{(!myDraftsQuery.loading && myDraftsQuery.data?.getMyDrafts && myDraftsQuery.data.getMyDrafts.length > 0) &&
<div className="bg-white border-2 border-gray-200 rounded-16 p-16">
<Card>
<p className="text-body2 font-bolder mb-16">Saved Drafts</p>
<ul className=''>
{myDraftsQuery.data.getMyDrafts.map(draft =>
<li key={draft.id} className='py-16 border-b-[1px] border-gray-200 last-of-type:border-b-0 ' >
<li key={draft.id} className='py-16 border-b-[1px] border-gray-200 last-of-type:border-b-0 last-of-type:pb-0' >
<p
className="hover:underline"
role={'button'}
@@ -113,7 +114,7 @@ export default function DraftsContainer({ id, type, onDraftLoad }: Props) {
</div>
</li>)}
</ul>
</div>}
</Card>}
{loading && <LoadingPage />}
</div>
)

View File

@@ -10,6 +10,7 @@ const ErrorsContainer = forwardRef<HTMLDivElement, Props>((props, ref) => {
const { formState: { isValid, isSubmitted, errors } } = useFormContext<IStoryFormInputs>();
const hasErrors = Object.values(errors).length > 0
return (

View File

@@ -2,6 +2,7 @@ import { marked } from 'marked';
import styles from 'src/features/Posts/pages/PostDetailsPage/Components/PageContent/styles.module.scss'
import Badge from "src/Components/Badge/Badge";
import { Post } from "src/graphql";
import Card from 'src/Components/Card/Card';
function isPost(type?: string): type is 'story' {
return type === 'story'
@@ -31,8 +32,8 @@ export default function PreviewPostContent({ post, }: Props) {
return (
<>
<div id="content" className="bg-white p-32 border-2 border-gray-200 rounded-16">
<div id="content">
<Card>
{coverImg &&
<img src={coverImg}
className='w-full h-[120px] md:h-[240px] object-cover rounded-12 mb-16'
@@ -48,10 +49,9 @@ export default function PreviewPostContent({ post, }: Props) {
<div className={`mt-42 ${styles.body}`} dangerouslySetInnerHTML={{ __html: marked.parse(post.body, {}) }}>
</div>
</div>
{/* <div id="comments" className="mt-10 comments_col">
<CommentsSection comments={story.comments} />
</div> */}
</>
</Card>
</div>
)
}

View File

@@ -14,6 +14,7 @@ import { StorageService } from 'src/services';
import { useThrottledCallback } from '@react-hookz/web';
import { CreateStoryType } from '../../CreateStoryPage/CreateStoryPage';
import CoverImageInput from 'src/Components/Inputs/FilesInputs/CoverImageInput/CoverImageInput';
import TagProjectInput from '../TagProjectInput/TagProjectInput';
interface Props {
isUpdating?: boolean;
@@ -91,6 +92,7 @@ export default function StoryForm(props: Props) {
tags: data.tags.map(t => t.title),
is_published: publish_now,
cover_image: data.cover_image,
project_id: data.project?.id,
},
}
})
@@ -171,6 +173,18 @@ export default function StoryForm(props: Props) {
)}
/>
<Controller
control={control}
name="project"
render={({ field: { onChange, value, onBlur } }) => (
<TagProjectInput
classes={{ container: 'mt-16' }}
value={value}
onChange={onChange}
onBlur={onBlur} />
)}
/>
</div>
<ContentEditor
@@ -189,8 +203,8 @@ export default function StoryForm(props: Props) {
disabled={loading}
>
{props.isUpdating ?
"Update" :
"Publish"
(loading ? "Updating..." : "Update") :
(loading ? "Publishing..." : "Publish")
}
</Button>
{!props.isPublished &&

View File

@@ -12,6 +12,12 @@ mutation createStory($data: StoryInputType) {
is_published
type
cover_image
project {
id
title
hashtag
thumbnail_image
}
# comments_count
}
}

View File

@@ -0,0 +1,211 @@
import Select, { ValueContainerProps } from 'react-select';
import { OnChangeValue, StylesConfig, components, OptionProps, MenuListProps } from "react-select";
import { useMyProjectsQuery, MyProjectsQuery } from "src/graphql";
import Avatar from "src/features/Profiles/Components/Avatar/Avatar";
import { createRoute } from "src/utils/routing"
import { FiPlus } from 'react-icons/fi';
import { IoMdClose } from 'react-icons/io';
import React from 'react'
import { Link } from 'react-router-dom'
type Project = NonNullable<MyProjectsQuery['me']>['projects'][number]
interface Props {
classes?: {
container?: string
input?: string
}
placeholder?: string,
value?: Project | null
onChange?: (selectedProject: Project | null) => void
onBlur?: () => void
}
export default function TagProjectInput({
classes,
...props }: Props) {
const query = useMyProjectsQuery()
const placeholder = props.placeholder ?? <div className="flex gap-8 items-center text-gray-500"> <span className="w-32 h-32 bg-gray-50 border border-gray-100 rounded-full flex justify-center items-center"><FiPlus /></span> Tag a project </div>
const handleChange = (newValue: OnChangeValue<Project, false>,) => {
props.onChange?.(newValue);
}
const isEmpty = query.data?.me?.projects.length === 0
// if (!props.value && (!query.data?.me?.projects || query.data.me.projects.length === 0)) return null
return (
<div className={`${classes?.container}`}>
<Select
isLoading={query.loading}
value={props.value}
options={query.data?.me?.projects}
placeholder={placeholder}
loadingMessage={() => "Loading your projects..."}
noOptionsMessage={() =>
isEmpty ?
<div>
<div className='text-body1 mb-24'>🚀</div>
<p>Looks like you dont have any projects yet. You can <Link className='text-blue-500' to={createRoute({ type: "edit-project" })} >create a project here.</Link></p>
</div>
:
"No projects here"}
onChange={handleChange as any}
components={{
MenuList,
Option: OptionComponent,
ValueContainer,
}}
getOptionValue={o => o.id.toString()}
getOptionLabel={o => o.title}
onBlur={props.onBlur}
styles={colourStyles as any}
theme={(theme) => ({
...theme,
borderRadius: 8,
colors: {
...theme.colors,
primary: 'var(--primary)',
},
})}
isClearable
/>
{/* <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>
)
}
const MenuList = ({
children,
...props
}: MenuListProps<Project, false>) => {
return (
<components.MenuList {...props} className='!flex' >
{props.options.length > 0 && <p className='mb-8 font-medium'>Your projects ({props.options.length})</p>}
{children}
</components.MenuList>
);
}
const ValueContainer = ({
children,
...props
}: ValueContainerProps<Project, false>) => {
const { getValue, hasValue } = props;
const value = getValue()[0]
return (
<components.ValueContainer {...props} className='!flex' >
{hasValue ?
<>
<div className="flex gap-8 items-center px-8 py-4 border border-gray-200 rounded-8">
<Avatar width={32} src={value.thumbnail_image} /> <span className="text-body5 text-gray-900 font-medium">{value.title}</span> <IoMdClose
onMouseDown={e => e.stopPropagation()}
onClick={(e) => {
(props.selectProps.onChange as any)(null);
}} />
</div>
{React.Children.map(children, (child: any) =>
child && child.type === components.Input ? child : null
)}
</>
:
children
}
</components.ValueContainer>
);
}
const OptionComponent = (props: OptionProps<Project>) => {
return (
<>
<components.Option {...props} >
<Avatar src={props.data.thumbnail_image} width={48} />
<div>
<p className="font-medium self-center">
{props.data.title}
</p>
<p className="text-body5 text-gray-500">
{props.data.category.icon} {props.data.category.title}
</p>
</div>
</components.Option>
</>
);
};
const colourStyles: StylesConfig = {
control: (styles, state) => ({
...styles,
borderRadius: 12,
border: 'none',
boxShadow: 'none',
":hover": {
cursor: "pointer"
},
}),
multiValueRemove: (styles) => ({
...styles,
":hover": {
background: 'none'
}
}),
menuList: (styles) => ({
...styles,
borderRadius: 12,
padding: 16,
display: 'flex',
flexDirection: 'column',
gap: 4,
}),
// flex rounded-12 items-center gap-16 !py-16 ${props.isSelected && "!bg-gray-200
option: (styles, state) => ({
...styles,
display: 'flex',
paddingInline: 8,
borderRadius: 12,
alignContent: 'center',
paddingBlock: 12,
gap: 16,
...(state.isFocused && { background: "#F9FAFB" }),
...(state.isSelected && { background: "#F5F2FF", color: "black" }),
}),
indicatorsContainer: () => ({ display: 'none' }),
clearIndicator: () => ({ display: 'none' }),
indicatorSeparator: () => ({ display: "none" }),
input: (styles, state) => ({
...styles,
" input": {
boxShadow: 'none !important'
},
}),
multiValue: styles => ({
...styles,
padding: '4px 12px',
borderRadius: 48,
fontWeight: 500
}),
valueContainer: (styles) => ({
...styles,
paddingLeft: 0,
paddingRight: 0,
})
}

View File

@@ -0,0 +1,15 @@
query MyProjects {
me {
id
projects {
id
title
thumbnail_image
category {
id
icon
title
}
}
}
}

View File

@@ -0,0 +1,134 @@
import React from 'react'
import { useFormContext } from 'react-hook-form';
import Skeleton from 'react-loading-skeleton';
import Card from 'src/Components/Card/Card'
import { useOfficialTagsQuery } from 'src/graphql';
import { CreateStoryType } from '../../CreateStoryPage/CreateStoryPage';
export default function TemplatesCard() {
const { formState: { isDirty }, reset } = useFormContext<CreateStoryType>();
const officalTags = useOfficialTagsQuery();
const clickTemplate = (template: typeof templates[number]) => {
if (!isDirty || window.confirm("Your current unsaved changes will be lost, are you sure you want to continue?")) {
reset({
id: -1 * Math.random(),
is_published: false,
title: template.value.title,
body: template.value.body,
tags: officalTags.data?.officialTags.filter(tag => template.value.tags.includes(tag.title)) ?? []
})
}
}
return (
<Card>
<p className="text-body2 font-bolder">Story templates</p>
<ul className=''>
{officalTags.loading && Array(3).fill(0).map((_, idx) => <li key={idx} className='py-16 border-b-[1px] border-gray-200 last-of-type:border-b-0 last-of-type:pb-0 ' >
<p
className="hover:underline" >
<Skeleton width="12ch" />
</p>
<p className="text-body5 text-gray-400 mt-4"><Skeleton width="25ch" /></p>
</li>)}
{!officalTags.loading && templates.map(template =>
<li key={template.id} className='py-16 border-b-[1px] border-gray-200 last-of-type:border-b-0 last-of-type:pb-0 ' >
<p
className="hover:underline"
role={'button'}
onClick={() => clickTemplate(template)}
>
{template.title}
</p>
<p className="text-body5 text-gray-400 mt-4">{template.description}</p>
</li>)}
</ul>
</Card>
)
}
const templates = [{
id: 1,
title: "👋 Maker intro",
description: "Tell the community about yourself",
value: {
title: "Hi, I'm ___ 👋",
body:
`### About me 👋
Tell the community about yourself, your hobbies, and interests...
#### How did you get into bitcoin?
We've all been down the rabit hole, let's hear your side of things...
#### What's something that excites you about the space now?
What's ignigniting your maker spark? Is it a new layer spec, event, or app? We want to hear about it...
#### What's your spirit animal/star sign?
It's important, ok...
### My roles & skills 🦄
Let everyone know what roles you usually take in your product teams, plus some of your top skills and levels...
#### How can you help others?
Those skills sound awesome, is there anything you could help other makers with in particular? I'm sure they'd return the favour...
### What I'm currently working on 🧑‍💻
Working on anything exciting? List your current projects here so other makers can check out your work...
#### What can the BOLT.FUN community help you with right now?
Is there anything you need help with? There are plenty of makers around ready to help you out...
`,
tags: ['introduction']
},
},
{
id: 2,
title: "🚀 Product launch / update",
description: "Launch your product with a story",
value: {
title: "Introducing ___ 🚀",
body:
`### Product feature/name 🚀
What is the product/feature you are launching? Tell others a bit more about what youve been working on?
### Problems & Solutions 🚨
What problems does this product/feature solve? Really show it off and convince makers why its so awesome...
### How was it built? 🛠
Tell other makers about how you built this product/feature? What lightning specs, codebases, templates, packages, etc does it use? Maybe others can learn from your experience...
### Blockers & Issues ✋
Did you have any trouble building this product/feature? Its good to share these details for others to learn from...
### Try it out 🔗
Got a link to your product/feature? Post it here for others to find...`,
tags: ["product", "activity"]
}
},
{
id: 3,
title: "🚦 Weekly Report",
description: "Let others know about your recent activity",
value: {
title: "PPPs: Week ___ 🚀",
body: `### Plans 📆
- Start writing your plans for next week here...
### Progress ✅
- Start writing your progress from last week here...
### Problems ✋
- Start writing your problems and blockers from last week here...
### Links 🔗
- Reference your Github issues, notes, or anything else you might want to add...`, tags: ['activity']
},
}
]

View File

@@ -1,10 +1,10 @@
import { yupResolver } from "@hookform/resolvers/yup";
import { useRef, useState } from "react";
import { ErrorBoundary, withErrorBoundary } from "react-error-boundary";
import { withErrorBoundary } from "react-error-boundary";
import { FormProvider, NestedValue, Resolver, useForm } from "react-hook-form";
import ErrorPage from "src/Components/Errors/ErrorPage/ErrorPage";
import { CreateStoryMutationVariables, Post_Type } from "src/graphql";
import { Category, CreateStoryMutationVariables, MyProjectsQuery, Post_Type, Project } from "src/graphql";
import { StorageService } from "src/services";
import { useAppSelector } from "src/utils/hooks";
import { Override } from "src/utils/interfaces";
@@ -13,32 +13,37 @@ import * as yup from "yup";
import DraftsContainer from "../Components/DraftsContainer/DraftsContainer";
import ErrorsContainer from "../Components/ErrorsContainer/ErrorsContainer";
import StoryForm from "../Components/StoryForm/StoryForm";
import TemplatesCard from "../Components/TemplatesCard/TemplatesCard";
import styles from './styles.module.scss'
const schema = yup.object({
id: yup.number().transform(v => v <= 0 ? undefined : v).nullable(),
title: yup.string().trim().required().min(10, 'Story title must be 2+ words').transform(v => v.replace(/(\r\n|\n|\r)/gm, "")),
tags: yup.array().of(tagSchema).required().min(1, 'Add at least one tag'),
body: yup.string().required("Write some content in the post").min(50, 'Post must contain at least 10+ words'),
cover_image: imageSchema.nullable(true),
cover_image: imageSchema.default(null).nullable(),
}).required();
type ApiStoryInput = NonNullable<CreateStoryMutationVariables['data']>;
type ProjectInput = Pick<Project, 'id' | 'title' | 'thumbnail_image'> & { category: Pick<Category, 'id' | 'title' | 'icon'> }
export type IStoryFormInputs = {
id: ApiStoryInput['id']
title: ApiStoryInput['title']
body: ApiStoryInput['body']
cover_image: NestedValue<NonNullable<ApiStoryInput['cover_image']>> | null
tags: NestedValue<ApiStoryInput['tags']>
project: NestedValue<ProjectInput> | null
is_published: ApiStoryInput['is_published']
}
export type CreateStoryType = Override<IStoryFormInputs, {
cover_image: ApiStoryInput['cover_image'],
project: ProjectInput | null
tags: { title: string }[]
}>
@@ -52,6 +57,7 @@ function CreateStoryPage() {
story: state.staging.story || storageService.get()
}))
const formMethods = useForm<CreateStoryType>({
resolver: yupResolver(schema) as Resolver<CreateStoryType>,
shouldFocusError: false,
@@ -62,6 +68,7 @@ function CreateStoryPage() {
tags: story?.tags ?? [],
body: story?.body ?? '',
is_published: story?.is_published ?? false,
project: story?.project,
},
});
@@ -86,7 +93,9 @@ function CreateStoryPage() {
/>
<ErrorsContainer id='errors' ref={errorsContainerRef} />
<div id="templates" className="mb-24">
<TemplatesCard />
</div>
<DraftsContainer id='drafts' type={Post_Type.Story} onDraftLoad={resetForm} />
</div>

View File

@@ -16,6 +16,7 @@
"errors"
"preview-switch"
"form"
"templates"
"drafts";
:global {
@@ -31,6 +32,9 @@
#drafts {
grid-area: drafts;
}
#templates {
grid-area: templates;
}
}
@include gt-xl {
@@ -40,6 +44,7 @@
grid-template-areas:
"preview-switch preview-switch"
"form errors"
"form templates"
"form drafts"
"form .";

View File

@@ -79,7 +79,7 @@ export default function FeedPage() {
filterChanged={setSortByFilter}
/>
</div>
<div id="content">
<div id="content" className='pt-16 md:pt-0'>
<PostsList
isLoading={feedQuery.loading}
items={feedQuery.data?.getFeed}
@@ -88,22 +88,23 @@ export default function FeedPage() {
/>
</div>
<aside id='categories' className='no-scrollbar'>
<div className="pb-16 md:overflow-y-scroll sticky-side-element">
<h1 className="text-h2 font-bolder mb-24">Discover</h1>
<Button
href={PAGES_ROUTES.blog.writeStory}
color='primary'
fullWidth
>
Write a story
</Button>
<div className="my-24"></div>
<div className="my-24"></div>
<PopularTagsFilter
value={tagFilter}
onChange={setTagFilter as any}
/>
<div className="md:overflow-y-scroll sticky-side-element flex flex-col gap-16 md:gap-24">
<h1 className={`${tagFilter && "hidden"} md:block text-h2 font-bolder order-1`}>Discover</h1>
<div className='order-3 md:order-2'>
<Button
href={createRoute({ type: "write-story" })}
color='primary'
fullWidth
>
Write a story
</Button>
</div>
<div className='order-2 md:order-3'>
<PopularTagsFilter
value={tagFilter}
onChange={setTagFilter as any}
/>
</div>
</div>
</aside>
<aside id='side' className='no-scrollbar'>

View File

@@ -19,6 +19,12 @@ query Feed($take: Int, $skip: Int, $sortBy: String, $tag: Int) {
type
cover_image
comments_count
project {
id
title
thumbnail_image
hashtag
}
}
... on Bounty {
id

View File

@@ -36,7 +36,7 @@
.grid {
display: grid;
// grid-template-columns: 1fr;
gap: 32px;
gap: 16px;
& > * {
min-width: 0;
@@ -44,8 +44,8 @@
grid-template-areas:
"title"
"sort-by"
"categories"
"sort-by"
"content";
:global {
@@ -69,6 +69,7 @@
}
@media screen and (min-width: 768px) {
gap: 32px;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: auto auto 1fr;

View File

@@ -8,10 +8,13 @@ import HeaderSkeleton from "src/features/Posts/Components/PostCard/Header/Header
export default function PageContentSkeleton() {
return <div id="content" className="bg-white md:p-32 md:border-2 border-gray-200 rounded-16 relative">
<div className="flex flex-col gap-24 relative">
<HeaderSkeleton />
<div className="relative w-full h-[120px] md:h-[240px] object-cover rounded-12 mb-16">
<Skeleton height='100%' className='!leading-inherit rounded-8' />
</div>
<h1 className="text-[42px] leading-[58px] font-bolder">
<Skeleton width={'min(80%,16ch)'} />
</h1>
<HeaderSkeleton />
<div className="flex flex-wrap gap-8">
{Array(3).fill(0).map((_, idx) => <Badge key={idx} size='sm'>
<div className="opacity-0">hidden</div>

View File

@@ -1,4 +1,4 @@
import { isBounty, isQuestion, isStory, Post } from "src/features/Posts/types"
import { isBounty, isQuestion, isStory } from "src/features/Posts/types"
import StoryPageContent from "../StoryPageContent/StoryPageContent";
import BountyPageContent from "../BountyPageContent/BountyPageContent";
import QuestionPageContent from "../QuestionPageContent/QuestionPageContent";

View File

@@ -0,0 +1,55 @@
import Avatar from 'src/features/Profiles/Components/Avatar/Avatar';
import dayjs from 'dayjs'
import { trimText } from 'src/utils/helperFunctions';
import { Link } from 'react-router-dom';
import { createRoute } from 'src/utils/routing';
import { Project, User } from 'src/graphql';
interface Props {
author?: Pick<User, 'id' | 'name' | 'avatar'>
project?: Pick<Project, 'id' | 'title' | "thumbnail_image" | 'hashtag'> | null
date: string;
className?: string
}
export default function PostPageHeader(props: Props) {
const dateToShow = () => {
const passedTimeHrs = dayjs().diff(props.date, 'hour');
const passedTimesDays = Math.ceil(passedTimeHrs / 24);
if (passedTimeHrs === 0) return 'now';
if (passedTimeHrs < 24) return `${dayjs().diff(props.date, 'hour')}h ago`
if (passedTimesDays < 29) return `${passedTimesDays} days`
return dayjs(props.date).format('DD MMM');
}
if (!props.author) return null
return (
<div className={`flex gap-16 items-center ${props.className}`}>
<div className='relative'>
<Link to={createRoute({ type: 'profile', id: props.author.id, username: props.author.name })}>
<Avatar width={48} src={props.author.avatar} />
</Link>
{props.project && <Link className='absolute bottom-0 right-0 translate-x-8' to={createRoute({ type: "project", tag: props.project.hashtag })}>
<Avatar src={props.project.thumbnail_image} width={24} />
</Link>}
</div>
<div className="flex flex-col gap-4">
<span className='flex gap-4'>
<Link className='hover:underline' to={createRoute({ type: 'profile', id: props.author.id, username: props.author.name })}>
<p className="text-gray-900 text-body4 font-medium">{trimText(props.author.name, 20)}</p>
</Link>
{props.project && <>
<span className="text-body4 text-gray-500 font-medium">for</span>
<Link className='hover:underline' to={createRoute({ type: "project", tag: props.project.hashtag })}>
<p className="text-gray-900 text-body4 font-medium">{trimText(props.project.title, 15)}</p>
</Link>
</>}
</span>
<p className="text-body5 text-gray-500">Published {dateToShow()}</p>
</div>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import Header from "src/features/Posts/Components/PostCard/Header/Header"
import { Story } from "src/features/Posts/types"
import { marked } from 'marked';
import styles from '../PageContent/styles.module.scss'
@@ -7,9 +7,13 @@ import IconButton from "src/Components/IconButton/IconButton";
import { Menu, MenuItem } from "@szhsin/react-menu";
import { useAppSelector } from "src/utils/hooks";
import { useUpdateStory } from './useUpdateStory'
import { FaPen } from "react-icons/fa";
import DOMPurify from 'dompurify';
import Card from "src/Components/Card/Card";
import PostPageHeader from "../PostPageHeader/PostPageHeader";
import { FiEdit2, FiLink } from "react-icons/fi";
import CopyToClipboard from "react-copy-to-clipboard";
import { createRoute } from "src/utils/routing";
import { NotificationsService } from "src/services";
interface Props {
@@ -29,30 +33,47 @@ export default function StoryPageContent({ story }: Props) {
<>
<div id="content" className="bg-white md:p-32 md:border-2 border-gray-200 rounded-16 relative"> </div>
<Card id="content" onlyMd className="relative max">
<div className="flex justify-between items-center flex-wrap mb-16">
<PostPageHeader
author={story.author}
project={story.project}
date={story.createdAt} />
<div className="shrink-0 text-gray-400">
<CopyToClipboard
text={createRoute({ type: "story", title: story.title, id: story.id })}
onCopy={() => NotificationsService.info(" Copied share link to clipboard", { icon: "📋" })}
>
<IconButton>
<FiLink />
</IconButton>
</CopyToClipboard>
{curUser?.id === story.author.id && <Menu
menuClassName='!p-8 !rounded-12'
menuButton={<IconButton className="text-gray-400"><FiEdit2 /></IconButton>}>
<MenuItem
onClick={handleEdit}
className='!p-16 font-medium flex gap-16 hover:bg-gray-100 !rounded-12'
>
Edit story
</MenuItem>
<MenuItem
onClick={handleDelete}
className='!p-16 font-medium flex gap-16 hover:bg-gray-100 !rounded-12'
>
Delete
</MenuItem>
</Menu>}
</div>
</div>
{story.cover_image &&
<img src={story.cover_image}
className='w-full h-[120px] md:h-[240px] object-cover rounded-12 mb-16'
className='w-full min-h-[120px] max-h-[320px] object-cover rounded-12 mb-16'
// className='w-full object-cover rounded-12 md:rounded-16 mb-16'
alt="" />}
<div className="flex flex-col gap-24 relative">
{curUser?.id === story.author.id && <Menu
menuClassName='!p-8 !rounded-12'
menuButton={<IconButton className="absolute top-0 right-0 text-gray-400"><FaPen /></IconButton>}>
<MenuItem
onClick={handleEdit}
className='!p-16 font-medium flex gap-16 hover:bg-gray-100 !rounded-12'
>
Edit story
</MenuItem>
<MenuItem
onClick={handleDelete}
className='!p-16 font-medium flex gap-16 hover:bg-gray-100 !rounded-12'
>
Delete
</MenuItem>
</Menu>}
<h1 className="text-[42px] leading-[58px] font-bolder">{story.title}</h1>
<Header size="lg" showTimeAgo={false} author={story.author} date={story.createdAt} />
{story.tags.length > 0 && <div className="flex flex-wrap gap-8">
{story.tags.map(tag => <Badge key={tag.id} size='sm'>
{tag.title}

View File

@@ -1,18 +1,8 @@
import { Helmet } from 'react-helmet'
import { useParams } from 'react-router-dom'
import LoadingPage from 'src/Components/LoadingPage/LoadingPage'
import NotFoundPage from 'src/features/Shared/pages/NotFoundPage/NotFoundPage'
import { usePostDetailsQuery } from 'src/graphql'
import { capitalize } from 'src/utils/helperFunctions'
import { useAppSelector, } from 'src/utils/hooks'
import { PostCardSkeleton } from '../../Components/PostCard'
import TrendingCard from '../../Components/TrendingCard/TrendingCard'
import AuthorCard from './Components/AuthorCard/AuthorCard'
import AuthorCardSkeleton from './Components/AuthorCard/AuthorCard.skeleton'
import PageContent from './Components/PageContent/PageContent'
import PageContentSkeleton from './Components/PageContent/PageContent.skeleton'
import PostActions from './Components/PostActions/PostActions'
import PostActionsSkeleton from './Components/PostActions/PostActions.skeleton'
import styles from './styles.module.scss'

View File

@@ -19,6 +19,12 @@ query PostDetails($id: Int!, $type: POST_TYPE!) {
type
cover_image
is_published
project {
id
title
thumbnail_image
hashtag
}
# comments_count
# comments {
# id

View File

@@ -1,16 +1,42 @@
import { usePopperTooltip } from "react-popper-tooltip";
interface Props {
src: string;
alt?: string;
width?: number | string;
className?: string
renderTooltip?: () => JSX.Element
}
export default function Avatar({ src, alt, className, width = 40 }: Props) {
export default function Avatar({ src, alt, className, width = 40, renderTooltip }: Props) {
const {
getArrowProps,
getTooltipProps,
setTooltipRef,
setTriggerRef,
visible,
} = usePopperTooltip({
});
return (
<img src={src} className={`shrink-0 rounded-full object-cover border-2 bg-white border-gray-100 ${className}`} style={{
width: width,
aspectRatio: '1/1'
}} alt={alt ?? "avatar"} />
<>
<img ref={setTriggerRef} src={src} className={`shrink-0 rounded-full object-cover border-2 bg-white border-gray-100 ${className}`} style={{
width: width,
aspectRatio: '1/1'
}} alt={alt ?? "avatar"} />
{
(renderTooltip && visible) && (
<div
ref={setTooltipRef}
{...getTooltipProps({ className: 'tooltip-container z-10' })}
>
<div {...getArrowProps({ className: 'tooltip-arrow' })} />
{renderTooltip()}
</div>
)
}
</>
)
}

View File

@@ -100,11 +100,11 @@ export default function AboutCard({ user, isOwner }: Props) {
</a>
:
<CopyToClipboard
key={idx}
text={link.value}
onCopy={() => NotificationsService.info(" Copied to clipboard", { icon: "📋" })}
>
<button
key={idx}
onClick={() => { }}
className={`w-40 aspect-square rounded-full flex justify-center items-center ${link.colors}`}
>

View File

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

View File

@@ -0,0 +1,39 @@
import { Link } from 'react-router-dom'
import Button from 'src/Components/Button/Button'
import Card from 'src/Components/Card/Card'
import Avatar from 'src/features/Profiles/Components/Avatar/Avatar'
import { ProfileQuery, User, useSimilarProjectsQuery } from 'src/graphql'
import { createRoute } from 'src/utils/routing'
interface Props {
projects: NonNullable<ProfileQuery['profile']>['projects']
isOwner?: boolean;
}
export default function MakerProjectsCard({ projects, isOwner }: Props) {
return (
<Card>
<h3 className="text-body2 font-bolder">🚀 Projects ({projects.length})</h3>
{projects.length === 0 && <>
<p className="text-gray-700 text-body4">😐 No projects listed</p>
{isOwner && <Button color='primary' className='mt-16' size='sm' href={createRoute({ type: "edit-project" })}>List your first project</Button>}
</>}
<ul className='flex flex-col'>
{projects.map(project => {
return <Link key={project.id} to={createRoute({ type: "project", tag: project.hashtag })} className="md:border-b py-16 last-of-type:border-b-0 last-of-type:pb-0">
<li className="flex items-center gap-12">
<img className='w-48 aspect-square rounded-12 border border-gray-100' alt='' src={project.thumbnail_image} />
<div className='overflow-hidden'>
<p className="text-body4 text-gray-800 font-medium whitespace-nowrap overflow-hidden text-ellipsis">{project.title}</p>
<p className="text-body5 text-gray-500">{project.category.icon} {project.category.title}</p>
</div>
</li>
</Link>
})}
</ul>
</Card>
)
}

View File

@@ -12,6 +12,7 @@ import SkillsCard from "./SkillsCard/SkillsCard"
import TournamentsCard from "./TournamentsCard/TournamentsCard"
import { MEDIA_QUERIES } from "src/utils/theme"
import SimilarMakersCard from "./SimilarMakersCard/SimilarMakersCard"
import MakerProjectsCard from "./MakerProjectsCard/MakerProjectsCard"
export default function ProfilePage() {
@@ -65,6 +66,7 @@ export default function ProfilePage() {
<StoriesCard stories={profileQuery.data.profile.stories} isOwner={isOwner} />
</main>
<aside className="min-w-0">
<MakerProjectsCard projects={profileQuery.data.profile.projects} isOwner={isOwner} />
<SimilarMakersCard makers={profileQuery.data.profile.similar_makers} />
</aside>
</>
@@ -74,6 +76,7 @@ export default function ProfilePage() {
<AboutCard user={profileQuery.data.profile} isOwner={isOwner} />
<RolesCard roles={profileQuery.data.profile.roles} isOwner={isOwner} />
<SkillsCard skills={profileQuery.data.profile.skills} isOwner={isOwner} />
<MakerProjectsCard projects={profileQuery.data.profile.projects} isOwner={isOwner} />
<StoriesCard stories={profileQuery.data.profile.stories} isOwner={isOwner} />
</main>
</>

View File

@@ -18,7 +18,7 @@ export default function SkillsCard({ skills, isOwner }: Props) {
<p className="text-gray-700 text-body4">No skills added</p>
{isOwner && <Button color='primary' className='mt-16' size='sm' href='/edit-profile/roles-skills'>Add skills</Button>}
</>}
<ul className=' flex flex-wrap gap-x-8 gap-y-20'>
<ul className=' flex flex-wrap gap-8'>
{skills.map((skill) => <li key={skill.id} className="text-body5 border border-gray-200 px-12 py-4 bg-gray-100 rounded-48 font-medium">{skill.title}</li>)}
</ul>
</div>

View File

@@ -21,11 +21,12 @@ interface Props {
tags: Array<Pick<Tag, 'id' | 'icon' | 'title'>>
}
>
onlyMd?: boolean
}
export default function StoriesCard({ stories, isOwner }: Props) {
export default function StoriesCard({ stories, isOwner, onlyMd }: Props) {
return (
<Card>
<Card onlyMd={onlyMd}>
<p className="text-body2 font-bold">Stories ({stories.length})</p>
{stories.length > 0 &&
<ul className="">
@@ -51,12 +52,12 @@ export default function StoriesCard({ stories, isOwner }: Props) {
</ul>}
{stories.length === 0 &&
<div className="flex flex-col gap-16 mt-24">
<p className="text-body3 font-medium">
<p className="text-body4 text-gray-600">
😐 No Stories Added Yet
</p>
<p className="text-body5 text-gray-500">
{/* <p className="text-body5 text-gray-500">
The maker have not written any stories yet
</p>
</p> */}
{isOwner && <Button
href='/blog/create-post'
color='primary'

View File

@@ -13,12 +13,13 @@ interface Props {
| 'end_date'
>[]
isOwner?: boolean;
onlyMd?: boolean;
}
export default function TournamentsCard({ tournaments, isOwner }: Props) {
export default function TournamentsCard({ tournaments, isOwner, onlyMd }: Props) {
return (
<Card>
<Card onlyMd={onlyMd}>
<p className="text-body2 font-bold">🏆 Tournaments </p>
<div className="mt-16">
{tournaments.length === 0 && <>

View File

@@ -17,6 +17,17 @@ query profile($profileId: Int!) {
start_date
end_date
}
projects {
id
hashtag
title
thumbnail_image
category {
id
icon
title
}
}
similar_makers {
id
name

View File

@@ -3,7 +3,7 @@ import Skeleton from 'react-loading-skeleton'
export default function ProjectCardMiniSkeleton() {
return (
<div className="bg-gray-25 select-none px-16 py-16 flex min-w-[300px] gap-16 border border-gray-200 rounded-10 items-center" >
<div className="bg-gray-25 select-none px-16 py-16 flex flex-[0_0_100%] max-w-[296px] gap-16 border border-gray-200 rounded-10 items-center" >
<Skeleton circle width={64} height={64} containerClassName='flex-shrink-0' />
<div className="justify-around items-start min-w-0">
<p className="text-body4 w-full font-bold overflow-ellipsis overflow-hidden whitespace-nowrap"><Skeleton width="15ch" /></p>

View File

@@ -19,6 +19,7 @@ export const bannerData = {
link: {
content: "Register Now",
url: createRoute({ type: "tournament", id: 1 }),
newTab: false
},
}
@@ -29,7 +30,8 @@ const headerLinks = [
img: Assets.Images_ExploreHeader1,
link: {
content: "Submit project",
url: "https://form.jotform.com/220301236112030",
url: createRoute({ type: "edit-project" }),
newTab: false,
},
},
];
@@ -90,7 +92,7 @@ export default function Header() {
<div className="max-w-[90%]">
{headerLinks[1].title}
</div>
<Button color="white" href={headerLinks[1].link.url} newTab className="mt-24">
<Button color="white" href={headerLinks[1].link.url} newTab={headerLinks[1].link.newTab ?? false} className="mt-24">
{headerLinks[1].link.content}
</Button>
</div>

View File

@@ -0,0 +1,21 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { WrapFormController } from 'src/utils/storybook/utils';
import CapabilitiesInput from './CapabilitiesInput';
export default {
title: 'Projects/List Project Page/Inputs/Capabilites Input',
component: CapabilitiesInput,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof CapabilitiesInput>;
const Template: ComponentStory<typeof CapabilitiesInput> = (args) => WrapFormController('v', [])(<CapabilitiesInput {...args as any} ></CapabilitiesInput>)
export const Default = Template.bind({});
Default.args = {
}

View File

@@ -0,0 +1,55 @@
import React from 'react'
import Button from 'src/Components/Button/Button';
import { useGetAllCapabilitiesQuery } from 'src/graphql';
import { random } from 'src/utils/helperFunctions';
interface Props {
value: number[];
onChange?: (v: number[]) => void;
}
export default function CapabilitiesInput(props: Props) {
const categoriesQuery = useGetAllCapabilitiesQuery();
const handleClick = (clickedValue: number) => {
if (props.value.includes(clickedValue))
props.onChange?.(props.value.filter(v => v !== clickedValue));
else
props.onChange?.([...props.value, clickedValue])
}
return (
<div className="flex flex-wrap gap-8">
{categoriesQuery.loading ?
Array(10).fill(0).map((_, idx) =>
<div
key={idx}
className="bg-gray-100 border border-gray-200 p-8 rounded-10">
<span className='invisible'>{"loading category skeleton".slice(random(6, 12))}</span>
</div>)
:
categoriesQuery.data?.getAllCapabilities.map(item =>
<Button
key={item.id}
color='none'
size='sm'
className={`
border border-gray-200
${props.value.includes(item.id) ?
'text-primary-600 bg-primary-100'
:
"bg-gray-100"
}
`}
onClick={() => handleClick(item.id)}
>
{item.icon} {item.title}
</Button>)
}
</div>
)
}

View File

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

View File

@@ -0,0 +1,20 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { WrapFormController } from 'src/utils/storybook/utils';
import CategoriesInput from './CategoriesInput';
export default {
title: 'Projects/List Project Page/Inputs/Categories Input',
component: CategoriesInput,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof CategoriesInput>;
const Template: ComponentStory<typeof CategoriesInput> = (args) => WrapFormController('v', [])(<CategoriesInput {...args as any} ></CategoriesInput>)
export const Default = Template.bind({});
Default.args = {
}

View File

@@ -0,0 +1,46 @@
import React from 'react'
import Button from 'src/Components/Button/Button';
import { useAllCategoriesQuery } from 'src/graphql'
import { random } from 'src/utils/helperFunctions';
interface Props {
value?: number;
onChange?: (v: number) => void;
}
export default function CategoriesInput(props: Props) {
const categoriesQuery = useAllCategoriesQuery();
return (
<div className="flex flex-wrap gap-8">
{categoriesQuery.loading ?
Array(10).fill(0).map((_, idx) =>
<div
key={idx}
className="bg-gray-100 border border-gray-200 p-8 rounded-10">
<span className='invisible'>{"loading category skeleton".slice(random(6, 12))}</span>
</div>)
:
categoriesQuery.data?.allCategories.map(category =>
<Button
key={category.id}
color='none'
size='sm'
className={`
border border-gray-200
${props.value === category.id ?
'text-primary-600 bg-primary-100'
:
"bg-gray-100"
}
`}
onClick={() => props.onChange?.(category.id)}
>
{category.icon} {category.title}
</Button>)
}
</div>
)
}

View File

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

View File

@@ -0,0 +1,68 @@
import { Controller, useFormContext } from "react-hook-form"
import Card from "src/Components/Card/Card";
import TournamentsInput from "../TournamentsInput/TournamentsInput";
import { IListProjectForm } from "../FormContainer/FormContainer";
import { ProjectLaunchStatusEnum } from "src/graphql";
interface Props { }
export default function ExtrasTab(props: Props) {
const { register, formState: { errors, }, control } = useFormContext<IListProjectForm>();
return (
<div className="flex flex-col gap-24">
<Card>
<h2 className="text-body2 font-bolder">🚀 Launch status</h2>
<p className="text-body4 font-light text-gray-600 mt-8">Has this product been launched already, or is it still a work in progress?</p>
<div className="mt-24 flex flex-col gap-24">
<div className="flex gap-16">
<input
{...register("launch_status")}
type="radio"
name="launch_status"
value={ProjectLaunchStatusEnum.Wip}
/>
<div>
<p className="text-body4 font-medium">WIP 🛠</p>
<p className="text-body5 text-gray-500 mt-4">Its still a Work In Progress.</p>
</div>
</div>
<div className="flex gap-16">
<input
{...register("launch_status")}
type="radio"
name="launch_status"
value={ProjectLaunchStatusEnum.Launched}
/>
<div>
<p className="text-body4 font-medium">Launched 🚀</p>
<p className="text-body5 text-gray-500 mt-4">The product is ready for launch, or has been launched already.</p>
</div>
</div>
{errors.launch_status && <p className='input-error'>{errors.launch_status?.message}</p>}
</div>
</Card>
<Card>
<h2 className="text-body2 font-bolder"> Tournaments</h2>
<p className="text-body4 font-light text-gray-600 mt-8">Is your application part of a tournament? If so, select the tournament(s) that apply and it will automatically be listed for you.</p>
<div className="mt-24">
<Controller
control={control}
name="tournaments"
render={({ field: { onChange, value } }) => (
<TournamentsInput
value={value}
onChange={onChange}
/>
)}
/>
{errors.tournaments && <p className='input-error'>{errors.tournaments?.message}</p>}
</div>
</Card>
</div>
)
}

View File

@@ -0,0 +1,234 @@
import { FormProvider, NestedValue, Resolver, SubmitHandler, useForm } from "react-hook-form"
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
import { IsValidProjectHashtagDocument, ProjectDetailsQuery, ProjectLaunchStatusEnum, ProjectPermissionEnum, Team_Member_Role, UpdateProjectInput, useProjectDetailsQuery } from "src/graphql";
import { PropsWithChildren } from "react";
import { useSearchParams } from "react-router-dom";
import { usePrompt } from "src/utils/hooks";
import { imageSchema } from "src/utils/validation";
import { Override } from "src/utils/interfaces";
import LoadingPage from "src/Components/LoadingPage/LoadingPage";
import { apolloClient } from "src/utils/apollo";
import { store } from "src/redux/store";
import UpdateProjectContextProvider from './updateProjectContext'
import { useNavigate } from 'react-router-dom'
import { createRoute } from 'src/utils/routing'
import { nanoid } from "@reduxjs/toolkit";
import { Helmet } from "react-helmet";
interface Props {
}
export type IListProjectForm = Override<UpdateProjectInput, {
members: NestedValue<ProjectMember[]>
capabilities: NestedValue<UpdateProjectInput['capabilities']>
recruit_roles: NestedValue<UpdateProjectInput['recruit_roles']>
tournaments: NestedValue<UpdateProjectInput['tournaments']>
cover_image: NestedValue<UpdateProjectInput['cover_image']>
thumbnail_image: NestedValue<UpdateProjectInput['thumbnail_image']>
}>
export type ProjectMember = {
id: number,
name: string,
jobTitle: string | null,
avatar: string,
role: Team_Member_Role,
}
const schema: yup.SchemaOf<IListProjectForm> = yup.object({
id: yup.number().optional(),
title: yup.string().trim().required("please provide a title").min(2),
hashtag: yup
.string()
.required("please provide a project tag")
.matches(
/^[^ !@#$%^&*(),.?":{}|<>]*$/,
"your project's tag can only contain letters, numbers and '_"
)
.min(3, "your project tag must be longer than 2 characters.")
.max(35, 'your project tag must be shorter than 35 characters.')
.test({
name: "is unique hashtag",
test: async (value, context) => {
// TODO: debounce this validation function
try {
const res = await apolloClient.query({
query: IsValidProjectHashtagDocument,
variables: {
hashtag: value,
projectId: context.parent.id
}
})
if (res.data.checkValidProjectHashtag) return true;
return false;
} catch (error) {
return false;
}
},
message: "this hashtag is already used by another project"
}),
website: yup.string().trim().url().required().label("project's link"),
tagline: yup.string().trim().required("please provide a tagline").min(10),
description: yup.string().trim().required("please provide a description for your project").min(50, 'Write at least 10 words descriping your project'),
lightning_address: yup
.string()
.test({
name: "is valid lightning_address",
test: async value => {
try {
if (value) {
const [name, domain] = value.split("@");
const lnurl = `https://${domain}/.well-known/lnurlp/${name}`;
const res = await fetch(lnurl);
if (res.status === 200) return true;
}
return true;
} catch (error) {
return false;
}
},
message: "this lightning address isn't valid"
})
.nullable()
.label("lightning address"),
thumbnail_image: imageSchema.required("please pick a thumbnail image").default(undefined),
cover_image: imageSchema.required("please pick a cover image").default(undefined),
twitter: yup.string().url().nullable(),
discord: yup.string().url().nullable(),
github: yup.string().url().nullable(),
slack: yup.string().url().nullable(),
telegram: yup.string().url().nullable(),
category_id: yup.number().required("please choose a category"),
capabilities: yup.array().of(yup.number().required()).default([]),
screenshots: yup.array().of(imageSchema.required()).default([]),
members: yup.array().of(yup.object() as any).default([]),
recruit_roles: yup.array().of(yup.number().required()).default([]),
launch_status: yup.mixed().oneOf([ProjectLaunchStatusEnum.Wip, ProjectLaunchStatusEnum.Launched]).default(ProjectLaunchStatusEnum.Wip),
tournaments: yup.array().of(yup.number().required()).default([])
}).required();
export default function FormContainer(props: PropsWithChildren<Props>) {
const [params] = useSearchParams();
const id = params.get('id') ? Number(params.get('id')) : null;
const isUpdating = !!id;
const navigate = useNavigate()
const methods = useForm<IListProjectForm>({
defaultValues: {
cover_image: undefined,
thumbnail_image: undefined,
id: isUpdating ? id : undefined,
title: "",
website: "",
tagline: "",
description: "",
category_id: undefined,
capabilities: [],
screenshots: [],
members: prepareMembers([]),
recruit_roles: [],
launch_status: ProjectLaunchStatusEnum.Wip,
tournaments: [],
},
resolver: yupResolver(schema) as Resolver<IListProjectForm>,
mode: 'onTouched'
});
const query = useProjectDetailsQuery({
variables: {
projectId: id!,
projectTag: null,
},
skip: !isUpdating,
onCompleted: (res) => {
if (res.getProject) {
const data = res.getProject
if (!res.getProject.permissions.includes(ProjectPermissionEnum.UpdateInfo))
navigate({ pathname: createRoute({ type: "projects-page" }) })
else
methods.reset({
id: data.id,
title: data.title,
cover_image: { url: data.cover_image },
thumbnail_image: { url: data.thumbnail_image },
tagline: data.tagline,
website: data.website,
description: data.description,
hashtag: data.hashtag,
twitter: data.twitter,
discord: data.discord,
slack: data.slack,
telegram: data.telegram,
github: data.github,
lightning_address: data.lightning_address,
category_id: data.category.id,
capabilities: data.capabilities.map(c => c.id),
screenshots: data.screenshots.map(url => ({ url, local_id: nanoid(5), })),
members: prepareMembers(data.members),
recruit_roles: data.recruit_roles.map(r => r.id),
tournaments: [],
launch_status: data.launch_status,
})
}
}
})
usePrompt('You may have some unsaved changes. You still want to leave?', methods.formState.isDirty)
const onSubmit: SubmitHandler<IListProjectForm> = data => console.log(data);
if (query.loading)
return <LoadingPage />
return (<>
<Helmet>
<title>{isUpdating ? "Update project" : "List a project"}</title>
</Helmet>
<FormProvider {...methods} >
<UpdateProjectContextProvider permissions={query.data?.getProject.permissions ?? Object.values(ProjectPermissionEnum)}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
{props.children}
</form>
</UpdateProjectContextProvider>
</FormProvider>
</>)
}
function prepareMembers(members: ProjectDetailsQuery['getProject']['members']): ProjectMember[] {
const me = store.getState().user.me;
if (!me) {
window.location.href = '/login';
return [];
}
if (members.length === 0)
return [{
id: me.id,
avatar: me.avatar,
name: me.name,
jobTitle: me.jobTitle,
role: Team_Member_Role.Owner,
}]
const _members = members.map(({ role, user }) => ({ role, id: user.id, avatar: user.avatar, name: user.name, jobTitle: user.jobTitle }))
const myMember = _members.find(m => m.id === me.id);
if (!myMember) throw new Error("Not a member of the project")
return [myMember, ..._members.filter(m => m.id !== me.id)]
}

View File

@@ -0,0 +1,72 @@
fragment ProjectDetails on Project {
id
title
tagline
description
hashtag
cover_image
thumbnail_image
launch_status
twitter
discord
github
slack
telegram
screenshots
website
lightning_address
votes_count
category {
id
icon
title
}
permissions
members {
role
user {
id
name
jobTitle
avatar
}
}
awards {
title
image
url
id
}
tags {
id
title
}
recruit_roles {
id
title
icon
level
}
capabilities {
id
title
icon
}
}
mutation CreateProject($input: CreateProjectInput) {
createProject(input: $input) {
project {
...ProjectDetails
}
}
}
mutation UpdateProject($input: UpdateProjectInput) {
updateProject(input: $input) {
project {
...ProjectDetails
}
}
}

View File

@@ -0,0 +1,31 @@
import React, { PropsWithChildren } from 'react'
import { ProjectDetailsQuery } from 'src/graphql'
interface Props {
permissions: ProjectDetailsQuery['getProject']['permissions']
}
interface State {
permissions: ProjectDetailsQuery['getProject']['permissions']
}
const context = React.createContext<State | undefined>(undefined)
const UpdateProjectContextProvider = React.memo(function (props: PropsWithChildren<Props>) {
return (
<context.Provider value={{ permissions: props.permissions }}>
{props.children}
</context.Provider>
)
})
export default UpdateProjectContextProvider;
export const useUpdateProjectContext = () => {
const res = React.useContext(context);
if (!res) throw new Error("No context provider was found for useUpdateProjectContext")
return res;
}

View File

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

View File

@@ -0,0 +1,278 @@
import { Controller, useFormContext } from "react-hook-form"
import Card from "src/Components/Card/Card";
import { FaDiscord, FaSlack, FaTelegram } from "react-icons/fa";
import { FiCamera, FiGithub, FiTwitter } from "react-icons/fi";
import CategoriesInput from "../CategoriesInput/CategoriesInput";
import CapabilitiesInput from "../CapabilitiesInput/CapabilitiesInput";
import { IListProjectForm } from "../FormContainer/FormContainer";
import AvatarInput from "src/Components/Inputs/FilesInputs/AvatarInput/AvatarInput";
import CoverImageInput from "src/Components/Inputs/FilesInputs/CoverImageInput/CoverImageInput";
import ScreenshotsInput from "src/Components/Inputs/FilesInputs/ScreenshotsInput/ScreenshotsInput";
import { BsLightningChargeFill } from "react-icons/bs";
import InfoCard from "src/Components/InfoCard/InfoCard";
import TextInput from "src/Components/Inputs/TextInput/TextInput";
import TextareaInput from "src/Components/Inputs/TextareaInput/TextareaInput";
import { registerDebounceValidation } from "src/utils/validation";
interface Props { }
export default function ProjectDetailsTab(props: Props) {
const { register, formState: { errors, dirtyFields }, control, getValues, trigger } = useFormContext<IListProjectForm>();
const isUpdating = !!getValues('id');
return (
<div className="md:col-span-2 flex flex-col gap-24">
<Card className="" defaultPadding={false}>
<div className="bg-gray-600 relative h-[160px] rounded-t-12 md:rounded-t-16">
<Controller
control={control}
name="cover_image"
render={({ field: { onChange, value, onBlur, ref } }) => <CoverImageInput
value={value}
rounded='rounded-t-12 md:rounded-t-16'
onChange={e => {
onChange(e)
}}
/>
}
/>
<div className="absolute left-24 bottom-0 translate-y-1/2">
<Controller
control={control}
name="thumbnail_image"
render={({ field: { onChange, value } }) => (
<AvatarInput value={value} onChange={onChange} width={120} />
)}
/>
</div>
</div>
<div className="p-16 md:p-24 mt-64">
{(errors.cover_image || errors.thumbnail_image) && <div className="mb-16">
{errors.cover_image && <p className="input-error">
{errors.cover_image.message}
</p>}
{errors.thumbnail_image && <p className="input-error">
{errors.thumbnail_image.message}
</p>}
</div>}
<p className="text-body5 font-medium">
Project name<sup className="text-red-500">*</sup>
</p>
<TextInput
className="mt-8"
isError={!!errors.title}
placeholder='e.g BOLT🔩FUN'
{...register("title")}
/>
{errors.title && <p className="input-error">
{errors.title.message}
</p>}
<p className="text-body5 mt-16 font-medium">
Project link<sup className="text-red-500">*</sup>
</p>
<TextInput
className="mt-8"
isError={!!errors.website}
placeholder='https://lightning.xyz'
{...register("website")}
/>
{errors.website && <p className="input-error">
{errors.website.message}
</p>}
<p className="text-body5 mt-16 font-medium">
Tagline<sup className="text-red-500">*</sup>
</p>
<TextInput
className="mt-8"
isError={!!errors.tagline}
placeholder='Your products one liner'
{...register("tagline")}
/>
{errors.tagline && <p className="input-error">
{errors.tagline.message}
</p>}
<p className="text-body5 mt-16 font-medium">
Description<sup className="text-red-500">*</sup>
</p>
<TextareaInput
className="mt-8"
isError={!!errors.description}
rows={5}
placeholder='Provide a short description your product...'
{...register("description")}
/>
{errors.description && <p className="input-error">
{errors.description.message}
</p>}
<p className="text-body5 font-medium mt-16">
Project tag<sup className="text-red-500">*</sup>
</p>
<TextInput
className="mt-8"
isError={!!errors.hashtag}
placeholder='my_project_name'
inputClass="pl-8"
renderBefore={() => <span className="flex flex-col justify-center pl-16 shrink-0">#</span>}
{...registerDebounceValidation("hashtag", 1000, trigger, register)}
/>
{errors.hashtag && <p className="input-error">
{errors.hashtag.message}
</p>}
{(isUpdating && dirtyFields.hashtag) &&
<InfoCard className="mt-8 bg-warning-50 border-warning-100">
<span className="font-medium text-orange-600"> Warning:</span> when you change the tag of your project, existing links that use this tag will no longer work & will need to be updateded.
</InfoCard>}
{!isUpdating &&
<InfoCard className="mt-8">
<span className="font-medium text-gray-900"> Project tag</span> allows you to mention your project in stories, or across other platforms like Discord. You can change your projects tag later, but links that use the old tag will no longer work & need to be updated.
</InfoCard>
}
</div>
</Card>
<Card className="">
<h2 className="text-body2 font-bolder">🔗 Links</h2>
<p className="text-body4 font-light text-gray-600 mt-8">Make sure that people can find your project online. </p>
<div className="flex flex-col gap-8 mt-24">
<div>
<TextInput
className="mt-8"
isError={!!errors.twitter}
renderBefore={() => <FiTwitter className="text-blue-400 text-body2 pl-16 py-0 w-auto shrink-0 self-center" />}
placeholder='https://twitter.com/project_handle'
{...register("twitter")}
/>
{errors.twitter && <p className="input-error">
{errors.twitter.message}
</p>}
</div>
<div>
<TextInput
className="mt-8"
isError={!!errors.discord}
renderBefore={() => <FaDiscord className="text-violet-500 text-body2 pl-16 py-0 w-auto shrink-0 self-center" />}
placeholder='https://discord.com/'
{...register("discord")}
/>
{errors.discord && <p className="input-error">
{errors.discord.message}
</p>}
</div>
<div>
<TextInput
className="mt-8"
isError={!!errors.github}
renderBefore={() => <FiGithub className="text-gray-700 text-body2 pl-16 py-0 w-auto shrink-0 self-center" />}
placeholder='https://github.com/'
{...register("github")}
/>
{errors.github && <p className="input-error">
{errors.github.message}
</p>}
</div>
<div>
<TextInput
className="mt-8"
isError={!!errors.slack}
renderBefore={() => <FaSlack className="text-pink-500 text-body2 pl-16 py-0 w-auto shrink-0 self-center" />}
placeholder='https://slack.com/'
{...register("slack")}
/>
{errors.slack && <p className="input-error">
{errors.slack.message}
</p>}
</div>
<div>
<TextInput
className="mt-8"
isError={!!errors.telegram}
renderBefore={() => <FaTelegram className="text-teal-500 text-body2 pl-16 py-0 w-auto shrink-0 self-center" />}
placeholder='https://t.me/XXXXXX'
{...register("telegram")}
/>
{errors.telegram && <p className="input-error">
{errors.telegram.message}
</p>}
</div>
<div>
<TextInput
className="mt-8"
isError={!!errors.lightning_address}
renderBefore={() => <BsLightningChargeFill className="text-yellow-400 text-body2 pl-16 py-0 w-auto shrink-0 self-center" />}
placeholder='lightning_address@XXX.com'
{...register("lightning_address")}
/>
{errors.lightning_address && <p className="input-error">
{errors.lightning_address.message}
</p>}
</div>
</div>
</Card >
<Card>
<h2 className="text-body2 font-bolder">🌶 Category<sup className="text-red-500">*</sup></h2>
<p className="text-body4 font-light text-gray-600 mt-8">Select one of the categories below.</p>
<div className="mt-24">
<Controller
control={control}
name="category_id"
render={({ field: { onChange, value } }) => (
<CategoriesInput
value={value}
onChange={onChange}
/>
)}
/>
{errors.category_id && <p className='input-error'>{errors.category_id?.message}</p>}
</div>
</Card>
<Card>
<h2 className="text-body2 font-bolder">🦾 Capabilities</h2>
<p className="text-body4 font-light text-gray-600 mt-8">Let other makers know what lightning capabilities your application has.</p>
<div className="mt-24">
<Controller
control={control}
name="capabilities"
render={({ field: { onChange, value } }) => (
<CapabilitiesInput
value={value}
onChange={onChange}
/>
)}
/>
{errors.capabilities && <p className='input-error'>{errors.capabilities?.message}</p>}
</div>
</Card>
<Card>
<h2 className="text-body2 font-bolder">📷 Screenshots</h2>
<p className="text-body4 font-light text-gray-600 mt-8">Choose up to 4 screenshots from your project</p>
<div className="mt-24">
<Controller
control={control}
name="screenshots"
render={({ field: { onChange, value, onBlur, ref } }) => <ScreenshotsInput
value={value}
onChange={e => {
onChange(e)
}}
/>
}
/>
{errors.capabilities && <p className='input-error'>{errors.capabilities?.message}</p>}
</div>
</Card>
</div >
)
}

View File

@@ -0,0 +1,3 @@
query IsValidProjectHashtag($hashtag: String!, $projectId: Int) {
checkValidProjectHashtag(hashtag: $hashtag, projectId: $projectId)
}

View File

@@ -0,0 +1,26 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import ProjectListedModal from './ProjectListedModal';
export default {
title: 'Projects/List Project Page/Modals/Project Listed Modal',
component: ProjectListedModal,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof ProjectListedModal>;
const Template: ComponentStory<typeof ProjectListedModal> = (args) => <ProjectListedModal {...args as any} ></ProjectListedModal>
export const Default = Template.bind({});
Default.args = {
project: {
id: 12,
name: "BOLT FUN",
img: "https://picsum.photos/id/870/150/150.jpg",
tagline: "An awesome directory for lightning projects and makers"
}
}

View File

@@ -0,0 +1,96 @@
import { motion } from 'framer-motion'
import { useAppSelector, useMediaQuery, useWindowSize } from 'src/utils/hooks';
import { ModalCard, modalCardVariants } from 'src/Components/Modals/ModalsContainer/ModalsContainer'
import Button from 'src/Components/Button/Button'
import { IoClose } from 'react-icons/io5';
import NutImg from './nut.png'
import AlbyImg from './alby.png'
import Avatar from 'src/features/Profiles/Components/Avatar/Avatar';
import { createRoute } from 'src/utils/routing';
import Confetti from 'react-confetti'
import { Portal } from 'src/Components/Portal/Portal';
interface Props extends ModalCard {
project: {
id: number,
img: string,
name: string,
tagline: string,
}
}
export default function ProjectListedModal({ onClose, direction, project, ...props }: Props) {
const size = useWindowSize();
const isSmallScreen = useMediaQuery('screen and (max-width: 680px)')
return (
<motion.div
custom={direction}
variants={modalCardVariants}
initial='initial'
animate="animate"
exit='exit'
className="modal-card max-w-[442px] p-24 rounded-xl relative"
>
<Portal id='confetti'>
<Confetti recycle={false} width={size.width} height={size.height} numberOfPieces={isSmallScreen ? 200 : 400} />
</Portal>
<IoClose className='absolute text-body2 top-24 right-24 hover:cursor-pointer' onClick={onClose} />
<h2 className='text-h5 font-bold text-center'>Product listed!</h2>
<div className="flex flex-col gap-16 justify-center items-center my-24">
<Avatar src={project.img} width={80} />
<p className="text-body3 font-medium">{project.name}</p>
</div>
<p className="text-body4 font-light text-gray-600 mt-24 text-center">
Nice work, you successfully listed your product! Here are a few ideas to get your started.
</p>
<div className="flex flex-col gap-16 my-32">
<div className='!flex items-center gap-16'>
<div className={`rounded-8 w-48 h-48 text-center py-12 shrink-0 bg-gray-100`}>
</div>
<div>
<p className="font-medium self-center">
Stories
</p>
<p className="text-body5 text-gray-500">
Tell the maker community about your product.
</p>
</div>
</div>
<div className='!flex items-center gap-16'>
<div className={`rounded-8 w-48 h-48 text-center py-12 shrink-0 bg-gray-100`}>
</div>
<div>
<p className="font-medium self-center">
Start hacking
</p>
<p className="text-body5 text-gray-500">
Kickstart your hacking journey with a tournament.
</p>
</div>
</div>
</div>
<div className="flex flex-col gap-16" >
<Button
color='primary'
fullWidth
newTab
href={createRoute({ type: "write-story" })}
> Write a story</Button>
<Button
color='white'
fullWidth
newTab
href='/tournaments'
> Explore tournaments</Button>
</div>
</motion.div>
)
}

View File

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

View File

@@ -0,0 +1,100 @@
import Button from 'src/Components/Button/Button';
import { useGetAllRolesQuery } from 'src/graphql';
import { random } from 'src/utils/helperFunctions';
interface Props {
value: number[];
onChange?: (v: number[]) => void;
}
export default function RecruitRolesInput(props: Props) {
const query = useGetAllRolesQuery();
const handleClick = (clickedValue: number) => {
if (props.value.includes(clickedValue))
props.onChange?.(props.value.filter(v => v !== clickedValue));
else
props.onChange?.([...props.value, clickedValue])
}
return (
<div className="flex flex-wrap gap-8">
{query.loading ?
Array(10).fill(0).map((_, idx) =>
<div
key={idx}
className="bg-gray-100 border border-gray-200 p-8 rounded-10">
<span className='invisible'>{"loading category skeleton".slice(random(6, 12))}</span>
</div>)
:
query.data?.getAllMakersRoles.map(item =>
<Button
key={item.id}
color='none'
size='sm'
className={`
border border-gray-200
${props.value.includes(item.id) ?
'text-primary-600 bg-primary-100'
:
"bg-gray-100"
}
`}
onClick={() => handleClick(item.id)}
>
{item.icon} {item.title}
</Button>)
}
</div>
)
}
const data = [
{
text: 'Frontend Dev',
icon: '💄️'
},
{
text: 'Backend Dev',
icon: '💻'
},
{
text: 'UI/UX Designer',
icon: '🌈️️'
},
{
text: 'Comm. Manager',
icon: '🎉️️'
},
{
text: 'Founder',
icon: '🦄️'
},
{
text: 'Marketer',
icon: '🚨️'
},
{
text: 'Content Creator',
icon: '🎥️'
},
{
text: 'Researcher',
icon: '🔬'
},
{
text: 'Data engineer',
icon: '💿️'
},
{
text: 'Growth hacker',
icon: '📉️'
},
{
text: 'Technical Writer',
icon: '✍️️'
},
]

View File

@@ -0,0 +1,21 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { WrapFormController } from 'src/utils/storybook/utils';
import RecruitRolesInput from './RecruitRolesInput';
export default {
title: 'Projects/List Project Page/Inputs/Recruiter Roles Input',
component: RecruitRolesInput,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof RecruitRolesInput>;
const Template: ComponentStory<typeof RecruitRolesInput> = (args) => WrapFormController('v', [])(<RecruitRolesInput {...args as any} ></RecruitRolesInput>)
export const Default = Template.bind({});
Default.args = {
}

View File

@@ -0,0 +1,148 @@
import Button from 'src/Components/Button/Button'
import Card from 'src/Components/Card/Card'
import Avatar from 'src/features/Profiles/Components/Avatar/Avatar'
import { useFormContext } from "react-hook-form"
import { IListProjectForm } from "../FormContainer/FormContainer";
import { useMemo, useState } from 'react'
import { tabs } from '../../ListProjectPage'
import { NotificationsService } from 'src/services'
import { useAppDispatch } from 'src/utils/hooks';
import { openModal } from 'src/redux/features/modals.slice';
import { useCreateProjectMutation, useUpdateProjectMutation, UpdateProjectInput } from 'src/graphql'
import { Navigate, useNavigate } from 'react-router-dom';
import { createRoute } from 'src/utils/routing';
import { wrapLink } from 'src/utils/hoc';
interface Props {
currentTab: keyof typeof tabs
onNext: () => void
onBackToFirstPage: () => void
}
export default function SaveChangesCard(props: Props) {
const { handleSubmit, formState: { isDirty, }, reset, getValues, watch } = useFormContext<IListProjectForm>();
const dispatch = useAppDispatch();
const isUpdating = useMemo(() => !!getValues('id'), [getValues]);
const [navigateToCreatedProject, setNavigateToCreatedProject] = useState(false)
const [update, updatingStatus] = useUpdateProjectMutation();
const [create, creatingStatus] = useCreateProjectMutation()
const isLoading = updatingStatus.loading || creatingStatus.loading
const [hashtag, img, name, tagline] = watch(['hashtag', 'thumbnail_image', 'title', 'tagline',])
const clickCancel = () => {
if (window.confirm('You might lose some unsaved changes. Are you sure you want to continue?'))
reset();
}
const clickSubmit = handleSubmit<IListProjectForm>(async data => {
try {
const input: UpdateProjectInput = {
...data,
members: data.members.map(m => ({ id: m.id, role: m.role })),
screenshots: data.screenshots.map(s => ({ id: s.id, name: s.name, url: s.url }))
}
await (isUpdating ?
update({ variables: { input } })
: create({ variables: { input } })
)
reset(data)
} catch (error) {
NotificationsService.error("A network error happened...");
return;
}
if (isUpdating)
NotificationsService.success("Saved changes successfully")
else {
dispatch(openModal({
Modal: "ProjectListedModal", props: {
project: {
id: data.id!,
name: data.title,
img: data.thumbnail_image.url || "https://picsum.photos/id/870/150/150.jpg",
tagline: data.tagline,
}
}
}))
setNavigateToCreatedProject(true);
}
}, (errors) => {
NotificationsService.error("Please fill all the required fields");
props.onBackToFirstPage()
})
let ctaBtn = useMemo(() => {
if (isUpdating)
return <Button
color="primary"
fullWidth
onClick={clickSubmit}
disabled={!isDirty || isLoading}
>
{isLoading ? "Saving..." : "Save Changes"}
</Button>
else if (props.currentTab === 'project-details')
return <Button
color="primary"
fullWidth
onClick={props.onNext}
>
Next step: {tabs.team.text}
</Button>
else if (props.currentTab === 'team')
return <Button
color="primary"
fullWidth
onClick={props.onNext}
>
Next step: {tabs.extras.text}
</Button>
else
return <Button
color="primary"
fullWidth
onClick={clickSubmit}
disabled={!isDirty || isLoading}
>
{isLoading ? "Listing your product..." : "List your product"}
</Button>
}, [clickSubmit, isDirty, isLoading, isUpdating, props.currentTab, props.onNext])
if (navigateToCreatedProject) return <Navigate to={createRoute({ type: "project", tag: hashtag })} />
return (
<Card className='flex flex-col gap-24'>
{wrapLink(<div className='flex gap-8 items-center'>
{img ?
<Avatar width={48} src={img.url} /> :
<div className="bg-gray-50 border border-gray-200 rounded-full w-48 h-48 shrink-0"></div>
}
<div className='overflow-hidden'>
<p className={`text-body4 text-black font-medium overflow-hidden text-ellipsis`}>{name || "Product preview"}</p>
{<p className={`text-body6 text-gray-600 text-ellipsis overflow-hidden whitespace-nowrap`}>{tagline || "Provide some more details."}</p>}
</div>
</div>, isUpdating ? createRoute({ type: "project", tag: hashtag }) : undefined)}
<div className="border-b border-gray-200"></div>
{/* <p className="hidden md:block text-body5">{trimText(profileQuery.data.profile.bio, 120)}</p> */}
<div className="flex flex-col gap-16">
{ctaBtn}
<Button
color="gray"
onClick={clickCancel}
disabled={!isDirty || isLoading}
>
Cancel
</Button>
</div>
</Card>
)
}

View File

@@ -0,0 +1,54 @@
import { Menu, MenuButton, MenuItem } from '@szhsin/react-menu';
import { FaChevronDown, FaRegTrashAlt, } from 'react-icons/fa';
import Avatar from 'src/features/Profiles/Components/Avatar/Avatar';
import { Team_Member_Role } from 'src/graphql';
import { Value } from './TeamMembersInput'
interface Props {
user: Value[number]
onRemove: () => void;
onUpdateRole: (role: Team_Member_Role) => void
disabled?: boolean;
canUpdateRole?: boolean;
canDelete?: boolean;
}
export default function MemberRow({ user, onRemove, onUpdateRole, disabled, canUpdateRole, canDelete }: Props) {
return (
<div
key={user.id}
className="border-b py-16 last-of-type:border-b-0 flex gap-16 items-center">
<Avatar width={40} src={user.avatar} />
<div className='grow overflow-hidden'>
<p className="font-medium self-center overflow-hidden text-ellipsis whitespace-nowrap">
{user.name}
</p>
<p className="text-body5 text-gray-500 overflow-hidden text-ellipsis whitespace-nowrap">
{user.jobTitle}
</p>
</div>
<div className="ml-auto flex gap-12 md:gap-16 shrink-0">
{canUpdateRole ? <Menu
offsetY={12}
align='end'
menuButton={<MenuButton className='border text-body5 border-gray-200 p-8 rounded-8 text-gray-500'>{user.role} <FaChevronDown className='ml-4 text-gray-400' /></MenuButton>} transition>
{[Team_Member_Role.Admin, Team_Member_Role.Maker].map(role =>
<MenuItem
className={'text-body5'}
onClick={() => onUpdateRole(role)}
key={role}>{role}</MenuItem>
)}
</Menu>
:
<span className="text-gray-500">{user.role}</span>
}
{canDelete && <button onClick={() => onRemove()} className=''>
<FaRegTrashAlt className='text-red-400' />
</button>}
</div>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { WrapFormController } from 'src/utils/storybook/utils';
import TeamMembersInput from './TeamMembersInput';
export default {
title: 'Projects/List Project Page/Inputs/Team Members Input',
component: TeamMembersInput,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof TeamMembersInput>;
const Template: ComponentStory<typeof TeamMembersInput> = (args) => WrapFormController('v', [])(<TeamMembersInput {...args as any} ></TeamMembersInput>)
export const Default = Template.bind({});
Default.args = {
}

View File

@@ -0,0 +1,78 @@
import { ComponentProps } from 'react'
import { NestedValue } from 'react-hook-form'
import UsersInput from 'src/Components/Inputs/UsersInput/UsersInput'
import { ProjectPermissionEnum, Team_Member_Role } from 'src/graphql';
import { IListProjectForm } from '../FormContainer/FormContainer';
import { useUpdateProjectContext } from '../FormContainer/updateProjectContext';
import MemberRow from './MemberRow';
export type Value = IListProjectForm['members'] extends NestedValue<infer U> ? U : never;
type Props = {
value: Value,
onChange?: (new_value: Value) => void
}
export default function TeamMembersInput({ value, onChange = () => { } }: Props) {
const { permissions } = useUpdateProjectContext()
const canAddNew = permissions.includes(ProjectPermissionEnum.UpdateAdmins)
const canUpdateMembers = permissions.includes(ProjectPermissionEnum.UpdateMembers)
const canUpdateAdmins = permissions.includes(ProjectPermissionEnum.UpdateAdmins)
const addMember: ComponentProps<typeof UsersInput>['onSelect'] = (user) => {
if (value.some(u => u.id === user.id))
return;
onChange([
...value,
{
id: user.id,
name: user.name,
avatar: user.avatar,
jobTitle: user.jobTitle,
role: Team_Member_Role.Maker,
}])
}
const setMemberRole = (id: number, role: Team_Member_Role) => {
onChange(value.map(u => {
if (u.id !== id) return u;
return {
...u,
role,
}
}))
}
const removeMember = (id: number) => {
onChange(value.filter(u => u.id !== id));
}
return (
<>
{canAddNew && <UsersInput onSelect={addMember} />}
{value.length > 0 &&
<div className='flex flex-col mt-24'>
{value.map(member => {
let canEdit = false;
if (member.role === Team_Member_Role.Admin) canEdit = canUpdateAdmins;
if (member.role === Team_Member_Role.Maker) canEdit = canUpdateMembers;
return <MemberRow
key={member.id}
user={member}
canUpdateRole={canEdit}
canDelete={canEdit}
onRemove={() => removeMember(member.id)}
onUpdateRole={role => setMemberRole(member.id, role)}
/>
})}
</div>}
</>
)
}

View File

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

View File

@@ -0,0 +1,62 @@
import { Controller, useFormContext } from "react-hook-form"
import Card from "src/Components/Card/Card";
import TeamMembersInput from "../TeamMembersInput/TeamMembersInput";
import RecruitRolesInput from "../RecruitRolesInput/RecruitRolesInput";
import { IListProjectForm } from "../FormContainer/FormContainer";
interface Props {
}
export default function TeamTab() {
const { formState: { errors, }, control } = useFormContext<IListProjectForm>();
return (
<div className="flex flex-col gap-24">
<Card >
<h2 className="text-body2 font-bolder"> Team</h2>
<p className="text-body4 font-light text-gray-600 mt-8">Let us know who is on this products team.</p>
<div className="mt-24">
<Controller
control={control}
name="members"
render={({ field: { onChange, value } }) => (
<TeamMembersInput
value={value}
onChange={onChange}
/>
)}
/>
{errors.members && <p className='input-error'>{errors.members?.message}</p>}
</div>
<div className="bg-gray-50 p-16 rounded-12 border border-gray-200 mt-24">
<p className="text-body5">
<span className="font-bold"> Onboard your team:</span> Make sure you onboard any other team members so they can help you manage this project and its development progress. To add them, they will first need to create a maker profile.
</p>
</div>
</Card>
<Card>
<h2 className="text-body2 font-bolder">💪 Recruit</h2>
<p className="text-body4 font-light text-gray-600 mt-8">Are you looking to recruit more makers to your project? Select the roles youre looking for below and let makers discover your project at Tournaments.</p>
<div className="mt-24">
<Controller
control={control}
name="recruit_roles"
render={({ field: { onChange, value } }) => (
<RecruitRolesInput
value={value}
onChange={onChange}
/>
)}
/>
{errors.recruit_roles && <p className='input-error'>{errors.recruit_roles?.message}</p>}
</div>
</Card>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { WrapFormController } from 'src/utils/storybook/utils';
import TournamentsInput from './TournamentsInput';
export default {
title: 'Projects/List Project Page/Inputs/Tournaments Input',
component: TournamentsInput,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof TournamentsInput>;
const Template: ComponentStory<typeof TournamentsInput> = (args) => WrapFormController('v', [])(<TournamentsInput {...args as any} ></TournamentsInput>)
export const Default = Template.bind({});
Default.args = {
}

View File

@@ -0,0 +1,60 @@
import Button from 'src/Components/Button/Button';
import { useGetTournamentsToRegisterQuery } from 'src/graphql';
import { random } from 'src/utils/helperFunctions';
interface Props {
value: number[];
onChange?: (v: number[]) => void;
}
export default function TournamentsInput(props: Props) {
const query = useGetTournamentsToRegisterQuery();
const handleClick = (clickedValue: number) => {
if (props.value.includes(clickedValue))
props.onChange?.(props.value.filter(v => v !== clickedValue));
else
props.onChange?.([...props.value, clickedValue])
}
return (
<div className="flex flex-wrap gap-8">
{query.loading ?
Array(4).fill(0).map((_, idx) =>
<div
key={idx}
className="bg-gray-100 border border-gray-200 p-8 rounded-10">
<span className='invisible'>{"loading category skeleton".slice(random(6, 12))}</span>
</div>)
:
((query.data?.getTournamentToRegister && query.data?.getTournamentToRegister.length < 0) ?
query.data?.getTournamentToRegister.map(item =>
<Button
key={item.id}
color='none'
size='sm'
className={`
border text-gray-800
${props.value.includes(item.id) ?
'title-primary-600 bg-primary-100 border-primary-200'
:
"bg-gray-100 border-gray-200"
}
`}
onClick={() => handleClick(item.id)}
>
{item.title}
</Button>)
:
<p className='text-gray-400 font-medium'>
There is no running tournaments currently.
</p>)
}
</div>
)
}

View File

@@ -0,0 +1,6 @@
query GetTournamentsToRegister {
getTournamentToRegister {
id
title
}
}

View File

@@ -0,0 +1,140 @@
import { useCarousel, useMediaQuery, } from "src/utils/hooks";
import { Helmet } from 'react-helmet'
import { MEDIA_QUERIES } from "src/utils/theme";
import Card from "src/Components/Card/Card";
import LoadingPage from "src/Components/LoadingPage/LoadingPage";
import ProjectDetailsTab from "./Components/ProjectDetailsTab/ProjectDetailsTab";
import TeamTab from "./Components/TeamTab/TeamTab";
import ExtrasTab from "./Components/ExtrasTab/ExtrasTab";
import FormContainer from "./Components/FormContainer/FormContainer";
import { useState } from "react";
import SaveChangesCard from "./Components/SaveChangesCard/SaveChangesCard";
import { useMeQuery } from "src/graphql";
import { Navigate, useLocation } from 'react-router-dom'
export const tabs = {
'project-details': {
text: "🚀️ Project details",
path: 'project-details',
},
'team': {
text: "⚡️ Team",
path: 'team',
},
'extras': {
text: "💎 Extras",
path: 'extras',
}
} as const;
const links = [tabs['project-details'], tabs['team'], tabs['extras']];
type TabsKeys = keyof typeof tabs;
export default function ListProjectPage() {
const isMediumScreen = useMediaQuery(MEDIA_QUERIES.isMedium)
const [curTab, setCurTab] = useState<TabsKeys>(tabs['project-details'].path)
const { viewportRef, } = useCarousel({
align: 'start', slidesToScroll: 2,
containScroll: "trimSnaps",
})
const location = useLocation()
const meQuery = useMeQuery({
});
if (meQuery.loading) return <LoadingPage />
if (meQuery.error || !meQuery.data?.me) return <Navigate to={'/login'} state={{ from: location.pathname }} />
return (
<>
<Helmet>
<title>List a project</title>
<meta property="og:title" content='List a project' />
</Helmet>
<div className="page-container">
<div className="grid grid-cols-1 md:grid-cols-4 gap-24">
{isMediumScreen ?
<aside >
<Card className="sticky-side-element">
<p className="text-body2 font-bolder text-black mb-16">List a project</p>
<ul className=' flex flex-col gap-8'>
{links.map((link, idx) =>
<li key={idx}>
<button
// className={({ isActive }) => `flex items-start rounded-8 cursor-pointer font-bold p-12
// active:scale-95 transition-transform
// ${isActive ? 'bg-gray-100' : 'hover:bg-gray-50'}
// `}
className={`flex w-full items-start rounded-8 cursor-pointer font-bold p-12
active:scale-95 transition-transform
${link.path === curTab ? 'bg-gray-100' : 'hover:bg-gray-50'}
`}
onClick={() => setCurTab(link.path)}
>
{link.text}
</button>
</li>)}
</ul>
</Card>
</aside>
:
<aside
className=" bg-white z-10 w-full sticky-top-element !col-start-1"
>
<div className="relative group overflow-hidden">
<div className="border-b-2 border-gray-200" ref={viewportRef}>
<div className="select-none w-full flex gap-16">
{links.map((link, idx) =>
<button
key={idx}
className={`flex min-w-max items-start cursor-pointer font-bold py-12 px-8
active:scale-95 transition-transform`}
style={{
...(link.path === curTab && {
borderBottom: '2px solid var(--primary)',
marginBottom: -2
}),
}
}
onClick={() => setCurTab(link.path)}
>
{link.text}
</button>
)}
</div>
</div>
</div>
</aside>
}
<main className="md:col-span-3">
<FormContainer>
<div className="grid grid-cols-1 md:grid-cols-3 gap-24">
<div className="md:col-span-2">
{curTab === tabs["project-details"].path && <ProjectDetailsTab />}
{curTab === tabs["team"].path && <TeamTab />}
{curTab === tabs["extras"].path && <ExtrasTab />}
</div>
<div className="self-start sticky-side-element">
<SaveChangesCard
currentTab={curTab}
onNext={() => {
if (curTab === 'project-details') setCurTab(tabs['team'].path)
else if (curTab === 'team') setCurTab(tabs['extras'].path)
}}
onBackToFirstPage={() => setCurTab(tabs["project-details"].path)}
/>
</div>
</div>
</FormContainer>
</main>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,130 @@
import linkifyHtml from 'linkifyjs/lib/linkify-html'
import { useState } from 'react'
import { MdLocalFireDepartment } from 'react-icons/md'
import Button from 'src/Components/Button/Button'
import Card from 'src/Components/Card/Card'
import Lightbox from 'src/Components/Lightbox/Lightbox'
import { ProjectDetailsQuery, ProjectLaunchStatusEnum, ProjectPermissionEnum, } from 'src/graphql'
import { openModal } from 'src/redux/features/modals.slice'
import { setVoteAmount } from 'src/redux/features/vote.slice'
import { numberFormatter } from 'src/utils/helperFunctions'
import { useAppDispatch } from 'src/utils/hooks'
import { createRoute } from 'src/utils/routing'
import LinksCard from '../LinksCard/LinksCard'
interface Props {
project: Pick<ProjectDetailsQuery['getProject'],
| "id"
| "cover_image"
| "thumbnail_image"
| "title"
| "category"
| "permissions"
| "launch_status"
| "description"
| "screenshots"
| "tagline"
| "website"
| "votes_count"
| 'discord'
| 'website'
| 'github'
| 'twitter'
| 'slack'
| 'telegram'
>
}
export default function AboutCard({ project }: Props) {
const dispatch = useAppDispatch();
const [screenshotsOpen, setScreenshotsOpen] = useState(-1);
const onVote = (votes?: number) => {
dispatch(setVoteAmount(votes ?? 10));
dispatch(openModal({
Modal: 'VoteCard', props: {
projectId: project.id,
title: project.title,
initVotes: votes
}
}))
}
const canEdit = project.permissions.includes(ProjectPermissionEnum.UpdateInfo);
return (
<Card defaultPadding={false} onlyMd>
{/* Cover Image */}
<div className="hidden md:block relative rounded-t-12 md:rounded-t-16 h-[120px] lg:h-[160px]">
<img className="w-full h-full object-cover rounded-12 md:rounded-0 md:rounded-t-16" src={project.cover_image} alt="" />
<div className="absolute top-16 md:top-24 left-24 flex gap-8 bg-gray-800 bg-opacity-60 text-white rounded-48 py-4 px-12 text-body6 font-medium">
{project.launch_status === ProjectLaunchStatusEnum.Launched && `🚀 Launched`}
{project.launch_status === ProjectLaunchStatusEnum.Wip && `🔧 WIP`}
</div>
<div className="absolute left-24 bottom-0 translate-y-1/2 w-[108px] aspect-square">
<img className="w-full h-full border-2 border-white rounded-24" src={project.thumbnail_image} alt="" />
</div>
</div>
<div className="md:p-24 md:pt-0 flex flex-col gap-24">
{/* Title & Basic Info */}
<div className="flex flex-col gap-24 relative">
<div className="flex flex-wrap justify-end items-center gap-16 min-h-[40px] mt-12">
{canEdit && <Button size="sm" color="gray" href={createRoute({ type: "edit-project", id: project.id })}>Edit Project</Button>}
<Button size="sm" variant='outline' color='gray' className='hidden md:block hover:!text-red-500 hover:!border-red-200 hover:!bg-red-50' onClick={() => onVote()}>
<MdLocalFireDepartment />{<span className="align-middle w-[4ch]"> {numberFormatter(project.votes_count)}</span>}
</Button>
</div>
<div className='flex flex-col gap-8 items-start justify-between -mt-12'>
<a href={project.website} target='_blank' rel="noreferrer"><h3 className="text-body1 font-bold">{project.title}</h3></a>
<p className="text-body4 text-gray-600">{project.tagline}</p>
<div>
<span className="font-medium text-body5 text-gray-900">{project.category.icon} {project.category.title}</span>
</div>
</div>
<Button size="sm" fullWidth variant='outline' color='gray' className='md:hidden hover:!text-red-500 hover:!border-red-200 hover:!bg-red-50' onClick={() => onVote()}>
<MdLocalFireDepartment />{<span className="align-middle w-[4ch]"> {numberFormatter(project.votes_count)}</span>}
</Button>
</div>
<div className="md:hidden">
<LinksCard links={project} />
</div>
{/* About */}
<div>
<div className="text-body4 text-gray-600 leading-normal whitespace-pre-line" dangerouslySetInnerHTML={{
__html: linkifyHtml(project.description, {
className: ' text-blue-500 underline',
defaultProtocol: 'https',
target: "_blank",
rel: 'noreferrer'
})
}}></div>
</div>
{project.screenshots.length > 0 && <>
<div className="">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 justify-items-center">
{project.screenshots.slice(0, 4).map((screenshot, idx) => <div
key={idx}
className="w-full relative pt-[56%] cursor-pointer bg-gray-100 border rounded-10 overflow-hidden"
onClick={() => setScreenshotsOpen(idx)}
>
<img src={screenshot} className="absolute top-0 left-0 w-full h-full object-cover" alt='' />
</div>)}
</div>
</div>
<Lightbox
images={project.screenshots}
isOpen={screenshotsOpen !== -1}
initOpenIndex={screenshotsOpen}
onClose={() => setScreenshotsOpen(-1)}
/>
</>}
</div>
</Card>
)
}

View File

@@ -0,0 +1,26 @@
import React from 'react'
import Badge from 'src/Components/Badge/Badge'
import Card from 'src/Components/Card/Card'
import { ProjectDetailsQuery } from 'src/graphql'
interface Props {
capabilities: ProjectDetailsQuery['getProject']['capabilities']
}
export default function CapabilitiesCard({ capabilities }: Props) {
return (
<Card onlyMd>
<p className="text-body6 max-md:uppercase max-md:text-gray-400 md:text-body2 font-bold">🦾 Capabilities</p>
<div className="mt-16">
{capabilities.length === 0 && <>
<p className="text-gray-700 text-body4">No capabilities added</p>
</>}
<div className="flex flex-wrap gap-8">
{capabilities.map(cap => <Badge key={cap.id} size='sm'>{cap.icon} {cap.title}</Badge>)}
</div>
</div>
</Card>
)
}

View File

@@ -0,0 +1,78 @@
import Card from 'src/Components/Card/Card'
import { Project } from 'src/graphql'
import { FaDiscord, } from 'react-icons/fa';
import { FiGithub, FiGlobe, FiTwitter } from 'react-icons/fi';
import CopyToClipboard from 'react-copy-to-clipboard';
import { NotificationsService, } from 'src/services'
interface Props {
links: Pick<Project, 'discord' | 'website' | 'github' | 'twitter' | 'slack' | 'telegram'>
}
export default function LinksCard({ links }: Props) {
const linksList = [
{
value: links.discord,
text: links.discord,
icon: FaDiscord,
colors: "bg-violet-100 text-violet-900",
},
{
value: links.website,
text: links.website?.replace(/(^\w+:|^)\/\//, '').replace(/\/$/, ""),
icon: FiGlobe,
colors: "bg-gray-100 text-gray-900",
url: links.website
},
{
value: links.twitter,
text: links.twitter,
icon: FiTwitter,
colors: "bg-blue-100 text-blue-500",
url: links.twitter
},
{
value: links.github,
text: links.github,
icon: FiGithub,
colors: "bg-pink-100 text-pink-600",
url: links.github
},
];
return (
<Card onlyMd>
<p className="text-body2 font-bold mb-16 hidden md:block">🔗 Links</p>
<div className="">
{linksList.length === 0 && <>
<p className="text-gray-700 text-body4">No links added</p>
</>}
<div className="flex flex-wrap gap-16">
{linksList.filter(link => !!link.value).map((link, idx) =>
(link.url ? <a
key={idx}
href={link.url!}
className={`w-40 aspect-square rounded-full flex justify-center items-center ${link.colors}`}
target='_blank'
rel="noreferrer">
<link.icon className="scale-125" />
</a>
:
<CopyToClipboard
text={link.value!}
onCopy={() => NotificationsService.info(" Copied to clipboard", { icon: "📋" })}
>
<button
key={idx}
onClick={() => { }}
className={`w-40 aspect-square rounded-full flex justify-center items-center ${link.colors}`}
>
<link.icon className="scale-125" />
</button>
</CopyToClipboard>
))}
</div>
</div>
</Card>
)
}

View File

@@ -0,0 +1,50 @@
import { Link } from 'react-router-dom'
import Badge from 'src/Components/Badge/Badge'
import Card from 'src/Components/Card/Card'
import Avatar from 'src/features/Profiles/Components/Avatar/Avatar'
import { sortMembersByRole } from 'src/features/Projects/utils/helperFunctions'
import { ProjectDetailsQuery } from 'src/graphql'
import { createRoute } from 'src/utils/routing'
interface Props {
members: ProjectDetailsQuery['getProject']['members']
recruit_roles: ProjectDetailsQuery['getProject']['recruit_roles']
}
export default function MakersCard({ members, recruit_roles }: Props) {
return (
<Card onlyMd>
<p className="text-body6 max-md:uppercase max-md:text-gray-400 md:text-body2 font-bold">👾 Makers</p>
<div className="mt-16">
<div className="flex flex-wrap gap-8">
{members.length === 0 && <p className="text-body4 text-gray-500">Not listed</p>}
{sortMembersByRole(members).map(m => <Link key={m.user.id} to={createRoute({ type: "profile", id: m.user.id, username: m.user.name })}>
<Avatar
width={40}
src={m.user.avatar}
renderTooltip={() => <div className='bg-white px-12 py-8 border border-gray-200 rounded-12 flex flex-wrap gap-12 shadow-lg'>
<Avatar width={48} src={m.user.avatar} />
<div className='overflow-hidden'>
<p className={`text-black font-medium overflow-hidden text-ellipsis`}>{m.user.name}</p>
<p className={`text-body6 text-gray-600`}>{m.user.jobTitle}</p>
</div>
</div>}
/>
</Link>)}
</div>
</div>
<p className="text-body6 uppercase font-medium text-gray-400 mt-24">Open roles</p>
<div className="mt-8">
{recruit_roles.length === 0 && <>
<p className="text-gray-700 text-body4">No open roles for now</p>
</>}
<div className="flex flex-wrap gap-8">
{recruit_roles.map(role => <Badge key={role.id} size='sm'>{role.icon} {role.title}</Badge>)}
</div>
</div>
</Card>
)
}

View File

@@ -0,0 +1,25 @@
import Badge from 'src/Components/Badge/Badge'
import Card from 'src/Components/Card/Card'
import { ProjectDetailsQuery } from 'src/graphql'
interface Props {
recruit_roles: ProjectDetailsQuery['getProject']['recruit_roles']
}
export default function OpenRolesCard({ recruit_roles }: Props) {
return (
<Card onlyMd>
<p className="text-body2 font-bold">👀 Open roles</p>
<div className="mt-16">
{recruit_roles.length === 0 && <>
<p className="text-gray-700 text-body4">No open roles for now</p>
</>}
<div className="flex flex-wrap gap-16">
{recruit_roles.map(role => <Badge key={role.id} size='sm'>{role.icon} {role.title}</Badge>)}
</div>
</div>
</Card>
)
}

View File

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

View File

@@ -0,0 +1,38 @@
import { Link } from 'react-router-dom'
import Card from 'src/Components/Card/Card'
import Avatar from 'src/features/Profiles/Components/Avatar/Avatar'
import { User, useSimilarProjectsQuery } from 'src/graphql'
import { createRoute } from 'src/utils/routing'
interface Props {
id: number
}
export default function SimilarProjectsCard({ id }: Props) {
const query = useSimilarProjectsQuery({ variables: { projectId: id } })
if (query.loading) return null;
if (query.data?.similarProjects.length === 0) return null;
return (
<Card onlyMd>
<h3 className="text-body2 font-bolder">🚀 Similar projects</h3>
<ul className='flex flex-col'>
{query.data?.similarProjects.map(project => {
return <Link key={project.id} to={createRoute({ type: "project", tag: project.hashtag })} className="md:border-b py-16 last-of-type:border-b-0 last-of-type:pb-0">
<li className="flex items-center gap-12">
<img className='w-48 aspect-square rounded-12 border border-gray-100' alt='' src={project.thumbnail_image} />
<div className='overflow-hidden'>
<p className="text-body4 text-gray-800 font-medium whitespace-nowrap overflow-hidden text-ellipsis">{project.title}</p>
<p className="text-body5 text-gray-500">{project.category.icon} {project.category.title}</p>
</div>
</li>
</Link>
})}
</ul>
</Card>
)
}

View File

@@ -1,19 +1,37 @@
query ProjectDetails($projectId: Int!) {
getProject(id: $projectId) {
query ProjectDetails($projectId: Int, $projectTag: String) {
getProject(id: $projectId, tag: $projectTag) {
id
title
tagline
description
hashtag
cover_image
thumbnail_image
launch_status
twitter
discord
github
slack
telegram
screenshots
website
lightning_address
lnurl_callback_url
votes_count
category {
id
icon
title
}
permissions
members {
role
user {
id
name
jobTitle
avatar
}
}
awards {
title
image
@@ -24,5 +42,40 @@ query ProjectDetails($projectId: Int!) {
id
title
}
recruit_roles {
id
title
icon
level
}
stories {
id
title
createdAt
tags {
id
title
icon
}
}
capabilities {
id
title
icon
}
}
}
query SimilarProjects($projectId: Int!) {
similarProjects(id: $projectId) {
id
title
hashtag
thumbnail_image
category {
id
icon
title
}
}
}

View File

@@ -5,6 +5,7 @@ import Skeleton from 'react-loading-skeleton';
import Badge from 'src/Components/Badge/Badge';
import { useMediaQuery } from 'src/utils/hooks';
import { MEDIA_QUERIES } from 'src/utils/theme';
import Button from 'src/Components/Button/Button';
interface Props extends ModalCard {
@@ -17,8 +18,6 @@ export default function ProjectDetailsCardSkeleton({ onClose, direction, ...prop
const isMdScreen = useMediaQuery(MEDIA_QUERIES.isMedium)
return (
<motion.div
custom={direction}
@@ -26,39 +25,45 @@ export default function ProjectDetailsCardSkeleton({ onClose, direction, ...prop
initial='initial'
animate="animate"
exit='exit'
className={`modal-card max-w-[768px] ${props.isPageModal && !isMdScreen && 'rounded-0 w-full min-h-screen'}`}
className={`modal-card max-w-[676px] ${props.isPageModal && !isMdScreen && 'rounded-0 w-full min-h-screen'}`}
>
<div className="relative h-[80px] lg:h-[152px]">
<div className="relative h-[100px] lg:h-[80px]">
<Skeleton height='100%' className='!leading-inherit' />
<button className="w-40 h-40 md:w-48 md:h-48 bg-white z-10 absolute top-1/2 left-32 -translate-y-1/2 rounded-full hover:bg-gray-200 text-center" onClick={onClose}><MdClose className=' inline-block text-body2 lg:text-body1' /></button>
<button className="w-32 h-32 bg-gray-600 bg-opacity-80 text-white absolute top-24 right-24 rounded-full hover:bg-gray-800 text-center" onClick={onClose}><MdClose className=' inline-block' /></button>
</div>
<div className="p-24">
<div className="flex gap-24 items-center h-[93px]">
<div className="flex-shrink-0 w-[93px] h-[93px] rounded-md overflow-hidden">
<Skeleton height='100%' />
<div className="p-24 flex flex-col gap-24">
<div className="flex flex-col mt-[-80px] md:flex-row md:mt-0 gap-24 items-start relative">
<div className="flex-shrink-0 w-[108px] h-[108px] ">
<Skeleton height='100%' className='rounded-24 border-2 border-white' />
</div>
<div className='flex flex-col items-start justify-between self-stretch'>
<h3 className="text-h3 font-regular"> <Skeleton width='13ch' /></h3>
<span className="text-blue-400 font-regular text-body4" > <Skeleton width='6ch' /></span>
<div className='flex gap-8'>
<Badge size='sm' isLoading />
<Badge size='sm' isLoading />
<div className='flex flex-col gap-8 items-start justify-between'>
<h3 className="text-body1 font-bold"><Skeleton width='13ch' /></h3>
<p className="text-body4 text-gray-600"><Skeleton width='30ch' /></p>
<div>
<span className="font-medium text-body4 text-gray-600"><Skeleton width='10ch' /></span>
</div>
</div>
<div className="flex-shrink-0 w-full md:w-auto md:flex ml-auto gap-16 self-stretch">
<Button fullWidth variant='outline' color='gray' className='!px-8'>
<p className='opacity-0'>votes</p>
</Button>
</div>
</div>
<p className="mt-40 text-body4 leading-normal h-[120px]">
<p className="text-body4 leading-normal">
<Skeleton width='98%' />
<Skeleton width='90%' />
<Skeleton width='70%' />
<Skeleton width='40%' />
</p>
<div className="mt-40">
<h3 className="text-h5 font-bold mb-16">Screenshots</h3>
<div className="flex flex-wrap gap-16">
<Skeleton width='40px' height='40px' className='rounded-full' />
<Skeleton width='40px' height='40px' className='rounded-full' />
</div>
<div >
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 justify-items-center">
{
Array(4).fill(0).map((_, idx) => <div key={idx} className="w-full relative pt-[56%] cursor-pointer bg-gray-200 shadow-sm rounded-10 overflow-hidden">
@@ -67,10 +72,7 @@ export default function ProjectDetailsCardSkeleton({ onClose, direction, ...prop
}
</div>
</div>
<hr className="my-40" />
<div className="text-center h-[100px]">
</div>
<div className="text-center h-[46px]"></div>
</div>
</motion.div>
)

View File

@@ -1,22 +1,29 @@
import { useEffect, useState } from 'react'
import { BsJoystick } from 'react-icons/bs'
import { MdClose, MdLocalFireDepartment } from 'react-icons/md';
import { MdLocalFireDepartment } from 'react-icons/md';
import { ModalCard } from 'src/Components/Modals/ModalsContainer/ModalsContainer';
import { useAppDispatch, useAppSelector, useMediaQuery } from 'src/utils/hooks';
import { openModal, scheduleModal } from 'src/redux/features/modals.slice';
import { setProject } from 'src/redux/features/project.slice';
import Button from 'src/Components/Button/Button';
import { AiFillThunderbolt } from 'react-icons/ai';
import ProjectCardSkeleton from './ProjectDetailsCard.Skeleton'
import VoteButton from 'src/features/Projects/pages/ProjectPage/VoteButton/VoteButton';
import { Wallet_Service } from 'src/services'
import { useProjectDetailsQuery } from 'src/graphql';
// import VoteButton from 'src/features/Projects/pages/ProjectPage/VoteButton/VoteButton';
import { NotificationsService, Wallet_Service } from 'src/services'
import { ProjectLaunchStatusEnum, ProjectPermissionEnum, useProjectDetailsQuery } from 'src/graphql';
import Lightbox from 'src/Components/Lightbox/Lightbox'
import linkifyHtml from 'linkify-html';
import ErrorMessage from 'src/Components/Errors/ErrorMessage/ErrorMessage';
import { setVoteAmount } from 'src/redux/features/vote.slice';
import { numberFormatter } from 'src/utils/helperFunctions';
import { MEDIA_QUERIES } from 'src/utils/theme';
import { FaDiscord, } from 'react-icons/fa';
import { FiEdit2, FiGithub, FiGlobe, FiTwitter } from 'react-icons/fi';
import CopyToClipboard from 'react-copy-to-clipboard';
import Badge from 'src/Components/Badge/Badge';
import Avatar from 'src/features/Profiles/Components/Avatar/Avatar';
import { Link } from 'react-router-dom';
import { createRoute } from 'src/utils/routing';
import { IoMdClose } from 'react-icons/io';
import { sortMembersByRole } from 'src/features/Projects/utils/helperFunctions';
interface Props extends ModalCard {
@@ -29,14 +36,13 @@ export default function ProjectDetailsCard({ direction, projectId, ...props }: P
const [screenshotsOpen, setScreenshotsOpen] = useState(-1);
const { isWalletConnected, project } = useAppSelector(state => ({
const { isWalletConnected } = useAppSelector(state => ({
isWalletConnected: state.wallet.isConnected,
project: state.project.project
}));
const isMdScreen = useMediaQuery(MEDIA_QUERIES.isMedium)
const { loading, error } = useProjectDetailsQuery({
variables: { projectId: projectId! },
const { data, loading, error } = useProjectDetailsQuery({
variables: { projectId: projectId!, projectTag: null },
onCompleted: data => {
dispatch(setProject(data.getProject))
},
@@ -46,10 +52,6 @@ export default function ProjectDetailsCard({ direction, projectId, ...props }: P
skip: !Boolean(projectId)
});
useEffect(() => {
return () => {
}
}, [dispatch]);
const closeModal = () => {
props.onClose?.();
@@ -65,89 +67,144 @@ export default function ProjectDetailsCard({ direction, projectId, ...props }: P
</div>
</div>
if (loading || !project)
if (loading || !data?.getProject)
return <ProjectCardSkeleton onClose={closeModal} direction={direction} isPageModal={props.isPageModal} />;
const onConnectWallet = async () => {
Wallet_Service.connectWallet()
}
const project = data.getProject;
const links = [
{
value: project.discord,
text: project.discord,
icon: FaDiscord,
colors: "bg-violet-100 text-violet-900",
},
{
value: project.website,
text: project.website?.replace(/(^\w+:|^)\/\//, '').replace(/\/$/, ""),
icon: FiGlobe,
colors: "bg-gray-100 text-gray-900",
url: project.website
},
{
value: project.twitter,
text: project.twitter,
icon: FiTwitter,
colors: "bg-blue-100 text-blue-500",
url: project.twitter
},
{
value: project.github,
text: project.github,
icon: FiGithub,
colors: "bg-pink-100 text-pink-600",
url: project.github
},
];
const onVote = (votes?: number) => {
dispatch(setVoteAmount(votes ?? 10));
dispatch(openModal({
Modal: 'VoteCard', props: {
projectId: project.id,
title: project.title,
initVotes: votes
}
}))
}
const onClaim = () => {
if (!isWalletConnected) {
dispatch(scheduleModal({
Modal: 'Claim_GenerateSignatureCard',
}))
dispatch(openModal({
Modal: 'Login_ScanningWalletCard'
}))
} else
dispatch(openModal({
Modal: 'Claim_GenerateSignatureCard',
}))
}
return (
<div
className={`modal-card max-w-[768px] ${(props.isPageModal && !isMdScreen) && '!rounded-0 w-full min-h-screen'}`}
className={`modal-card max-w-[676px] ${(props.isPageModal && !isMdScreen) && '!rounded-0 w-full min-h-screen'}`}
>
<div className="relative h-[80px] lg:h-[152px]">
{/* Cover Image */}
<div className="relative h-[120px] lg:h-[80px]">
<img className="w-full h-full object-cover" src={project.cover_image} alt="" />
<button className="w-40 h-40 md:w-48 md:h-48 bg-white absolute top-1/2 left-32 -translate-y-1/2 rounded-full hover:bg-gray-200 text-center" onClick={closeModal}><MdClose className=' inline-block text-body2 lg:text-body1' /></button>
</div>
<div className="p-24">
<div className="flex gap-24 items-start">
<div className="flex-shrink-0 w-[93px] h-[93px]">
<img className="w-full h-full rounded-md border" src={project?.thumbnail_image} alt="" />
<div className="absolute w-full px-16 md:px-24 top-16 md:top-24 flex justify-between items-center">
<div className="flex gap-8 bg-gray-800 bg-opacity-60 text-white rounded-48 py-4 px-12 text-body6 font-medium">
{project.launch_status === ProjectLaunchStatusEnum.Launched && `🚀 Launched`}
{project.launch_status === ProjectLaunchStatusEnum.Wip && `🔧 WIP`}
</div>
<div className='flex flex-col items-start justify-between self-stretch'>
<h3 className="text-h3 font-regular">{project?.title}</h3>
<a className="text-blue-400 font-regular text-body4 truncate max-w-[20ch]" target='_blank' rel="noreferrer" href={project?.website}>{project?.website?.replace(/(^\w+:|^)\/\//, '').replace(/\/$/, "")}</a>
<div className="flex gap-8">
{project.permissions.includes(ProjectPermissionEnum.UpdateInfo) &&
<Link className="w-32 h-32 bg-gray-800 bg-opacity-60 text-white rounded-full hover:bg-opacity-40 text-center flex flex-col justify-center items-center" onClick={() => props.onClose?.()} to={createRoute({ type: "edit-project", id: project.id })}><FiEdit2 /></Link>}
<button className="w-32 h-32 bg-gray-800 bg-opacity-60 text-white rounded-full hover:bg-opacity-40 text-center flex flex-col justify-center items-center" onClick={closeModal}><IoMdClose className=' inline-block' /></button>
</div>
</div>
</div>
<div className="p-24 flex flex-col gap-24">
{/* Title & Basic Info */}
<div className="flex flex-col mt-[-80px] md:flex-row md:mt-0 gap-24 md:items-center relative">
<div className="flex-shrink-0 w-[108px] h-[108px]">
<img className="w-full h-full border-2 border-white rounded-24" src={project.thumbnail_image} alt="" />
</div>
<div className='flex flex-col gap-8 items-start justify-between'>
<a href={project.website} target='_blank' rel="noreferrer"><h3 className="text-body1 font-bold">{project.title}</h3></a>
<p className="text-body4 text-gray-600">{project.tagline}</p>
<div>
<span className="chip-small font-light text-body5 py-4 px-12 mr-8"> {project?.category.title}</span>
<span className="chip-small bg-warning-50 font-light text-body5 py-4 px-12"><MdLocalFireDepartment className='inline-block text-fire transform text-body4 align-middle' /> {numberFormatter(project?.votes_count)}</span>
<span className="font-medium text-body4 text-gray-600">{project.category.icon} {project.category.title}</span>
</div>
</div>
<div className="flex-shrink-0 hidden md:flex ml-auto gap-16">
<Button color='primary' size='md' className=" my-16" href={project.website} newTab >Visit <BsJoystick /></Button>
{isWalletConnected ?
<VoteButton onVote={onVote} />
<div className="flex-shrink-0 w-full md:w-auto md:flex ml-auto gap-16 self-stretch">
{/* <Button color='primary' size='md' className=" my-16" href={project.website} newTab >Visit <BsJoystick /></Button> */}
{/* <VoteButton onVote={onVote} /> */}
{/* <VoteButton fullWidth votes={project.votes_count} direction='vertical' onVote={onVote} /> */}
{/* {isWalletConnected ?
:
<Button onClick={onConnectWallet} size='md' className="border border-gray-200 bg-gray-100 hover:bg-gray-50 active:bg-gray-100 my-16"><AiFillThunderbolt className='inline-block text-thunder transform scale-125' /> Connect Wallet to Vote</Button>
}
} */}
<Button fullWidth variant='outline' color='gray' className='!px-8' onClick={() => onVote()}>
<div className="flex justify-center items-center gap-8 md:flex-col ">
<MdLocalFireDepartment />{<span className="align-middle w-[4ch]"> {numberFormatter(project.votes_count)}</span>}
</div>
</Button>
</div>
</div>
<p className="mt-40 text-body4 leading-normal whitespace-pre-line" dangerouslySetInnerHTML={{
__html: linkifyHtml(project?.description, {
className: ' text-blue-500 underline',
defaultProtocol: 'https',
target: "_blank",
rel: 'noreferrer'
})
}}></p>
<div className="md:hidden">
<Button color='primary' size='md' fullWidth href={project.website} newTab className="w-full mt-24 mb-16">Visit <BsJoystick /></Button>
{isWalletConnected ?
<VoteButton fullWidth onVote={onVote} />
:
<Button size='md' fullWidth className="bg-gray-200 hover:bg-gray-100 mb-24" onClick={onConnectWallet}><AiFillThunderbolt className='inline-block text-thunder transform scale-125' /> Connect Wallet to Vote</Button>
}
{/* About */}
<div>
<p className="text-body6 uppercase font-medium text-gray-400 mb-8">About</p>
<div className=" text-body4 text-gray-600 leading-normal whitespace-pre-line" dangerouslySetInnerHTML={{
__html: linkifyHtml(project.description, {
className: ' text-blue-500 underline',
defaultProtocol: 'https',
target: "_blank",
rel: 'noreferrer'
})
}}></div>
{/* Links */}
<div className="mt-16 flex flex-wrap gap-16">
{links.filter(link => !!link.value).map((link, idx) =>
(link.url ? <a
key={idx}
href={link.url!}
className={`w-40 aspect-square rounded-full flex justify-center items-center ${link.colors}`}
target='_blank'
rel="noreferrer">
<link.icon className="scale-125" />
</a>
:
<CopyToClipboard
text={link.value!}
onCopy={() => NotificationsService.info(" Copied to clipboard", { icon: "📋" })}
>
<button
key={idx}
onClick={() => { }}
className={`w-40 aspect-square rounded-full flex justify-center items-center ${link.colors}`}
>
<link.icon className="scale-125" />
</button>
</CopyToClipboard>
))}
</div>
</div>
{project.screenshots.length > 0 && <>
<div className="mt-40">
<h3 className="text-h5 font-bold mb-16">Screenshots</h3>
<div className="">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 justify-items-center">
{project.screenshots.slice(0, 4).map((screenshot, idx) => <div
key={idx}
@@ -165,9 +222,39 @@ export default function ProjectDetailsCard({ direction, projectId, ...props }: P
onClose={() => setScreenshotsOpen(-1)}
/>
</>}
<hr className="my-40" />
<div className="text-center">
<h3 className="text-body4 font-regular">Are you the creator of this project?</h3>
{project.capabilities.length > 0 &&
<div>
<p className="text-body6 uppercase font-medium text-gray-400 mb-8">CAPABILITIES</p>
<div className="flex flex-wrap gap-8">
{project.capabilities.map(cap => <Badge key={cap.id} size='sm'>{cap.icon} {cap.title}</Badge>)}
</div>
</div>}
{project.members.length > 0 &&
<div className='relative'>
<p className="text-body6 uppercase font-medium text-gray-400 mb-8">MAKERS</p>
<div className="flex flex-wrap gap-8">
{sortMembersByRole(project.members).map(m => <Link key={m.user.id} to={createRoute({ type: "profile", id: m.user.id, username: m.user.name })}>
<Avatar
width={40}
src={m.user.avatar}
renderTooltip={() => <div className='bg-white px-12 py-8 border border-gray-200 rounded-12 flex flex-wrap gap-12 shadow-lg relative z-10'>
<Avatar width={48} src={m.user.avatar} />
<div className='overflow-hidden'>
<p className={`text-black font-medium overflow-hidden text-ellipsis`}>{m.user.name}</p>
<p className={`text-body6 text-gray-600`}>{m.user.jobTitle}</p>
</div>
</div>}
/>
</Link>)}
</div>
</div>}
<Button color='white' fullWidth href={createRoute({ type: "project", tag: project.hashtag })} onClick={props.onClose}>View project details</Button>
{/* <div className="text-center">
<h3 className="text-body4 font-regular">Are you the creator of this project</h3>
<Button
color='gray'
size='md'
@@ -176,7 +263,7 @@ export default function ProjectDetailsCard({ direction, projectId, ...props }: P
newTab
// onClick={onClaim}
>Claim 🖐</Button>
</div>
</div> */}
</div>
</div>
)

View File

@@ -0,0 +1,110 @@
import { useParams } from "react-router-dom"
import LoadingPage from "src/Components/LoadingPage/LoadingPage"
import NotFoundPage from "src/features/Shared/pages/NotFoundPage/NotFoundPage"
import { ProjectLaunchStatusEnum, useProjectDetailsQuery } from "src/graphql"
import { Helmet } from 'react-helmet'
import { useAppDispatch, useMediaQuery } from 'src/utils/hooks';
import styles from './styles.module.scss'
import { MEDIA_QUERIES } from "src/utils/theme"
import { setProject } from "src/redux/features/project.slice"
import LinksCard from "./Components/LinksCard/LinksCard"
import CapabilitiesCard from "./Components/CapabilitiesCard/CapabilitiesCard"
import TournamentsCard from "src/features/Profiles/pages/ProfilePage/TournamentsCard/TournamentsCard"
import StoriesCard from "src/features/Profiles/pages/ProfilePage/StoriesCard/StoriesCard"
import MakersCard from "./Components/MakersCard/MakersCard"
import AboutCard from "./Components/AboutCard/AboutCard"
import SimilarProjectsCard from "./Components/SimilarProjectsCard/SimilarProjectsCard"
export default function ProjectPage() {
const { tag } = useParams()
const dispatch = useAppDispatch();
const { data, loading, error } = useProjectDetailsQuery({
variables: { projectId: null, projectTag: tag! },
onCompleted: data => {
dispatch(setProject(data.getProject))
},
onError: () => {
dispatch(setProject(null));
},
skip: !Boolean(tag)
});
const isMediumScreen = useMediaQuery(MEDIA_QUERIES.isMedium)
if (loading)
return <LoadingPage />
if (!data?.getProject)
return <NotFoundPage />
if (error) throw new Error("Couldn't fetch the project", { cause: error })
const project = data.getProject;
return (
<>
<Helmet>
<title>{project.title}</title>
<meta property="og:title" content={project.title} />
{project.lightning_address &&
<meta name="lightning" content={`lnurlp:${project.lightning_address}`} />
}
<meta property="og:image" content={project.cover_image} />
</Helmet>
<div className="relative w-full md:hidden h-[120px]">
<img className="w-full h-full object-cover" src={project.cover_image} alt="" />
<div className="absolute top-16 md:top-24 left-24 flex gap-8 bg-gray-800 bg-opacity-60 text-white rounded-48 py-4 px-12 text-body6 font-medium">
{project.launch_status === ProjectLaunchStatusEnum.Launched && `🚀 Launched`}
{project.launch_status === ProjectLaunchStatusEnum.Wip && `🔧 WIP`}
</div>
<div className="absolute left-24 bottom-0 translate-y-1/2 w-[108px] aspect-square">
<img className="w-full h-full border-2 border-white rounded-24" src={project.thumbnail_image} alt="" />
</div>
</div>
<div className={`content-container pb-32 md:pt-32 bg-white md:bg-inherit`}
>
<div className={` ${styles.grid}`}
>{isMediumScreen ?
<>
<aside>
<LinksCard links={project} />
<CapabilitiesCard capabilities={project.capabilities} />
<TournamentsCard tournaments={[]} />
</aside>
<main className="min-w-0">
<AboutCard project={project} />
<StoriesCard stories={project.stories} />
</main>
<aside className="min-w-0">
<MakersCard members={project.members} recruit_roles={project.recruit_roles} />
<SimilarProjectsCard id={project.id} />
</aside>
</>
:
<>
<main>
<AboutCard project={project} />
<CapabilitiesCard capabilities={project.capabilities} />
<hr className="bg-gray-100" />
<MakersCard members={project.members} recruit_roles={project.recruit_roles} />
<hr className="bg-gray-100" />
<StoriesCard onlyMd stories={project.stories} />
<TournamentsCard onlyMd tournaments={[]} />
<hr className="bg-gray-100" />
<SimilarProjectsCard id={project.id} />
</main>
</>
}</div>
</div>
</>
)
}

View File

@@ -7,6 +7,7 @@ import { PaymentStatus, useVote } from 'src/utils/hooks';
import Confetti from "react-confetti";
import { useWindowSize } from '@react-hookz/web';
import { Vote_Item_Type } from 'src/graphql';
import IconButton from 'src/Components/IconButton/IconButton';
const defaultOptions = [
{ text: '100 sat', value: 100 },
@@ -17,6 +18,7 @@ const defaultOptions = [
interface Props extends ModalCard {
projectId: number;
title?: string;
initVotes?: number;
}
@@ -69,8 +71,12 @@ export default function VoteCard({ onClose, direction, projectId, initVotes, ...
exit='exit'
className="modal-card max-w-[343px] 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'>Vote for this Project</h2>
<div className="flex items-start gap-12">
<h2 className='text-h5 font-bold'>Vote for {props.title ?? "project"}</h2>
<IconButton onClick={onClose} >
<IoClose className='text-body2' />
</IconButton>
</div>
<form onSubmit={requestPayment} className="mt-32 ">
<label className="block text-gray-700 text-body4 mb-2 ">
Enter Amount

View File

@@ -0,0 +1,30 @@
.grid {
display: grid;
grid-template-columns: 100%;
gap: 24px;
grid-template-areas: "main";
> aside:first-of-type {
display: flex;
flex-direction: column;
gap: 24px;
grid-area: aside1;
}
> main {
display: flex;
flex-direction: column;
gap: 24px;
grid-area: main;
}
> aside:last-of-type {
display: flex;
flex-direction: column;
gap: 24px;
grid-area: aside2;
}
@media screen and (min-width: 768px) {
grid-template-columns: 1fr 2fr 1fr;
grid-template-areas: "aside1 main aside2";
}
}

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