mirror of
https://github.com/aljazceru/landscape-template.git
synced 2025-12-17 06:14:27 +01:00
Merge pull request #182 from peakshift/dev
Release: Submit project, Project Page, Tag Project, Stories Templates
This commit is contained in:
@@ -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!
|
||||
}
|
||||
|
||||
@@ -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!]!
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Project" ADD COLUMN "contact_email" TEXT;
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) : [];
|
||||
|
||||
|
||||
32
src/Components/Inputs/TextInput/TextInput.tsx
Normal file
32
src/Components/Inputs/TextInput/TextInput.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
27
src/Components/Inputs/TextareaInput/TextareaInput.tsx
Normal file
27
src/Components/Inputs/TextareaInput/TextareaInput.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
29
src/Components/Inputs/UsersInput/UsersInput.stories.tsx
Normal file
29
src/Components/Inputs/UsersInput/UsersInput.stories.tsx
Normal 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({});
|
||||
164
src/Components/Inputs/UsersInput/UsersInput.tsx
Normal file
164
src/Components/Inputs/UsersInput/UsersInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
8
src/Components/Inputs/UsersInput/searchUsers.graphql
Normal file
8
src/Components/Inputs/UsersInput/searchUsers.graphql
Normal file
@@ -0,0 +1,8 @@
|
||||
query SearchUsers($value: String!) {
|
||||
searchUsers(value: $value) {
|
||||
id
|
||||
name
|
||||
avatar
|
||||
jobTitle
|
||||
}
|
||||
}
|
||||
@@ -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`}>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -12,6 +12,12 @@ mutation createStory($data: StoryInputType) {
|
||||
is_published
|
||||
type
|
||||
cover_image
|
||||
project {
|
||||
id
|
||||
title
|
||||
hashtag
|
||||
thumbnail_image
|
||||
}
|
||||
# comments_count
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 don’t 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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
query MyProjects {
|
||||
me {
|
||||
id
|
||||
projects {
|
||||
id
|
||||
title
|
||||
thumbnail_image
|
||||
category {
|
||||
id
|
||||
icon
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 you’ve been working on?
|
||||
|
||||
### Problems & Solutions 🚨
|
||||
What problems does this product/feature solve? Really show it off and convince makers why it’s 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? It’s 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']
|
||||
},
|
||||
}
|
||||
]
|
||||
@@ -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>
|
||||
|
||||
@@ -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 .";
|
||||
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}`}
|
||||
>
|
||||
|
||||
@@ -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 = {
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 && <>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = {
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
query GetAllCapabilities {
|
||||
getAllCapabilities {
|
||||
id
|
||||
title
|
||||
icon
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 = {
|
||||
}
|
||||
|
||||
|
||||
@@ -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">It’s 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>
|
||||
)
|
||||
}
|
||||
@@ -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)]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = {
|
||||
}
|
||||
|
||||
|
||||
@@ -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 product’s 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 project’s 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 >
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
query IsValidProjectHashtag($hashtag: String!, $projectId: Int) {
|
||||
checkValidProjectHashtag(hashtag: $hashtag, projectId: $projectId)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
import { lazyModal } from 'src/utils/helperFunctions';
|
||||
|
||||
export const { LazyComponent: ProjectListedModal } = lazyModal(() => import('./ProjectListedModal'))
|
||||
@@ -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: '✍️️'
|
||||
},
|
||||
]
|
||||
@@ -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 = {
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 = {
|
||||
}
|
||||
|
||||
|
||||
@@ -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>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 = {
|
||||
}
|
||||
|
||||
|
||||
@@ -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 product’s 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 you’re 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>
|
||||
)
|
||||
}
|
||||
@@ -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 = {
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
query GetTournamentsToRegister {
|
||||
getTournamentToRegister {
|
||||
id
|
||||
title
|
||||
}
|
||||
}
|
||||
140
src/features/Projects/pages/ListProjectPage/ListProjectPage.tsx
Normal file
140
src/features/Projects/pages/ListProjectPage/ListProjectPage.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 = {
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
110
src/features/Projects/pages/ProjectPage/ProjectPage.tsx
Normal file
110
src/features/Projects/pages/ProjectPage/ProjectPage.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
30
src/features/Projects/pages/ProjectPage/styles.module.scss
Normal file
30
src/features/Projects/pages/ProjectPage/styles.module.scss
Normal 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
Reference in New Issue
Block a user