diff --git a/api/functions/graphql/nexus-typegen.ts b/api/functions/graphql/nexus-typegen.ts index d405ede..7120730 100644 --- a/api/functions/graphql/nexus-typegen.ts +++ b/api/functions/graphql/nexus-typegen.ts @@ -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 @@ -70,6 +93,33 @@ export interface NexusGenInputs { 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 +132,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 +184,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 +275,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 +452,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 +466,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 +519,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! @@ -489,23 +573,41 @@ 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 + 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 +621,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,6 +631,7 @@ export interface NexusGenFieldTypes { profile: NexusGenRootTypes['User'] | null; // User projectsByCategory: NexusGenRootTypes['Project'][]; // [Project!]! searchProjects: NexusGenRootTypes['Project'][]; // [Project!]! + searchUsers: NexusGenRootTypes['User'][]; // [User!]! similarMakers: NexusGenRootTypes['User'][]; // [User!]! tournamentParticipationInfo: NexusGenRootTypes['ParticipationInfo'] | null; // ParticipationInfo } @@ -736,6 +840,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 +854,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 +907,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' @@ -846,23 +961,41 @@ 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' + 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 +1009,7 @@ export interface NexusGenFieldTypeNames { getProject: 'Project' getProjectsInTournament: 'TournamentProjectsResponse' getTournamentById: 'Tournament' + getTournamentToRegister: 'Tournament' getTrendingPosts: 'Post' hottestProjects: 'Project' me: 'MyProfile' @@ -885,6 +1019,7 @@ export interface NexusGenFieldTypeNames { profile: 'User' projectsByCategory: 'Project' searchProjects: 'Project' + searchUsers: 'User' similarMakers: 'User' tournamentParticipationInfo: 'ParticipationInfo' } @@ -1064,9 +1199,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 +1224,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 +1250,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 @@ -1171,6 +1319,9 @@ export interface NexusGenArgTypes { skip?: number | null; // Int take: number | null; // Int } + searchUsers: { // args + value: string; // String! + } similarMakers: { // args id: number; // Int! } diff --git a/api/functions/graphql/schema.graphql b/api/functions/graphql/schema.graphql index ebfa9b4..2481ecb 100644 --- a/api/functions/graphql/schema.graphql +++ b/api/functions/graphql/schema.graphql @@ -67,6 +67,12 @@ type BountyApplication { workplan: String! } +type Capability { + icon: String! + id: Int! + title: String! +} + type Category { apps_count: Int! cover_image: String @@ -77,6 +83,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 +186,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! @@ -246,24 +283,55 @@ 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 + 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!]! @@ -277,6 +345,7 @@ type Query { getProject(id: Int!): 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,6 +355,7 @@ 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!]! tournamentParticipationInfo(tournamentId: Int!): ParticipationInfo } @@ -343,6 +413,12 @@ input StoryInputType { title: String! } +enum TEAM_MEMBER_ROLE { + Admin + Maker + Owner +} + type Tag { description: String icon: String @@ -351,6 +427,11 @@ type Tag { title: String! } +input TeamMemberInput { + id: Int! + role: TEAM_MEMBER_ROLE! +} + type Tournament { cover_image: String! description: String! @@ -430,6 +511,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 diff --git a/api/functions/graphql/types/project.js b/api/functions/graphql/types/project.js index 6a360c6..ef7bf46 100644 --- a/api/functions/graphql/types/project.js +++ b/api/functions/graphql/types/project.js @@ -1,23 +1,33 @@ +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 { 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 +38,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 +60,6 @@ const Project = objectType({ }); } }); - t.nonNull.string('website'); t.string('lightning_address'); t.string('lnurl_callback_url'); t.nonNull.int('votes_count'); @@ -68,6 +85,40 @@ 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('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 +140,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 +212,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({ @@ -284,10 +439,638 @@ const getLnurlDetailsForProject = extendType({ } }) +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 +1078,12 @@ module.exports = { hottestProjects, searchProjects, projectsByCategory, - getLnurlDetailsForProject + getLnurlDetailsForProject, + getAllCapabilities, + checkValidProjectHashtag, + + // Mutations + createProject, + updateProject, + deleteProject, } \ No newline at end of file diff --git a/api/functions/graphql/types/tournament.js b/api/functions/graphql/types/tournament.js index 7e05b78..196f376 100644 --- a/api/functions/graphql/types/tournament.js +++ b/api/functions/graphql/types/tournament.js @@ -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, diff --git a/api/functions/graphql/types/tournaments.js b/api/functions/graphql/types/tournaments.js deleted file mode 100644 index e69de29..0000000 diff --git a/api/functions/graphql/types/users.js b/api/functions/graphql/types/users.js index dfd3afd..b67b64c 100644 --- a/api/functions/graphql/types/users.js +++ b/api/functions/graphql/types/users.js @@ -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'); @@ -245,6 +245,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 +553,7 @@ module.exports = { // Queries me, profile, + searchUsers, similarMakers, getAllMakersRoles, getAllMakersSkills, diff --git a/api/prisma/index.js b/api/prisma/index.js index 5befab5..a63f077 100644 --- a/api/prisma/index.js +++ b/api/prisma/index.js @@ -1,15 +1,19 @@ 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 } \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 4234abe..7de6cf1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +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"; @@ -98,6 +99,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/src/Components/Inputs/FilesInputs/CoverImageInput/CoverImageInput.tsx b/src/Components/Inputs/FilesInputs/CoverImageInput/CoverImageInput.tsx index 975366d..b4975a4 100644 --- a/src/Components/Inputs/FilesInputs/CoverImageInput/CoverImageInput.tsx +++ b/src/Components/Inputs/FilesInputs/CoverImageInput/CoverImageInput.tsx @@ -34,7 +34,7 @@ export default function CoverImageInput(props: Props) { wrapperClass='h-full' render={({ img, isUploading, isDraggingOnWindow }) =>
- {!img &&
+ {!img &&

Drop a COVER IMAGE here or
Click to browse @@ -56,7 +56,7 @@ export default function CoverImageInput(props: Props) { } {isUploading &&
{ 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) diff --git a/src/Components/Inputs/FilesInputs/ScreenshotsInput/ScreenshotThumbnail.tsx b/src/Components/Inputs/FilesInputs/ScreenshotsInput/ScreenshotThumbnail.tsx index 0003c43..5f64ac6 100644 --- a/src/Components/Inputs/FilesInputs/ScreenshotsInput/ScreenshotThumbnail.tsx +++ b/src/Components/Inputs/FilesInputs/ScreenshotsInput/ScreenshotThumbnail.tsx @@ -45,7 +45,7 @@ export default function ScreenshotThumbnail({ url, isLoading, isError, onCancel Failed...
} {!isEmpty && - + }
) diff --git a/src/Components/Inputs/FilesInputs/ScreenshotsInput/ScreenshotsInput.stories.tsx b/src/Components/Inputs/FilesInputs/ScreenshotsInput/ScreenshotsInput.stories.tsx index 1575707..e308f85 100644 --- a/src/Components/Inputs/FilesInputs/ScreenshotsInput/ScreenshotsInput.stories.tsx +++ b/src/Components/Inputs/FilesInputs/ScreenshotsInput/ScreenshotsInput.stories.tsx @@ -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 }>({ + WrapFormController<{ screenshots: Array }>({ logValues: true, name: "screenshots", defaultValues: { @@ -29,7 +30,7 @@ Empty.args = { export const WithValues = Template.bind({}); WithValues.decorators = [ - WrapFormController<{ screenshots: Array }>({ + WrapFormController<{ screenshots: Array }>({ logValues: true, name: "screenshots", defaultValues: { @@ -51,7 +52,7 @@ WithValues.args = { export const Full = Template.bind({}); Full.decorators = [ - WrapFormController<{ screenshots: Array }>({ + WrapFormController<{ screenshots: Array }>({ logValues: true, name: "screenshots", defaultValues: { diff --git a/src/Components/Inputs/FilesInputs/ScreenshotsInput/ScreenshotsInput.tsx b/src/Components/Inputs/FilesInputs/ScreenshotsInput/ScreenshotsInput.tsx index b5bf6c0..c1b8aa2 100644 --- a/src/Components/Inputs/FilesInputs/ScreenshotsInput/ScreenshotsInput.tsx +++ b/src/Components/Inputs/FilesInputs/ScreenshotsInput/ScreenshotsInput.tsx @@ -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 ( { 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)) } } }} >
- {canUploadMore && } - {uploadedFiles.map(f => + {uploadedFiles.map((f, idx) => { - onChange(uploadedFiles.filter(file => file.id !== f.id)) + onChange(removeArrayItemAtIndex(uploadedFiles, idx)) }} />)} {(placeholdersCount > 0) && @@ -92,21 +85,22 @@ export default function ScreenshotsInput(props: Props) { } const DropZone = forwardRef((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((props, ref) => { [onClick] ); + if (!canUploadMore) return null + return JSX.Element + renderAfter?: () => JSX.Element +} & React.ComponentPropsWithoutRef<'input'> + +export default React.forwardRef(function TextInput({ className, inputClass, isError, renderBefore, renderAfter, ...props }, ref) { + + return ( +
+ {renderBefore?.()} + + {renderAfter?.()} +
+ ) +}) diff --git a/src/Components/Inputs/TextareaInput/TextareaInput.tsx b/src/Components/Inputs/TextareaInput/TextareaInput.tsx new file mode 100644 index 0000000..09eb276 --- /dev/null +++ b/src/Components/Inputs/TextareaInput/TextareaInput.tsx @@ -0,0 +1,27 @@ +import React from "react"; + +type Props = { + isError?: boolean; + className?: string; + inputClass?: string +} & React.ComponentPropsWithoutRef<'textarea'> + +export default React.forwardRef(function TextareaInput({ className, inputClass, isError, ...props }, ref) { + + return ( +
+