diff --git a/backend/src/coverage/clover.xml b/backend/src/coverage/clover.xml
new file mode 100644
index 0000000..bcae853
--- /dev/null
+++ b/backend/src/coverage/clover.xml
@@ -0,0 +1,481 @@
+
+
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| src | +
+
+ |
+ 95.45% | +105/110 | +100% | +0/0 | +92.59% | +25/27 | +95.45% | +105/110 | +
| src/business | +
+
+ |
+ 81.85% | +194/237 | +64.8% | +81/125 | +77.27% | +34/44 | +85.11% | +183/215 | +
| src/controller | +
+
+ |
+ 80.18% | +89/111 | +75% | +3/4 | +87.5% | +21/24 | +80.9% | +89/110 | +
| src/utils | +
+
+ |
+ 100% | +4/4 | +100% | +0/0 | +100% | +1/1 | +100% | +4/4 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 | 5x +5x +5x +5x + + + + + +5x +5x +5x +5x + + + +5x +5x + + + +5x + + + +5x + + + +5x | import express from 'express';
+import cors from 'cors';
+import routes from './routes';
+import { errorHandler } from './middleware/errorHandler';
+
+class App {
+ public server: express.Application;
+
+ constructor() {
+ this.server = express();
+ this.middlewares();
+ this.routes();
+ this.handleErrors();
+ }
+
+ private middlewares(): void {
+ this.server.use(express.json());
+ this.server.use(cors());
+ }
+
+ private routes(): void {
+ this.server.use(routes);
+ }
+
+ private handleErrors(): void {
+ this.server.use(errorHandler);
+ }
+}
+
+export default new App().server; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 | + +5x +5x +5x + + + + + + + +82x + +82x + + + + +82x +2x +1x + +1x +1x + + + +80x + +80x + +80x + + +80x + + + + + + + + +80x + + + +80x + +80x + + + + +77x +77x + + + +77x +1x + + +76x + + + + +76x +1x + + +75x + +75x + + + +2x + + + + + + + +5x |
+import { UserData, LoginData } from "../models/User";
+import { userValidate } from "../utils/validationUser";
+import knex from '../data/index';
+import bcrypt from 'bcryptjs';
+
+
+
+class BusinessLogicAuth{
+
+ async newUser(data: UserData){
+
+ userValidate(data);
+
+ const existingUser = await knex('User')
+ .where('username', data.username)
+ .orWhere('email', data.email)
+ .first();
+
+ if (existingUser) {
+ if (existingUser.username === data.username) {
+ throw new Error('Este username já está em uso.');
+ }
+ Eif (existingUser.email === data.email) {
+ throw new Error('Este e-mail já está em uso.');
+ }
+ }
+
+ const salt = await bcrypt.genSalt(10);
+
+ const senhaHash = await bcrypt.hash(data.senha, salt);
+
+ const { nomeCompleto, username, email, telefone, dataNascimento } = data;
+
+
+ await knex('User').insert({
+ fullName: nomeCompleto,
+ username,
+ email,
+ phone: telefone,
+ birthDate: dataNascimento,
+ passwordHash: senhaHash
+ });
+
+ const user = await knex('User')
+ .where('username', data.username)
+ .first();
+
+ const { passwordHash, ...userData} = user;
+
+ return userData;
+
+ }
+
+ async enterUser(data: LoginData){
+ try{
+ const user = await knex('User')
+ .where('username', data.username)
+ .first();
+
+ if(!user){
+ throw new Error('Usuário ou senha incorretos.');
+ }
+
+ const isPasswordValid = await bcrypt.compare(
+ data.senha,
+ user.passwordHash
+ );
+
+ if (!isPasswordValid) {
+ throw new Error('Usuário ou senha incorretos.');
+ }
+
+ const { passwordHash, ...userData } = user;
+
+ return userData;
+
+ }catch(error){
+
+ throw error;
+
+ }
+ }
+
+}
+
+
+export default new BusinessLogicAuth(); |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 | 5x + + + + + + + + +17x + + + +17x +2x + + +15x + +15x + + + + + +15x + + + +15x + + + +15x + + + + + + +15x + +2x + + + +2x +2x + + + + + +2x +2x + + + +15x + + + + + +4x + + + +4x + + + +4x + + + + +4x +1x + + +3x + + + + + + +3x + + + + +3x + + + +3x + + + +3x +1x + + +2x + + + +2x +1x + + +1x + + + +1x + + + + +1x + + + + + + + + +1x + +1x + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + +1x + + + +1x + +2x + +1x + +1x + + + + +2x + + + + + + +1x + + + + +1x + + + +1x + + + +1x + + + +1x +1x + +1x + + + + +1x + + + + +1x + + + + +1x + + + + + + + + + + + + + +3x + +3x + + + +3x +1x + + +2x +1x + + +1x +1x +1x + +1x +1x +1x + + + + +1x +1x + + + +1x +1x + + + +1x + + + + +1x +1x + + + + +1x + + + + +1x + + + +1x + + + +1x +1x + + + + + + + + + + +5x | import knex from '../data/index';
+import { CommunityData } from '../models/Community'
+
+
+
+class BusinessLogicCommunity{
+
+ async newCommunity(data: CommunityData, creatorID: number){
+
+ const existingCommunity = await knex('Communities')
+ .where('name', data.name)
+ .first();
+
+ if (existingCommunity) {
+ throw new Error("Já existe uma comunidade com este nome.");
+ }
+
+ return knex.transaction(async (trx) => {
+
+ const communityToInsert = {
+ name: data.name,
+ description: data.description,
+ creatorID: creatorID
+ };
+
+ const [createdCommunity] = await trx('Communities')
+ .insert(communityToInsert)
+ .returning('*');
+
+ Iif (!createdCommunity) {
+ throw new Error("Falha ao criar comunidade.");
+ }
+
+ await trx('CommunityMembers').insert({
+ communityID: createdCommunity.communityID,
+ userID: creatorID,
+ role: 'admin',
+ joinedAt: new Date()
+ });
+
+ if (data.technologies && data.technologies.length > 0) {
+
+ const keywordIDs = await trx('Keywords')
+ .whereIn('tag', data.technologies)
+ .select('keywordID');
+
+ const keywordsToInsert = keywordIDs.map(keyword => {
+ return {
+ communityID: createdCommunity.communityID,
+ keywordID: keyword.keywordID
+ };
+ });
+
+ Eif (keywordsToInsert.length > 0) {
+ await trx('CommunitiesKeywords').insert(keywordsToInsert);
+ }
+ }
+
+ return createdCommunity;
+ });
+ }
+
+ async newMemberCommunity(userID: number, communityID: string){
+
+ const community = await knex('Communities')
+ .where('communityID', communityID)
+ .first();
+
+ Iif (!community) {
+ throw new Error("Comunidade não encontrada.");
+ }
+
+ const existingMember = await knex('CommunityMembers')
+ .where('communityID', communityID)
+ .andWhere('userID', userID)
+ .first();
+
+ if (existingMember) {
+ throw new Error("Usuário já é membro desta comunidade.");
+ }
+
+ await knex('CommunityMembers').insert({
+ communityID: communityID,
+ userID: userID,
+ role: 'member',
+ joinedAt: new Date()
+ });
+
+ return { message: "Membro adicionado com sucesso", communityID, userID };
+ }
+
+ async leaveMemberCommunity(userID: number, communityID: string){
+
+ const community = await knex('Communities')
+ .where('communityID', communityID)
+ .first();
+
+ Iif (!community) {
+ throw new Error("Comunidade não encontrada.");
+ }
+
+ if (community.creatorID === userID) {
+ throw new Error("O criador não pode sair da comunidade. Você deve deletá-la ou transferir a propriedade.");
+ }
+
+ const member = await knex('CommunityMembers')
+ .where({ communityID, userID })
+ .first();
+
+ if (!member) {
+ throw new Error("Você não é membro desta comunidade.");
+ }
+
+ await knex('CommunityMembers')
+ .where({ communityID, userID })
+ .del();
+
+ return { message: "Você saiu da comunidade com sucesso." };
+ }
+
+ async getAllUserCommunities(userID: number) {
+
+ const userCommunities = await knex('Communities')
+ .join('CommunityMembers', 'Communities.communityID', '=', 'CommunityMembers.communityID')
+ .where('CommunityMembers.userID', userID)
+ .select(
+ 'Communities.*',
+ 'CommunityMembers.role',
+ 'CommunityMembers.joinedAt'
+ );
+
+ const communityIds = userCommunities.map(c => c.communityID);
+
+ Eif (communityIds.length === 0) return [];
+
+ const keywords = await knex('CommunitiesKeywords')
+ .join('Keywords', 'CommunitiesKeywords.keywordID', '=', 'Keywords.keywordID')
+ .whereIn('CommunitiesKeywords.communityID', communityIds)
+ .select('CommunitiesKeywords.communityID', 'Keywords.tag');
+
+ const result = userCommunities.map(comm => ({
+ ...comm,
+ technologies: keywords
+ .filter((k: any) => k.communityID === comm.communityID)
+ .map((k: any) => k.tag)
+ }));
+
+ return result;
+ }
+
+ async getUserFeed(userID: number){
+ const feed = await knex('Projects')
+
+ .join('ProjectCommunities', 'Projects.projectID', '=', 'ProjectCommunities.projectID')
+
+ .join('CommunityMembers', 'ProjectCommunities.communityID', '=', 'CommunityMembers.communityID')
+
+ .join('User', 'Projects.creatorID', '=', 'User.id')
+
+ .where('CommunityMembers.userID', userID)
+
+ .distinct('Projects.projectID')
+
+ .select(
+ 'Projects.*',
+ 'User.username as authorUsername',
+ 'User.fullName as authorName'
+ )
+
+ .orderBy('Projects.createdAt', 'desc');
+
+ return feed;
+ }
+
+ async getAllCommunities(){
+ const communities = await knex('Communities').select('*');
+
+ const communityIds = communities.map(c => c.communityID);
+
+ Iif (communityIds.length === 0) return [];
+
+ const keywords = await knex('CommunitiesKeywords')
+ .join('Keywords', 'CommunitiesKeywords.keywordID', '=', 'Keywords.keywordID')
+ .whereIn('CommunitiesKeywords.communityID', communityIds)
+ .select('CommunitiesKeywords.communityID', 'Keywords.tag');
+
+ const result = communities.map(comm => ({
+ ...comm,
+ technologies: keywords
+ .filter((k: any) => k.communityID === comm.communityID)
+ .map((k: any) => k.tag)
+ }));
+
+ return result;
+ }
+
+ async getCommunityData(communityID: string, userID: number) {
+
+ const community = await knex('Communities')
+ .where('communityID', communityID)
+ .first();
+
+ Iif (!community) {
+ throw new Error("Comunidade não encontrada.");
+ }
+
+ const membership = await knex('CommunityMembers')
+ .where({ communityID: communityID, userID: userID })
+ .first();
+
+ const isMember = !!membership;
+ const isAdmin = (community.creatorID === userID) || (membership && membership.role === 'admin');
+
+ const keywords = await knex('CommunitiesKeywords')
+ .join('Keywords', 'CommunitiesKeywords.keywordID', '=', 'Keywords.keywordID')
+ .where('CommunitiesKeywords.communityID', communityID)
+ .select('Keywords.tag');
+
+ const memberCount = await knex('CommunityMembers')
+ .where('communityID', communityID)
+ .count('userID as count')
+ .first();
+
+ const projects = await knex('Projects')
+ .join('ProjectCommunities', 'Projects.projectID', '=', 'ProjectCommunities.projectID')
+ .where('ProjectCommunities.communityID', communityID)
+ .select('Projects.*');
+
+ return {
+ community: {
+ ...community,
+ technologies: keywords.map((k: any) => k.tag),
+ memberCount: memberCount?.count || 0,
+ isMember: isMember,
+ isAdmin: isAdmin
+ },
+ posts: projects || []
+ };
+ }
+
+ async updateCommunity(creatorID: number, communityID: string, data: CommunityData){
+
+ return knex.transaction(async (trx) => {
+
+ const community = await trx('Communities')
+ .where('communityID', communityID)
+ .first();
+
+ if (!community) {
+ throw new Error("Comunidade não encontrada.");
+ }
+
+ if (community.creatorID !== creatorID) {
+ throw new Error("Você não tem permissão para editar esta comunidade.");
+ }
+
+ const fieldsToUpdate: any = {};
+ Eif (data.name) fieldsToUpdate.name = data.name;
+ Eif (data.description) fieldsToUpdate.description = data.description;
+
+ Eif (Object.keys(fieldsToUpdate).length > 0) {
+ fieldsToUpdate.updatedAt = new Date();
+ await trx('Communities')
+ .where('communityID', communityID)
+ .update(fieldsToUpdate);
+ }
+
+ Eif (data.technologies !== undefined) {
+ await trx('CommunitiesKeywords')
+ .where('communityID', communityID)
+ .del();
+
+ Eif (Array.isArray(data.technologies) && data.technologies.length > 0) {
+ const keywordIDs = await trx('Keywords')
+ .whereIn('tag', data.technologies)
+ .select('keywordID');
+
+ const linksToInsert = keywordIDs.map((k: any) => ({
+ communityID: communityID,
+ keywordID: k.keywordID
+ }));
+
+ Eif (linksToInsert.length > 0) {
+ await trx('CommunitiesKeywords').insert(linksToInsert);
+ }
+ }
+ }
+
+ return await trx('Communities').where('communityID', communityID).first();
+ });
+ }
+
+ async removeCommunity(creatorID: number, communityID: string){
+ const community = await knex('Communities')
+ .where('communityID', communityID)
+ .first();
+
+ Iif (!community) {
+ throw new Error("Comunidade não encontrada.");
+ }
+
+ Eif (community.creatorID !== creatorID) {
+ throw new Error("Você não tem permissão para deletar esta comunidade.");
+ }
+
+ await knex('Communities')
+ .where('communityID', communityID)
+ .del();
+ }
+
+}
+
+
+export default new BusinessLogicCommunity(); |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 | 5x + +5x + + + + + + + + +3x + +3x +3x +3x +3x +3x + +3x + + + + +3x +1x + + +2x + +2x + + + +2x + + + + +2x + + + +2x + +2x + + + +2x + + + +2x + + + + + +5x | import knex from '../data/index';
+import { UserData } from '../models/User';
+import bcrypt from 'bcryptjs';
+
+
+
+class businessLogicProfile{
+
+
+ async updateProfile(data: UserData, userID: number){
+
+ const fieldsToUpdate: any = {};
+
+ if (data.nomeCompleto) fieldsToUpdate.fullName = data.nomeCompleto;
+ Iif (data.email) fieldsToUpdate.email = data.email;
+ Iif (data.username) fieldsToUpdate.username = data.username;
+ if (data.telefone) fieldsToUpdate.phone = data.telefone;
+ Iif (data.dataNascimento) fieldsToUpdate.birthDate = data.dataNascimento;
+
+ Iif (data.senha) {
+ const salt = await bcrypt.genSalt(10);
+ fieldsToUpdate.passwordHash = await bcrypt.hash(data.senha, salt);
+ }
+
+ if (Object.keys(fieldsToUpdate).length === 0) {
+ throw new Error("Nenhum dado para atualizar.");
+ }
+
+ fieldsToUpdate.updatedAt = new Date();
+
+ await knex('User')
+ .where('id', userID)
+ .update(fieldsToUpdate);
+
+ const updatedUser = await knex('User')
+ .where('id', userID)
+ .select('id', 'fullName', 'username', 'email', 'phone', 'birthDate', 'createdAt')
+ .first();
+
+ return updatedUser;
+ }
+
+ async removeProfile(userID: number){
+ const user = await knex('User').where('id', userID).first();
+
+ Iif (!user) {
+ throw new Error("Usuário não encontrado.");
+ }
+
+ await knex('User')
+ .where('id', userID)
+ .del();
+
+ return { message: "Perfil deletado com sucesso." };
+ }
+
+}
+
+
+export default new businessLogicProfile(); |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 | +5x + + + + + + +17x + +17x + + + + + + + +17x + + + +17x + +3x + + + +3x +5x + + + + + +3x +3x + + + + + +3x +5x + + +3x +1x + + + + + +1x + + + + +17x + + + + + +2x + +2x + + + +2x + + + +2x +1x + + +1x +1x +1x +1x +1x + +1x +1x +1x + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + +4x + + +4x + + + + +9x + +9x + +9x + + + + + + +9x + + + +2x + + + + + + + + + +2x + + + +3x + + + + + + + + + +3x + + + + +2x + + + +2x + + + +2x +1x + + +1x + + + + + + + +2x + + + +2x + + + +2x +1x + + +1x + + + + + + +3x + + + + + + + + + + +3x +2x + + + +1x + + + + + +1x + +2x + + + + + +5x |
+import knex from '../data/index';
+import { ProjectData } from '../models/Project';
+
+class BusinessLogicProject{
+
+ async newProject(data: ProjectData, creatorID: number){
+
+ return knex.transaction(async (trx) => {
+
+ const projectToInsert = {
+ title: data.title,
+ description: data.description,
+ status: data.status,
+ startDate: data.startDate,
+ creatorID: creatorID
+ };
+
+ const [newProject] = await trx('Projects')
+ .insert(projectToInsert)
+ .returning('*');
+
+ if (data.technologies && data.technologies.length > 0) {
+
+ const keywordIDs = await trx('Keywords')
+ .whereIn('tag', data.technologies)
+ .select('keywordID');
+
+ const keywordsToInsert = keywordIDs.map(keyword => {
+ return {
+ projectID: newProject.projectID,
+ keywordID: keyword.keywordID
+ };
+ });
+
+ Eif (keywordsToInsert.length > 0) {
+ await trx('ProjectsKeywords').insert(keywordsToInsert);
+
+ // --- NOVA LÓGICA DE ASSOCIAÇÃO AUTOMÁTICA ---
+
+ // 3. Busca Comunidades que usam essas mesmas Keywords
+ // Usamos .distinct() para evitar duplicatas (caso uma comunidade tenha React E Node, por exemplo)
+ const matchingCommunities = await trx('CommunitiesKeywords')
+ .whereIn('keywordID', keywordIDs.map(k => k.keywordID))
+ .distinct('communityID');
+
+ if (matchingCommunities.length > 0) {
+ const communitiesToLink = matchingCommunities.map(comm => ({
+ projectID: newProject.projectID,
+ communityID: comm.communityID,
+ associatedAt: new Date()
+ }));
+
+ await trx('ProjectCommunities').insert(communitiesToLink);
+ }
+ }
+ }
+
+ return newProject;
+ });
+ }
+
+ async updateProject(projectId: string, projectData: Partial<any>, userId: number) {
+
+ return knex.transaction(async (trx) => {
+
+ const existingProject = await trx('Projects')
+ .where({ projectID: projectId })
+ .first();
+
+ Iif (!existingProject) {
+ throw new Error("Projeto não encontrado.");
+ }
+
+ if (existingProject.creatorID !== userId) {
+ throw new Error("Você não tem permissão para editar este projeto.");
+ }
+
+ const fieldsToUpdate: any = {};
+ Eif (projectData.title !== undefined) fieldsToUpdate.title = projectData.title;
+ Eif (projectData.description !== undefined) fieldsToUpdate.description = projectData.description;
+ Iif (projectData.status !== undefined) fieldsToUpdate.status = projectData.status;
+ Iif (projectData.startDate !== undefined) fieldsToUpdate.startDate = projectData.startDate;
+
+ Eif (Object.keys(fieldsToUpdate).length > 0) {
+ fieldsToUpdate.updatedAt = new Date();
+ await trx('Projects')
+ .where({ projectID: projectId })
+ .update(fieldsToUpdate);
+ }
+
+ Iif (projectData.technologies !== undefined) {
+
+ await trx('ProjectsKeywords')
+ .where({ projectID: projectId })
+ .del();
+
+ await trx('ProjectCommunities')
+ .where({ projectID: projectId })
+ .del();
+
+ if (Array.isArray(projectData.technologies) && projectData.technologies.length > 0) {
+
+ const keywordIDs = await trx('Keywords')
+ .whereIn('tag', projectData.technologies)
+ .select('keywordID');
+
+ const keywordsToInsert = keywordIDs.map((k: any) => ({
+ projectID: projectId,
+ keywordID: k.keywordID
+ }));
+
+ if (keywordsToInsert.length > 0) {
+ await trx('ProjectsKeywords').insert(keywordsToInsert);
+ }
+
+ const matchingCommunities = await trx('CommunitiesKeywords')
+ .whereIn('keywordID', keywordIDs.map((k:any) => k.keywordID))
+ .distinct('communityID');
+
+ if (matchingCommunities.length > 0) {
+ const communitiesToLink = matchingCommunities.map((comm:any) => ({
+ projectID: projectId,
+ communityID: comm.communityID,
+ associatedAt: new Date()
+ }));
+
+ await trx('ProjectCommunities').insert(communitiesToLink);
+ }
+ }
+ }
+
+ return await trx('Projects').where({ projectID: projectId }).first();
+ });
+ }
+
+ async userProjects(creatorID: number){
+
+ const projects = await knex('Projects')
+ .where('creatorID', creatorID);
+
+ return projects;
+ }
+
+ async newComment(userID: number, projectID: string, content: string){
+
+ const project = await knex('Projects').where('projectID', projectID).first();
+
+ Iif (!project) throw new Error("Projeto não encontrado.");
+
+ const [newComment] = await knex('Comments').insert({
+ authorID: userID,
+ projectID: projectID,
+ content: content,
+ createdAt: new Date()
+ }).returning('*');
+
+ return newComment;
+ }
+
+ async getProjectComments(projectID: string){
+ const comments = await knex('Comments')
+ .join('User', 'Comments.authorID', '=', 'User.id')
+ .where('Comments.projectID', projectID)
+ .select(
+ 'Comments.*',
+ 'User.fullName',
+ 'User.username'
+ )
+ .orderBy('Comments.createdAt', 'desc');
+
+ return comments;
+ }
+
+ async getUserComments(userID: number){
+ const comments = await knex('Comments')
+ .join('Projects', 'Comments.projectID', '=', 'Projects.projectID')
+ .where('Comments.authorID', userID)
+ .select(
+ 'Comments.*',
+ 'Projects.title as projectTitle'
+ )
+ .orderBy('Comments.createdAt', 'desc')
+ .orderBy('Comments.commentID', 'desc');
+
+ return comments;
+ }
+
+ async removeComment(userID: number, commentID: string){
+
+ const comment = await knex('Comments')
+ .where('commentID', commentID)
+ .first();
+
+ Iif (!comment) {
+ throw new Error("Comentário não encontrado.");
+ }
+
+ if (comment.authorID !== userID) {
+ throw new Error("Você não tem permissão para deletar este comentário.");
+ }
+
+ await knex('Comments')
+ .where('commentID', commentID)
+ .del();
+
+ }
+
+ async removeProject(creatorID: number, projectID: string){
+
+ const project = await knex('Projects')
+ .where('projectID', projectID)
+ .first();
+
+ Iif (!project) {
+ throw new Error("Projeto não encontrado.");
+ }
+
+ if (project.creatorID !== creatorID) {
+ throw new Error("Você não tem permissão para deletar este projeto.");
+ }
+
+ await knex('Projects')
+ .where('projectID', projectID)
+ .del();
+ }
+
+ async getProjectById(projectId: string) {
+ // Busca o projeto com dados do autor (join)
+ const project = await knex('Projects')
+ .join('User', 'Projects.creatorID', '=', 'User.id')
+ .where('Projects.projectID', projectId)
+ .select(
+ 'Projects.*',
+ 'User.username as authorUsername',
+ 'User.fullName as authorName',
+ 'User.id as authorID'
+ )
+ .first();
+
+ if (!project) {
+ throw new Error("Projeto não encontrado.");
+ }
+
+ // Busca as tecnologias (keywords) associadas
+ const keywords = await knex('ProjectsKeywords')
+ .join('Keywords', 'ProjectsKeywords.keywordID', '=', 'Keywords.keywordID')
+ .where('ProjectsKeywords.projectID', projectId)
+ .select('Keywords.tag');
+
+ // Retorna o objeto formatado
+ return {
+ ...project,
+ technologies: keywords.map((k: any) => k.tag)
+ };
+ }
+
+}
+
+export default new BusinessLogicProject(); |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| businessLogicAuth.ts | +
+
+ |
+ 100% | +28/28 | +90% | +9/10 | +100% | +2/2 | +100% | +28/28 | +
| businessLogicCommunity.ts | +
+
+ |
+ 83% | +83/100 | +65.45% | +36/55 | +68.18% | +15/22 | +84.61% | +77/91 | +
| businessLogicProfile.ts | +
+
+ |
+ 78.57% | +22/28 | +68.75% | +11/16 | +100% | +2/2 | +86.95% | +20/23 | +
| businessLogicProject.ts | +
+
+ |
+ 75.3% | +61/81 | +56.81% | +25/44 | +83.33% | +15/18 | +79.45% | +58/73 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| requestController.ts | +
+
+ |
+ 80.18% | +89/111 | +75% | +3/4 | +87.5% | +21/24 | +80.9% | +89/110 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 | 5x +5x + + +5x +5x +5x + +5x +5x +5x + +5x + + + + +82x + +82x + +80x + +80x + + + + + +80x + + +2x + + + + +77x + +77x + +75x + +75x + + + + + +75x + + + +2x + + + + +17x +17x +17x + +17x + + + + + + + + + + + + + + + + + + + + + + + + +4x + +4x + +4x + + + + + + + +2x + +2x + +1x + + +1x + + + + +2x + +2x + + +1x + + + + +3x +3x +1x + +2x + + + + +17x + +17x +15x + + +2x + + + + + + + + + + + + + + +1x +1x + +1x + +1x + + + + + + + + + +1x + +1x + +1x + + + + + + + + +1x + +1x + +1x + + + + + + + +4x + +4x +3x + + +1x + + + + +3x + +3x + +1x + + +2x + + + + + +3x + +3x + +1x + + +2x + + + + +1x + +1x + + +1x + + + + +9x + +9x + + + +9x + +9x + + + + + + + +2x + +2x + +2x + + + + + + + +3x + +3x + +2x + + +1x + + + + + +2x + +2x +2x + + + + + + + + +2x + +2x + + +1x + + + + +3x + +3x + +3x + + + + + + + + +5x | import businessLogicUser from '../business/businessLogicAuth'
+import BusinessLogicProject from '../business/businessLogicProject';
+import {UserData, LoginData } from '../models/User'
+import { ProjectData } from '../models/Project';
+import jwt from 'jsonwebtoken';
+import { authConfig } from '../config/auth'
+import knex from '../data';
+import { CommunityData } from '../models/Community';
+import BusinessLogicCommunity from '../business/businessLogicCommunity';
+import businessLogicProject from '../business/businessLogicProject';
+import businessLogicCommunity from '../business/businessLogicCommunity';
+import { error } from 'console';
+import businessLogicProfile from '../business/businessLogicProfile';
+
+class RequestController {
+
+ async createUser(data: UserData) {
+ try{
+
+ const userData = await businessLogicUser.newUser(data);
+
+ const payload = { id: userData.id, username: userData.username };
+
+ const token = jwt.sign(
+ payload,
+ authConfig.secret,
+ { expiresIn: authConfig.expiresIn }
+ );
+
+ return { user: userData, token: token };
+
+ } catch(error){
+ throw error;
+ }
+ }
+
+ async enterUser(data: LoginData){
+ try{
+
+ const userData = await businessLogicUser.enterUser(data);
+
+ const payload = { id: userData.id, username: userData.username };
+
+ const token = jwt.sign(
+ payload,
+ authConfig.secret,
+ { expiresIn: authConfig.expiresIn }
+ );
+
+ return { user: userData, token: token };
+
+ }catch(error){
+
+ throw error;
+ }
+ }
+
+ async createProject(data: ProjectData, creatorID: number){
+ try{
+ console.log(data);
+ const newProject = await BusinessLogicProject.newProject(data, creatorID);
+
+ return newProject;
+
+ }catch(error){
+
+ throw new Error("Erro nos dados do projeto.");
+
+ }
+ }
+
+ async getKeywords(){
+ try{
+
+ const keywords = await knex('Keywords').select('tag');
+
+ const tags = keywords.map(kw => kw.tag);
+
+ return tags;
+
+ }catch(error){
+ console.error("Erro ao buscar keywords:", error);
+ throw new Error("Não foi possível buscar as keywords.");
+ }
+ }
+
+ async getUserProjects(creatorID: number){
+ try{
+
+ const projects = await BusinessLogicProject.userProjects(creatorID);
+
+ return projects;
+
+ }catch(error){
+ throw new Error("Erro ao buscar os projetos do usuário.");
+ }
+ }
+
+ async updateProject(projectId: string, data: ProjectData, userId: number){
+ try{
+
+ const updatedProject = await BusinessLogicProject.updateProject(projectId, data, userId);
+
+ return updatedProject;
+
+ }catch(error){
+ throw new Error("Erro ao atualizar o projeto.");
+ }
+ }
+
+ async removeProject(userID: number, projectID: string){
+ try{
+
+ await BusinessLogicProject.removeProject(userID, projectID);
+
+ }catch(error){
+ throw error;
+ }
+ }
+
+ async getProjectById(projectId: string) {
+ try {
+ const project = await businessLogicProject.getProjectById(projectId);
+ return project;
+ } catch (error) {
+ throw error;
+ }
+ }
+
+ async newCommunity(data: CommunityData, creatorID: number){
+ try{
+
+ const newCommunity = await BusinessLogicCommunity.newCommunity(data, creatorID);
+ return newCommunity;
+
+ }catch(error){
+ throw error;
+ }
+ }
+
+ async getAllUserCommunities(userID: number) {
+ try {
+ const communities = await BusinessLogicCommunity.getAllUserCommunities(userID);
+ return communities;
+ } catch (error) {
+ console.error("Erro ao buscar comunidades:", error);
+ throw new Error("Erro ao carregar as comunidades.");
+ }
+ }
+
+ async getUserHome(userID: number){
+ try{
+ const communities = await businessLogicCommunity.getAllUserCommunities(userID);
+
+ const feed = await businessLogicCommunity.getUserFeed(userID);
+
+ return {
+ communities: communities,
+ feed: feed
+ };
+
+ }catch(error){
+ throw error;
+ }
+ }
+ async getAllCommunities(){
+ try{
+
+ const communities = await BusinessLogicCommunity.getAllCommunities();
+
+ return communities;
+
+ }catch(error){
+ throw error;
+ }
+
+ }
+
+ async getCommunityData(communityID: string, userID: number){
+ try{
+
+ const result = await BusinessLogicCommunity.getCommunityData(communityID,userID);
+
+ return result;
+
+ }catch(error){
+ throw error;
+ }
+ }
+
+ async newMemberCommunity(userID: number, communityID: string){
+ try{
+
+ const result = await BusinessLogicCommunity.newMemberCommunity(userID, communityID);
+ return result;
+
+ }catch(error){
+ throw error;
+ }
+ }
+
+ async leaveMemberCommunity(userID: number, communityID: string){
+ try{
+
+ const result = await businessLogicCommunity.leaveMemberCommunity(userID, communityID);
+
+ return result;
+
+ }catch(error){
+ throw error;
+ }
+
+ }
+
+ async updateCommunity(creatorID: number, communityID: string, data: CommunityData){
+ try{
+
+ const updatedCommunity = await BusinessLogicCommunity.updateCommunity(creatorID, communityID, data);
+
+ return updatedCommunity;
+
+ }catch(error){
+ throw error;
+ }
+ }
+
+ async removeCommunity(creatorID: number, communityID: string){
+ try{
+
+ await BusinessLogicCommunity.removeCommunity(creatorID, communityID);
+
+ }catch(error){
+ throw error;
+ }
+ }
+
+ async newComment(userID: number, projectID: string, content: string){
+ try{
+
+ Iif (!content || content.trim() === "") {
+ throw new Error("O comentário não pode ser vazio.");
+ }
+
+ const comment = await businessLogicProject.newComment(userID, projectID, content);
+
+ return comment;
+
+ }catch(error){
+ throw error;
+ }
+ }
+
+ async getProjectComments(projectID: string) {
+ try{
+
+ const projectComments = await businessLogicProject.getProjectComments(projectID);
+
+ return projectComments;
+
+ }catch(error){
+ throw error;
+ }
+ }
+
+ async updateProfile(data: UserData, userID: number){
+ try{
+
+ const result = await businessLogicProfile.updateProfile(data, userID);
+
+ return result;
+
+ }catch(error){
+ throw error;
+ }
+
+ }
+
+ async deleteProfile(userID: number){
+ try{
+
+ const message = await businessLogicProfile.removeProfile(userID);
+ return message;
+
+ }catch(error){
+ throw error;
+ }
+
+ }
+
+ async deleteComment(userID: number, commentID: string){
+ try{
+
+ await businessLogicProject.removeComment(userID, commentID);
+
+ }catch(error){
+ throw error;
+ }
+ }
+
+ async getUserComments(userID: number){
+ try{
+
+ const allComments = await businessLogicProject.getUserComments(userID);
+
+ return allComments;
+
+ }catch(error){
+ throw error;
+ }
+ }
+
+}
+
+export default new RequestController(); |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ ++ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 | 5x +5x +5x + + + +5x + + +5x +82x +82x +80x + + + + + + + +5x +77x +75x + + + + + + + + +5x + + + + + + +5x +17x + +17x + +17x + +17x + + + + + + + +5x +4x +4x +4x + + + + + +5x +2x +2x +2x + +2x + +1x + + + + + +5x + +2x + +2x + +1x + + + +5x + +3x + +3x + +1x + + + +5x + +17x +15x + + + + + +5x + + + + + + +5x + +1x +1x + +1x + +1x + + +5x + +1x +1x + + + +5x + +4x +4x + +4x + +3x + + + +5x +3x +3x + +3x + +1x + + + + + + + +5x +1x + +1x + + + + + + +5x + +9x +9x + +9x + +9x + + + +5x + +3x + +3x + + +5x + +2x + +2x + +1x + + + +5x + +2x + +2x + +2x + + + + +5x + +3x + +2x + + +5x + +2x + +2x + + +5x + +1x + +1x + + +5x +3x +3x +3x +1x + +2x + + + + +5x | import express from 'express';
+import RequestController from './controller/requestController';
+import authMiddleware from './middleware/auth';
+import auth from './middleware/auth';
+import { request } from 'http';
+
+const routes = express.Router();
+
+// Endpoint para CADASTRAR um novo usuário
+routes.post('/api/register', async (request, response) => {
+ console.log("Recebendo requisição de cadastro:", request.body);
+ const {user, token} = await RequestController.createUser(request.body);
+ return response.status(201).json({
+ user,
+ token,
+ message: 'Usuário cadastrado com sucesso!'
+ });
+});
+
+// Endpoint para verificar a existência de usuário
+routes.post('/api/login', async(request, response) => {
+ const { user, token } = await RequestController.enterUser(request.body);
+ return response.status(200).json({
+ user,
+ token,
+ message: 'Usuário autenticado com sucesso.'
+ });
+});
+
+
+// Endpoint para enviar ao fronend os keywords disponíveis
+routes.get('/api/keywords', async(request, response) => {
+ const tags = await RequestController.getKeywords();
+
+ return response.status(200).json(tags);
+});
+
+// Endpoint para criar projeto
+routes.post('/api/user/newproject', authMiddleware, async(request, response) =>{
+ const projectData = request.body;
+
+ const creatorID = request.user.id;
+
+ const newProject = await RequestController.createProject(projectData, creatorID);
+
+ return response.status(201).json({
+ message: "Projeto criado com sucesso!",
+ project: newProject
+ });
+});
+
+
+// Endpoint para enviar ao frontend os projetos de determinado usuário
+routes.get('/api/user/projects', authMiddleware, async(request, response) => {
+ const creatorID = request.user.id;
+ const projects = await RequestController.getUserProjects(creatorID);
+ return response.status(200).json({
+ projects
+ })
+});
+
+// Endpoint para atualizar um projeto existente
+routes.put('/api/user/updateproject/:projectId', authMiddleware, async(request, response) => {
+ const { projectId } = request.params;
+ const updatedData = request.body;
+ const creatorID = request.user.id;
+
+ const updatedProject = await RequestController.updateProject(projectId, updatedData, creatorID);
+
+ return response.status(200).json({
+ message: "Projeto atualizado com sucesso!",
+ project: updatedProject
+ });
+});
+
+routes.delete('/api/user/deleteproject/:projectId', authMiddleware, async(request, response) =>{
+
+ const { projectId } = request.params;
+
+ await RequestController.removeProject(request.user.id, projectId);
+
+ return response.status(200).json({ message: "Projeto deletado com sucesso." });
+});
+
+
+routes.delete('/api/user/leavecommunity/:communityID', authMiddleware, async(request, response) =>{
+
+ const { communityID } = request.params;
+
+ const result = await RequestController.leaveMemberCommunity(request.user.id, communityID);
+
+ return response.status(200).json(result);
+})
+
+// Endpoint para criar uma comunidade
+routes.post('/api/newcommunity', authMiddleware, async(request, response) => {
+
+ const newCommunity = await RequestController.newCommunity(request.body, request.user.id);
+ return response.status(201).json({
+ community: newCommunity
+ })
+
+});
+
+routes.get('/api/user/communities', authMiddleware, async (request, response) => {
+
+ const communities = await RequestController.getAllUserCommunities(request.user.id);
+ return response.status(200).json(communities);
+
+});
+
+routes.get('/api/communities/data/:communityId', authMiddleware, async (request, response) =>{
+
+ const { communityId } = request.params;
+ const userID = request.user.id;
+
+ const data = await RequestController.getCommunityData(communityId, userID);
+
+ return response.status(200).json(data);
+});
+
+routes.get('/api/communities', async(request, response) => {
+
+ const communities = await RequestController.getAllCommunities();
+ return response.status(200).json(communities);
+
+});
+
+routes.post('/api/communities/:communityId/join', authMiddleware, async(request, response) =>{
+
+ const userID = request.user.id;
+ const { communityId } = request.params;
+
+ const result = await RequestController.newMemberCommunity(userID, communityId);
+
+ return response.status(201).json(result);
+});
+
+
+routes.put('/api/communities/updatecommunity/:communityId', authMiddleware, async(request, response) => {
+ const { communityId } = request.params;
+ const data = request.body;
+
+ const updatedCommunity = await RequestController.updateCommunity(request.user.id, communityId, data);
+
+ return response.status(200).json({
+ message: "Comunidade atualizada com sucesso!",
+ community: updatedCommunity
+ });
+
+});
+
+
+routes.delete('/api/communities/deletecommunity/:communityId', authMiddleware, async(request, response) => {
+ const { communityId } = request.params;
+
+ await RequestController.removeCommunity(request.user.id, communityId);
+
+ return response.status(200).json({ message: "Comunidade deletada com sucesso." });
+
+})
+
+
+routes.post('/api/project/:projectID/comments', authMiddleware, async(request, response) =>{
+
+ const { projectID } = request.params;
+ const { content } = request.body;
+
+ const comment = await RequestController.newComment(request.user.id, projectID, content);
+
+ return response.status(201).json(comment);
+
+})
+
+routes.get('/api/user/comments', authMiddleware, async(request, response) => {
+
+ const allComments = await RequestController.getUserComments(request.user.id);
+
+ return response.status(200).json(allComments);
+})
+
+routes.delete('/api/project/:commentID/deletecomment', authMiddleware, async(request, response) =>{
+
+ const { commentID } = request.params;
+
+ await RequestController.deleteComment( request.user.id, commentID );
+
+ return response.status(200).json({ message: "Comentário deletado com sucesso."});
+
+})
+
+routes.get('/api/project/:projectID/comments', async(request, response) => {
+
+ const { projectID } = request.params;
+
+ const projectComments = await RequestController.getProjectComments( projectID );
+
+ return response.status(200).json(projectComments);
+
+})
+
+
+routes.put('/api/user/editprofile', authMiddleware, async(request, response) =>{
+
+ const updatedProfile = await RequestController.updateProfile(request.body, request.user.id);
+
+ return response.status(200).json(updatedProfile);
+})
+
+routes.delete('/api/user/deleteprofile', authMiddleware, async(request, response) =>{
+
+ const message = await RequestController.deleteProfile(request.user.id);
+
+ return response.status(200).json(message);
+})
+
+routes.get('/api/user/home', authMiddleware, async(request, response) => {
+
+ const homeData = await RequestController.getUserHome(request.user.id);
+
+ return response.status(200).json(homeData);
+})
+
+routes.get('/api/projects/:projectId', authMiddleware, async (request, response) => {
+ const { projectId } = request.params;
+ try {
+ const project = await RequestController.getProjectById(projectId);
+ return response.status(200).json(project);
+ } catch (error) {
+ return response.status(404).json({ message: "Projeto não encontrado" });
+ }
+});
+
+
+export default routes; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| validationUser.ts | +
+
+ |
+ 100% | +4/4 | +100% | +0/0 | +100% | +1/1 | +100% | +4/4 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 | 5x + + +5x + + + + + + + + +5x +82x + | import { z } from 'zod';
+import { UserData } from '../models/User';
+
+const userSchema = z.object({
+ nomeCompleto: z.string().min(3, "Nome muito curto"),
+ username: z.string().min(4, "Username muito curto"),
+ email: z.string().email("Formato de e-mail inválido"),
+ telefone: z.string().optional(),
+ dataNascimento: z.string()
+});
+
+
+export function userValidate(data: UserData){
+ userSchema.parse(data);
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| src | +
+
+ |
+ 100% | +1/1 | +100% | +0/0 | +100% | +1/1 | +100% | +1/1 | +
| src/API | +
+
+ |
+ 5.3% | +6/113 | +4% | +2/50 | +4.54% | +1/22 | +5.3% | +6/113 | +
| src/components/common/Keyword | +
+
+ |
+ 100% | +3/3 | +100% | +0/0 | +100% | +1/1 | +100% | +3/3 | +
| src/components/common/Modal | +
+
+ |
+ 100% | +12/12 | +100% | +4/4 | +100% | +4/4 | +100% | +12/12 | +
| src/components/common/Toast | +
+
+ |
+ 100% | +7/7 | +100% | +0/0 | +100% | +4/4 | +100% | +7/7 | +
| src/components/domain/CreationForm | +
+
+ |
+ 87.32% | +62/71 | +85.71% | +12/14 | +84.48% | +49/58 | +86.95% | +60/69 | +
| src/components/domain/Postcard | +
+
+ |
+ 69.23% | +72/104 | +58.9% | +43/73 | +64.28% | +18/28 | +72.16% | +70/97 | +
| src/components/domain/Searchbar | +
+
+ |
+ 91.07% | +51/56 | +83.33% | +30/36 | +78.94% | +15/19 | +90.9% | +50/55 | +
| src/components/domain/TagInput | +
+
+ |
+ 94.28% | +33/35 | +76.19% | +16/21 | +92.85% | +13/14 | +93.93% | +31/33 | +
| src/components/layout/Header | +
+
+ |
+ 100% | +1/1 | +100% | +0/0 | +100% | +1/1 | +100% | +1/1 | +
| src/components/layout/HeaderHome | +
+
+ |
+ 100% | +1/1 | +100% | +0/0 | +100% | +1/1 | +100% | +1/1 | +
| src/components/layout/Sidebar | +
+
+ |
+ 93.75% | +15/16 | +75% | +3/4 | +100% | +5/5 | +93.75% | +15/16 | +
| src/pages/CommunityPage | +
+
+ |
+ 83.94% | +115/137 | +68.42% | +39/57 | +88.05% | +59/67 | +85.24% | +104/122 | +
| src/pages/CreateCommunity | +
+
+ |
+ 81.81% | +27/33 | +90.9% | +20/22 | +71.42% | +5/7 | +81.81% | +27/33 | +
| src/pages/CreateProject | +
+
+ |
+ 84.44% | +38/45 | +87.09% | +27/31 | +80% | +8/10 | +86.04% | +37/43 | +
| src/pages/EditProfile | +
+
+ |
+ 87.09% | +27/31 | +55.55% | +10/18 | +66.66% | +4/6 | +93.1% | +27/29 | +
| src/pages/Feed | +
+
+ |
+ 86.27% | +44/51 | +69.23% | +9/13 | +74.07% | +20/27 | +87.75% | +43/49 | +
| src/pages/Home | +
+
+ |
+ 100% | +1/1 | +100% | +0/0 | +100% | +1/1 | +100% | +1/1 | +
| src/pages/Login | +
+
+ |
+ 87.5% | +14/16 | +75% | +3/4 | +50% | +2/4 | +87.5% | +14/16 | +
| src/pages/Profile | +
+
+ |
+ 92.43% | +110/119 | +82.25% | +51/62 | +89.58% | +43/48 | +94.54% | +104/110 | +
| src/pages/ProjectPage | +
+
+ |
+ 90% | +18/20 | +75% | +6/8 | +100% | +4/4 | +94.11% | +16/17 | +
| src/pages/Register | +
+
+ |
+ 87.5% | +14/16 | +75% | +3/4 | +60% | +3/5 | +87.5% | +14/16 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| src | +
+
+ |
+ 100% | +1/1 | +100% | +0/0 | +100% | +1/1 | +100% | +1/1 | +
| src/API | +
+
+ |
+ 5.3% | +6/113 | +4% | +2/50 | +4.54% | +1/22 | +5.3% | +6/113 | +
| src/components/common/Keyword | +
+
+ |
+ 100% | +3/3 | +100% | +0/0 | +100% | +1/1 | +100% | +3/3 | +
| src/components/common/Modal | +
+
+ |
+ 100% | +12/12 | +100% | +4/4 | +100% | +4/4 | +100% | +12/12 | +
| src/components/common/Toast | +
+
+ |
+ 100% | +7/7 | +100% | +0/0 | +100% | +4/4 | +100% | +7/7 | +
| src/components/domain/CreationForm | +
+
+ |
+ 87.32% | +62/71 | +85.71% | +12/14 | +84.48% | +49/58 | +86.95% | +60/69 | +
| src/components/domain/Postcard | +
+
+ |
+ 69.23% | +72/104 | +58.9% | +43/73 | +64.28% | +18/28 | +72.16% | +70/97 | +
| src/components/domain/Searchbar | +
+
+ |
+ 91.07% | +51/56 | +83.33% | +30/36 | +78.94% | +15/19 | +90.9% | +50/55 | +
| src/components/domain/TagInput | +
+
+ |
+ 94.28% | +33/35 | +76.19% | +16/21 | +92.85% | +13/14 | +93.93% | +31/33 | +
| src/components/layout/Header | +
+
+ |
+ 100% | +1/1 | +100% | +0/0 | +100% | +1/1 | +100% | +1/1 | +
| src/components/layout/HeaderHome | +
+
+ |
+ 100% | +1/1 | +100% | +0/0 | +100% | +1/1 | +100% | +1/1 | +
| src/components/layout/Sidebar | +
+
+ |
+ 93.75% | +15/16 | +75% | +3/4 | +100% | +5/5 | +93.75% | +15/16 | +
| src/pages/CommunityPage | +
+
+ |
+ 83.94% | +115/137 | +68.42% | +39/57 | +88.05% | +59/67 | +85.24% | +104/122 | +
| src/pages/CreateCommunity | +
+
+ |
+ 81.81% | +27/33 | +90.9% | +20/22 | +71.42% | +5/7 | +81.81% | +27/33 | +
| src/pages/CreateProject | +
+
+ |
+ 84.44% | +38/45 | +87.09% | +27/31 | +80% | +8/10 | +86.04% | +37/43 | +
| src/pages/EditProfile | +
+
+ |
+ 87.09% | +27/31 | +55.55% | +10/18 | +66.66% | +4/6 | +93.1% | +27/29 | +
| src/pages/Feed | +
+
+ |
+ 86.27% | +44/51 | +69.23% | +9/13 | +74.07% | +20/27 | +87.75% | +43/49 | +
| src/pages/Home | +
+
+ |
+ 100% | +1/1 | +100% | +0/0 | +100% | +1/1 | +100% | +1/1 | +
| src/pages/Login | +
+
+ |
+ 87.5% | +14/16 | +75% | +3/4 | +50% | +2/4 | +87.5% | +14/16 | +
| src/pages/Profile | +
+
+ |
+ 92.43% | +110/119 | +82.25% | +51/62 | +89.58% | +43/48 | +94.54% | +104/110 | +
| src/pages/ProjectPage | +
+
+ |
+ 90% | +18/20 | +75% | +6/8 | +100% | +4/4 | +94.11% | +16/17 | +
| src/pages/Register | +
+
+ |
+ 87.5% | +14/16 | +75% | +3/4 | +60% | +3/5 | +87.5% | +14/16 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 | + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import api from './api';
+import { isAxiosError } from 'axios';
+export interface CommentProps {
+ projectTitle?: string;
+ commentID?: string;
+ content: string;
+ createdAt?: string;
+ authorID?: string;
+ projectID?: string;
+ username?: string;
+ fullName?: string;
+}
+
+const getAuthHeader = () => {
+ return {
+ headers: {
+ 'Authorization': `Bearer ${localStorage.getItem('token')}`
+ }
+ };
+};
+
+export async function CreateComment(projectId: string, content: string) {
+ try{
+
+ const response = await api.post(`/api/project/${projectId}/comments`,
+ {content},
+ getAuthHeader()
+ );
+
+ return response.data;
+
+ }catch(error){
+
+ if (isAxiosError(error) && error.response) {
+ throw new Error(error.response.data.message);
+ }
+ throw new Error("Erro ao enviar comentário.");
+
+ }
+}
+
+export async function GetComments(projectId: string): Promise<CommentProps[]> {
+ try{
+
+ const response = await api.get(`/api/project/${projectId}/comments`, getAuthHeader());
+
+ return await response.data;
+ }catch(error){
+ console.error("Erro ao buscar comentários:", error);
+ throw new Error("Erro ao carregar comentários.");
+ }
+
+
+}
+
+export async function GetUserComments(): Promise<CommentProps[]> {
+ try{
+ const response = await api.get('/api/user/comments', getAuthHeader());
+
+ return response.data;
+ }catch(error){
+ console.error("Erro ao buscar comentários do usuário:", error);
+ throw new Error("Erro ao carregar comentários do usuário.");
+ }
+}
+
+export async function DeleteComment(commentId: string) {
+ try{
+
+ const response = await api.delete(`/api/project/${commentId}/deletecomment`, getAuthHeader());
+
+ return response.data;
+ }catch(error){
+ if (isAxiosError(error) && error.response) {
+ throw new Error(error.response.data.message);
+ }
+ throw new Error("Erro ao excluir comentário.");
+ }
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 | + + + + + + + + + + + + + + +2x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import api from './api';
+import { isAxiosError } from 'axios';
+
+export interface CommunityProps {
+ communityID: string;
+ name: string;
+ description: string;
+ technologies: string[];
+ createdAt: Date;
+ updatedAt: Date;
+ memberCount?: number;
+ isMember?: boolean;
+ isAdmin?: boolean;
+}
+
+const getAuthHeader = () => {
+ return {
+ headers: {
+ 'Authorization': `Bearer ${localStorage.getItem('token')}`
+ }
+ };
+};
+
+export async function NewCommunity(data: CommunityProps) {
+
+ try{
+ console.log("Enviando dados da comunidade:", data);
+ const response = await api.post('/api/newcommunity', data, getAuthHeader());
+ return response.data
+ }catch(error){
+ if (isAxiosError(error) && error.response) {
+ throw new Error(error.response.data.message);
+ }
+ throw new Error("Erro ao criar comunidade.");
+ }
+}
+
+export async function GetUserCommunities(): Promise<CommunityProps[]> {
+ try{
+ const response = await api.get<CommunityProps[]>('/api/user/communities', getAuthHeader());
+ return response.data
+ }catch(error){
+ console.error("Erro ao obter comunidades:", error);
+ throw new Error('Erro ao obter comunidades');
+ }
+}
+
+export async function GetCommunityById(communityId: string) {
+ try{
+ const response = await api.get(`/api/communities/data/${communityId}`, getAuthHeader());
+
+ return await response.data;
+ }catch(error){
+ if (isAxiosError(error) && error.response?.status === 404) {
+ throw new Error('Comunidade não encontrada');
+ }
+ throw new Error('Erro ao carregar a comunidade');
+ }
+}
+
+export async function JoinCommunity(communityId: string) {
+ try{
+ const response = await api.post(`/api/communities/${communityId}/join`, {}, getAuthHeader());
+
+ return await response.data;
+ }catch(error){
+ if (isAxiosError(error) && error.response) {
+ throw new Error(error.response.data.message);
+ }
+ throw new Error('Erro ao entrar na comunidade');
+ }
+}
+
+export async function DeleteCommunity(communityId: string) {
+ try{
+ const response = await api.delete(`/api/communities/deletecommunity/${communityId}`, getAuthHeader());
+ return response.data
+ }catch(error){
+ if (isAxiosError(error) && error.response) {
+ throw new Error(error.response.data.message);
+ }
+ throw new Error("Erro ao excluir comunidade.");
+ }
+}
+
+export async function UpdateCommunity(communityId: string, data: CommunityProps) {
+ try{
+ const response = await api.put(`/api/communities/updatecommunity/${communityId}`,
+ data,
+ getAuthHeader()
+ );
+
+ return response.data;
+ }catch(error){
+ if (isAxiosError(error) && error.response) {
+ throw new Error(error.response.data.message);
+ }
+ throw new Error("Erro ao atualizar comunidade.");
+ }
+}
+
+export async function LeaveCommunity(communityId: string) {
+ try{
+ const response = await api.delete(`/api/user/leavecommunity/${communityId}`, getAuthHeader());
+
+ return await response.data;
+ }catch(error){
+ if (isAxiosError(error) && error.response) {
+ throw new Error(error.response.data.message);
+ }
+ throw new Error("Erro ao sair da comunidade.");
+ }
+}
+
+export async function GetAllCommunities(): Promise<CommunityProps[]> {
+ try{
+ const response = await api.get<CommunityProps[]>('/api/communities', getAuthHeader());
+ return response.data
+ }catch(error){
+ console.error("Erro ao obter todas as comunidades:", error);
+ throw new Error('Erro ao obter todas as comunidades');
+ }
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 | + + + + + + + + + + + + + + + + +2x + + +2x + + +4x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import api from './api';
+import { isAxiosError } from 'axios';
+
+export interface ProjectProps {
+ id?: string;
+ title: string;
+ description: string;
+ technologies: string[];
+ status: string;
+ startDate: Date;
+ authorUsername?: string;
+ authorName?: string;
+ creatorID?: number;
+}
+
+export function parseDate(dataString: string): Date {
+ // Divide a string "20/11/2025" em partes
+ const [dia, mes, ano] = dataString.split('/');
+
+ // Cria a data: new Date(ano, mês - 1, dia)
+ return new Date(Number(ano), Number(mes) - 1, Number(dia));
+}
+
+const getAuthHeader = () => {
+ return {
+ headers: {
+ 'Authorization': `Bearer ${localStorage.getItem('token')}`
+ }
+ };
+};
+
+export async function NewProject(data: ProjectProps) {
+ try{
+ const response = await api.post('/api/user/newproject', data, getAuthHeader());
+
+ return response.data;
+ }catch(error){
+ if (isAxiosError(error) && error.response) {
+ throw new Error(error.response.data.message);
+ }
+ throw new Error("Erro ao criar projeto.");
+ }
+}
+
+export async function UpdateProject(projectId: string, data: ProjectProps) {
+ try{
+ const response = await api.put(`/api/user/updateproject/${projectId}`,
+ data,
+ getAuthHeader()
+ );
+ return response.data;
+ }catch(error){
+ if (isAxiosError(error) && error.response) {
+ throw new Error(error.response.data.message);
+ }
+ throw new Error("Erro ao atualizar projeto.");
+ }
+
+}
+
+export async function GetFeedProjects(): Promise<ProjectProps[]> {
+
+ try{
+ const response = await api.get('/api/user/home', getAuthHeader());
+
+ console.log("Dados do feed de projetos:", response.data);
+
+ return response.data.feed;
+ }catch(error){
+ console.error("Erro ao buscar feed:", error);
+ throw new Error("Erro ao carregar o feed de projetos.");
+ }
+}
+
+export async function DeleteProject(projectId: string) {
+ try{
+ await api.delete(`/api/user/deleteproject/${projectId}`, getAuthHeader());
+ }
+ catch(error){
+ if (isAxiosError(error) && error.response) {
+ throw new Error(error.response.data.message);
+ }
+ throw new Error("Erro ao excluir projeto.");
+ }
+}
+
+export async function GetUserProjects(): Promise<ProjectProps[]> {
+ try{
+ const response = await api.get('/api/user/projects', getAuthHeader());
+
+ console.log("Dados dos projetos do usuário:", response.data.projects);
+
+ return response.data.projects;
+ }catch(error){
+ if (isAxiosError(error) && error.response) {
+ throw new Error(error.response.data.message);
+ }
+ throw new Error("Erro ao buscar projetos do usuário.");
+ }
+}
+
+export async function GetProjectById(projectId: string): Promise<ProjectProps> {
+ try{
+ const response = await api.get(`/api/projects/${projectId}`, getAuthHeader());
+
+ return await response.data;
+ }catch(error){
+ console.error("Erro ao buscar projeto:", error);
+ throw new Error('Erro ao carregar projeto');
+ }
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 | + +5x + + + + | import axios from 'axios';
+
+const api = axios.create({
+ baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000',
+});
+
+export default api; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| Comment.ts | +
+
+ |
+ 4.16% | +1/24 | +0% | +0/8 | +0% | +0/5 | +4.16% | +1/24 | +
| Community.ts | +
+
+ |
+ 2.04% | +1/49 | +0% | +0/24 | +0% | +0/9 | +2.04% | +1/49 | +
| Project.ts | +
+
+ |
+ 7.69% | +3/39 | +0% | +0/16 | +12.5% | +1/8 | +7.69% | +3/39 | +
| api.ts | +
+
+ |
+ 100% | +1/1 | +100% | +2/2 | +100% | +0/0 | +100% | +1/1 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 | + + + + + + + +2x + + + + + + + + + + + | import { ThemeProvider } from "styled-components"
+import { defaultTheme } from './styles/themes/default'
+import { GlobalStyle } from './styles/global'
+import { Router } from "./Router"
+import { BrowserRouter } from "react-router-dom"
+
+function App() {
+
+ return (
+ <ThemeProvider theme={defaultTheme}>
+ <BrowserRouter>
+ <GlobalStyle/>
+ <Router />
+ </BrowserRouter>
+ </ThemeProvider>
+ )
+}
+
+export default App
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 100% | +3/3 | +100% | +0/0 | +100% | +1/1 | +100% | +3/3 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 | + + + + + + + + +1x +2x + + + + + + + + + +1x | import React from 'react';
+import * as S from './styles';
+
+// Props que o componente Keyword aceita
+interface KeywordProps {
+ children: React.ReactNode;
+ onRemove: () => void;
+}
+
+export const Keyword: React.FC<KeywordProps> = ({ children, onRemove }) => {
+ return (
+ <S.KeywordTag>
+ {children}
+ <S.KeywordRemoveButton onClick={onRemove} aria-label={`Remover ${children}`}>
+ ×
+ </S.KeywordRemoveButton>
+ </S.KeywordTag>
+ );
+};
+
+export const KeywordContainer = S.KeywordContainer; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 100% | +12/12 | +100% | +4/4 | +100% | +4/4 | +100% | +12/12 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 | + + + + + + + + + + +1x + +5x +5x +4x + +1x + + +5x +5x + + + +5x +1x + + + +4x +2x + + +4x + + + + + + + + + + + + | import React, { useEffect } from 'react';
+import { createPortal } from 'react-dom';
+import * as S from './styles';
+
+interface ModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ children: React.ReactNode;
+ title: string;
+}
+
+const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
+ // Bloqueia o scroll do body quando o modal está aberto
+ useEffect(() => {
+ if (isOpen) {
+ document.body.style.overflow = 'hidden';
+ } else {
+ document.body.style.overflow = 'unset';
+ }
+
+ return () => {
+ document.body.style.overflow = 'unset';
+ };
+ }, [isOpen]);
+
+ if (!isOpen) {
+ return null;
+ }
+
+ // Impede que o clique no modal feche o modal (só o overlay)
+ const handleContentClick = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ };
+
+ return createPortal(
+ <S.ModalOverlay onClick={onClose}>
+ <S.ModalContent onClick={handleContentClick}>
+ <S.CloseButton onClick={onClose}>×</S.CloseButton>
+ <S.ModalTitle>{title}</S.ModalTitle>
+ {children}
+ </S.ModalContent>
+ </S.ModalOverlay>,
+ document.body
+ );
+};
+
+export default Modal; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 100% | +7/7 | +100% | +0/0 | +100% | +4/4 | +100% | +7/7 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 | + + + + + + + + + + + + + +1x + + +4x +4x +1x + + + +4x +4x + + + +4x + + + + + + + + + + | import React, { useEffect } from 'react';
+import { ToastContainer, ToastMessage, CloseButton } from './styles';
+
+interface ToastProps {
+ message: string;
+ type: 'success' | 'error';
+ onClose: () => void; // Função para fechar o toast
+}
+
+export interface NotificationState {
+ message: string;
+ type: 'success' | 'error';
+}
+
+const Toast: React.FC<ToastProps> = ({ message, type, onClose }) => {
+
+ // Efeito para fechar o toast automaticamente após 5 segundos
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ onClose();
+ }, 5000);
+
+ // Limpa o timer se o componente for desmontado
+ return () => {
+ clearTimeout(timer);
+ };
+ }, [onClose]);
+
+ return (
+ <ToastContainer type={type}>
+ <ToastMessage>{message}</ToastMessage>
+ <CloseButton onClick={onClose}>
+ ×
+ </CloseButton>
+ </ToastContainer>
+ );
+};
+
+export default Toast; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| styles.ts | +
+
+ |
+ 87.32% | +62/71 | +85.71% | +12/14 | +84.48% | +49/58 | +86.95% | +60/69 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 | + + +2x +20x + + + + + + + +20x + + + + + + + + + + + + + + + + +2x + + + + + + + + + + + + + + + + + + + +2x + + + +86x + + + + + + + + + + + + + + + + + +86x + + + + + + + + + + + +2x + + + + +33x + + +33x + + + + + +33x + + + + +33x +33x + + + + +33x +33x +33x + + + + + +2x + + + + + + + +13x + + + + + + + + + +13x + + + +13x + + + + + +2x + + + + +13x + + +13x + + + + + + + + + + + + +13x +13x + + + + +13x +13x +13x + + + + + +13x + + + + +13x +13x + + + + +2x + + + + + +2x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +2x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +2x + + + + + + + + + + + + + + + + + +2x + + + +20x +20x + + + + + +20x + + + + + + + + + + + + + + + + + + + + + +20x + + + + + + + + + + +20x + + + + + + + + +2x + + + + + + +20x + + +20x + + + + + + + +20x + + + + +20x +20x + + + + +20x +20x +20x + + + + + + + + + + +20x + + + + +20x + + + + +20x + + + + +2x + + +20x + + + + +20x + + + + + + +20x +20x + + + +20x +20x + + | import styled from 'styled-components';
+
+// O <form> container
+export const FormContainer = styled.form`
+ background: ${props => props.theme.white};
+ border-radius: 24px;
+ padding: 40px;
+ width: 100%;
+ max-width: 800px;
+ margin: 0 auto;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08),
+ 0 2px 8px rgba(0, 0, 0, 0.04);
+ border: 1px solid ${props => props.theme.placeholder}20;
+
+ animation: slideUp 0.5s ease-out;
+
+ @keyframes slideUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+`;
+
+// Wrapper para cada par de (label + input)
+export const InputGroup = styled.div`
+ margin-bottom: 28px;
+ width: 100%;
+
+ animation: fadeIn 0.4s ease-out backwards;
+ animation-delay: calc(var(--index, 0) * 0.05s);
+
+ @keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+`;
+
+// Label
+export const Label = styled.label`
+ display: block;
+ font-size: 0.95em;
+ font-weight: 700;
+ color: ${props => props.theme.title};
+ margin-bottom: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ font-size: 0.85rem;
+ padding-left: 4px;
+
+ /* Indicador visual opcional */
+ position: relative;
+
+ &::after {
+ content: '';
+ position: absolute;
+ left: -12px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 3px;
+ height: 14px;
+ background: linear-gradient(180deg, ${props => props.theme.button} 0%, ${props => props.theme['hover-button'] || props.theme.button} 100%);
+ border-radius: 2px;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ }
+
+ ${InputGroup}:focus-within &::after {
+ opacity: 1;
+ }
+`;
+
+// Input
+export const Input = styled.input`
+ width: 100%;
+ padding: 14px 20px;
+ font-size: 1em;
+
+ background-color: ${props => props.theme['gray-100']};
+ border: 2px solid transparent;
+ border-radius: 12px;
+ color: ${props => props.theme.title};
+ box-sizing: border-box;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ font-weight: 500;
+
+ &::placeholder {
+ color: ${props => props.theme.placeholder};
+ font-weight: 400;
+ }
+
+ &:hover {
+ background-color: ${props => props.theme.white};
+ border-color: ${props => props.theme.placeholder}40;
+ }
+
+ &:focus {
+ outline: none;
+ background-color: ${props => props.theme.white};
+ border-color: ${props => props.theme.button};
+ box-shadow: 0 0 0 4px ${props => props.theme.button}15,
+ 0 2px 8px rgba(0, 0, 0, 0.04);
+ transform: translateY(-1px);
+ }
+`;
+
+export const SelectWrapper = styled.div`
+ position: relative;
+ width: 100%;
+
+ /* A seta de seleção (CSS) */
+ &::after {
+ content: '▼';
+ font-size: 0.85em;
+ color: ${props => props.theme.placeholder};
+ position: absolute;
+ right: 20px;
+ top: 50%;
+ transform: translateY(-50%);
+ pointer-events: none;
+ transition: all 0.3s ease;
+ }
+
+ &:hover::after {
+ color: ${props => props.theme.button};
+ }
+
+ &:focus-within::after {
+ color: ${props => props.theme.button};
+ transform: translateY(-50%) rotate(180deg);
+ }
+`;
+
+// Componente de Select
+export const Select = styled.select`
+ width: 100%;
+ padding: 14px 20px;
+ font-size: 1em;
+
+ background-color: ${props => props.theme['gray-100']};
+ border: 2px solid transparent;
+ border-radius: 12px;
+ color: ${props => props.theme.title};
+ box-sizing: border-box;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ font-weight: 500;
+ cursor: pointer;
+
+ appearance: none;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+
+ padding-right: 50px;
+
+ &:hover {
+ background-color: ${props => props.theme.white};
+ border-color: ${props => props.theme.placeholder}40;
+ }
+
+ &:focus {
+ outline: none;
+ background-color: ${props => props.theme.white};
+ border-color: ${props => props.theme.button};
+ box-shadow: 0 0 0 4px ${props => props.theme.button}15,
+ 0 2px 8px rgba(0, 0, 0, 0.04);
+ transform: translateY(-1px);
+ }
+
+ &:invalid {
+ color: ${props => props.theme.placeholder};
+ }
+
+ option {
+ padding: 10px;
+ background: ${props => props.theme.white};
+ color: ${props => props.theme.title};
+ }
+`;
+
+// Wrapper para o input de busca E a lista de resultados
+export const SearchWrapper = styled.div`
+ position: relative;
+ width: 100%;
+`;
+
+// Lista de resultados de busca que aparece abaixo do input
+export const SearchResultsList = styled.ul`
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+ right: 0;
+ background: ${props => props.theme.white};
+ border: 2px solid ${props => props.theme.button}20;
+ border-radius: 12px;
+ max-height: 240px;
+ overflow-y: auto;
+ margin: 0;
+ padding: 6px;
+ z-index: 100;
+ list-style: none;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
+ 0 2px 8px rgba(0, 0, 0, 0.08);
+
+ animation: slideDown 0.2s ease-out;
+
+ @keyframes slideDown {
+ from {
+ opacity: 0;
+ transform: translateY(-8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+
+ /* Estilização da scrollbar */
+ &::-webkit-scrollbar {
+ width: 8px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: ${props => props.theme['gray-300']};
+ border-radius: 4px;
+ }
+
+ &::-webkit-scrollbar-thumb:hover {
+ background: ${props => props.theme['gray-400']};
+ }
+`;
+
+// Item individual na lista de resultados
+export const SearchResultItem = styled.li`
+ padding: 12px 16px;
+ cursor: pointer;
+ border-radius: 8px;
+ color: ${props => props.theme.title};
+ font-weight: 500;
+ transition: all 0.2s ease;
+ position: relative;
+
+ /* Barra lateral que aparece no hover */
+ &::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 3px;
+ background: ${props => props.theme.button};
+ border-radius: 0 2px 2px 0;
+ transform: scaleY(0);
+ transition: transform 0.2s ease;
+ }
+
+ &:hover {
+ background-color: ${props => props.theme['gray-100']};
+ padding-left: 20px;
+
+ &::before {
+ transform: scaleY(1);
+ }
+ }
+
+ &:active {
+ background-color: ${props => props.theme['gray-100']};
+ }
+`;
+
+// Mensagem de erro
+export const ErrorMessage = styled.span`
+ font-size: 0.85em;
+ margin-top: 8px;
+ color: ${props => props.theme['red-500'] || '#ef4444'};
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding-left: 4px;
+
+ /* Ícone de alerta */
+ &::before {
+ content: '⚠';
+ font-size: 1.1em;
+ }
+`;
+
+// Botão de submissão
+export const SubmitButton = styled.button`
+ padding: 16px 40px;
+ font-size: 1.05em;
+ font-weight: 700;
+ color: ${props => props.theme.white};
+ background: linear-gradient(135deg, ${props => props.theme.button} 0%, ${props => props.theme['hover-button'] || props.theme.button} 100%);
+ border: none;
+ border-radius: 12px;
+ cursor: pointer;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ margin-top: 16px;
+ box-shadow: 0 4px 16px ${props => props.theme.button}40;
+ position: relative;
+ overflow: hidden;
+
+ /* Efeito de brilho deslizante */
+ &::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: -100%;
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
+ transition: left 0.5s;
+ }
+
+ &:hover::before {
+ left: 100%;
+ }
+
+ &:hover {
+ transform: translateY(-3px);
+ box-shadow: 0 8px 24px ${props => props.theme.button}50;
+ }
+
+ &:active {
+ transform: translateY(-1px);
+ }
+
+ &:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ transform: none;
+ box-shadow: 0 2px 8px ${props => props.theme.button}20;
+
+ &:hover {
+ transform: none;
+ }
+ }
+`;
+
+// Estilo para o campo de texto de várias linhas (Descrição)
+export const TextArea = styled.textarea`
+ width: 100%;
+ padding: 16px 20px;
+ font-size: 1em;
+ font-family: inherit;
+ line-height: 1.6;
+
+ background-color: ${props => props.theme['gray-100']};
+ border: 2px solid transparent;
+ border-radius: 12px;
+ color: ${props => props.theme.title};
+ box-sizing: border-box;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ resize: vertical;
+ min-height: 150px;
+ font-weight: 500;
+
+ &::placeholder {
+ color: ${props => props.theme.placeholder};
+ font-weight: 400;
+ }
+
+ &:hover {
+ background-color: ${props => props.theme.white};
+ border-color: ${props => props.theme.placeholder}40;
+ }
+
+ &:focus {
+ outline: none;
+ background-color: ${props => props.theme.white};
+ border-color: ${props => props.theme.button};
+ box-shadow: 0 0 0 4px ${props => props.theme.button}15,
+ 0 2px 8px rgba(0, 0, 0, 0.04);
+ transform: translateY(-1px);
+ }
+
+ /* Estilização da scrollbar */
+ &::-webkit-scrollbar {
+ width: 10px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: ${props => props.theme['gray-100']};
+ border-radius: 0 10px 10px 0;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: ${props => props.theme['gray-300']};
+ border-radius: 10px;
+ }
+
+ &::-webkit-scrollbar-thumb:hover {
+ background: ${props => props.theme.button};
+ }
+`;
+
+// Contador de caracteres
+export const CharacterCount = styled.div`
+ text-align: right;
+ font-size: 0.85em;
+ color: ${props => props.theme['gray-500'] || props.theme.placeholder};
+ margin-top: 6px;
+ margin-right: 4px;
+ font-weight: 600;
+ padding: 4px 8px;
+ background: ${props => props.theme['gray-100']};
+ border-radius: 6px;
+ display: inline-block;
+ float: right;
+
+ /* Muda de cor quando próximo do limite */
+ &[data-warning="true"] {
+ color: ${props => props.theme['yellow-600'] || '#d97706'};
+ background: ${props => props.theme['yellow-50'] || '#fef3c7'};
+ }
+
+ &[data-limit="true"] {
+ color: ${props => props.theme['red-500'] || '#ef4444'};
+ background: ${props => '#fee2e2'};
+ }
+`; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 69.23% | +72/104 | +58.9% | +43/73 | +64.28% | +18/28 | +72.16% | +70/97 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 | + + + + + + + + + + + + + +1x + + + + + + + +1x +21x + + + + + + +1x +12x + + + + + + + + + + + + + +21x + +21x +21x +21x +21x +21x + + +21x +21x +21x +21x +21x + +21x + +21x + +21x +10x +2x + + + + +2x +2x +2x + + + + + + +21x + + + + + +13x +4x + +13x +13x + + + +21x +1x + +1x +1x + + + + + +21x +2x +2x + +2x + +2x + + +2x +1x + + +1x +1x + + +1x + + + +21x +1x + +1x + + + + +1x + +1x +1x +1x + +1x +1x +1x + + + + + + +1x + + + +21x + + + + + + + + + + + + +21x + +12x + + +12x + + +12x + + + + + + +12x + + + + + + + + +21x +1x + + + + + +21x + + + + + + + + + + + + + + + + + + +4x + + + + + + + + + +2x +2x + + + + + + + + + + +2x + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + +1x + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import React, { useState, useRef, useEffect } from 'react';
+import * as S from './styles';
+import * as D from '../../common/Dropdown/styles';
+import { useNavigate } from 'react-router-dom';
+import type { ProjectProps } from '../../../API/Project';
+import { DeleteProject } from '../../../API/Project';
+import Modal from '../../common/Modal';
+import type { NotificationState } from '../../common/Toast';
+import Toast from '../../common/Toast';
+import * as ModalS from '../../common/Modal/styles';
+import { CreateComment, GetComments, DeleteComment } from '../../../API/Comment';
+import type { CommentProps } from '../../../API/Comment';
+import { useAuth } from '../../../API/AuthContext';
+
+const TrashIcon = () => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+ <polyline points="3 6 5 6 21 6"></polyline>
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
+ </svg>
+);
+
+// Ícone de Comentário (Balão)
+const CommentIcon = () => (
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+ <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
+ </svg>
+);
+
+
+// --- Ícone de Menu (Ellipsis) ---
+const EllipsisIcon = () => (
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+ <circle cx="12" cy="12" r="1" /><circle cx="19" cy="12" r="1" /><circle cx="5" cy="12" r="1" />
+ </svg>
+);
+
+interface PostcardProps {
+ post: ProjectProps;
+ showMenu: boolean; // Verifica se o menu deve ser mostrado (se é dono do post)
+ onDelete?: (id: string) => Promise<void>;
+ deleteLabel?: string;
+}
+
+// --- Componente Postcard ---
+export default function Postcard({ post, showMenu, onDelete, deleteLabel = 'Projeto' }: PostcardProps) {
+ const { currentUser } = useAuth();
+
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+ const menuRef = useRef<HTMLDivElement>(null);
+ const navigate = useNavigate(); // Inicialize o hook de navegação
+ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); // Estado para o modal de exclusão
+ const [notification, setNotification] = useState<NotificationState | null>(null);
+
+ // Estados para o formulário de comentário
+ const [isCommentBoxOpen, setIsCommentBoxOpen] = useState(false);
+ const [commentText, setCommentText] = useState("");
+ const [isSubmittingComment, setIsSubmittingComment] = useState(false);
+ const MAX_COMMENT_CHARS = 500;
+ const [comments, setComments] = useState<CommentProps[]>([]);
+
+ const [commentToDelete, setCommentToDelete] = useState<string | null>(null);
+
+ const projectId = (post as any).id || (post as any).projectID;
+
+ useEffect(() => {
+ if (isCommentBoxOpen && projectId) {
+ loadComments();
+ }
+ }, [isCommentBoxOpen, projectId]);
+
+ async function loadComments() {
+ try {
+ const data = await GetComments(projectId);
+ setComments(data);
+ } catch (error) {
+ console.error("Erro ao carregar comentários", error);
+ }
+ }
+
+ // Lógica para fechar o menu ao clicar fora (sem alterações)
+ useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
+ setIsMenuOpen(false);
+ }
+ }
+ if (isMenuOpen) {
+ document.addEventListener('mousedown', handleClickOutside);
+ }
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [isMenuOpen]);
+
+ const handleEditClick = () => {
+ const projectId = (post as any).id || (post as any).projectID;
+
+ if (projectId) {
+ navigate(`/editProject/${projectId}`, { state: { projectToEdit: post } });
+ } else E{
+ console.error("Erro: ID do projeto não encontrado no objeto:", post);
+ }
+ };
+
+ const handleDeleteMainItem = async () => {
+ try {
+ const id = (post as any).id || (post as any).projectID || (post as any).commentID;
+
+ Iif (!id) throw new Error("ID não encontrado.");
+
+ Iif (onDelete) {
+ await onDelete(id);
+ } else {
+ await DeleteProject(id);
+ setTimeout(() => navigate('/feed'), 1000);
+ }
+
+ setNotification({ message: `${deleteLabel} excluído com sucesso!`, type: 'success' });
+ setIsDeleteModalOpen(false);
+
+ } catch (error) {
+ setNotification({ message: `Erro ao excluir ${deleteLabel?.toLowerCase()}.`, type: 'error' });
+ }
+ };
+
+ const handleCommentSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ Iif (!projectId) {
+ setNotification({ message: 'Erro: Projeto sem ID.', type: 'error' });
+ return;
+ }
+
+ Iif (!commentText.trim()) return;
+
+ try {
+ setIsSubmittingComment(true);
+ await CreateComment(projectId, commentText);
+
+ setNotification({ message: 'Comentário enviado!', type: 'success' });
+ setCommentText(""); // Limpa o campo
+ setIsCommentBoxOpen(false);
+
+ } catch (error) {
+ if (error instanceof Error) {
+ setNotification({ message: error.message, type: 'error' });
+ }
+ } finally {
+ setIsSubmittingComment(false);
+ }
+ };
+
+ const handleConfirmDeleteInternalComment = async () => {
+ if (!commentToDelete) return;
+ try {
+ await DeleteComment(commentToDelete);
+ setNotification({ message: 'Comentário excluído.', type: 'success' });
+ setCommentToDelete(null);
+ await loadComments();
+ } catch (error: any) {
+ setNotification({ message: error.message, type: 'error' });
+ setCommentToDelete(null);
+ }
+ };
+
+ const handleCardClick = (e: React.MouseEvent) => {
+ // Não navega se estiver na aba de comentários do perfil (deleteLabel 'Comentário')
+ Iif (deleteLabel !== 'Projeto') return;
+
+ // Não navega se o clique foi em um elemento interativo
+ const target = e.target as HTMLElement;
+
+ // Verifica se clicou em botões, inputs, links ou no próprio menu
+ Eif (
+ target.closest('button') ||
+ target.closest('input') ||
+ target.closest('textarea') ||
+ target.closest('a') ||
+ target.closest(D.DropdownMenu as any) // Garante que itens do dropdown não disparem
+ ) {
+ return;
+ }
+
+ // Realiza a navegação
+ if (projectId) {
+ navigate(`/project/${projectId}`);
+ }
+ };
+
+ const formatDate = (dateString?: string) => {
+ Eif (!dateString) return "";
+ return new Date(dateString).toLocaleDateString('pt-BR', {
+ day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'
+ });
+ };
+
+ return (
+ <S.PostCardWrapper onClick={handleCardClick}>
+ {notification && (
+ <Toast
+ message={notification.message}
+ type={notification.type}
+ onClose={() => setNotification(null)}
+ />
+ )}
+
+ <S.PostHeader>
+ <img src={(post as any).avatarUrl || 'url_placeholder_avatar.png'} alt={post.title} />
+ <span>{post.title}</span>
+ <small>• {(post as any).author?.title || 'Autor'}</small>
+ </S.PostHeader>
+
+ <S.PostContent>
+ {showMenu && (
+ <S.MenuWrapper ref={menuRef}>
+ <S.MenuButton onClick={() => setIsMenuOpen(prev => !prev)}>
+ <EllipsisIcon />
+ </S.MenuButton>
+
+ {isMenuOpen && (
+ <D.DropdownMenu>
+ {deleteLabel === 'Projeto' && (
+ <D.MenuItem onClick={handleEditClick}>Editar</D.MenuItem>
+ )}
+ <D.DangerMenuItem onClick={() => {
+ setIsMenuOpen(false);
+ setIsDeleteModalOpen(true);}}>
+ Excluir
+ </D.DangerMenuItem>
+ </D.DropdownMenu>
+ )}
+ </S.MenuWrapper>
+ )}
+
+ <p>{post.description}</p>
+
+ <S.ActionRow>
+ <S.ActionButton onClick={() => setIsCommentBoxOpen(!isCommentBoxOpen)}>
+ <CommentIcon />
+ <span>Comentar</span>
+ </S.ActionButton>
+ </S.ActionRow>
+
+ {isCommentBoxOpen && (
+ <>
+ <S.CommentForm onSubmit={handleCommentSubmit}>
+ <S.CommentTextArea
+ placeholder="Escreva seu comentário..."
+ value={commentText}
+ onChange={(e) => setCommentText(e.target.value)}
+ maxLength={MAX_COMMENT_CHARS}
+ disabled={isSubmittingComment}
+ />
+ <S.CommentFooter>
+ <S.CharacterCount isLimit={commentText.length >= MAX_COMMENT_CHARS}>
+ {commentText.length} / {MAX_COMMENT_CHARS}
+ </S.CharacterCount>
+ <S.SubmitCommentButton
+ type="submit"
+ disabled={isSubmittingComment || !commentText.trim()}
+ >
+ {isSubmittingComment ? 'Enviando...' : 'Enviar'}
+ </S.SubmitCommentButton>
+ </S.CommentFooter>
+ </S.CommentForm>
+
+ {/* LISTA DE COMENTÁRIOS */}
+ {comments.length > 0 && (
+ <S.CommentsSection>
+ {comments.map((comment) => {
+ // Verifica se o usuário atual é o autor do comentário
+ const isAuthor = currentUser?.id && String(currentUser.id) === String(comment.authorID);
+
+ return (
+ <S.CommentItem key={comment.commentID || Math.random()}>
+ <S.CommentAvatar />
+ <S.CommentBubble>
+ <S.CommentHeader>
+ <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
+ <strong>{comment.username || "Usuário"}</strong>
+ <span>{formatDate(comment.createdAt)}</span>
+ </div>
+
+ {/* Botão de Deletar (Só aparece para o dono) */}
+ {isAuthor && (
+ <S.DeleteCommentButton
+ onClick={() => setCommentToDelete(comment.commentID!)}
+ title="Excluir comentário"
+ >
+ <TrashIcon />
+ </S.DeleteCommentButton>
+ )}
+ </S.CommentHeader>
+ <S.CommentText>{comment.content}</S.CommentText>
+ </S.CommentBubble>
+ </S.CommentItem>
+ );
+ })}
+ </S.CommentsSection>
+ )}
+ </>
+ )}
+
+ </S.PostContent>
+ <Modal
+ isOpen={isDeleteModalOpen}
+ onClose={() => setIsDeleteModalOpen(false)}
+ title={`Excluir ${deleteLabel}`}
+ >
+ <div style={{ textAlign: 'center' }}>
+ <p style={{ marginBottom: '24px', color: '#555' }}>
+ Tem certeza que deseja excluir este {deleteLabel?.toLowerCase()}?
+ </p>
+ <ModalS.ModalActions>
+ <ModalS.ChoiceButton onClick={() => setIsDeleteModalOpen(false)}>
+ Cancelar
+ </ModalS.ChoiceButton>
+ <ModalS.ChoiceButton onClick={handleDeleteMainItem} style={{ backgroundColor: '#e74c3c' }}>
+ Excluir
+ </ModalS.ChoiceButton>
+ </ModalS.ModalActions>
+ </div>
+ </Modal>
+
+ <Modal
+ isOpen={!!commentToDelete}
+ onClose={() => setCommentToDelete(null)}
+ title="Excluir Comentário"
+ >
+ <div style={{ textAlign: 'center' }}>
+ <p style={{ marginBottom: '24px', color: '#555' }}>
+ Deseja realmente apagar este comentário?
+ </p>
+ <ModalS.ModalActions>
+ <ModalS.ChoiceButton onClick={() => setCommentToDelete(null)}>
+ Cancelar
+ </ModalS.ChoiceButton>
+ <ModalS.ChoiceButton
+ onClick={handleConfirmDeleteInternalComment}
+ style={{ backgroundColor: '#e74c3c' }}
+ >
+ Excluir
+ </ModalS.ChoiceButton>
+ </ModalS.ModalActions>
+ </div>
+ </Modal>
+
+ </S.PostCardWrapper>
+ );
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 91.07% | +51/56 | +83.33% | +30/36 | +78.94% | +15/19 | +90.9% | +50/55 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 | + + + + + + + + + + +47x +47x + +47x + +47x +47x + + +47x +47x + + +47x +47x + +47x +6x +6x +6x +6x + +6x + + + + +6x +6x + + + +6x + + + + + +47x +20x +8x +8x +8x + + +12x + + +12x +3x + + + + + +12x +3x + + + +12x +12x + + + + +47x + +1x +1x + + +6x +6x + + + +47x +1x +1x +1x + + +47x +1x +1x +1x + + +47x + +47x + + + + + + +7x + + + + + + + + + + + + + + + + + +1x + +1x + + + + + + + + + + + + + + +3x + +1x + + + + + + + + + + + + + + + | import React, { useState, useEffect, useRef } from 'react';
+import { FiSearch } from 'react-icons/fi';
+import * as S from './styles';
+import { useNavigate } from 'react-router-dom';
+
+import { GetAllCommunities } from '../../../API/Community';
+import { GetFeedProjects } from '../../../API/Project';
+import type { CommunityProps } from '../../../API/Community';
+import type { ProjectProps } from '../../../API/Project';
+
+export default function Searchbar() {
+ const [query, setQuery] = useState('');
+ const navigate = useNavigate();
+
+ const wrapperRef = useRef<HTMLDivElement>(null);
+
+ const [isFocused, setIsFocused] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+
+ // Dados brutos (cache local)
+ const [allCommunities, setAllCommunities] = useState<CommunityProps[]>([]);
+ const [allProjects, setAllProjects] = useState<ProjectProps[]>([]);
+
+ // Dados filtrados para exibição
+ const [filteredCommunities, setFilteredCommunities] = useState<CommunityProps[]>([]);
+ const [filteredProjects, setFilteredProjects] = useState<ProjectProps[]>([]);
+
+ const handleFocus = async () => {
+ setIsFocused(true);
+ Eif (allCommunities.length === 0 && allProjects.length === 0) {
+ setIsLoading(true);
+ try {
+ // Busca em paralelo
+ const [communitiesData, projectsData] = await Promise.all([
+ GetAllCommunities().catch(() => []), // Se falhar, retorna array vazio
+ GetFeedProjects().catch(() => [])
+ ]);
+
+ setAllCommunities(communitiesData);
+ setAllProjects(projectsData);
+ } catch (error) {
+ console.error("Erro ao carregar dados para pesquisa", error);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+ };
+
+ // Efeito para filtrar sempre que a query ou os dados mudarem
+ useEffect(() => {
+ if (!query.trim()) {
+ setFilteredCommunities([]);
+ setFilteredProjects([]);
+ return;
+ }
+
+ const lowerQuery = query.toLowerCase();
+
+ // Filtra Comunidades (pelo nome ou descrição ou tecnologias)
+ const filteredComms = allCommunities.filter(comm =>
+ comm.name.toLowerCase().includes(lowerQuery) ||
+ comm.description?.toLowerCase().includes(lowerQuery) ||
+ comm.technologies?.some(tech => tech.toLowerCase().includes(lowerQuery))
+ );
+
+ // Filtra Projetos (pelo título ou tecnologias)
+ const filteredProjs = allProjects.filter(proj =>
+ proj.title.toLowerCase().includes(lowerQuery) ||
+ proj.technologies?.some(tech => tech.toLowerCase().includes(lowerQuery))
+ );
+
+ setFilteredCommunities(filteredComms.slice(0, 5)); // Limita a 5 resultados
+ setFilteredProjects(filteredProjs.slice(0, 5)); // Limita a 5 resultados
+
+ }, [query, allCommunities, allProjects]);
+
+ // Fecha ao clicar fora
+ useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ Eif (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
+ setIsFocused(false);
+ }
+ }
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ // Navegação
+ const handleNavigateToCommunity = (communityId: string) => {
+ navigate(`/r/${communityId}`);
+ setIsFocused(false);
+ setQuery('');
+ };
+
+ const handleNavigateToProject = (projectId: string) => {
+ navigate(`/project/${projectId}`);
+ console.log("Navegar para projeto:", projectId);
+ setIsFocused(false);
+ };
+
+ const hasResults = filteredCommunities.length > 0 || filteredProjects.length > 0;
+
+ return (
+ <S.SearchWrapper ref={wrapperRef}>
+ <FiSearch size={20} />
+ <S.SearchInput
+ type="text"
+ placeholder="Busque comunidades e projetos..."
+ value={query}
+ onChange={(e) => setQuery(e.target.value)}
+ onFocus={handleFocus}
+ />
+
+ {/* Renderiza o dropdown apenas se estiver focado e houver texto */}
+ {isFocused && query.length > 0 && (
+ <S.ResultsDropdown>
+ {isLoading ? (
+ <S.NoResults>Carregando...</S.NoResults>
+ ) : !hasResults ? (
+ <S.NoResults>Nenhum resultado encontrado.</S.NoResults>
+ ) : (
+ <>
+ {/* Seção de Comunidades */}
+ {filteredCommunities.length > 0 && (
+ <S.ResultSection>
+ <h4>Comunidades</h4>
+ {filteredCommunities.map(comm => (
+ <S.ResultItem
+ key={comm.communityID}
+ onClick={() => handleNavigateToCommunity(comm.communityID)}
+ >
+ <span>{comm.name}</span>
+ {/* Opcional: mostrar tecnologia principal */}
+ {comm.technologies?.[0] && <small>{comm.technologies[0]}</small>}
+ </S.ResultItem>
+ ))}
+ </S.ResultSection>
+ )}
+
+ {/* Seção de Projetos */}
+ {filteredProjects.length > 0 && (
+ <S.ResultSection>
+ <h4>Projetos</h4>
+ {filteredProjects.map((proj: any) => (
+ <S.ResultItem
+ key={proj.id || proj.projectID}
+ onClick={() => handleNavigateToProject(proj.id || proj.projectID)}
+ >
+ <span>{proj.title}</span>
+ <small>por {proj.authorUsername || 'Usuário'}</small>
+ </S.ResultItem>
+ ))}
+ </S.ResultSection>
+ )}
+ </>
+ )}
+ </S.ResultsDropdown>
+ )}
+ </S.SearchWrapper>
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 94.28% | +33/35 | +76.19% | +16/21 | +92.85% | +13/14 | +93.93% | +31/33 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 | + + + + + + + + + + + + + + + + + + + + + +20x +20x +20x +20x + +20x +9x +3x +11x + + +3x + +6x + + + + +20x + + + + + +5x +5x +5x + + + + +20x + +2x +1x +1x + +1x +1x +1x +1x +1x + + + + +20x +2x +1x + + +20x + + + + +18x + +1x + + + + + + + + + + + + + +3x + + + + + + +4x + + + + + + + + + + | import React, { useState, useEffect, useRef } from 'react';
+import * as S from '../CreationForm/styles';
+import { Keyword, KeywordContainer } from '../../common/Keyword';
+
+interface TagInputProps {
+ value: string[];
+ onChange: (value: string[]) => void;
+
+ searchList: string[];
+ limit: number;
+ placeholder: string;
+}
+
+
+export default function TagInput({
+ value: selectedTags = [],
+ onChange,
+ searchList,
+ limit,
+ placeholder
+}: TagInputProps) {
+
+ const [searchQuery, setSearchQuery] = useState('');
+ const [searchResults, setSearchResults] = useState<string[]>([]);
+ const [error, setError] = useState('');
+ const wrapperRef = useRef<HTMLDivElement>(null);
+
+ useEffect(() => {
+ if (searchQuery.length > 0) {
+ const filtered = searchList.filter(item =>
+ item.toLowerCase().includes(searchQuery.toLowerCase()) &&
+ !selectedTags.includes(item) // Não mostra o que já foi selecionado
+ );
+ setSearchResults(filtered);
+ } else {
+ setSearchResults([]);
+ }
+ }, [searchQuery, selectedTags, searchList]);
+
+ // Lógica para fechar a lista de resultados ao clicar fora
+ useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
+ setSearchResults([]); // Fecha a lista
+ }
+ }
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [wrapperRef]);
+
+ // Adiciona uma tag
+ const handleAddTag = (tag: string) => {
+ // Usa o 'limit' da prop
+ if (selectedTags.length >= limit) {
+ setError(`Limite de ${limit} itens atingido.`);
+ return;
+ }
+ Eif (!selectedTags.includes(tag)) {
+ onChange([...selectedTags, tag]); // Atualiza o react-hook-form
+ setSearchQuery('');
+ setSearchResults([]);
+ setError('');
+ }
+ };
+
+ // Remove uma tag
+ const handleRemoveTag = (tagToRemove: string) => {
+ onChange(selectedTags.filter(tag => tag !== tagToRemove));
+ setError('');
+ };
+
+ return (
+ <S.SearchWrapper ref={wrapperRef}>
+ {selectedTags.length > 0 && (
+ <KeywordContainer>
+ {selectedTags.map(tag => (
+ <Keyword
+ key={tag}
+ onRemove={() => handleRemoveTag(tag)}
+ >
+ {tag}
+ </Keyword>
+ ))}
+ </KeywordContainer>
+ )}
+
+ <div style={{ height: selectedTags.length > 0 ? '12px' : '0' }} />
+
+ <S.Input
+ type="text"
+ placeholder={placeholder}
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ disabled={selectedTags.length >= limit}
+ />
+
+ {searchResults.length > 0 && (
+ <S.SearchResultsList>
+ {searchResults.map(tag => (
+ <S.SearchResultItem key={tag} onClick={() => handleAddTag(tag)}>
+ {tag}
+ </S.SearchResultItem>
+ ))}
+ </S.SearchResultsList>
+ )}
+
+ {error && <S.ErrorMessage>{error}</S.ErrorMessage>}
+ </S.SearchWrapper>
+ );
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 100% | +1/1 | +100% | +0/0 | +100% | +1/1 | +100% | +1/1 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 | + + + + + + + + + +2x + + + + + + + + + + + + + + + + + | import { FiPlus } from 'react-icons/fi';
+import Searchbar from '../../domain/Searchbar';
+import * as S from './styles';
+
+
+interface HeaderProps {
+ onCreateClick: () => void;
+}
+
+export default function Header({ onCreateClick }: HeaderProps) {
+ return (
+ <S.HeaderContainer>
+ <S.SearchContainer>
+ <Searchbar />
+ </S.SearchContainer>
+
+ <S.ActionsContainer>
+ <S.CreateButton onClick={onCreateClick}>
+ <FiPlus size={20} />
+ <span>Create</span>
+ </S.CreateButton>
+
+ <S.ProfileIcon to={`/profile`}>
+ </S.ProfileIcon>
+ </S.ActionsContainer>
+ </S.HeaderContainer>
+ );
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 100% | +1/1 | +100% | +0/0 | +100% | +1/1 | +100% | +1/1 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 | + + +1x + + + + + + + + + + + + + + | import * as S from './styles';
+
+export default function HeaderHome() {
+ return (
+ <S.HeaderContainer>
+
+ <S.ActionsContainer>
+ <S.Button to="/login">
+ Login
+ </S.Button>
+
+ <S.Button to="/register">
+ Register
+ </S.Button>
+ </S.ActionsContainer>
+ </S.HeaderContainer>
+ );
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 93.75% | +15/16 | +75% | +3/4 | +100% | +5/5 | +93.75% | +15/16 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 | + + + + + + + + + +9x +9x +9x + +9x +2x + + +9x + +8x +8x + +8x +8x +8x + + + + + + +8x +8x + + +9x + + + + + + + + + + + + + + + + +4x + + + + + + + + + + + + + + + + + + | import { useState } from 'react';
+import { FiHome, FiChevronDown } from 'react-icons/fi';
+import { GetUserCommunities } from '../../../API/Community';
+import * as S from './styles';
+import type { CommunityProps } from '../../../API/Community';
+import { useAuth } from '../../../API/AuthContext';
+import { useEffect } from 'react';
+
+export default function Sidebar() {
+ // Estado para controlar se a lista de comunidades está visível
+ const [isCommunitiesOpen, setIsCommunitiesOpen] = useState(true);
+ const { currentUser } = useAuth();
+ const [userCommunities, setUserCommunities] = useState<CommunityProps[]>([]);
+
+ const toggleCommunities = () => {
+ setIsCommunitiesOpen(!isCommunitiesOpen);
+ };
+
+ useEffect(() => {
+ // Função assíncrona para buscar todos os dados
+ const fetchCommunities = async () => {
+ try {;
+
+ const apiUserCommunities = await GetUserCommunities();
+ console.log("Comunidades do usuário:", apiUserCommunities);
+ setUserCommunities(apiUserCommunities);
+
+ } catch (error) {
+ console.error("Falha ao buscar comunidades:", error);
+ }
+
+ };
+ Eif(currentUser)
+ fetchCommunities();
+ }, [currentUser]);
+
+ return (
+ <S.SidebarContainer>
+ <S.SidebarNav>
+
+ <S.HomeLink to="/feed">
+ <FiHome size={22} />
+ <span>Home</span>
+ </S.HomeLink>
+
+ <S.CommunitiesHeader onClick={toggleCommunities} isOpen={isCommunitiesOpen}>
+ <span>COMUNIDADES</span>
+ <FiChevronDown size={20} />
+ </S.CommunitiesHeader>
+
+ {isCommunitiesOpen && (
+ <S.CommunitiesList>
+ {userCommunities.map((community) => (
+ <S.CommunityLink
+ to={`/r/${community.communityID}`}
+ key={community.communityID}
+ >
+ <S.CommunityIcon
+ alt={`${community.name} icon`}
+ />
+ <span>{community.name}</span>
+ </S.CommunityLink>
+ ))}
+ </S.CommunitiesList>
+ )}
+
+ </S.SidebarNav>
+ </S.SidebarContainer>
+ );
+}
+
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| App.tsx | +
+
+ |
+ 100% | +1/1 | +100% | +0/0 | +100% | +1/1 | +100% | +1/1 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ ++ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 | + + + + + + + + + + + + + + +14x +14x + +14x +14x +14x + +14x +14x +14x + +14x + +14x + +4x +4x +4x +4x +4x +4x + + + + +4x + + +14x + + + + + +6x +6x + + +14x +1x + +1x +1x + + +1x + + + + + +1x + + + + + + + + +14x + + + +14x +1x +1x +1x +1x +1x + + +1x + + + + + + + + + + + +14x +1x + +1x +1x + + +1x + + + + + +1x +1x + + + + + + + + + +14x + +10x + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + +1x + + + + + + + + + + +1x +1x + + + + + + + + + + + + + +10x + + + + + + + + + + + + +10x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { useParams, useNavigate } from 'react-router-dom';
+import Sidebar from '../../components/layout/Sidebar';
+import Postcard from '../../components/domain/Postcard';
+import * as S from './styles';
+import { FiMoreHorizontal } from 'react-icons/fi';
+import { useEffect, useState, useRef } from 'react';
+import { GetCommunityById, JoinCommunity, DeleteCommunity, LeaveCommunity } from '../../API/Community';
+import type { NotificationState } from '../../components/common/Toast';
+import Toast from '../../components/common/Toast';
+import type { CommunityProps } from '../../API/Community';
+import * as D from '../../components/common/Dropdown/styles';
+import Modal from '../../components/common/Modal';
+import * as ModalS from '../../components/common/Modal/styles';
+
+export default function CommunityPage() {
+ const { communityID } = useParams<{ communityID: string }>();
+ const navigate = useNavigate();
+
+ const [notification, setNotification] = useState<NotificationState | null>(null);
+ const [community, setCommunity] = useState<any>(null);
+ const [posts, setPosts] = useState<any[]>([]);
+
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+ const menuRef = useRef<HTMLDivElement>(null);
+
+ const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
+
+ useEffect(() => {
+ async function loadData() {
+ console.log("Carregando dados da comunidade para ID:", communityID);
+ Iif (!communityID) return;
+ try {
+ const data = await GetCommunityById(communityID);
+ setCommunity(data.community);
+ setPosts(data.posts);
+ } catch (error) {
+ console.error(error);
+ }
+ }
+ loadData();
+ }, [communityID]);
+
+ useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
+ setIsMenuOpen(false);
+ }
+ }
+ if (isMenuOpen) document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, [isMenuOpen]);
+
+ const handleJoin = async () => {
+ Iif (!community) return;
+
+ try {
+ await JoinCommunity(community.communityID);
+
+ // Atualiza a interface após entrar na comunidade
+ setCommunity((prev: CommunityProps | null) => prev ? ({
+ ...prev,
+ isMember: true, // Esconde o botão
+ memberCount: (prev.memberCount || 0) + 1 // Atualiza o contador visualmente
+ }) : null);
+
+ setNotification({ message: 'Você entrou na comunidade!', type: 'success' });
+
+ } catch (error) {
+ if (error instanceof Error) {
+ setNotification({ message: error.message, type: 'error' });
+ }
+ }
+ };
+
+ const handleEdit = () => {
+ navigate('/editCommunity', { state: { communityToEdit: community } });
+ };
+
+ const handleDelete = async () => {
+ Iif (!community) return;
+ try {
+ await DeleteCommunity(community.communityID);
+ setNotification({ message: 'Comunidade excluída com sucesso.', type: 'success' });
+ setIsDeleteModalOpen(false);
+
+ // Redireciona para home após excluir
+ setTimeout(() => {
+ navigate('/feed');
+ }, 1500);
+
+ } catch (error) {
+ if (error instanceof Error) {
+ setNotification({ message: error.message, type: 'error' });
+ }
+ setIsDeleteModalOpen(false);
+ }
+ };
+
+ const handleLeave = async () => {
+ Iif (!community) return;
+
+ try {
+ await LeaveCommunity(community.communityID);
+
+ // Atualiza a interface otimisticamente
+ setCommunity((prev: CommunityProps | null) => prev ? ({
+ ...prev,
+ isMember: false, // O usuário não é mais membro
+ memberCount: Math.max((prev.memberCount || 0) - 1, 0) // Decrementa contador
+ }) : null);
+
+ setNotification({ message: 'Você saiu da comunidade.', type: 'success' });
+ setIsLeaveModalOpen(false); // Fecha o modal
+
+ } catch (error) {
+ if (error instanceof Error) {
+ setNotification({ message: error.message, type: 'error' });
+ }
+ setIsLeaveModalOpen(false);
+ }
+ };
+
+ if (!community) return <div>Comunidade não encontrada</div>;
+
+ return (
+ <S.PageWrapper>
+ <Sidebar />
+
+ {notification && (
+ <Toast
+ message={notification.message}
+ type={notification.type}
+ onClose={() => setNotification(null)}
+ />
+ )}
+
+ <S.MainContent>
+ <S.Banner />
+
+ <S.HeaderContainer>
+ <S.Avatar />
+ <S.HeaderInfo>
+ <h1>{community.name}</h1>
+ <span>{community.memberCount} membros</span>
+ </S.HeaderInfo>
+
+ <S.HeaderActions>
+ {!community.isMember && (
+ <S.JoinButton onClick={handleJoin}>
+ Join
+ </S.JoinButton>
+ )}
+ {community.isMember && !community.isAdmin && (
+ <S.LeaveButton onClick={() => setIsLeaveModalOpen(true)}>
+ Sair
+ </S.LeaveButton>
+ )}
+ {community.isAdmin && (
+ <S.MenuWrapper ref={menuRef}>
+ <S.OptionsButton onClick={() => setIsMenuOpen(!isMenuOpen)}>
+ <FiMoreHorizontal />
+ </S.OptionsButton>
+
+ {isMenuOpen && (
+ <D.DropdownMenu>
+ <D.MenuItem onClick={handleEdit}>
+ Editar Comunidade
+ </D.MenuItem>
+ <D.Separator />
+ <D.DangerMenuItem onClick={() => {
+ setIsMenuOpen(false);
+ setIsDeleteModalOpen(true);
+ }}>
+ Excluir Comunidade
+ </D.DangerMenuItem>
+ </D.DropdownMenu>
+ )}
+ </S.MenuWrapper>
+ )}
+ </S.HeaderActions>
+ </S.HeaderContainer>
+
+ <S.ContentGrid>
+ <S.FeedColumn>
+ {posts.map(post => (
+ <S.FeedCardWrapper key={post.projectID || post.id}>
+ <Postcard post={post} showMenu={false} />
+ </S.FeedCardWrapper>
+ ))}
+ </S.FeedColumn>
+
+ <S.InfoSidebar>
+ <h3>DESCRIPTION</h3>
+ <p>{community.description}</p>
+
+ <h3>KEYWORDS</h3>
+ <S.KeywordsContainer>
+ {community.technologies?.map((keyword: string) => (
+ <S.KeywordTag key={keyword}>{keyword}</S.KeywordTag>
+ ))}
+ </S.KeywordsContainer>
+ </S.InfoSidebar>
+ </S.ContentGrid>
+ </S.MainContent>
+
+ <Modal
+ isOpen={isDeleteModalOpen}
+ onClose={() => setIsDeleteModalOpen(false)}
+ title="Excluir Comunidade"
+ >
+ <div style={{ textAlign: 'center' }}>
+ <p style={{ marginBottom: '24px', color: '#555' }}>
+ Tem certeza que deseja excluir a comunidade <strong>{community.name}</strong>?<br/>
+ Todos os posts e vínculos serão removidos.
+ </p>
+ <ModalS.ModalActions>
+ <ModalS.ChoiceButton onClick={() => setIsDeleteModalOpen(false)}>
+ Cancelar
+ </ModalS.ChoiceButton>
+ <ModalS.ChoiceButton onClick={handleDelete} style={{ backgroundColor: '#e74c3c' }}>
+ Excluir
+ </ModalS.ChoiceButton>
+ </ModalS.ModalActions>
+ </div>
+ </Modal>
+
+ <Modal
+ isOpen={isLeaveModalOpen}
+ onClose={() => setIsLeaveModalOpen(false)}
+ title="Sair da Comunidade"
+ >
+ <div style={{ textAlign: 'center' }}>
+ <p style={{ marginBottom: '24px', color: '#555' }}>
+ Tem certeza que deseja sair da comunidade <strong>{community.name}</strong>?
+ </p>
+ <ModalS.ModalActions>
+ <ModalS.ChoiceButton onClick={() => setIsLeaveModalOpen(false)}>
+ Cancelar
+ </ModalS.ChoiceButton>
+ <ModalS.ChoiceButton
+ onClick={handleLeave}
+ style={{ backgroundColor: '#e74c3c' }} // Botão vermelho
+ >
+ Sair
+ </ModalS.ChoiceButton>
+ </ModalS.ModalActions>
+ </div>
+ </Modal>
+ </S.PageWrapper>
+ );
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 | + +1x + + +10x + + +1x + + + + + + + + + + + + + + + + + + + +1x + + +10x + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + +10x +10x + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + +10x + + + + + +10x + + + + + + + + + + + + + + + + +10x + + + + + +10x + + + +10x + + + + + + + + + + + + + + +1x + + + + + + +1x + + +3x +3x +3x + + + + +3x + + + + + + + + + + + + + + + + + + + + + +3x +3x + + + + + + + +1x + + +3x + +3x + + + + + + + + + + + + + + +3x + + + + + + + + + + + + +3x +3x +3x + + + + + + + + + + + + + +1x + + + +4x +4x +4x + + + + + + + + + +4x +4x + +4x + + + + + + + + + +1x + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + +1x +10x + + + + + + + + + + + + + +1x +10x +10x + + + + + + + + + + + + + + + + + + + + + + + + +10x + + + + + + + + + + + + + + + + + + +10x + + + + + + + + + +10x + + + + + + + + + + + +1x + + + + + + +1x +10x +10x + + + + +10x + + + + +10x + +10x + + + +1x + + + + + | import styled from 'styled-components';
+
+export const PageWrapper = styled.div`
+ display: flex;
+ min-height: 100vh;
+ background: linear-gradient(135deg, ${props => props.theme.white} 0%, ${props => props.theme['gray-100'] || props.theme['gray-100']} 100%);
+`;
+
+export const MainContent = styled.main`
+ flex: 1;
+ margin-left: 250px;
+ display: flex;
+ flex-direction: column;
+
+ animation: fadeIn 0.5s ease-out;
+
+ @keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+ }
+`;
+
+// --- Área do Topo (Banner + Avatar + Título) ---
+
+export const Banner = styled.div`
+ width: 100%;
+ height: 220px;
+ background: linear-gradient(135deg, ${props => props.theme.button} 0%, ${props => props.theme['hover-button'] || props.theme.placeholder} 100%);
+ position: relative;
+ overflow: hidden;
+
+ /* Padrão decorativo com formas geométricas */
+ &::before {
+ content: '';
+ position: absolute;
+ top: -50%;
+ right: -10%;
+ width: 600px;
+ height: 600px;
+ background: radial-gradient(circle, rgba(255, 255, 255, 0.15) 0%, transparent 70%);
+ border-radius: 50%;
+ }
+
+ &::after {
+ content: '';
+ position: absolute;
+ bottom: -30%;
+ left: -5%;
+ width: 400px;
+ height: 400px;
+ background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
+ border-radius: 50%;
+ }
+`;
+
+export const HeaderContainer = styled.div`
+ display: flex;
+ align-items: flex-end;
+ padding: 0 40px;
+ margin-top: -60px;
+ margin-bottom: 32px;
+ gap: 24px;
+ position: relative;
+ z-index: 2;
+ flex-wrap: wrap;
+
+ animation: slideUp 0.6s ease-out;
+
+ @keyframes slideUp {
+ from {
+ opacity: 0;
+ transform: translateY(30px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+`;
+
+export const Avatar = styled.div`
+ width: 130px;
+ height: 130px;
+ border-radius: 24px;
+ background: linear-gradient(135deg, ${props => props.theme.sidebar} 0%, ${props => props.theme.button} 100%);
+ border: 5px solid ${props => props.theme.white};
+ flex-shrink: 0;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2),
+ 0 4px 12px rgba(0, 0, 0, 0.15);
+ position: relative;
+
+ /* Efeito de brilho */
+ &::after {
+ content: '';
+ position: absolute;
+ top: 15%;
+ left: 15%;
+ width: 35%;
+ height: 35%;
+ background: radial-gradient(circle, rgba(255, 255, 255, 0.4) 0%, transparent 70%);
+ border-radius: 50%;
+ }
+
+ transition: transform 0.3s ease, box-shadow 0.3s ease;
+
+ &:hover {
+ transform: scale(1.05) rotate(2deg);
+ box-shadow: 0 15px 40px rgba(0, 0, 0, 0.25),
+ 0 6px 16px rgba(0, 0, 0, 0.2);
+ }
+`;
+
+export const HeaderInfo = styled.div`
+ display: flex;
+ flex-direction: column;
+ padding-bottom: 12px;
+ flex: 1;
+ gap: 14px;
+ min-width: 0;
+
+ h1 {
+ font-size: 2.8rem;
+ font-weight: 800;
+ color: ${props => props.theme.black};
+ margin: 0;
+ margin-bottom: 8px;
+ line-height: 1.1;
+ letter-spacing: -0.02em;
+
+ background: linear-gradient(135deg, ${props => props.theme.white} 0%, ${props => props.theme.button} 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+
+ position: relative;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+
+ /* Underline decorativo */
+ &::after {
+ content: '';
+ position: absolute;
+ bottom: -10px;
+ left: 0;
+ width: 80px;
+ height: 5px;
+ background: linear-gradient(90deg, ${props => props.theme.button} 0%, transparent 100%);
+ border-radius: 3px;
+ }
+ }
+
+ span {
+ color: ${props => props.theme['gray-600'] || props.theme['gray-500']};
+ font-size: 1rem;
+ font-weight: 600;
+ padding: 6px 14px;
+ background: ${props => props.theme['gray-100']};
+ border-radius: 20px;
+ width: fit-content;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+
+ /* Ícone decorativo */
+ &::before {
+ content: '👥';
+ font-size: 0.9em;
+ }
+ }
+`;
+
+export const HeaderActions = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding-bottom: 15px;
+`;
+
+export const JoinButton = styled.button`
+ padding: 12px 32px;
+ border-radius: 12px;
+ border: 2px solid ${props => props.theme.button};
+ background: ${props => props.theme.button};
+ color: ${props => props.theme.white};
+ font-weight: 700;
+ font-size: 1rem;
+ cursor: pointer;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ box-shadow: 0 4px 12px ${props => props.theme.button}40;
+ position: relative;
+ overflow: hidden;
+
+ /* Efeito de brilho deslizante */
+ &::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: -100%;
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
+ transition: left 0.5s;
+ }
+
+ &:hover::before {
+ left: 100%;
+ }
+
+ &:hover {
+ transform: translateY(-3px);
+ box-shadow: 0 8px 20px ${props => props.theme.button}50;
+ background: ${props => props.theme['hover-button'] || props.theme.button};
+ }
+
+ &:active {
+ transform: translateY(-1px);
+ }
+`;
+
+export const LeaveButton = styled.button`
+ padding: 12px 32px;
+ border-radius: 12px;
+ border: 2px solid ${props => props.theme['red-500']};
+ background: transparent;
+ color: ${props => props.theme['red-500']};
+ font-weight: 700;
+ font-size: 1rem;
+ cursor: pointer;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ position: relative;
+ overflow: hidden;
+
+ &::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 0;
+ height: 0;
+ background: ${props => props.theme['red-500']};
+ border-radius: 50%;
+ transform: translate(-50%, -50%);
+ transition: width 0.4s, height 0.4s;
+ z-index: -1;
+ }
+
+ &:hover::before {
+ width: 300%;
+ height: 300%;
+ }
+
+ &:hover {
+ color: ${props => props.theme.white};
+ border-color: ${props => props.theme['red-500']};
+ box-shadow: 0 4px 16px ${props => props.theme['red-500']}40;
+ transform: translateY(-2px);
+ }
+
+ &:active {
+ transform: translateY(0);
+ }
+
+ > * {
+ position: relative;
+ z-index: 1;
+ }
+`;
+
+export const OptionsButton = styled.button`
+ width: 48px;
+ height: 48px;
+ border-radius: 12px;
+ border: 2px solid ${props => props.theme.button};
+ background: ${props => props.theme.white};
+ color: ${props => props.theme.button};
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ font-size: 1.3rem;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+
+ &:hover {
+ background-color: ${props => props.theme.button};
+ color: ${props => props.theme.white};
+ transform: scale(1.1) rotate(90deg);
+ box-shadow: 0 4px 16px ${props => props.theme.button}30;
+ }
+
+ &:active {
+ transform: scale(1.05) rotate(90deg);
+ }
+`;
+
+// --- Grid de Conteúdo (Feed + Info Lateral) ---
+
+export const ContentGrid = styled.div`
+ display: grid;
+ grid-template-columns: 1fr 340px;
+ gap: 28px;
+ padding: 0 40px 40px 40px;
+ max-width: 1400px;
+ width: 100%;
+ box-sizing: border-box;
+
+ @media (max-width: 900px) {
+ grid-template-columns: 1fr;
+ }
+`;
+
+export const FeedColumn = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+
+ animation: slideInLeft 0.5s ease-out;
+
+ @keyframes slideInLeft {
+ from {
+ opacity: 0;
+ transform: translateX(-20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+ }
+`;
+
+export const FeedCardWrapper = styled.div`
+ background: linear-gradient(135deg, ${props => props.theme.background} 0%, ${props => props.theme['gray-100'] || props.theme.background} 100%);
+ border-radius: 18px;
+ padding: 10px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
+ transition: all 0.3s ease;
+
+ &:hover {
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
+ transform: translateY(-2px);
+ }
+`;
+
+// --- Sidebar de Informações (Direita) ---
+
+export const InfoSidebar = styled.aside`
+ background: ${props => props.theme.white};
+ border: 2px solid ${props => props.theme.button}25;
+ border-radius: 20px;
+ padding: 28px;
+ height: fit-content;
+ position: sticky;
+ top: 20px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
+ overflow-wrap: break-word;
+ word-wrap: break-word;
+ word-break: break-word;
+
+ animation: slideInRight 0.5s ease-out;
+
+ @keyframes slideInRight {
+ from {
+ opacity: 0;
+ transform: translateX(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+ }
+
+ h3 {
+ color: ${props => props.theme.title};
+ font-size: 0.85rem;
+ font-weight: 800;
+ letter-spacing: 1.5px;
+ text-transform: uppercase;
+ margin-bottom: 16px;
+ margin-top: 28px;
+ position: relative;
+ padding-left: 12px;
+
+ /* Barra lateral decorativa */
+ &::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 4px;
+ height: 18px;
+ background: linear-gradient(180deg, ${props => props.theme.button} 0%, ${props => props.theme['hover-button'] || props.theme.button} 100%);
+ border-radius: 2px;
+ }
+ }
+
+ h3:first-child {
+ margin-top: 0;
+ }
+
+ p {
+ color: ${props => props.theme['gray-700'] || props.theme.black};
+ font-size: 0.95rem;
+ line-height: 1.7;
+ margin-bottom: 16px;
+ padding-left: 12px;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ word-break: break-word;
+ white-space: pre-wrap;
+ }
+`;
+
+export const KeywordsContainer = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ padding-left: 12px;
+`;
+
+export const KeywordTag = styled.span`
+ border: 2px solid ${props => props.theme.keyword};
+ color: ${props => props.theme.keyword};
+ border-radius: 10px;
+ padding: 8px 18px;
+ font-size: 0.85rem;
+ font-weight: 700;
+ background: ${props => props.theme.keyword}10;
+ transition: all 0.2s ease;
+ cursor: default;
+
+ &:hover {
+ background: ${props => props.theme.keyword}20;
+ transform: translateY(-2px);
+ box-shadow: 0 4px 8px ${props => props.theme.keyword}20;
+ }
+`;
+
+export const MenuWrapper = styled.div`
+ position: relative;
+ display: flex;
+ align-items: center;
+ z-index: 10;
+`; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 81.81% | +27/33 | +90.9% | +20/22 | +71.42% | +5/7 | +81.81% | +27/33 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 | + + + + + + + + + + + + + +7x +7x + +7x +7x + +7x + + + + + + + + + +7x +7x +7x +7x +7x + +7x + +2x +2x +2x + + + + +2x + + +7x +2x +2x + +1x +1x +1x + + +1x +1x +1x + + +2x + + + + + + + + + + + + +7x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +10x + + + + + + + + + + + + + + + + + | import {useState, useEffect} from 'react';
+import { useNavigate, useLocation } from 'react-router-dom';
+import { useForm, Controller } from 'react-hook-form';
+import Sidebar from '../../components/layout/Sidebar';
+import { PageWrapper, ContentWrapper } from '../Feed/styles';
+import * as S from '../../components/domain/CreationForm/styles';
+import TagInput from '../../components/domain/TagInput';
+import type { NotificationState } from '../../components/common/Toast';
+import { GetKeywords } from '../../API/Keywords';
+import Toast from '../../components/common/Toast';
+import type { CommunityProps } from '../../API/Community';
+import { NewCommunity, UpdateCommunity } from '../../API/Community';
+
+export default function CreateCommunity() {
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ const communityToEdit = location.state?.communityToEdit as CommunityProps | undefined;
+ const isEditMode = !!communityToEdit;
+
+ const { register, handleSubmit, control, watch } = useForm<CommunityProps>({
+ // Preenche os valores iniciais com os dados da comunidade se estiver editando
+ defaultValues: {
+ communityID: communityToEdit?.communityID || "",
+ name: communityToEdit?.name || "",
+ description: communityToEdit?.description || "",
+ technologies: communityToEdit?.technologies || []
+ }
+ });
+
+ const descriptionValue = watch('description');
+ const descriptionLength = descriptionValue ? descriptionValue.length : 0;
+ const MAX_CHARS = 500;
+ const [notification, setNotification] = useState<NotificationState | null>(null);
+ const [keywords, setKeywords] = useState<string[]>([]);
+
+ useEffect(() => {
+ async function loadTechs() {
+ try {
+ const techsFromDB = await GetKeywords();
+ setKeywords(techsFromDB);
+ } catch (error) {
+ console.error("Falha ao carregar tecnologias:", error);
+ }
+ }
+ loadTechs();
+ }, []);
+
+ const onSubmit = (data: CommunityProps) => {
+ try{
+ if (isEditMode && communityToEdit) {
+ // --- MODO EDIÇÃO ---
+ console.log("Atualizando Comunidade:", communityToEdit.communityID, data);
+ UpdateCommunity(communityToEdit.communityID, data);
+ setNotification({ message: 'Comunidade atualizada com sucesso!', type: 'success' });
+ } else {
+ // --- MODO CRIAÇÃO ---
+ console.log("Criando Comunidade:", data);
+ NewCommunity(data);
+ setNotification({ message: 'Comunidade criada com sucesso!', type: 'success' });
+ }
+
+ setTimeout(() => {
+ navigate('/feed');
+ }, 1000);
+
+ } catch (error) {
+ console.error('Erro ao criar comunidade:', error);
+ if (error instanceof Error){
+ setNotification({ message: error.message, type: 'error' });
+ }
+ }
+
+ };
+
+ return (
+ <PageWrapper>
+ <Sidebar />
+
+ {notification && (
+ <Toast
+ message={notification.message}
+ type={notification.type}
+ onClose={() => setNotification(null)}
+ />
+ )}
+
+ <ContentWrapper>
+ <S.FormContainer onSubmit={handleSubmit(onSubmit)}>
+
+ <h2>{isEditMode ? 'Editar Comunidade' : 'Criar Comunidade'}</h2>
+
+ <S.InputGroup>
+ <S.Label htmlFor="name">Nome da Comunidade</S.Label>
+ <S.Input id="name" {...register('name', { required: true })} />
+ </S.InputGroup>
+
+ <S.InputGroup>
+ <S.Label htmlFor="description">Descrição do Projeto</S.Label>
+ <S.TextArea
+ id="description"
+ placeholder="Descreva sua comunidade..."
+ maxLength={MAX_CHARS}
+ {...register('description', {
+ maxLength: {
+ value: MAX_CHARS,
+ message: `A descrição não pode exceder ${MAX_CHARS} caracteres`
+ }
+ })}
+ />
+ <S.CharacterCount>
+ {descriptionLength} / {MAX_CHARS}
+ </S.CharacterCount>
+ </S.InputGroup>
+
+ <S.InputGroup>
+ <S.Label htmlFor="keywords">Palavras-chave</S.Label>
+
+ <Controller
+ name="technologies"
+ control={control}
+ render={({ field }) => (
+ <TagInput
+ value={field.value}
+ onChange={field.onChange}
+ searchList={keywords}
+ limit={10}
+ placeholder="Adicione até 10 palavras-chave..."
+ />
+ )}
+ />
+ </S.InputGroup>
+
+ <S.SubmitButton type="submit">{isEditMode ? 'Salvar Alterações' : 'Criar Comunidade'}</S.SubmitButton>
+
+ </S.FormContainer>
+ </ContentWrapper>
+ </PageWrapper>
+ );
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 84.44% | +38/45 | +87.09% | +27/31 | +80% | +8/10 | +86.04% | +37/43 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 | + + + + + + + + + + + + + + + + + + + +13x +13x +13x + +13x + + +13x + +13x + +13x + + + +13x +13x +6x + +6x +6x + + +13x + + + + + + + + + + +13x +13x +13x +13x + + +13x + +4x +4x +4x + + + + +4x + + + +13x + +2x + + + + +2x +2x + +1x +1x +1x + + +1x +1x +1x + + + +2x + + + + + + + + + + + +13x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +15x + + + + + +2x + + + + + + + + + + + + +15x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { useState, useEffect } from 'react';
+import { useForm, Controller } from 'react-hook-form';
+import Sidebar from '../../components/layout/Sidebar';
+import { PageWrapper, ContentWrapper } from '../Feed/styles';
+import * as S from '../../components/domain/CreationForm/styles';
+import TagInput from '../../components/domain/TagInput';
+import { IMaskInput } from 'react-imask';
+import { NewProject, UpdateProject } from '../../API/Project';
+import type { ProjectProps } from '../../API/Project';
+import Toast from '../../components/common/Toast';
+import { useNavigate, useLocation, useParams } from 'react-router-dom';
+import type { NotificationState } from '../../components/common/Toast';
+import { GetKeywords } from '../../API/Keywords';
+import { parseDate } from '../../API/Project';
+
+interface ProjectFormProps extends Omit<ProjectProps, 'startDate'> {
+ startDate: string;
+}
+
+export default function CreateProject() {
+ const navigate = useNavigate();
+ const location = useLocation(); // Hook para ler o state
+ const [keywords, setKeywords] = useState<string[]>([]);
+
+ const { projectId: paramId } = useParams<{ projectId?: string }>();
+
+ // Recupera o projeto do estado
+ const projectToEdit = location.state?.projectToEdit as (ProjectProps & { id?: string; projectID?: string }) | undefined;
+ // Define se é modo edição
+ const isEditMode = !!projectToEdit;
+
+ const validProjectId = projectToEdit?.id
+ || projectToEdit?.projectID
+ || (paramId !== 'undefined' ? paramId : undefined);
+
+ const formatDateToString = (date?: Date | string) => {
+ if (!date) return "";
+ const d = new Date(date);
+ // Verifica se é data válida
+ Iif (isNaN(d.getTime())) return "";
+ return d.toLocaleDateString('pt-BR'); // Retorna "20/11/2025"
+ };
+
+ const { register, handleSubmit, control, watch } = useForm<ProjectFormProps>({
+ // Preenche os valores padrão se estiver em modo de edição
+ defaultValues: {
+ title: projectToEdit?.title || "",
+ description: projectToEdit?.description || "",
+ technologies: projectToEdit?.technologies || [],
+ status: projectToEdit?.status || "",
+ startDate: formatDateToString(projectToEdit?.startDate)
+ }
+ });
+
+ const descriptionValue = watch('description');
+ const descriptionLength = descriptionValue ? descriptionValue.length : 0;
+ const MAX_CHARS = 2500;
+ const [notification, setNotification] = useState<NotificationState | null>(null);
+
+ // Carrega as tecnologias disponíveis do backend
+ useEffect(() => {
+ async function loadTechs() {
+ try {
+ const techsFromDB = await GetKeywords();
+ setKeywords(techsFromDB);
+ } catch (error) {
+ console.error("Falha ao carregar tecnologias:", error);
+ }
+ }
+ loadTechs();
+ }, []);
+
+ // Lida com CRIAR ou ATUALIZAR
+ const onSubmit = (data: ProjectFormProps) => {
+
+ const finalData: ProjectProps = {
+ ...data,
+ startDate: parseDate(data.startDate)
+ };
+
+ try {
+ if (isEditMode && validProjectId) {
+ // MODO DE EDIÇÃO
+ console.log("Atualizando Projeto:", validProjectId, finalData);
+ UpdateProject(validProjectId, finalData);
+ setNotification({ message: 'Projeto atualizado com sucesso!', type: 'success' });
+ } else {
+ // MODO DE CRIAÇÃO
+ console.log("Criando Projeto:", finalData);
+ NewProject(finalData);
+ setNotification({ message: 'Projeto criado com sucesso!', type: 'success' });
+ }
+
+ // Redireciona após o sucesso
+ setTimeout(() => {
+ navigate('/feed');
+ }, 1000);
+
+ } catch(error) {
+ console.error('Erro ao salvar projeto:', error);
+ if (error instanceof Error){
+ setNotification({ message: error.message, type: 'error' });
+ }
+ }
+ };
+
+ return (
+ <PageWrapper>
+ <Sidebar />
+ {notification && (
+ <Toast
+ message={notification.message}
+ type={notification.type}
+ onClose={() => setNotification(null)}
+ />
+ )}
+
+ <ContentWrapper>
+ <S.FormContainer onSubmit={handleSubmit(onSubmit)}>
+
+ <h2>{isEditMode ? 'Editar Projeto' : 'Criar Projeto'}</h2>
+
+ <S.InputGroup>
+ <S.Label htmlFor="title">Nome do Projeto</S.Label>
+ <S.Input id="title" {...register('title', { required: true })} />
+ </S.InputGroup>
+
+ <S.InputGroup>
+ <S.Label htmlFor="description">Descrição do Projeto</S.Label>
+ <S.TextArea
+ id="description"
+ placeholder="Descreva seu projeto..."
+ maxLength={MAX_CHARS}
+ {...register('description', {
+ maxLength: {
+ value: MAX_CHARS,
+ message: `A descrição não pode exceder ${MAX_CHARS} caracteres`
+ }
+ })}
+ />
+ <S.CharacterCount>
+ {descriptionLength} / {MAX_CHARS}
+ </S.CharacterCount>
+ </S.InputGroup>
+
+ <S.InputGroup>
+ <S.Label htmlFor="startDate">Data de Início</S.Label>
+ <Controller
+ name="startDate"
+ control={control}
+ render={({ field: { onChange, value } }) => (
+ <S.Input
+ as={IMaskInput}
+ mask="00/00/0000"
+ id="startDate"
+ placeholder="DD/MM/AAAA"
+ value={value}
+ onAccept={(value: string) => onChange(value)}
+ disabled={isEditMode}
+ />
+ )}
+ />
+ </S.InputGroup>
+
+ <S.InputGroup>
+ <S.Label htmlFor="technologies">Tecnologias (Palavras-chave)</S.Label>
+ <Controller
+ name="technologies"
+ control={control}
+ render={({ field }) => (
+ <TagInput
+ value={field.value}
+ onChange={field.onChange}
+ searchList={keywords}
+ limit={6}
+ placeholder="Adicione até 6 tecnologias..."
+ />
+ )}
+ />
+ </S.InputGroup>
+
+ <S.InputGroup>
+ <S.Label htmlFor="status">Status</S.Label>
+ <S.SelectWrapper>
+ <S.Select
+ id="status"
+ {...register('status', { required: true })}
+ required
+ >
+ <option value="" disabled>Selecione um status...</option>
+ <option value="em-andamento">Em andamento</option>
+ <option value="pausado">Pausado</option>
+ <option value="finalizado">Finalizado</option>
+ </S.Select>
+ </S.SelectWrapper>
+ </S.InputGroup>
+
+ {/* 10. Botão de submit dinâmico */}
+ <S.SubmitButton type="submit">
+ {isEditMode ? 'Atualizar Projeto' : 'Criar Projeto'}
+ </S.SubmitButton>
+
+ </S.FormContainer>
+ </ContentWrapper>
+ </PageWrapper>
+ );
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 87.09% | +27/31 | +55.55% | +10/18 | +66.66% | +4/6 | +93.1% | +27/29 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 | + + + + + + + + + + + + +8x +8x +8x + + +8x +3x +3x +3x +3x + + +8x + + +8x +3x +3x +3x +3x +3x +3x + + + +8x +2x +2x +2x + +1x + +1x + + +1x + + + + +1x +1x +1x + + + + +8x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { useState, useEffect } from 'react';
+import { useForm } from 'react-hook-form';
+import { useNavigate } from 'react-router-dom';
+import Sidebar from '../../components/layout/Sidebar';
+import { PageWrapper, ContentWrapper } from '../Feed/styles';
+import * as S from '../../components/domain/CreationForm/styles';
+import Toast from '../../components/common/Toast';
+import type { NotificationState } from '../../components/common/Toast';
+import { useAuth } from '../../API/AuthContext';
+import { UpdateProfile } from '../../API/User';
+import type { UserProfileData } from '../../API/User';
+
+export default function EditProfile() {
+ const { currentUser, updateUser } = useAuth();
+ const navigate = useNavigate();
+ const [notification, setNotification] = useState<NotificationState | null>(null);
+
+ // Formata a data para o input (YYYY-MM-DD)
+const formatDateForInput = (dateString?: string) => {
+ Iif (!dateString) return "";
+ const date = new Date(dateString);
+ Iif (isNaN(date.getTime())) return "";
+ return date.toISOString().split('T')[0];
+};
+
+ const { register, handleSubmit, setValue } = useForm<UserProfileData>();
+
+ // Preenche o formulário com os dados atuais
+ useEffect(() => {
+ Eif (currentUser) {
+ setValue('nomeCompleto', currentUser.nomeCompleto || '');
+ setValue('username', currentUser.username || '');
+ setValue('email', currentUser.email || '');
+ setValue('telefone', (currentUser as any).phone || '');
+ setValue('dataNascimento', formatDateForInput((currentUser as any).birthDate));
+ }
+ }, [currentUser, setValue]);
+
+ const onSubmit = async (data: UserProfileData) => {
+ try {
+ console.log("Atualizando perfil:", data);
+ await UpdateProfile(data);
+
+ setNotification({ message: 'Perfil atualizado com sucesso!', type: 'success' });
+
+ updateUser(data);
+
+ // Redireciona
+ setTimeout(() => {
+ navigate('/profile');
+ }, 1000);
+
+ } catch (error) {
+ console.error(error);
+ Eif (error instanceof Error) {
+ setNotification({ message: error.message, type: 'error' });
+ }
+ }
+ };
+
+ return (
+ <PageWrapper>
+ <Sidebar />
+ {notification && (
+ <Toast
+ message={notification.message}
+ type={notification.type}
+ onClose={() => setNotification(null)}
+ />
+ )}
+
+ <ContentWrapper>
+ <S.FormContainer onSubmit={handleSubmit(onSubmit)}>
+ <h2>Editar Perfil</h2>
+
+ <S.InputGroup>
+ <S.Label htmlFor="nomeCompleto">Nome Completo</S.Label>
+ <S.Input
+ id="nomeCompleto"
+ {...register('nomeCompleto', { required: true })}
+ />
+ </S.InputGroup>
+
+ <S.InputGroup>
+ <S.Label htmlFor="username">Nome de Usuário</S.Label>
+ <S.Input
+ id="username"
+ {...register('username', { required: true })}
+ />
+ </S.InputGroup>
+
+ <S.InputGroup>
+ <S.Label htmlFor="email">Email</S.Label>
+ <S.Input
+ id="email"
+ type="email"
+ {...register('email', { required: true })}
+ />
+ </S.InputGroup>
+
+ <S.InputGroup>
+ <S.Label htmlFor="telefone">Telefone</S.Label>
+ <S.Input
+ id="telefone"
+ {...register('telefone')}
+ />
+ </S.InputGroup>
+
+ <S.InputGroup>
+ <S.Label htmlFor="dataNascimento">Data de Nascimento</S.Label>
+ <S.Input
+ id="dataNascimento"
+ type="date"
+ {...register('dataNascimento')}
+ />
+ </S.InputGroup>
+
+ <S.SubmitButton type="submit">
+ Salvar Alterações
+ </S.SubmitButton>
+
+ </S.FormContainer>
+ </ContentWrapper>
+ </PageWrapper>
+ );
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ ++ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 | + + + + + + + + + + + + + +18x +18x +18x +18x +18x + +18x + +6x +6x +6x +5x + +1x +1x +1x + + +6x + + +6x + + +18x +1x +1x + + +18x +1x +1x + + +18x + + + + + + + + + +2x + + + + + + + + + + + + +2x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { useState, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import Modal from '../../components/common/Modal';
+import Postcard from '../../components/domain/Postcard';
+import Header from '../../components/layout/Header';
+import Sidebar from '../../components/layout/Sidebar';
+import * as S from './styles';
+import * as ModalS from '../../components/common/Modal/styles';
+import { GetFeedProjects } from '../../API/Project';
+import type { ProjectProps } from '../../API/Project';
+import Toast from '../../components/common/Toast';
+import type { NotificationState } from '../../components/common/Toast';
+
+export default function Feed() {
+ const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
+ const [isLoading, setIsLoading] = useState(true);
+ const navigate = useNavigate();
+ const [posts, setPosts] = useState<ProjectProps[]>([]);
+ const [notification, setNotification] = useState<NotificationState | null>(null);
+
+ useEffect(() => {
+ async function loadFeed() {
+ setIsLoading(true);
+ try {
+ const feedData = await GetFeedProjects();
+ setPosts(feedData || []);
+ } catch (error) {
+ console.error("Erro ao carregar feed:", error);
+ Eif (error instanceof Error) {
+ setNotification({ message: "Não foi possível carregar o feed.", type: 'error' });
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ }
+ loadFeed();
+ }, []);
+
+ const handleCreateProject = () => {
+ setIsCreateModalOpen(false);
+ navigate('/createProject');
+ };
+
+ const handleCreateCommunity = () => {
+ setIsCreateModalOpen(false);
+ navigate('/createCommunity');
+ };
+
+ return (
+ <S.PageWrapper>
+ {notification && (
+ <Toast
+ message={notification.message}
+ type={notification.type}
+ onClose={() => setNotification(null)}
+ />
+ )}
+
+ <Header onCreateClick={() => setIsCreateModalOpen(true)}/>
+ <Sidebar />
+
+ <S.ContentWrapper>
+ <S.FeedContainer>
+ <S.PostList>
+ {isLoading ? (
+ <S.LoadingContainer>
+ <div className="spinner"></div>
+ <p>Carregando feed...</p>
+ </S.LoadingContainer>
+ ) : posts.length > 0 ? (
+ posts.map((post, index) => (
+ <Postcard
+ key={(post as unknown as ProjectProps).id || (post as any).projectID || index}
+ post={post}
+ showMenu={false}
+ />
+ ))
+ ) : (
+ <S.EmptyFeedMessage>
+ <svg
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
+ <line x1="9" y1="9" x2="15" y2="15"/>
+ <line x1="15" y1="9" x2="9" y2="15"/>
+ </svg>
+ <h3>Nenhum post encontrado</h3>
+ <p style={{ color: '#ccc', textAlign: 'center', marginTop: '20px' }}>
+ Nenhum post encontrado. Entre em comunidades para ver atualizações!
+ </p>
+ </S.EmptyFeedMessage>
+ )}
+ </S.PostList>
+ </S.FeedContainer>
+ </S.ContentWrapper>
+
+ <Modal
+ isOpen={isCreateModalOpen}
+ onClose={() => setIsCreateModalOpen(false)}
+ title="O que você deseja criar?"
+ >
+ <ModalS.ModalActions>
+ <ModalS.ChoiceButton onClick={handleCreateProject}>
+ Criar Projeto
+ </ModalS.ChoiceButton>
+ <ModalS.ChoiceButton onClick={handleCreateCommunity}>
+ Criar Comunidade
+ </ModalS.ChoiceButton>
+ </ModalS.ModalActions>
+ </Modal>
+ </S.PageWrapper>
+ );
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 | + + +4x +44x + + + + + + + + + + +44x +44x + + + + + + +4x + + + + + + + + + + + + + + + + + + + + + + + + + + +4x + + + +16x + + + + + + +16x + + + + + + + + + + + + + + + + + + + + + + + + +16x + + + + + + + + + +4x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +4x + + +5x + + + + + + + + + + + + + +5x + + + + + + +5x + + + + + + + + +4x + + + + + + + + + + + + + + + + + + + + + + + +4x + + + + + + + + + + +10x +10x + + + + + + + + + +10x + + + | import styled from 'styled-components';
+
+// Wrapper para a página inteira
+export const PageWrapper = styled.div`
+ background: linear-gradient(135deg, ${props => props.theme['gray-100']} 0%, ${props => props.theme['gray-100']}f0 100%);
+ min-height: 100vh;
+ position: relative;
+
+ &::before {
+ content: '';
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: radial-gradient(circle at 20% 50%, ${props => props.theme.keyword}08 0%, transparent 50%),
+ radial-gradient(circle at 80% 80%, ${props => props.theme.button}08 0%, transparent 50%);
+ pointer-events: none;
+ z-index: 0;
+ }
+`;
+
+// Wrapper para o conteúdo principal
+export const ContentWrapper = styled.main`
+ margin-left: 250px;
+ margin-top: 60px;
+ padding: 32px 24px;
+ box-sizing: border-box;
+ position: relative;
+ z-index: 1;
+ animation: fadeIn 0.5s ease-out;
+
+ @keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+
+ @media (max-width: 768px) {
+ margin-left: 0;
+ padding: 20px 16px;
+ }
+`;
+
+// Container principal do feed com posts
+export const FeedContainer = styled.main`
+ width: 100%;
+ max-width: 800px;
+ margin: 0 auto;
+ background: ${props => props.theme.background};
+ backdrop-filter: blur(10px);
+ border-radius: 20px;
+ padding: 28px;
+ box-sizing: border-box;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
+ 0 2px 8px rgba(0, 0, 0, 0.08),
+ 0 0 0 1px ${props => props.theme.placeholder}15;
+ position: relative;
+ transition: all 0.3s ease;
+
+ &::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 3px;
+
+ background-size: 200% 100%;
+ border-radius: 20px 20px 0 0;
+ animation: gradientShift 3s ease infinite;
+ }
+
+ @keyframes gradientShift {
+ 0%, 100% { background-position: 0% 50%; }
+ 50% { background-position: 100% 50%; }
+ }
+
+ &:hover {
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15),
+ 0 4px 12px rgba(0, 0, 0, 0.1),
+ 0 0 0 1px ${props => props.theme.placeholder}25;
+ }
+
+ @media (max-width: 768px) {
+ padding: 20px;
+ border-radius: 16px;
+ }
+`;
+
+// Lista que agrupa os posts
+export const PostList = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ position: relative;
+
+ /* Animação de entrada para cada post */
+ & > * {
+ animation: slideInUp 0.4s ease-out backwards;
+ }
+
+ & > *:nth-child(1) { animation-delay: 0.05s; }
+ & > *:nth-child(2) { animation-delay: 0.1s; }
+ & > *:nth-child(3) { animation-delay: 0.15s; }
+ & > *:nth-child(4) { animation-delay: 0.2s; }
+ & > *:nth-child(5) { animation-delay: 0.25s; }
+
+ @keyframes slideInUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+`;
+
+// Mensagem de feed vazio
+export const EmptyFeedMessage = styled.div`
+ text-align: center;
+ padding: 60px 24px;
+ color: ${props => props.theme.placeholder};
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+
+ svg {
+ width: 80px;
+ height: 80px;
+ opacity: 0.5;
+ margin-bottom: 8px;
+ }
+
+ h3 {
+ color: ${props => props.theme.subtitle};
+ font-size: 1.3rem;
+ font-weight: 600;
+ margin: 0 0 8px 0;
+ }
+
+ p {
+ color: ${props => props.theme.placeholder};
+ font-size: 0.95rem;
+ line-height: 1.5;
+ max-width: 400px;
+ margin: 0;
+ }
+`;
+
+// Header do feed
+export const FeedHeader = styled.div`
+ margin-bottom: 24px;
+ padding-bottom: 20px;
+ border-bottom: 1px solid ${props => props.theme.placeholder}20;
+
+ h1 {
+ color: ${props => props.theme.white};
+ font-size: 1.8rem;
+ font-weight: 700;
+ margin: 0 0 8px 0;
+ background: linear-gradient(135deg, ${props => props.theme.white}, ${props => props.theme.subtitle});
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ }
+
+ p {
+ color: ${props => props.theme.placeholder};
+ font-size: 0.9rem;
+ margin: 0;
+ }
+`;
+
+// Container para loading state
+export const LoadingContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 60px 24px;
+ gap: 16px;
+
+ .spinner {
+ width: 40px;
+ height: 40px;
+ border: 3px solid ${props => props.theme.placeholder}30;
+ border-top-color: ${props => props.theme.keyword};
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+ }
+
+ @keyframes spin {
+ to { transform: rotate(360deg); }
+ }
+
+ p {
+ color: ${props => props.theme.placeholder};
+ font-size: 0.9rem;
+ }
+`; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 100% | +1/1 | +100% | +0/0 | +100% | +1/1 | +100% | +1/1 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 | + + + +1x + + + + + + + + + | import HeaderHome from '../../components/layout/HeaderHome';
+import * as S from './styles';
+
+export default function Home() {
+ return (
+ <S.HomePageContainer>
+ <HeaderHome />
+ <S.ContentWrapper>
+ <S.SiteTitle>CTable</S.SiteTitle>
+ <S.Tagline>Descubra, compartilhe, aprenda, converse: um novo mundo se abre para os amantes de computação.</S.Tagline>
+ </S.ContentWrapper>
+ </S.HomePageContainer>
+ );
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 87.5% | +14/16 | +75% | +3/4 | +50% | +2/4 | +87.5% | +14/16 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 | + + + + + + + + + + + + + + + + + +10x +10x + +10x +10x + + +2x +2x +2x + +1x + + +1x + +1x + + + + +1x + + +1x +1x + + + + +10x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import {useState} from 'react';
+import {
+ FormPageContainer,
+ FormWrapper,
+ FormTitle,
+ StyledForm,
+ StyledInput,
+ SubmitButton,
+ RedirectLink
+} from '../../components/domain/Form/styles';
+import { useForm} from 'react-hook-form';
+import Toast from '../../components/common/Toast';
+import { useNavigate } from 'react-router-dom';
+import type { LoginProps } from '../../API/Auth';
+import { useAuth } from '../../API/AuthContext';
+import type { NotificationState } from '../../components/common/Toast';
+
+export default function LoginPage() {
+ const [notification, setNotification] = useState<NotificationState | null>(null);
+ const { login } = useAuth();
+
+ const { register, handleSubmit, formState: {isSubmitting} } = useForm<LoginProps>();
+ const navigate = useNavigate();
+
+ async function onSubmit(data: LoginProps) {
+ console.log(data);
+ try{
+ await login(data);
+
+ console.log('Usuário registrado com sucesso');
+
+ // Define estado para mostrar notificação de sucesso
+ setNotification({ message: 'Usuário registrado com sucesso!', type: 'success' });
+
+ setTimeout(() => {
+ navigate('/feed'); // Navega para a página de feed
+ }, 1000);
+
+ } catch (error) {
+ console.error('Erro ao registrar usuário:', error);
+
+ // Define estado para mostrar notificação de erro
+ Eif (error instanceof Error){
+ setNotification({ message: error.message, type: 'error' });
+ }
+ }
+ }
+
+ return (
+ <FormPageContainer>
+ {notification && (
+ <Toast
+ message={notification.message}
+ type={notification.type}
+ onClose={() => setNotification(null)}
+ />
+ )}
+ <FormWrapper>
+ <FormTitle>Login</FormTitle>
+
+ <StyledForm onSubmit={handleSubmit(onSubmit)}>
+ <StyledInput
+ type="text"
+ placeholder="Email ou usuário"
+ required
+ {...register('username')}
+ />
+ <StyledInput
+ type="password"
+ placeholder="Senha"
+ required
+ {...register('senha')}
+ />
+
+ <SubmitButton disabled = {isSubmitting} type="submit">Entrar</SubmitButton>
+ </StyledForm>
+
+ <RedirectLink>
+ Não tem uma conta? <a href="/register">Cadastre-se</a>
+ </RedirectLink>
+ </FormWrapper>
+ </FormPageContainer>
+ );
+}
+
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ ++ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 | + + + + + + + + + + + + + + + + +1x +40x + + + + +1x +40x + + + +1x +40x + + + + + + + + +42x +42x +42x +42x + +42x + + +42x +42x + +42x +42x +42x + +42x + +40x +40x +40x + +40x +40x + +39x + +39x +39x + + +1x + + + +40x +40x + + + +42x + +1x +1x + + +25x +7x + +25x +25x + + + +42x +1x +1x + + +42x +1x + + +1x +1x + + + +42x +1x +1x + + +42x + +3x +3x + +3x +3x +1x + +1x + +1x + + + + + +2x +2x +1x +1x +1x + +1x + +1x + +3x +3x + + + + +42x +42x +37x +35x + + +2x +2x + + + + + +5x + +5x +2x + + + + + + + + + + + + + +1x + + + + + + + + +42x + + + + + + + + + + + + + + + + + + + + + + + + + + +2x + + + + + +7x + + + + + + + + + + + +4x +4x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + | import { useState, useRef, useEffect } from 'react';
+import Sidebar from '../../components/layout/Sidebar';
+import Postcard from '../../components/domain/Postcard';
+import * as S from './styles';
+import * as D from '../../components/common/Dropdown/styles';
+import type { ProjectProps } from '../../API/Project';
+import type { CommentProps } from '../../API/Comment';
+import { GetUserProjects } from '../../API/Project';
+import { GetUserComments, DeleteComment } from '../../API/Comment';
+import { useAuth } from '../../API/AuthContext';
+import { useNavigate } from 'react-router-dom';
+import { DeleteProfile } from '../../API/User';
+import Toast from '../../components/common/Toast';
+import Modal from '../../components/common/Modal';
+import * as ModalS from '../../components/common/Modal/styles';
+
+// --- ÍCONES ---
+const PostsIcon = () => (
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+ <path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
+ <polyline points="14 2 14 8 20 8" /><line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" /><line x1="10" y1="9" x2="8" y2="9" />
+ </svg>
+);
+const CommentsIcon = () => (
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
+ </svg>
+);
+const SettingsIcon = () => (
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+ <circle cx="12" cy="12" r="1" /><circle cx="19" cy="12" r="1" /><circle cx="5" cy="12" r="1" />
+ </svg>
+);
+// -----------------------
+
+type ViewState = 'posts' | 'comments';
+
+export default function Profile() {
+ const { currentUser, logout } = useAuth();
+ const [view, setView] = useState<ViewState>('posts');
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+ const menuRef = useRef<HTMLDivElement>(null);
+
+ const navigate = useNavigate();
+
+ // Estados para os dados da API
+ const [userPosts, setUserPosts] = useState<ProjectProps[]>([]);
+ const [userComments, setUserComments] = useState<CommentProps[]>([]);
+
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [isDeleteProfileModalOpen, setIsDeleteProfileModalOpen] = useState(false);
+ const [notification, setNotification] = useState<{message: string, type: 'success' | 'error'} | null>(null);
+
+ useEffect(() => {
+ // Função assíncrona para buscar todos os dados
+ console.log("Efeito de busca de dados do perfil disparado.");
+ const fetchProfileData = async () => {
+ try {;
+
+ console.log("Usuário atual no Profile:", currentUser);
+ const apiUserPosts = await GetUserProjects();
+
+ const apiUserComments: CommentProps[] = await GetUserComments();
+
+ setUserPosts(apiUserPosts);
+ setUserComments(apiUserComments);
+
+ } catch (error) {
+ console.error("Falha ao buscar dados do perfil:", error);
+ }
+
+ };
+ Eif(currentUser)
+ fetchProfileData();
+ }, [currentUser]);
+
+ // Lógica para fechar o menu ao clicar fora
+ useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ Eif (menuRef.current && !menuRef.current.contains(event.target as Node)) {
+ setIsMenuOpen(false);
+ }
+ }
+ if (isMenuOpen) {
+ document.addEventListener('mousedown', handleClickOutside);
+ }
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [isMenuOpen]);
+
+ const handleLogout = () => {
+ logout(); // Limpa o estado e o localStorage
+ navigate('/login'); // Redireciona para a tela de login
+ };
+
+ const handleDeleteComment = async (commentId: string) => {
+ await DeleteComment(commentId);
+
+ // Remove o comentário deletado do estado local para sumir da tela instantaneamente
+ setUserComments((prevComments) =>
+ prevComments.filter(comment => comment.commentID !== commentId)
+ );
+ };
+
+ const handleEditProfile = () => {
+ setIsMenuOpen(false);
+ navigate('/editProfile');
+ };
+
+ const handleDeleteProfile = async () => {
+
+ Iif (isDeleting) return;
+ setIsDeleting(true);
+
+ try {
+ await DeleteProfile();
+ setNotification({ message: "Perfil excluído com sucesso. Até logo!", type: 'success' });
+
+ setIsDeleteProfileModalOpen(false);
+
+ setTimeout(() => {
+ logout(); // Desloga o usuário e limpa o storage
+ navigate('/login'); // Manda para login
+ }, 2000);
+
+ } catch (error) {
+ Eif (error instanceof Error) {
+ if (error.message.includes("não encontrado") || error.message.includes("not found")) {
+ setNotification({ message: "Conta já encerrada. Redirecionando...", type: 'success' });
+ setTimeout(() => { logout(); navigate('/login'); }, 2000);
+ return;
+ }
+ setNotification({ message: error.message, type: 'error' });
+ }
+ setIsDeleteProfileModalOpen(false);
+ } finally {
+ setIsDeleting(false);
+ setIsDeleteProfileModalOpen(false);
+ }
+ };
+
+ // Função para renderizar o feed (posts ou comentários)
+ const renderFeed = () => {
+ if (view === 'posts') {
+ if (userPosts.length === 0) {
+ return <p style={{ color: '#fff', padding: '20px' }}>Nenhum projeto encontrado.</p>;
+ }
+
+ return userPosts.map((post, index) => (
+ <S.PostContainer key={index}>
+ <Postcard post={post} showMenu={true} />
+ </S.PostContainer>
+ ));
+ }
+
+ Eif (view === 'comments') {
+ // Mapeia 'userComments'
+ return userComments.map(comment => (
+ <S.PostContainer key={comment.commentID || Math.random()}>
+ <Postcard
+ post={{
+ id: comment.commentID,
+ title: `Comentou em: ${comment.projectTitle || 'Projeto'}`,
+ description: comment.content,
+ technologies: [],
+ status: '',
+ startDate: comment.createdAt,
+ // Passa o usuário atual como autor para o cabeçalho do card
+ author: { title: currentUser?.username || 'Você' }
+ } as unknown as ProjectProps}
+ showMenu={true}
+ deleteLabel="Comentário"
+ onDelete={() => handleDeleteComment(comment.commentID!)}
+ />
+ </S.PostContainer>
+ ));
+ }
+
+ return null;
+ };
+
+ return (
+ <S.PageWrapper>
+ <Sidebar />
+
+ {notification && (
+ <Toast
+ message={notification.message}
+ type={notification.type}
+ onClose={() => setNotification(null)}
+ />
+ )}
+
+ <S.ContentWrapper>
+
+ <S.ProfileHeader>
+ <S.ActionsWrapper ref={menuRef}>
+ <S.ProfileActions>
+ <S.IconButton
+ title="Ver publicações"
+ $active={view === 'posts'}
+ onClick={() => setView('posts')}
+ >
+ <PostsIcon />
+ </S.IconButton>
+ <S.IconButton
+ title="Ver comentários"
+ $active={view === 'comments'}
+ onClick={() => setView('comments')}
+ >
+ <CommentsIcon />
+ </S.IconButton>
+ <S.IconButton
+ title="Configurações"
+ onClick={() => setIsMenuOpen(prev => !prev)}
+ >
+ <SettingsIcon />
+ </S.IconButton>
+ </S.ProfileActions>
+
+ {isMenuOpen && (
+ <D.DropdownMenu>
+ <D.MenuItem onClick={handleEditProfile}>Editar Perfil</D.MenuItem>
+ <D.MenuItem onClick={handleLogout}>Sair</D.MenuItem>
+ <D.Separator />
+ <D.DangerMenuItem onClick={() => {
+ setIsMenuOpen(false);
+ setIsDeleteProfileModalOpen(true);
+ }}>
+ Excluir Perfil
+ </D.DangerMenuItem>
+ </D.DropdownMenu>
+ )}
+
+ </S.ActionsWrapper>
+ </S.ProfileHeader>
+
+ <S.ProfileInfo>
+ <S.ProfileAvatar
+ style={{
+ backgroundImage: undefined
+ }}
+ />
+ <S.Username>{currentUser?.username || 'Carregando...'}</S.Username>
+ </S.ProfileInfo>
+
+ <S.PostList>
+ {renderFeed()}
+ </S.PostList>
+
+ </S.ContentWrapper>
+
+ <Modal
+ isOpen={isDeleteProfileModalOpen}
+ onClose={() => !isDeleting && setIsDeleteProfileModalOpen(false)}
+ title="Excluir Conta"
+ >
+ <div style={{ textAlign: 'center' }}>
+ <p style={{ marginBottom: '24px', color: '#555' }}>
+ Tem certeza que deseja excluir sua conta? <br/>
+ <strong>Todos os seus projetos, comunidades e comentários serão apagados permanentemente.</strong>
+ </p>
+ <ModalS.ModalActions>
+ <ModalS.ChoiceButton
+ onClick={() => setIsDeleteProfileModalOpen(false)}
+ disabled={isDeleting}>
+ Cancelar
+ </ModalS.ChoiceButton>
+ <ModalS.ChoiceButton
+ onClick={handleDeleteProfile}
+ style={{ backgroundColor: '#e74c3c' }}
+ disabled={isDeleting}
+ >
+ {isDeleting ? 'Excluindo...' : 'Excluir Conta'}
+ </ModalS.ChoiceButton>
+ </ModalS.ModalActions>
+ </div>
+ </Modal>
+
+ </S.PageWrapper>
+ );
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 | + + +1x +40x + + + + +1x + + + + + + +1x + + + + +3x + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + +1x +40x + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + +40x +40x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + +40x + + +40x + + + + + + + + + + + + + + + +40x + + + + + +1x + + + + + + + + + + + + + + + +1x + + + + + + + + + + + +120x + + +120x + + + +120x + + + + + + + + + + + + + + +120x + + + + + + + + + + + + + +120x + + +120x + + + + + + + + + + + + + + + + + +120x + + + + +1x + + + | import styled from 'styled-components';
+
+// Wrapper para a página inteira
+export const PageWrapper = styled.div`
+ background: linear-gradient(135deg, ${props => props.theme['gray-100']} 0%, ${props => props.theme['gray-100'] || props.theme['gray-100']} 100%);
+ min-height: 100vh;
+`;
+
+// Wrapper para o conteúdo principal
+export const ContentWrapper = styled.main`
+ margin-left: 250px;
+ box-sizing: border-box;
+ padding-bottom: 60px;
+`;
+
+// Container principal com posts
+export const PostContainer = styled.main`
+ width: 100%;
+ max-width: 800px;
+ margin: 0 auto;
+
+ background-color: ${props => props.theme.background};
+ border-radius: 16px;
+ padding: 24px;
+ box-sizing: border-box;
+
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04),
+ 0 1px 2px rgba(0, 0, 0, 0.06);
+
+ transition: all 0.3s ease;
+
+ &:hover {
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08),
+ 0 2px 4px rgba(0, 0, 0, 0.06);
+ transform: translateY(-2px);
+ }
+`;
+
+// Lista que agrupa os posts do perfil
+export const PostList = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ padding: 32px 24px;
+
+ animation: fadeIn 0.4s ease-out;
+
+ @keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+`;
+
+// Cabeçalho do perfil
+export const ProfileHeader = styled.header`
+ background: linear-gradient(135deg, ${props => props.theme.button} 0%, ${props => props.theme['hover-button'] || props.theme.button} 100%);
+ height: 160px;
+ position: relative;
+ z-index: 0;
+
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
+
+ /* Padrão decorativo sutil */
+ &::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-image:
+ radial-gradient(circle at 20% 50%, rgba(255, 255, 255, 0.1) 0%, transparent 50%),
+ radial-gradient(circle at 80% 80%, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
+ pointer-events: none;
+ }
+
+ /* Posiciona os botões de ação */
+ display: flex;
+ justify-content: flex-end;
+ align-items: flex-end;
+ padding: 20px 32px;
+`;
+
+// Container da foto + nome
+export const ProfileInfo = styled.div`
+ display: flex;
+ align-items: flex-end;
+ gap: 20px;
+
+ margin-top: -70px;
+ margin-left: 32px;
+ padding-bottom: 24px;
+ position: relative;
+ z-index: 1;
+ pointer-events: none;
+
+ animation: slideUp 0.5s ease-out;
+
+ @keyframes slideUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+
+ /* Reativa pointer-events apenas nos elementos internos */
+ > * {
+ pointer-events: auto;
+ }
+`;
+
+// A foto de perfil circular
+export const ProfileAvatar = styled.div`
+ width: 120px;
+ height: 120px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, ${props => props.theme.sidebar} 0%, ${props => props.theme.button} 100%);
+ border: 5px solid ${props => props.theme.background};
+ background-size: cover;
+ background-position: center;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15),
+ 0 2px 8px rgba(0, 0, 0, 0.1);
+
+ position: relative;
+ flex-shrink: 0;
+
+ /* Efeito de brilho sutil */
+ &::after {
+ content: '';
+ position: absolute;
+ top: 10%;
+ left: 10%;
+ width: 40%;
+ height: 40%;
+ background: radial-gradient(circle, rgba(255, 255, 255, 0.3) 0%, transparent 70%);
+ border-radius: 50%;
+ }
+
+ transition: transform 0.3s ease, box-shadow 0.3s ease;
+
+ &:hover {
+ transform: scale(1.05);
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.2),
+ 0 4px 12px rgba(0, 0, 0, 0.15);
+ }
+`;
+
+// O nome do usuário
+export const Username = styled.h1`
+ font-size: 2.5em;
+ font-weight: 700;
+ color: ${props => props.theme.title};
+ margin: 0 0 10px 0; /* Margem inferior para alinhar com a base do avatar */
+
+ background: linear-gradient(135deg, ${props => props.theme.title} 0%, ${props => props.theme.subtitle} 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+
+ text-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
+
+ position: relative;
+
+ &::after {
+ content: '';
+ position: absolute;
+ bottom: -8px;
+ left: 0;
+ width: 60px;
+ height: 4px;
+ background: linear-gradient(90deg, ${props => props.theme.button} 0%, transparent 100%);
+ border-radius: 2px;
+ }
+`;
+
+// Container dos 3 botões de ação (dentro do banner)
+export const ProfileActions = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ position: relative;
+ z-index: 3;
+
+ /* Backdrop blur para destacar os botões */
+ background: rgba(255, 255, 255, 0.1);
+ backdrop-filter: blur(10px);
+ padding: 8px;
+ border-radius: 50px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+`;
+
+// Botão de ícone
+export const IconButton = styled.button<{ $active?: boolean }>`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ border: none;
+ cursor: pointer;
+ position: relative;
+ overflow: hidden;
+
+ background-color: ${props => props.$active
+ ? props.theme['hover-button'] || props.theme.button
+ : props.theme.background};
+ color: ${props => props.$active
+ ? '#FFFFFF'
+ : props.theme.button};
+
+ box-shadow: ${props => props.$active
+ ? '0 4px 12px rgba(0, 0, 0, 0.15), inset 0 1px 2px rgba(255, 255, 255, 0.1)'
+ : '0 2px 8px rgba(0, 0, 0, 0.08)'};
+
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+
+ /* Efeito ripple */
+ &::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 0;
+ height: 0;
+ border-radius: 50%;
+ background: ${props => props.$active
+ ? 'rgba(255, 255, 255, 0.3)'
+ : 'rgba(0, 0, 0, 0.1)'};
+ transform: translate(-50%, -50%);
+ transition: width 0.6s, height 0.6s;
+ }
+
+ &:hover::before {
+ width: 100%;
+ height: 100%;
+ }
+
+ &:hover {
+ transform: translateY(-3px) scale(1.05);
+ box-shadow: ${props => props.$active
+ ? '0 6px 20px rgba(0, 0, 0, 0.2)'
+ : '0 4px 16px rgba(0, 0, 0, 0.12)'};
+ background-color: ${props => props.$active
+ ? props.theme['hover-button'] || props.theme.button
+ : props.theme['gray-500'] || props.theme.background};
+ }
+
+ &:active {
+ transform: translateY(-1px) scale(1.02);
+ }
+
+ svg {
+ width: 22px;
+ height: 22px;
+ position: relative;
+ z-index: 1;
+ transition: transform 0.2s ease;
+ }
+
+ &:hover svg {
+ transform: ${props => props.$active ? 'rotate(5deg)' : 'scale(1.1)'};
+ }
+`;
+
+// Menus de ações flutuantes
+export const ActionsWrapper = styled.div`
+ position: relative;
+ z-index: 5; /* Aumentado para ficar acima de tudo */
+`; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 90% | +18/20 | +75% | +6/8 | +100% | +4/4 | +94.11% | +16/17 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 | + + + + + + + + +4x +4x +4x + +4x + +2x +2x +2x +2x +2x + + + +2x + + +2x + + +4x +2x + + +1x + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +2x + + + + + + + + + + + | import { useEffect, useState } from 'react';
+import { useParams } from 'react-router-dom';
+import Sidebar from '../../components/layout/Sidebar';
+import * as S from './styles';
+import { GetProjectById } from '../../API/Project';
+import type { ProjectProps } from '../../API/Project';
+import { FiCalendar, FiUser } from 'react-icons/fi';
+
+export default function ProjectPage() {
+ const { projectId } = useParams<{ projectId: string }>();
+ const [project, setProject] = useState<ProjectProps | null>(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ async function loadData() {
+ Iif (!projectId) return;
+ try {
+ setLoading(true);
+ const data = await GetProjectById(projectId);
+ setProject(data);
+ } catch (error) {
+ console.error("Erro ao carregar projeto:", error);
+ } finally {
+ setLoading(false);
+ }
+ }
+ loadData();
+ }, [projectId]);
+
+ if (loading) return <div>Carregando...</div>;
+ if (!project) return <div>Projeto não encontrado</div>;
+
+ // Formata data
+ const startDate = new Date(project.startDate).toLocaleDateString('pt-BR');
+
+ return (
+ <S.PageWrapper>
+ <Sidebar />
+
+ <S.MainContent>
+ <S.Banner />
+
+ <S.HeaderContainer>
+ {/* Ícone com a inicial do projeto */}
+ <S.ProjectIcon>
+ {project.title.charAt(0).toUpperCase()}
+ </S.ProjectIcon>
+
+ <S.HeaderInfo>
+ <h1>{project.title}</h1>
+ <span>
+ <FiUser style={{ marginRight: 5, verticalAlign: 'middle' }}/>
+ Criado por <strong>{project.authorUsername || project.authorName}</strong>
+ </span>
+ </S.HeaderInfo>
+ </S.HeaderContainer>
+
+ <S.ContentGrid>
+
+ {/* Coluna Principal: Descrição */}
+ <S.MainColumn>
+ <h2>Sobre o Projeto</h2>
+ <S.DescriptionBox>
+ {project.description}
+ </S.DescriptionBox>
+
+ </S.MainColumn>
+
+ <S.InfoSidebar>
+ <h3>Status</h3>
+ <S.StatusBadge status={project.status}>
+ {project.status.replace('-', ' ')}
+ </S.StatusBadge>
+
+ <h3>Início</h3>
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#555' }}>
+ <FiCalendar />
+ <span>{startDate}</span>
+ </div>
+
+ <h3>Tecnologias</h3>
+ <S.KeywordsContainer>
+ {project.technologies?.map((tech) => (
+ <S.KeywordTag key={tech}>
+ {tech}
+ </S.KeywordTag>
+ ))}
+ </S.KeywordsContainer>
+ </S.InfoSidebar>
+
+ </S.ContentGrid>
+ </S.MainContent>
+ </S.PageWrapper>
+ );
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 87.5% | +14/16 | +75% | +3/4 | +60% | +3/5 | +87.5% | +14/16 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 | + + + + + + + + + + + +8x + + +8x +8x + + +2x +2x +2x + +1x + + +1x +1x + + + + +1x + + +1x +1x + + + + +8x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +10x + + + + + + + + + + + + + + + + + + + + + | import { useState } from 'react';
+import { FormPageContainer, FormWrapper, FormTitle, StyledForm, StyledInput, SubmitButton, RedirectLink } from '../../components/domain/Form/styles';
+import { useForm, Controller} from 'react-hook-form';
+import { IMaskInput } from 'react-imask';
+import Toast from '../../components/common/Toast';
+import { useNavigate } from 'react-router-dom';
+import {Register as RegisterAPI} from '../../API/Auth'
+import type { RegisterProps } from '../../API/Auth';
+import type { NotificationState } from '../../components/common/Toast';
+
+export default function Register() {
+ // Estado para a notificação - usado no Toast
+ const [notification, setNotification] = useState<NotificationState | null>(null);
+
+
+ const { register, handleSubmit, formState: {isSubmitting}, control } = useForm<RegisterProps>();
+ const navigate = useNavigate();
+
+ async function onSubmit(data: RegisterProps) {
+ console.log(data);
+ try{
+ await RegisterAPI(data);
+
+ console.log('Usuário registrado com sucesso:');
+
+ // Define estado para mostrar notificação de sucesso
+ setNotification({ message: 'Usuário registrado com sucesso!', type: 'success' });
+ setTimeout(() => {
+ navigate('/feed'); // Navega para a página de feed
+ }, 1000);
+
+ } catch (error) {
+ console.error('Erro ao registrar usuário:', error);
+
+ // Define estado para mostrar notificação de erro
+ Eif (error instanceof Error){
+ setNotification({ message: error.message, type: 'error' });
+ }
+ }
+ }
+
+ return (
+ <FormPageContainer>
+
+ {notification && (
+ <Toast
+ message={notification.message}
+ type={notification.type}
+ onClose={() => setNotification(null)}
+ />
+ )}
+
+ <FormWrapper>
+ <FormTitle>Criar conta</FormTitle>
+
+ <StyledForm onSubmit={handleSubmit(onSubmit)}>
+ <StyledInput
+ type="text"
+ placeholder="Nome completo"
+ required
+ {...register('nomeCompleto')}
+ />
+ <StyledInput
+ type="text"
+ placeholder="Nome de usuário (user)"
+ required
+ {...register('username')}
+ />
+ <StyledInput
+ type="email"
+ placeholder="Email"
+ required
+ {...register('email')}
+ />
+ <StyledInput
+ type="password"
+ placeholder="Senha"
+ required
+ {...register('senha')}
+ />
+ <StyledInput
+ type="tel"
+ placeholder="Telefone"
+ required
+ {...register('telefone')}
+ />
+ <Controller
+ name="dataNascimento"
+ control={control}
+ rules={{ required: true }}
+ render={({ field }) => (
+ <StyledInput
+ {...field}
+ as={IMaskInput}
+ mask="00/00/0000"
+ placeholder="Data de nascimento"
+ required
+ />
+ )}
+ />
+
+ <SubmitButton disabled = {isSubmitting} type="submit">Cadastrar</SubmitButton>
+ </StyledForm>
+
+ <RedirectLink>
+ Já tem uma conta? <a href="/login">Faça login</a>
+ </RedirectLink>
+ </FormWrapper>
+ </FormPageContainer>
+ );
+}
+
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 | + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import api from './api';
+import { isAxiosError } from 'axios';
+export interface CommentProps {
+ projectTitle?: string;
+ commentID?: string;
+ content: string;
+ createdAt?: string;
+ authorID?: string;
+ projectID?: string;
+ username?: string;
+ fullName?: string;
+}
+
+const getAuthHeader = () => {
+ return {
+ headers: {
+ 'Authorization': `Bearer ${localStorage.getItem('token')}`
+ }
+ };
+};
+
+export async function CreateComment(projectId: string, content: string) {
+ try{
+
+ const response = await api.post(`/api/project/${projectId}/comments`,
+ {content},
+ getAuthHeader()
+ );
+
+ return response.data;
+
+ }catch(error){
+
+ if (isAxiosError(error) && error.response) {
+ throw new Error(error.response.data.message);
+ }
+ throw new Error("Erro ao enviar comentário.");
+
+ }
+}
+
+export async function GetComments(projectId: string): Promise<CommentProps[]> {
+ try{
+
+ const response = await api.get(`/api/project/${projectId}/comments`, getAuthHeader());
+
+ return await response.data;
+ }catch(error){
+ console.error("Erro ao buscar comentários:", error);
+ throw new Error("Erro ao carregar comentários.");
+ }
+
+
+}
+
+export async function GetUserComments(): Promise<CommentProps[]> {
+ try{
+ const response = await api.get('/api/user/comments', getAuthHeader());
+
+ return response.data;
+ }catch(error){
+ console.error("Erro ao buscar comentários do usuário:", error);
+ throw new Error("Erro ao carregar comentários do usuário.");
+ }
+}
+
+export async function DeleteComment(commentId: string) {
+ try{
+
+ const response = await api.delete(`/api/project/${commentId}/deletecomment`, getAuthHeader());
+
+ return response.data;
+ }catch(error){
+ if (isAxiosError(error) && error.response) {
+ throw new Error(error.response.data.message);
+ }
+ throw new Error("Erro ao excluir comentário.");
+ }
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 | + + + + + + + + + + + + + + +2x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import api from './api';
+import { isAxiosError } from 'axios';
+
+export interface CommunityProps {
+ communityID: string;
+ name: string;
+ description: string;
+ technologies: string[];
+ createdAt: Date;
+ updatedAt: Date;
+ memberCount?: number;
+ isMember?: boolean;
+ isAdmin?: boolean;
+}
+
+const getAuthHeader = () => {
+ return {
+ headers: {
+ 'Authorization': `Bearer ${localStorage.getItem('token')}`
+ }
+ };
+};
+
+export async function NewCommunity(data: CommunityProps) {
+
+ try{
+ console.log("Enviando dados da comunidade:", data);
+ const response = await api.post('/api/newcommunity', data, getAuthHeader());
+ return response.data
+ }catch(error){
+ if (isAxiosError(error) && error.response) {
+ throw new Error(error.response.data.message);
+ }
+ throw new Error("Erro ao criar comunidade.");
+ }
+}
+
+export async function GetUserCommunities(): Promise<CommunityProps[]> {
+ try{
+ const response = await api.get<CommunityProps[]>('/api/user/communities', getAuthHeader());
+ return response.data
+ }catch(error){
+ console.error("Erro ao obter comunidades:", error);
+ throw new Error('Erro ao obter comunidades');
+ }
+}
+
+export async function GetCommunityById(communityId: string) {
+ try{
+ const response = await api.get(`/api/communities/data/${communityId}`, getAuthHeader());
+
+ return await response.data;
+ }catch(error){
+ if (isAxiosError(error) && error.response?.status === 404) {
+ throw new Error('Comunidade não encontrada');
+ }
+ throw new Error('Erro ao carregar a comunidade');
+ }
+}
+
+export async function JoinCommunity(communityId: string) {
+ try{
+ const response = await api.post(`/api/communities/${communityId}/join`, {}, getAuthHeader());
+
+ return await response.data;
+ }catch(error){
+ if (isAxiosError(error) && error.response) {
+ throw new Error(error.response.data.message);
+ }
+ throw new Error('Erro ao entrar na comunidade');
+ }
+}
+
+export async function DeleteCommunity(communityId: string) {
+ try{
+ const response = await api.delete(`/api/communities/deletecommunity/${communityId}`, getAuthHeader());
+ return response.data
+ }catch(error){
+ if (isAxiosError(error) && error.response) {
+ throw new Error(error.response.data.message);
+ }
+ throw new Error("Erro ao excluir comunidade.");
+ }
+}
+
+export async function UpdateCommunity(communityId: string, data: CommunityProps) {
+ try{
+ const response = await api.put(`/api/communities/updatecommunity/${communityId}`,
+ data,
+ getAuthHeader()
+ );
+
+ return response.data;
+ }catch(error){
+ if (isAxiosError(error) && error.response) {
+ throw new Error(error.response.data.message);
+ }
+ throw new Error("Erro ao atualizar comunidade.");
+ }
+}
+
+export async function LeaveCommunity(communityId: string) {
+ try{
+ const response = await api.delete(`/api/user/leavecommunity/${communityId}`, getAuthHeader());
+
+ return await response.data;
+ }catch(error){
+ if (isAxiosError(error) && error.response) {
+ throw new Error(error.response.data.message);
+ }
+ throw new Error("Erro ao sair da comunidade.");
+ }
+}
+
+export async function GetAllCommunities(): Promise<CommunityProps[]> {
+ try{
+ const response = await api.get<CommunityProps[]>('/api/communities', getAuthHeader());
+ return response.data
+ }catch(error){
+ console.error("Erro ao obter todas as comunidades:", error);
+ throw new Error('Erro ao obter todas as comunidades');
+ }
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 | + + + + + + + + + + + + + + + + +2x + + +2x + + +4x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import api from './api';
+import { isAxiosError } from 'axios';
+
+export interface ProjectProps {
+ id?: string;
+ title: string;
+ description: string;
+ technologies: string[];
+ status: string;
+ startDate: Date;
+ authorUsername?: string;
+ authorName?: string;
+ creatorID?: number;
+}
+
+export function parseDate(dataString: string): Date {
+ // Divide a string "20/11/2025" em partes
+ const [dia, mes, ano] = dataString.split('/');
+
+ // Cria a data: new Date(ano, mês - 1, dia)
+ return new Date(Number(ano), Number(mes) - 1, Number(dia));
+}
+
+const getAuthHeader = () => {
+ return {
+ headers: {
+ 'Authorization': `Bearer ${localStorage.getItem('token')}`
+ }
+ };
+};
+
+export async function NewProject(data: ProjectProps) {
+ try{
+ const response = await api.post('/api/user/newproject', data, getAuthHeader());
+
+ return response.data;
+ }catch(error){
+ if (isAxiosError(error) && error.response) {
+ throw new Error(error.response.data.message);
+ }
+ throw new Error("Erro ao criar projeto.");
+ }
+}
+
+export async function UpdateProject(projectId: string, data: ProjectProps) {
+ try{
+ const response = await api.put(`/api/user/updateproject/${projectId}`,
+ data,
+ getAuthHeader()
+ );
+ return response.data;
+ }catch(error){
+ if (isAxiosError(error) && error.response) {
+ throw new Error(error.response.data.message);
+ }
+ throw new Error("Erro ao atualizar projeto.");
+ }
+
+}
+
+export async function GetFeedProjects(): Promise<ProjectProps[]> {
+
+ try{
+ const response = await api.get('/api/user/home', getAuthHeader());
+
+ console.log("Dados do feed de projetos:", response.data);
+
+ return response.data.feed;
+ }catch(error){
+ console.error("Erro ao buscar feed:", error);
+ throw new Error("Erro ao carregar o feed de projetos.");
+ }
+}
+
+export async function DeleteProject(projectId: string) {
+ try{
+ await api.delete(`/api/user/deleteproject/${projectId}`, getAuthHeader());
+ }
+ catch(error){
+ if (isAxiosError(error) && error.response) {
+ throw new Error(error.response.data.message);
+ }
+ throw new Error("Erro ao excluir projeto.");
+ }
+}
+
+export async function GetUserProjects(): Promise<ProjectProps[]> {
+ try{
+ const response = await api.get('/api/user/projects', getAuthHeader());
+
+ console.log("Dados dos projetos do usuário:", response.data.projects);
+
+ return response.data.projects;
+ }catch(error){
+ if (isAxiosError(error) && error.response) {
+ throw new Error(error.response.data.message);
+ }
+ throw new Error("Erro ao buscar projetos do usuário.");
+ }
+}
+
+export async function GetProjectById(projectId: string): Promise<ProjectProps> {
+ try{
+ const response = await api.get(`/api/projects/${projectId}`, getAuthHeader());
+
+ return await response.data;
+ }catch(error){
+ console.error("Erro ao buscar projeto:", error);
+ throw new Error('Erro ao carregar projeto');
+ }
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 | + +5x + + + + | import axios from 'axios';
+
+const api = axios.create({
+ baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000',
+});
+
+export default api; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| Comment.ts | +
+
+ |
+ 4.16% | +1/24 | +0% | +0/8 | +0% | +0/5 | +4.16% | +1/24 | +
| Community.ts | +
+
+ |
+ 2.04% | +1/49 | +0% | +0/24 | +0% | +0/9 | +2.04% | +1/49 | +
| Project.ts | +
+
+ |
+ 7.69% | +3/39 | +0% | +0/16 | +12.5% | +1/8 | +7.69% | +3/39 | +
| api.ts | +
+
+ |
+ 100% | +1/1 | +100% | +2/2 | +100% | +0/0 | +100% | +1/1 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 | + + + + + + + +2x + + + + + + + + + + + | import { ThemeProvider } from "styled-components"
+import { defaultTheme } from './styles/themes/default'
+import { GlobalStyle } from './styles/global'
+import { Router } from "./Router"
+import { BrowserRouter } from "react-router-dom"
+
+function App() {
+
+ return (
+ <ThemeProvider theme={defaultTheme}>
+ <BrowserRouter>
+ <GlobalStyle/>
+ <Router />
+ </BrowserRouter>
+ </ThemeProvider>
+ )
+}
+
+export default App
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 100% | +3/3 | +100% | +0/0 | +100% | +1/1 | +100% | +3/3 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 | + + + + + + + + +1x +2x + + + + + + + + + +1x | import React from 'react';
+import * as S from './styles';
+
+// Props que o componente Keyword aceita
+interface KeywordProps {
+ children: React.ReactNode;
+ onRemove: () => void;
+}
+
+export const Keyword: React.FC<KeywordProps> = ({ children, onRemove }) => {
+ return (
+ <S.KeywordTag>
+ {children}
+ <S.KeywordRemoveButton onClick={onRemove} aria-label={`Remover ${children}`}>
+ ×
+ </S.KeywordRemoveButton>
+ </S.KeywordTag>
+ );
+};
+
+export const KeywordContainer = S.KeywordContainer; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 100% | +12/12 | +100% | +4/4 | +100% | +4/4 | +100% | +12/12 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 | + + + + + + + + + + +1x + +5x +5x +4x + +1x + + +5x +5x + + + +5x +1x + + + +4x +2x + + +4x + + + + + + + + + + + + | import React, { useEffect } from 'react';
+import { createPortal } from 'react-dom';
+import * as S from './styles';
+
+interface ModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ children: React.ReactNode;
+ title: string;
+}
+
+const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
+ // Bloqueia o scroll do body quando o modal está aberto
+ useEffect(() => {
+ if (isOpen) {
+ document.body.style.overflow = 'hidden';
+ } else {
+ document.body.style.overflow = 'unset';
+ }
+
+ return () => {
+ document.body.style.overflow = 'unset';
+ };
+ }, [isOpen]);
+
+ if (!isOpen) {
+ return null;
+ }
+
+ // Impede que o clique no modal feche o modal (só o overlay)
+ const handleContentClick = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ };
+
+ return createPortal(
+ <S.ModalOverlay onClick={onClose}>
+ <S.ModalContent onClick={handleContentClick}>
+ <S.CloseButton onClick={onClose}>×</S.CloseButton>
+ <S.ModalTitle>{title}</S.ModalTitle>
+ {children}
+ </S.ModalContent>
+ </S.ModalOverlay>,
+ document.body
+ );
+};
+
+export default Modal; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 100% | +7/7 | +100% | +0/0 | +100% | +4/4 | +100% | +7/7 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 | + + + + + + + + + + + + + +1x + + +4x +4x +1x + + + +4x +4x + + + +4x + + + + + + + + + + | import React, { useEffect } from 'react';
+import { ToastContainer, ToastMessage, CloseButton } from './styles';
+
+interface ToastProps {
+ message: string;
+ type: 'success' | 'error';
+ onClose: () => void; // Função para fechar o toast
+}
+
+export interface NotificationState {
+ message: string;
+ type: 'success' | 'error';
+}
+
+const Toast: React.FC<ToastProps> = ({ message, type, onClose }) => {
+
+ // Efeito para fechar o toast automaticamente após 5 segundos
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ onClose();
+ }, 5000);
+
+ // Limpa o timer se o componente for desmontado
+ return () => {
+ clearTimeout(timer);
+ };
+ }, [onClose]);
+
+ return (
+ <ToastContainer type={type}>
+ <ToastMessage>{message}</ToastMessage>
+ <CloseButton onClick={onClose}>
+ ×
+ </CloseButton>
+ </ToastContainer>
+ );
+};
+
+export default Toast; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| styles.ts | +
+
+ |
+ 87.32% | +62/71 | +85.71% | +12/14 | +84.48% | +49/58 | +86.95% | +60/69 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 | + + +2x +20x + + + + + + + +20x + + + + + + + + + + + + + + + + +2x + + + + + + + + + + + + + + + + + + + +2x + + + +86x + + + + + + + + + + + + + + + + + +86x + + + + + + + + + + + +2x + + + + +33x + + +33x + + + + + +33x + + + + +33x +33x + + + + +33x +33x +33x + + + + + +2x + + + + + + + +13x + + + + + + + + + +13x + + + +13x + + + + + +2x + + + + +13x + + +13x + + + + + + + + + + + + +13x +13x + + + + +13x +13x +13x + + + + + +13x + + + + +13x +13x + + + + +2x + + + + + +2x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +2x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +2x + + + + + + + + + + + + + + + + + +2x + + + +20x +20x + + + + + +20x + + + + + + + + + + + + + + + + + + + + + +20x + + + + + + + + + + +20x + + + + + + + + +2x + + + + + + +20x + + +20x + + + + + + + +20x + + + + +20x +20x + + + + +20x +20x +20x + + + + + + + + + + +20x + + + + +20x + + + + +20x + + + + +2x + + +20x + + + + +20x + + + + + + +20x +20x + + + +20x +20x + + | import styled from 'styled-components';
+
+// O <form> container
+export const FormContainer = styled.form`
+ background: ${props => props.theme.white};
+ border-radius: 24px;
+ padding: 40px;
+ width: 100%;
+ max-width: 800px;
+ margin: 0 auto;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08),
+ 0 2px 8px rgba(0, 0, 0, 0.04);
+ border: 1px solid ${props => props.theme.placeholder}20;
+
+ animation: slideUp 0.5s ease-out;
+
+ @keyframes slideUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+`;
+
+// Wrapper para cada par de (label + input)
+export const InputGroup = styled.div`
+ margin-bottom: 28px;
+ width: 100%;
+
+ animation: fadeIn 0.4s ease-out backwards;
+ animation-delay: calc(var(--index, 0) * 0.05s);
+
+ @keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+`;
+
+// Label
+export const Label = styled.label`
+ display: block;
+ font-size: 0.95em;
+ font-weight: 700;
+ color: ${props => props.theme.title};
+ margin-bottom: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ font-size: 0.85rem;
+ padding-left: 4px;
+
+ /* Indicador visual opcional */
+ position: relative;
+
+ &::after {
+ content: '';
+ position: absolute;
+ left: -12px;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 3px;
+ height: 14px;
+ background: linear-gradient(180deg, ${props => props.theme.button} 0%, ${props => props.theme['hover-button'] || props.theme.button} 100%);
+ border-radius: 2px;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ }
+
+ ${InputGroup}:focus-within &::after {
+ opacity: 1;
+ }
+`;
+
+// Input
+export const Input = styled.input`
+ width: 100%;
+ padding: 14px 20px;
+ font-size: 1em;
+
+ background-color: ${props => props.theme['gray-100']};
+ border: 2px solid transparent;
+ border-radius: 12px;
+ color: ${props => props.theme.title};
+ box-sizing: border-box;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ font-weight: 500;
+
+ &::placeholder {
+ color: ${props => props.theme.placeholder};
+ font-weight: 400;
+ }
+
+ &:hover {
+ background-color: ${props => props.theme.white};
+ border-color: ${props => props.theme.placeholder}40;
+ }
+
+ &:focus {
+ outline: none;
+ background-color: ${props => props.theme.white};
+ border-color: ${props => props.theme.button};
+ box-shadow: 0 0 0 4px ${props => props.theme.button}15,
+ 0 2px 8px rgba(0, 0, 0, 0.04);
+ transform: translateY(-1px);
+ }
+`;
+
+export const SelectWrapper = styled.div`
+ position: relative;
+ width: 100%;
+
+ /* A seta de seleção (CSS) */
+ &::after {
+ content: '▼';
+ font-size: 0.85em;
+ color: ${props => props.theme.placeholder};
+ position: absolute;
+ right: 20px;
+ top: 50%;
+ transform: translateY(-50%);
+ pointer-events: none;
+ transition: all 0.3s ease;
+ }
+
+ &:hover::after {
+ color: ${props => props.theme.button};
+ }
+
+ &:focus-within::after {
+ color: ${props => props.theme.button};
+ transform: translateY(-50%) rotate(180deg);
+ }
+`;
+
+// Componente de Select
+export const Select = styled.select`
+ width: 100%;
+ padding: 14px 20px;
+ font-size: 1em;
+
+ background-color: ${props => props.theme['gray-100']};
+ border: 2px solid transparent;
+ border-radius: 12px;
+ color: ${props => props.theme.title};
+ box-sizing: border-box;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ font-weight: 500;
+ cursor: pointer;
+
+ appearance: none;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+
+ padding-right: 50px;
+
+ &:hover {
+ background-color: ${props => props.theme.white};
+ border-color: ${props => props.theme.placeholder}40;
+ }
+
+ &:focus {
+ outline: none;
+ background-color: ${props => props.theme.white};
+ border-color: ${props => props.theme.button};
+ box-shadow: 0 0 0 4px ${props => props.theme.button}15,
+ 0 2px 8px rgba(0, 0, 0, 0.04);
+ transform: translateY(-1px);
+ }
+
+ &:invalid {
+ color: ${props => props.theme.placeholder};
+ }
+
+ option {
+ padding: 10px;
+ background: ${props => props.theme.white};
+ color: ${props => props.theme.title};
+ }
+`;
+
+// Wrapper para o input de busca E a lista de resultados
+export const SearchWrapper = styled.div`
+ position: relative;
+ width: 100%;
+`;
+
+// Lista de resultados de busca que aparece abaixo do input
+export const SearchResultsList = styled.ul`
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+ right: 0;
+ background: ${props => props.theme.white};
+ border: 2px solid ${props => props.theme.button}20;
+ border-radius: 12px;
+ max-height: 240px;
+ overflow-y: auto;
+ margin: 0;
+ padding: 6px;
+ z-index: 100;
+ list-style: none;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
+ 0 2px 8px rgba(0, 0, 0, 0.08);
+
+ animation: slideDown 0.2s ease-out;
+
+ @keyframes slideDown {
+ from {
+ opacity: 0;
+ transform: translateY(-8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+
+ /* Estilização da scrollbar */
+ &::-webkit-scrollbar {
+ width: 8px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: ${props => props.theme['gray-300']};
+ border-radius: 4px;
+ }
+
+ &::-webkit-scrollbar-thumb:hover {
+ background: ${props => props.theme['gray-400']};
+ }
+`;
+
+// Item individual na lista de resultados
+export const SearchResultItem = styled.li`
+ padding: 12px 16px;
+ cursor: pointer;
+ border-radius: 8px;
+ color: ${props => props.theme.title};
+ font-weight: 500;
+ transition: all 0.2s ease;
+ position: relative;
+
+ /* Barra lateral que aparece no hover */
+ &::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 3px;
+ background: ${props => props.theme.button};
+ border-radius: 0 2px 2px 0;
+ transform: scaleY(0);
+ transition: transform 0.2s ease;
+ }
+
+ &:hover {
+ background-color: ${props => props.theme['gray-100']};
+ padding-left: 20px;
+
+ &::before {
+ transform: scaleY(1);
+ }
+ }
+
+ &:active {
+ background-color: ${props => props.theme['gray-100']};
+ }
+`;
+
+// Mensagem de erro
+export const ErrorMessage = styled.span`
+ font-size: 0.85em;
+ margin-top: 8px;
+ color: ${props => props.theme['red-500'] || '#ef4444'};
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding-left: 4px;
+
+ /* Ícone de alerta */
+ &::before {
+ content: '⚠';
+ font-size: 1.1em;
+ }
+`;
+
+// Botão de submissão
+export const SubmitButton = styled.button`
+ padding: 16px 40px;
+ font-size: 1.05em;
+ font-weight: 700;
+ color: ${props => props.theme.white};
+ background: linear-gradient(135deg, ${props => props.theme.button} 0%, ${props => props.theme['hover-button'] || props.theme.button} 100%);
+ border: none;
+ border-radius: 12px;
+ cursor: pointer;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ margin-top: 16px;
+ box-shadow: 0 4px 16px ${props => props.theme.button}40;
+ position: relative;
+ overflow: hidden;
+
+ /* Efeito de brilho deslizante */
+ &::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: -100%;
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
+ transition: left 0.5s;
+ }
+
+ &:hover::before {
+ left: 100%;
+ }
+
+ &:hover {
+ transform: translateY(-3px);
+ box-shadow: 0 8px 24px ${props => props.theme.button}50;
+ }
+
+ &:active {
+ transform: translateY(-1px);
+ }
+
+ &:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ transform: none;
+ box-shadow: 0 2px 8px ${props => props.theme.button}20;
+
+ &:hover {
+ transform: none;
+ }
+ }
+`;
+
+// Estilo para o campo de texto de várias linhas (Descrição)
+export const TextArea = styled.textarea`
+ width: 100%;
+ padding: 16px 20px;
+ font-size: 1em;
+ font-family: inherit;
+ line-height: 1.6;
+
+ background-color: ${props => props.theme['gray-100']};
+ border: 2px solid transparent;
+ border-radius: 12px;
+ color: ${props => props.theme.title};
+ box-sizing: border-box;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ resize: vertical;
+ min-height: 150px;
+ font-weight: 500;
+
+ &::placeholder {
+ color: ${props => props.theme.placeholder};
+ font-weight: 400;
+ }
+
+ &:hover {
+ background-color: ${props => props.theme.white};
+ border-color: ${props => props.theme.placeholder}40;
+ }
+
+ &:focus {
+ outline: none;
+ background-color: ${props => props.theme.white};
+ border-color: ${props => props.theme.button};
+ box-shadow: 0 0 0 4px ${props => props.theme.button}15,
+ 0 2px 8px rgba(0, 0, 0, 0.04);
+ transform: translateY(-1px);
+ }
+
+ /* Estilização da scrollbar */
+ &::-webkit-scrollbar {
+ width: 10px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: ${props => props.theme['gray-100']};
+ border-radius: 0 10px 10px 0;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: ${props => props.theme['gray-300']};
+ border-radius: 10px;
+ }
+
+ &::-webkit-scrollbar-thumb:hover {
+ background: ${props => props.theme.button};
+ }
+`;
+
+// Contador de caracteres
+export const CharacterCount = styled.div`
+ text-align: right;
+ font-size: 0.85em;
+ color: ${props => props.theme['gray-500'] || props.theme.placeholder};
+ margin-top: 6px;
+ margin-right: 4px;
+ font-weight: 600;
+ padding: 4px 8px;
+ background: ${props => props.theme['gray-100']};
+ border-radius: 6px;
+ display: inline-block;
+ float: right;
+
+ /* Muda de cor quando próximo do limite */
+ &[data-warning="true"] {
+ color: ${props => props.theme['yellow-600'] || '#d97706'};
+ background: ${props => props.theme['yellow-50'] || '#fef3c7'};
+ }
+
+ &[data-limit="true"] {
+ color: ${props => props.theme['red-500'] || '#ef4444'};
+ background: ${props => '#fee2e2'};
+ }
+`; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 69.23% | +72/104 | +58.9% | +43/73 | +64.28% | +18/28 | +72.16% | +70/97 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 | + + + + + + + + + + + + + +1x + + + + + + + +1x +21x + + + + + + +1x +12x + + + + + + + + + + + + + +21x + +21x +21x +21x +21x +21x + + +21x +21x +21x +21x +21x + +21x + +21x + +21x +10x +2x + + + + +2x +2x +2x + + + + + + +21x + + + + + +13x +4x + +13x +13x + + + +21x +1x + +1x +1x + + + + + +21x +2x +2x + +2x + +2x + + +2x +1x + + +1x +1x + + +1x + + + +21x +1x + +1x + + + + +1x + +1x +1x +1x + +1x +1x +1x + + + + + + +1x + + + +21x + + + + + + + + + + + + +21x + +12x + + +12x + + +12x + + + + + + +12x + + + + + + + + +21x +1x + + + + + +21x + + + + + + + + + + + + + + + + + + +4x + + + + + + + + + +2x +2x + + + + + + + + + + +2x + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + +1x + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import React, { useState, useRef, useEffect } from 'react';
+import * as S from './styles';
+import * as D from '../../common/Dropdown/styles';
+import { useNavigate } from 'react-router-dom';
+import type { ProjectProps } from '../../../API/Project';
+import { DeleteProject } from '../../../API/Project';
+import Modal from '../../common/Modal';
+import type { NotificationState } from '../../common/Toast';
+import Toast from '../../common/Toast';
+import * as ModalS from '../../common/Modal/styles';
+import { CreateComment, GetComments, DeleteComment } from '../../../API/Comment';
+import type { CommentProps } from '../../../API/Comment';
+import { useAuth } from '../../../API/AuthContext';
+
+const TrashIcon = () => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+ <polyline points="3 6 5 6 21 6"></polyline>
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
+ </svg>
+);
+
+// Ícone de Comentário (Balão)
+const CommentIcon = () => (
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+ <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
+ </svg>
+);
+
+
+// --- Ícone de Menu (Ellipsis) ---
+const EllipsisIcon = () => (
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+ <circle cx="12" cy="12" r="1" /><circle cx="19" cy="12" r="1" /><circle cx="5" cy="12" r="1" />
+ </svg>
+);
+
+interface PostcardProps {
+ post: ProjectProps;
+ showMenu: boolean; // Verifica se o menu deve ser mostrado (se é dono do post)
+ onDelete?: (id: string) => Promise<void>;
+ deleteLabel?: string;
+}
+
+// --- Componente Postcard ---
+export default function Postcard({ post, showMenu, onDelete, deleteLabel = 'Projeto' }: PostcardProps) {
+ const { currentUser } = useAuth();
+
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+ const menuRef = useRef<HTMLDivElement>(null);
+ const navigate = useNavigate(); // Inicialize o hook de navegação
+ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); // Estado para o modal de exclusão
+ const [notification, setNotification] = useState<NotificationState | null>(null);
+
+ // Estados para o formulário de comentário
+ const [isCommentBoxOpen, setIsCommentBoxOpen] = useState(false);
+ const [commentText, setCommentText] = useState("");
+ const [isSubmittingComment, setIsSubmittingComment] = useState(false);
+ const MAX_COMMENT_CHARS = 500;
+ const [comments, setComments] = useState<CommentProps[]>([]);
+
+ const [commentToDelete, setCommentToDelete] = useState<string | null>(null);
+
+ const projectId = (post as any).id || (post as any).projectID;
+
+ useEffect(() => {
+ if (isCommentBoxOpen && projectId) {
+ loadComments();
+ }
+ }, [isCommentBoxOpen, projectId]);
+
+ async function loadComments() {
+ try {
+ const data = await GetComments(projectId);
+ setComments(data);
+ } catch (error) {
+ console.error("Erro ao carregar comentários", error);
+ }
+ }
+
+ // Lógica para fechar o menu ao clicar fora (sem alterações)
+ useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
+ setIsMenuOpen(false);
+ }
+ }
+ if (isMenuOpen) {
+ document.addEventListener('mousedown', handleClickOutside);
+ }
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [isMenuOpen]);
+
+ const handleEditClick = () => {
+ const projectId = (post as any).id || (post as any).projectID;
+
+ if (projectId) {
+ navigate(`/editProject/${projectId}`, { state: { projectToEdit: post } });
+ } else E{
+ console.error("Erro: ID do projeto não encontrado no objeto:", post);
+ }
+ };
+
+ const handleDeleteMainItem = async () => {
+ try {
+ const id = (post as any).id || (post as any).projectID || (post as any).commentID;
+
+ Iif (!id) throw new Error("ID não encontrado.");
+
+ Iif (onDelete) {
+ await onDelete(id);
+ } else {
+ await DeleteProject(id);
+ setTimeout(() => navigate('/feed'), 1000);
+ }
+
+ setNotification({ message: `${deleteLabel} excluído com sucesso!`, type: 'success' });
+ setIsDeleteModalOpen(false);
+
+ } catch (error) {
+ setNotification({ message: `Erro ao excluir ${deleteLabel?.toLowerCase()}.`, type: 'error' });
+ }
+ };
+
+ const handleCommentSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ Iif (!projectId) {
+ setNotification({ message: 'Erro: Projeto sem ID.', type: 'error' });
+ return;
+ }
+
+ Iif (!commentText.trim()) return;
+
+ try {
+ setIsSubmittingComment(true);
+ await CreateComment(projectId, commentText);
+
+ setNotification({ message: 'Comentário enviado!', type: 'success' });
+ setCommentText(""); // Limpa o campo
+ setIsCommentBoxOpen(false);
+
+ } catch (error) {
+ if (error instanceof Error) {
+ setNotification({ message: error.message, type: 'error' });
+ }
+ } finally {
+ setIsSubmittingComment(false);
+ }
+ };
+
+ const handleConfirmDeleteInternalComment = async () => {
+ if (!commentToDelete) return;
+ try {
+ await DeleteComment(commentToDelete);
+ setNotification({ message: 'Comentário excluído.', type: 'success' });
+ setCommentToDelete(null);
+ await loadComments();
+ } catch (error: any) {
+ setNotification({ message: error.message, type: 'error' });
+ setCommentToDelete(null);
+ }
+ };
+
+ const handleCardClick = (e: React.MouseEvent) => {
+ // Não navega se estiver na aba de comentários do perfil (deleteLabel 'Comentário')
+ Iif (deleteLabel !== 'Projeto') return;
+
+ // Não navega se o clique foi em um elemento interativo
+ const target = e.target as HTMLElement;
+
+ // Verifica se clicou em botões, inputs, links ou no próprio menu
+ Eif (
+ target.closest('button') ||
+ target.closest('input') ||
+ target.closest('textarea') ||
+ target.closest('a') ||
+ target.closest(D.DropdownMenu as any) // Garante que itens do dropdown não disparem
+ ) {
+ return;
+ }
+
+ // Realiza a navegação
+ if (projectId) {
+ navigate(`/project/${projectId}`);
+ }
+ };
+
+ const formatDate = (dateString?: string) => {
+ Eif (!dateString) return "";
+ return new Date(dateString).toLocaleDateString('pt-BR', {
+ day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'
+ });
+ };
+
+ return (
+ <S.PostCardWrapper onClick={handleCardClick}>
+ {notification && (
+ <Toast
+ message={notification.message}
+ type={notification.type}
+ onClose={() => setNotification(null)}
+ />
+ )}
+
+ <S.PostHeader>
+ <img src={(post as any).avatarUrl || 'url_placeholder_avatar.png'} alt={post.title} />
+ <span>{post.title}</span>
+ <small>• {(post as any).author?.title || 'Autor'}</small>
+ </S.PostHeader>
+
+ <S.PostContent>
+ {showMenu && (
+ <S.MenuWrapper ref={menuRef}>
+ <S.MenuButton onClick={() => setIsMenuOpen(prev => !prev)}>
+ <EllipsisIcon />
+ </S.MenuButton>
+
+ {isMenuOpen && (
+ <D.DropdownMenu>
+ {deleteLabel === 'Projeto' && (
+ <D.MenuItem onClick={handleEditClick}>Editar</D.MenuItem>
+ )}
+ <D.DangerMenuItem onClick={() => {
+ setIsMenuOpen(false);
+ setIsDeleteModalOpen(true);}}>
+ Excluir
+ </D.DangerMenuItem>
+ </D.DropdownMenu>
+ )}
+ </S.MenuWrapper>
+ )}
+
+ <p>{post.description}</p>
+
+ <S.ActionRow>
+ <S.ActionButton onClick={() => setIsCommentBoxOpen(!isCommentBoxOpen)}>
+ <CommentIcon />
+ <span>Comentar</span>
+ </S.ActionButton>
+ </S.ActionRow>
+
+ {isCommentBoxOpen && (
+ <>
+ <S.CommentForm onSubmit={handleCommentSubmit}>
+ <S.CommentTextArea
+ placeholder="Escreva seu comentário..."
+ value={commentText}
+ onChange={(e) => setCommentText(e.target.value)}
+ maxLength={MAX_COMMENT_CHARS}
+ disabled={isSubmittingComment}
+ />
+ <S.CommentFooter>
+ <S.CharacterCount isLimit={commentText.length >= MAX_COMMENT_CHARS}>
+ {commentText.length} / {MAX_COMMENT_CHARS}
+ </S.CharacterCount>
+ <S.SubmitCommentButton
+ type="submit"
+ disabled={isSubmittingComment || !commentText.trim()}
+ >
+ {isSubmittingComment ? 'Enviando...' : 'Enviar'}
+ </S.SubmitCommentButton>
+ </S.CommentFooter>
+ </S.CommentForm>
+
+ {/* LISTA DE COMENTÁRIOS */}
+ {comments.length > 0 && (
+ <S.CommentsSection>
+ {comments.map((comment) => {
+ // Verifica se o usuário atual é o autor do comentário
+ const isAuthor = currentUser?.id && String(currentUser.id) === String(comment.authorID);
+
+ return (
+ <S.CommentItem key={comment.commentID || Math.random()}>
+ <S.CommentAvatar />
+ <S.CommentBubble>
+ <S.CommentHeader>
+ <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
+ <strong>{comment.username || "Usuário"}</strong>
+ <span>{formatDate(comment.createdAt)}</span>
+ </div>
+
+ {/* Botão de Deletar (Só aparece para o dono) */}
+ {isAuthor && (
+ <S.DeleteCommentButton
+ onClick={() => setCommentToDelete(comment.commentID!)}
+ title="Excluir comentário"
+ >
+ <TrashIcon />
+ </S.DeleteCommentButton>
+ )}
+ </S.CommentHeader>
+ <S.CommentText>{comment.content}</S.CommentText>
+ </S.CommentBubble>
+ </S.CommentItem>
+ );
+ })}
+ </S.CommentsSection>
+ )}
+ </>
+ )}
+
+ </S.PostContent>
+ <Modal
+ isOpen={isDeleteModalOpen}
+ onClose={() => setIsDeleteModalOpen(false)}
+ title={`Excluir ${deleteLabel}`}
+ >
+ <div style={{ textAlign: 'center' }}>
+ <p style={{ marginBottom: '24px', color: '#555' }}>
+ Tem certeza que deseja excluir este {deleteLabel?.toLowerCase()}?
+ </p>
+ <ModalS.ModalActions>
+ <ModalS.ChoiceButton onClick={() => setIsDeleteModalOpen(false)}>
+ Cancelar
+ </ModalS.ChoiceButton>
+ <ModalS.ChoiceButton onClick={handleDeleteMainItem} style={{ backgroundColor: '#e74c3c' }}>
+ Excluir
+ </ModalS.ChoiceButton>
+ </ModalS.ModalActions>
+ </div>
+ </Modal>
+
+ <Modal
+ isOpen={!!commentToDelete}
+ onClose={() => setCommentToDelete(null)}
+ title="Excluir Comentário"
+ >
+ <div style={{ textAlign: 'center' }}>
+ <p style={{ marginBottom: '24px', color: '#555' }}>
+ Deseja realmente apagar este comentário?
+ </p>
+ <ModalS.ModalActions>
+ <ModalS.ChoiceButton onClick={() => setCommentToDelete(null)}>
+ Cancelar
+ </ModalS.ChoiceButton>
+ <ModalS.ChoiceButton
+ onClick={handleConfirmDeleteInternalComment}
+ style={{ backgroundColor: '#e74c3c' }}
+ >
+ Excluir
+ </ModalS.ChoiceButton>
+ </ModalS.ModalActions>
+ </div>
+ </Modal>
+
+ </S.PostCardWrapper>
+ );
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 91.07% | +51/56 | +83.33% | +30/36 | +78.94% | +15/19 | +90.9% | +50/55 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 | + + + + + + + + + + +47x +47x + +47x + +47x +47x + + +47x +47x + + +47x +47x + +47x +6x +6x +6x +6x + +6x + + + + +6x +6x + + + +6x + + + + + +47x +20x +8x +8x +8x + + +12x + + +12x +3x + + + + + +12x +3x + + + +12x +12x + + + + +47x + +1x +1x + + +6x +6x + + + +47x +1x +1x +1x + + +47x +1x +1x +1x + + +47x + +47x + + + + + + +7x + + + + + + + + + + + + + + + + + +1x + +1x + + + + + + + + + + + + + + +3x + +1x + + + + + + + + + + + + + + + | import React, { useState, useEffect, useRef } from 'react';
+import { FiSearch } from 'react-icons/fi';
+import * as S from './styles';
+import { useNavigate } from 'react-router-dom';
+
+import { GetAllCommunities } from '../../../API/Community';
+import { GetFeedProjects } from '../../../API/Project';
+import type { CommunityProps } from '../../../API/Community';
+import type { ProjectProps } from '../../../API/Project';
+
+export default function Searchbar() {
+ const [query, setQuery] = useState('');
+ const navigate = useNavigate();
+
+ const wrapperRef = useRef<HTMLDivElement>(null);
+
+ const [isFocused, setIsFocused] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+
+ // Dados brutos (cache local)
+ const [allCommunities, setAllCommunities] = useState<CommunityProps[]>([]);
+ const [allProjects, setAllProjects] = useState<ProjectProps[]>([]);
+
+ // Dados filtrados para exibição
+ const [filteredCommunities, setFilteredCommunities] = useState<CommunityProps[]>([]);
+ const [filteredProjects, setFilteredProjects] = useState<ProjectProps[]>([]);
+
+ const handleFocus = async () => {
+ setIsFocused(true);
+ Eif (allCommunities.length === 0 && allProjects.length === 0) {
+ setIsLoading(true);
+ try {
+ // Busca em paralelo
+ const [communitiesData, projectsData] = await Promise.all([
+ GetAllCommunities().catch(() => []), // Se falhar, retorna array vazio
+ GetFeedProjects().catch(() => [])
+ ]);
+
+ setAllCommunities(communitiesData);
+ setAllProjects(projectsData);
+ } catch (error) {
+ console.error("Erro ao carregar dados para pesquisa", error);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+ };
+
+ // Efeito para filtrar sempre que a query ou os dados mudarem
+ useEffect(() => {
+ if (!query.trim()) {
+ setFilteredCommunities([]);
+ setFilteredProjects([]);
+ return;
+ }
+
+ const lowerQuery = query.toLowerCase();
+
+ // Filtra Comunidades (pelo nome ou descrição ou tecnologias)
+ const filteredComms = allCommunities.filter(comm =>
+ comm.name.toLowerCase().includes(lowerQuery) ||
+ comm.description?.toLowerCase().includes(lowerQuery) ||
+ comm.technologies?.some(tech => tech.toLowerCase().includes(lowerQuery))
+ );
+
+ // Filtra Projetos (pelo título ou tecnologias)
+ const filteredProjs = allProjects.filter(proj =>
+ proj.title.toLowerCase().includes(lowerQuery) ||
+ proj.technologies?.some(tech => tech.toLowerCase().includes(lowerQuery))
+ );
+
+ setFilteredCommunities(filteredComms.slice(0, 5)); // Limita a 5 resultados
+ setFilteredProjects(filteredProjs.slice(0, 5)); // Limita a 5 resultados
+
+ }, [query, allCommunities, allProjects]);
+
+ // Fecha ao clicar fora
+ useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ Eif (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
+ setIsFocused(false);
+ }
+ }
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ // Navegação
+ const handleNavigateToCommunity = (communityId: string) => {
+ navigate(`/r/${communityId}`);
+ setIsFocused(false);
+ setQuery('');
+ };
+
+ const handleNavigateToProject = (projectId: string) => {
+ navigate(`/project/${projectId}`);
+ console.log("Navegar para projeto:", projectId);
+ setIsFocused(false);
+ };
+
+ const hasResults = filteredCommunities.length > 0 || filteredProjects.length > 0;
+
+ return (
+ <S.SearchWrapper ref={wrapperRef}>
+ <FiSearch size={20} />
+ <S.SearchInput
+ type="text"
+ placeholder="Busque comunidades e projetos..."
+ value={query}
+ onChange={(e) => setQuery(e.target.value)}
+ onFocus={handleFocus}
+ />
+
+ {/* Renderiza o dropdown apenas se estiver focado e houver texto */}
+ {isFocused && query.length > 0 && (
+ <S.ResultsDropdown>
+ {isLoading ? (
+ <S.NoResults>Carregando...</S.NoResults>
+ ) : !hasResults ? (
+ <S.NoResults>Nenhum resultado encontrado.</S.NoResults>
+ ) : (
+ <>
+ {/* Seção de Comunidades */}
+ {filteredCommunities.length > 0 && (
+ <S.ResultSection>
+ <h4>Comunidades</h4>
+ {filteredCommunities.map(comm => (
+ <S.ResultItem
+ key={comm.communityID}
+ onClick={() => handleNavigateToCommunity(comm.communityID)}
+ >
+ <span>{comm.name}</span>
+ {/* Opcional: mostrar tecnologia principal */}
+ {comm.technologies?.[0] && <small>{comm.technologies[0]}</small>}
+ </S.ResultItem>
+ ))}
+ </S.ResultSection>
+ )}
+
+ {/* Seção de Projetos */}
+ {filteredProjects.length > 0 && (
+ <S.ResultSection>
+ <h4>Projetos</h4>
+ {filteredProjects.map((proj: any) => (
+ <S.ResultItem
+ key={proj.id || proj.projectID}
+ onClick={() => handleNavigateToProject(proj.id || proj.projectID)}
+ >
+ <span>{proj.title}</span>
+ <small>por {proj.authorUsername || 'Usuário'}</small>
+ </S.ResultItem>
+ ))}
+ </S.ResultSection>
+ )}
+ </>
+ )}
+ </S.ResultsDropdown>
+ )}
+ </S.SearchWrapper>
+ );
+}
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 94.28% | +33/35 | +76.19% | +16/21 | +92.85% | +13/14 | +93.93% | +31/33 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 | + + + + + + + + + + + + + + + + + + + + + +20x +20x +20x +20x + +20x +9x +3x +11x + + +3x + +6x + + + + +20x + + + + + +5x +5x +5x + + + + +20x + +2x +1x +1x + +1x +1x +1x +1x +1x + + + + +20x +2x +1x + + +20x + + + + +18x + +1x + + + + + + + + + + + + + +3x + + + + + + +4x + + + + + + + + + + | import React, { useState, useEffect, useRef } from 'react';
+import * as S from '../CreationForm/styles';
+import { Keyword, KeywordContainer } from '../../common/Keyword';
+
+interface TagInputProps {
+ value: string[];
+ onChange: (value: string[]) => void;
+
+ searchList: string[];
+ limit: number;
+ placeholder: string;
+}
+
+
+export default function TagInput({
+ value: selectedTags = [],
+ onChange,
+ searchList,
+ limit,
+ placeholder
+}: TagInputProps) {
+
+ const [searchQuery, setSearchQuery] = useState('');
+ const [searchResults, setSearchResults] = useState<string[]>([]);
+ const [error, setError] = useState('');
+ const wrapperRef = useRef<HTMLDivElement>(null);
+
+ useEffect(() => {
+ if (searchQuery.length > 0) {
+ const filtered = searchList.filter(item =>
+ item.toLowerCase().includes(searchQuery.toLowerCase()) &&
+ !selectedTags.includes(item) // Não mostra o que já foi selecionado
+ );
+ setSearchResults(filtered);
+ } else {
+ setSearchResults([]);
+ }
+ }, [searchQuery, selectedTags, searchList]);
+
+ // Lógica para fechar a lista de resultados ao clicar fora
+ useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
+ setSearchResults([]); // Fecha a lista
+ }
+ }
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [wrapperRef]);
+
+ // Adiciona uma tag
+ const handleAddTag = (tag: string) => {
+ // Usa o 'limit' da prop
+ if (selectedTags.length >= limit) {
+ setError(`Limite de ${limit} itens atingido.`);
+ return;
+ }
+ Eif (!selectedTags.includes(tag)) {
+ onChange([...selectedTags, tag]); // Atualiza o react-hook-form
+ setSearchQuery('');
+ setSearchResults([]);
+ setError('');
+ }
+ };
+
+ // Remove uma tag
+ const handleRemoveTag = (tagToRemove: string) => {
+ onChange(selectedTags.filter(tag => tag !== tagToRemove));
+ setError('');
+ };
+
+ return (
+ <S.SearchWrapper ref={wrapperRef}>
+ {selectedTags.length > 0 && (
+ <KeywordContainer>
+ {selectedTags.map(tag => (
+ <Keyword
+ key={tag}
+ onRemove={() => handleRemoveTag(tag)}
+ >
+ {tag}
+ </Keyword>
+ ))}
+ </KeywordContainer>
+ )}
+
+ <div style={{ height: selectedTags.length > 0 ? '12px' : '0' }} />
+
+ <S.Input
+ type="text"
+ placeholder={placeholder}
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ disabled={selectedTags.length >= limit}
+ />
+
+ {searchResults.length > 0 && (
+ <S.SearchResultsList>
+ {searchResults.map(tag => (
+ <S.SearchResultItem key={tag} onClick={() => handleAddTag(tag)}>
+ {tag}
+ </S.SearchResultItem>
+ ))}
+ </S.SearchResultsList>
+ )}
+
+ {error && <S.ErrorMessage>{error}</S.ErrorMessage>}
+ </S.SearchWrapper>
+ );
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 100% | +1/1 | +100% | +0/0 | +100% | +1/1 | +100% | +1/1 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 | + + + + + + + + + +2x + + + + + + + + + + + + + + + + + | import { FiPlus } from 'react-icons/fi';
+import Searchbar from '../../domain/Searchbar';
+import * as S from './styles';
+
+
+interface HeaderProps {
+ onCreateClick: () => void;
+}
+
+export default function Header({ onCreateClick }: HeaderProps) {
+ return (
+ <S.HeaderContainer>
+ <S.SearchContainer>
+ <Searchbar />
+ </S.SearchContainer>
+
+ <S.ActionsContainer>
+ <S.CreateButton onClick={onCreateClick}>
+ <FiPlus size={20} />
+ <span>Create</span>
+ </S.CreateButton>
+
+ <S.ProfileIcon to={`/profile`}>
+ </S.ProfileIcon>
+ </S.ActionsContainer>
+ </S.HeaderContainer>
+ );
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 100% | +1/1 | +100% | +0/0 | +100% | +1/1 | +100% | +1/1 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 | + + +1x + + + + + + + + + + + + + + | import * as S from './styles';
+
+export default function HeaderHome() {
+ return (
+ <S.HeaderContainer>
+
+ <S.ActionsContainer>
+ <S.Button to="/login">
+ Login
+ </S.Button>
+
+ <S.Button to="/register">
+ Register
+ </S.Button>
+ </S.ActionsContainer>
+ </S.HeaderContainer>
+ );
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 93.75% | +15/16 | +75% | +3/4 | +100% | +5/5 | +93.75% | +15/16 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 | + + + + + + + + + +9x +9x +9x + +9x +2x + + +9x + +8x +8x + +8x +8x +8x + + + + + + +8x +8x + + +9x + + + + + + + + + + + + + + + + +4x + + + + + + + + + + + + + + + + + + | import { useState } from 'react';
+import { FiHome, FiChevronDown } from 'react-icons/fi';
+import { GetUserCommunities } from '../../../API/Community';
+import * as S from './styles';
+import type { CommunityProps } from '../../../API/Community';
+import { useAuth } from '../../../API/AuthContext';
+import { useEffect } from 'react';
+
+export default function Sidebar() {
+ // Estado para controlar se a lista de comunidades está visível
+ const [isCommunitiesOpen, setIsCommunitiesOpen] = useState(true);
+ const { currentUser } = useAuth();
+ const [userCommunities, setUserCommunities] = useState<CommunityProps[]>([]);
+
+ const toggleCommunities = () => {
+ setIsCommunitiesOpen(!isCommunitiesOpen);
+ };
+
+ useEffect(() => {
+ // Função assíncrona para buscar todos os dados
+ const fetchCommunities = async () => {
+ try {;
+
+ const apiUserCommunities = await GetUserCommunities();
+ console.log("Comunidades do usuário:", apiUserCommunities);
+ setUserCommunities(apiUserCommunities);
+
+ } catch (error) {
+ console.error("Falha ao buscar comunidades:", error);
+ }
+
+ };
+ Eif(currentUser)
+ fetchCommunities();
+ }, [currentUser]);
+
+ return (
+ <S.SidebarContainer>
+ <S.SidebarNav>
+
+ <S.HomeLink to="/feed">
+ <FiHome size={22} />
+ <span>Home</span>
+ </S.HomeLink>
+
+ <S.CommunitiesHeader onClick={toggleCommunities} isOpen={isCommunitiesOpen}>
+ <span>COMUNIDADES</span>
+ <FiChevronDown size={20} />
+ </S.CommunitiesHeader>
+
+ {isCommunitiesOpen && (
+ <S.CommunitiesList>
+ {userCommunities.map((community) => (
+ <S.CommunityLink
+ to={`/r/${community.communityID}`}
+ key={community.communityID}
+ >
+ <S.CommunityIcon
+ alt={`${community.name} icon`}
+ />
+ <span>{community.name}</span>
+ </S.CommunityLink>
+ ))}
+ </S.CommunitiesList>
+ )}
+
+ </S.SidebarNav>
+ </S.SidebarContainer>
+ );
+}
+
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| App.tsx | +
+
+ |
+ 100% | +1/1 | +100% | +0/0 | +100% | +1/1 | +100% | +1/1 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ ++ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 | + + + + + + + + + + + + + + +14x +14x + +14x +14x +14x + +14x +14x +14x + +14x + +14x + +4x +4x +4x +4x +4x +4x + + + + +4x + + +14x + + + + + +6x +6x + + +14x +1x + +1x +1x + + +1x + + + + + +1x + + + + + + + + +14x + + + +14x +1x +1x +1x +1x +1x + + +1x + + + + + + + + + + + +14x +1x + +1x +1x + + +1x + + + + + +1x +1x + + + + + + + + + +14x + +10x + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + +1x + + + + + + + + + + +1x +1x + + + + + + + + + + + + + +10x + + + + + + + + + + + + +10x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { useParams, useNavigate } from 'react-router-dom';
+import Sidebar from '../../components/layout/Sidebar';
+import Postcard from '../../components/domain/Postcard';
+import * as S from './styles';
+import { FiMoreHorizontal } from 'react-icons/fi';
+import { useEffect, useState, useRef } from 'react';
+import { GetCommunityById, JoinCommunity, DeleteCommunity, LeaveCommunity } from '../../API/Community';
+import type { NotificationState } from '../../components/common/Toast';
+import Toast from '../../components/common/Toast';
+import type { CommunityProps } from '../../API/Community';
+import * as D from '../../components/common/Dropdown/styles';
+import Modal from '../../components/common/Modal';
+import * as ModalS from '../../components/common/Modal/styles';
+
+export default function CommunityPage() {
+ const { communityID } = useParams<{ communityID: string }>();
+ const navigate = useNavigate();
+
+ const [notification, setNotification] = useState<NotificationState | null>(null);
+ const [community, setCommunity] = useState<any>(null);
+ const [posts, setPosts] = useState<any[]>([]);
+
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+ const menuRef = useRef<HTMLDivElement>(null);
+
+ const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
+
+ useEffect(() => {
+ async function loadData() {
+ console.log("Carregando dados da comunidade para ID:", communityID);
+ Iif (!communityID) return;
+ try {
+ const data = await GetCommunityById(communityID);
+ setCommunity(data.community);
+ setPosts(data.posts);
+ } catch (error) {
+ console.error(error);
+ }
+ }
+ loadData();
+ }, [communityID]);
+
+ useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
+ setIsMenuOpen(false);
+ }
+ }
+ if (isMenuOpen) document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, [isMenuOpen]);
+
+ const handleJoin = async () => {
+ Iif (!community) return;
+
+ try {
+ await JoinCommunity(community.communityID);
+
+ // Atualiza a interface após entrar na comunidade
+ setCommunity((prev: CommunityProps | null) => prev ? ({
+ ...prev,
+ isMember: true, // Esconde o botão
+ memberCount: (prev.memberCount || 0) + 1 // Atualiza o contador visualmente
+ }) : null);
+
+ setNotification({ message: 'Você entrou na comunidade!', type: 'success' });
+
+ } catch (error) {
+ if (error instanceof Error) {
+ setNotification({ message: error.message, type: 'error' });
+ }
+ }
+ };
+
+ const handleEdit = () => {
+ navigate('/editCommunity', { state: { communityToEdit: community } });
+ };
+
+ const handleDelete = async () => {
+ Iif (!community) return;
+ try {
+ await DeleteCommunity(community.communityID);
+ setNotification({ message: 'Comunidade excluída com sucesso.', type: 'success' });
+ setIsDeleteModalOpen(false);
+
+ // Redireciona para home após excluir
+ setTimeout(() => {
+ navigate('/feed');
+ }, 1500);
+
+ } catch (error) {
+ if (error instanceof Error) {
+ setNotification({ message: error.message, type: 'error' });
+ }
+ setIsDeleteModalOpen(false);
+ }
+ };
+
+ const handleLeave = async () => {
+ Iif (!community) return;
+
+ try {
+ await LeaveCommunity(community.communityID);
+
+ // Atualiza a interface otimisticamente
+ setCommunity((prev: CommunityProps | null) => prev ? ({
+ ...prev,
+ isMember: false, // O usuário não é mais membro
+ memberCount: Math.max((prev.memberCount || 0) - 1, 0) // Decrementa contador
+ }) : null);
+
+ setNotification({ message: 'Você saiu da comunidade.', type: 'success' });
+ setIsLeaveModalOpen(false); // Fecha o modal
+
+ } catch (error) {
+ if (error instanceof Error) {
+ setNotification({ message: error.message, type: 'error' });
+ }
+ setIsLeaveModalOpen(false);
+ }
+ };
+
+ if (!community) return <div>Comunidade não encontrada</div>;
+
+ return (
+ <S.PageWrapper>
+ <Sidebar />
+
+ {notification && (
+ <Toast
+ message={notification.message}
+ type={notification.type}
+ onClose={() => setNotification(null)}
+ />
+ )}
+
+ <S.MainContent>
+ <S.Banner />
+
+ <S.HeaderContainer>
+ <S.Avatar />
+ <S.HeaderInfo>
+ <h1>{community.name}</h1>
+ <span>{community.memberCount} membros</span>
+ </S.HeaderInfo>
+
+ <S.HeaderActions>
+ {!community.isMember && (
+ <S.JoinButton onClick={handleJoin}>
+ Join
+ </S.JoinButton>
+ )}
+ {community.isMember && !community.isAdmin && (
+ <S.LeaveButton onClick={() => setIsLeaveModalOpen(true)}>
+ Sair
+ </S.LeaveButton>
+ )}
+ {community.isAdmin && (
+ <S.MenuWrapper ref={menuRef}>
+ <S.OptionsButton onClick={() => setIsMenuOpen(!isMenuOpen)}>
+ <FiMoreHorizontal />
+ </S.OptionsButton>
+
+ {isMenuOpen && (
+ <D.DropdownMenu>
+ <D.MenuItem onClick={handleEdit}>
+ Editar Comunidade
+ </D.MenuItem>
+ <D.Separator />
+ <D.DangerMenuItem onClick={() => {
+ setIsMenuOpen(false);
+ setIsDeleteModalOpen(true);
+ }}>
+ Excluir Comunidade
+ </D.DangerMenuItem>
+ </D.DropdownMenu>
+ )}
+ </S.MenuWrapper>
+ )}
+ </S.HeaderActions>
+ </S.HeaderContainer>
+
+ <S.ContentGrid>
+ <S.FeedColumn>
+ {posts.map(post => (
+ <S.FeedCardWrapper key={post.projectID || post.id}>
+ <Postcard post={post} showMenu={false} />
+ </S.FeedCardWrapper>
+ ))}
+ </S.FeedColumn>
+
+ <S.InfoSidebar>
+ <h3>DESCRIPTION</h3>
+ <p>{community.description}</p>
+
+ <h3>KEYWORDS</h3>
+ <S.KeywordsContainer>
+ {community.technologies?.map((keyword: string) => (
+ <S.KeywordTag key={keyword}>{keyword}</S.KeywordTag>
+ ))}
+ </S.KeywordsContainer>
+ </S.InfoSidebar>
+ </S.ContentGrid>
+ </S.MainContent>
+
+ <Modal
+ isOpen={isDeleteModalOpen}
+ onClose={() => setIsDeleteModalOpen(false)}
+ title="Excluir Comunidade"
+ >
+ <div style={{ textAlign: 'center' }}>
+ <p style={{ marginBottom: '24px', color: '#555' }}>
+ Tem certeza que deseja excluir a comunidade <strong>{community.name}</strong>?<br/>
+ Todos os posts e vínculos serão removidos.
+ </p>
+ <ModalS.ModalActions>
+ <ModalS.ChoiceButton onClick={() => setIsDeleteModalOpen(false)}>
+ Cancelar
+ </ModalS.ChoiceButton>
+ <ModalS.ChoiceButton onClick={handleDelete} style={{ backgroundColor: '#e74c3c' }}>
+ Excluir
+ </ModalS.ChoiceButton>
+ </ModalS.ModalActions>
+ </div>
+ </Modal>
+
+ <Modal
+ isOpen={isLeaveModalOpen}
+ onClose={() => setIsLeaveModalOpen(false)}
+ title="Sair da Comunidade"
+ >
+ <div style={{ textAlign: 'center' }}>
+ <p style={{ marginBottom: '24px', color: '#555' }}>
+ Tem certeza que deseja sair da comunidade <strong>{community.name}</strong>?
+ </p>
+ <ModalS.ModalActions>
+ <ModalS.ChoiceButton onClick={() => setIsLeaveModalOpen(false)}>
+ Cancelar
+ </ModalS.ChoiceButton>
+ <ModalS.ChoiceButton
+ onClick={handleLeave}
+ style={{ backgroundColor: '#e74c3c' }} // Botão vermelho
+ >
+ Sair
+ </ModalS.ChoiceButton>
+ </ModalS.ModalActions>
+ </div>
+ </Modal>
+ </S.PageWrapper>
+ );
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 | + +1x + + +10x + + +1x + + + + + + + + + + + + + + + + + + + +1x + + +10x + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + +10x +10x + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + +10x + + + + + +10x + + + + + + + + + + + + + + + + +10x + + + + + +10x + + + +10x + + + + + + + + + + + + + + +1x + + + + + + +1x + + +3x +3x +3x + + + + +3x + + + + + + + + + + + + + + + + + + + + + +3x +3x + + + + + + + +1x + + +3x + +3x + + + + + + + + + + + + + + +3x + + + + + + + + + + + + +3x +3x +3x + + + + + + + + + + + + + +1x + + + +4x +4x +4x + + + + + + + + + +4x +4x + +4x + + + + + + + + + +1x + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + +1x +10x + + + + + + + + + + + + + +1x +10x +10x + + + + + + + + + + + + + + + + + + + + + + + + +10x + + + + + + + + + + + + + + + + + + +10x + + + + + + + + + +10x + + + + + + + + + + + +1x + + + + + + +1x +10x +10x + + + + +10x + + + + +10x + +10x + + + +1x + + + + + | import styled from 'styled-components';
+
+export const PageWrapper = styled.div`
+ display: flex;
+ min-height: 100vh;
+ background: linear-gradient(135deg, ${props => props.theme.white} 0%, ${props => props.theme['gray-100'] || props.theme['gray-100']} 100%);
+`;
+
+export const MainContent = styled.main`
+ flex: 1;
+ margin-left: 250px;
+ display: flex;
+ flex-direction: column;
+
+ animation: fadeIn 0.5s ease-out;
+
+ @keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+ }
+`;
+
+// --- Área do Topo (Banner + Avatar + Título) ---
+
+export const Banner = styled.div`
+ width: 100%;
+ height: 220px;
+ background: linear-gradient(135deg, ${props => props.theme.button} 0%, ${props => props.theme['hover-button'] || props.theme.placeholder} 100%);
+ position: relative;
+ overflow: hidden;
+
+ /* Padrão decorativo com formas geométricas */
+ &::before {
+ content: '';
+ position: absolute;
+ top: -50%;
+ right: -10%;
+ width: 600px;
+ height: 600px;
+ background: radial-gradient(circle, rgba(255, 255, 255, 0.15) 0%, transparent 70%);
+ border-radius: 50%;
+ }
+
+ &::after {
+ content: '';
+ position: absolute;
+ bottom: -30%;
+ left: -5%;
+ width: 400px;
+ height: 400px;
+ background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
+ border-radius: 50%;
+ }
+`;
+
+export const HeaderContainer = styled.div`
+ display: flex;
+ align-items: flex-end;
+ padding: 0 40px;
+ margin-top: -60px;
+ margin-bottom: 32px;
+ gap: 24px;
+ position: relative;
+ z-index: 2;
+ flex-wrap: wrap;
+
+ animation: slideUp 0.6s ease-out;
+
+ @keyframes slideUp {
+ from {
+ opacity: 0;
+ transform: translateY(30px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+`;
+
+export const Avatar = styled.div`
+ width: 130px;
+ height: 130px;
+ border-radius: 24px;
+ background: linear-gradient(135deg, ${props => props.theme.sidebar} 0%, ${props => props.theme.button} 100%);
+ border: 5px solid ${props => props.theme.white};
+ flex-shrink: 0;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2),
+ 0 4px 12px rgba(0, 0, 0, 0.15);
+ position: relative;
+
+ /* Efeito de brilho */
+ &::after {
+ content: '';
+ position: absolute;
+ top: 15%;
+ left: 15%;
+ width: 35%;
+ height: 35%;
+ background: radial-gradient(circle, rgba(255, 255, 255, 0.4) 0%, transparent 70%);
+ border-radius: 50%;
+ }
+
+ transition: transform 0.3s ease, box-shadow 0.3s ease;
+
+ &:hover {
+ transform: scale(1.05) rotate(2deg);
+ box-shadow: 0 15px 40px rgba(0, 0, 0, 0.25),
+ 0 6px 16px rgba(0, 0, 0, 0.2);
+ }
+`;
+
+export const HeaderInfo = styled.div`
+ display: flex;
+ flex-direction: column;
+ padding-bottom: 12px;
+ flex: 1;
+ gap: 14px;
+ min-width: 0;
+
+ h1 {
+ font-size: 2.8rem;
+ font-weight: 800;
+ color: ${props => props.theme.black};
+ margin: 0;
+ margin-bottom: 8px;
+ line-height: 1.1;
+ letter-spacing: -0.02em;
+
+ background: linear-gradient(135deg, ${props => props.theme.white} 0%, ${props => props.theme.button} 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+
+ position: relative;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+
+ /* Underline decorativo */
+ &::after {
+ content: '';
+ position: absolute;
+ bottom: -10px;
+ left: 0;
+ width: 80px;
+ height: 5px;
+ background: linear-gradient(90deg, ${props => props.theme.button} 0%, transparent 100%);
+ border-radius: 3px;
+ }
+ }
+
+ span {
+ color: ${props => props.theme['gray-600'] || props.theme['gray-500']};
+ font-size: 1rem;
+ font-weight: 600;
+ padding: 6px 14px;
+ background: ${props => props.theme['gray-100']};
+ border-radius: 20px;
+ width: fit-content;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+
+ /* Ícone decorativo */
+ &::before {
+ content: '👥';
+ font-size: 0.9em;
+ }
+ }
+`;
+
+export const HeaderActions = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding-bottom: 15px;
+`;
+
+export const JoinButton = styled.button`
+ padding: 12px 32px;
+ border-radius: 12px;
+ border: 2px solid ${props => props.theme.button};
+ background: ${props => props.theme.button};
+ color: ${props => props.theme.white};
+ font-weight: 700;
+ font-size: 1rem;
+ cursor: pointer;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ box-shadow: 0 4px 12px ${props => props.theme.button}40;
+ position: relative;
+ overflow: hidden;
+
+ /* Efeito de brilho deslizante */
+ &::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: -100%;
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
+ transition: left 0.5s;
+ }
+
+ &:hover::before {
+ left: 100%;
+ }
+
+ &:hover {
+ transform: translateY(-3px);
+ box-shadow: 0 8px 20px ${props => props.theme.button}50;
+ background: ${props => props.theme['hover-button'] || props.theme.button};
+ }
+
+ &:active {
+ transform: translateY(-1px);
+ }
+`;
+
+export const LeaveButton = styled.button`
+ padding: 12px 32px;
+ border-radius: 12px;
+ border: 2px solid ${props => props.theme['red-500']};
+ background: transparent;
+ color: ${props => props.theme['red-500']};
+ font-weight: 700;
+ font-size: 1rem;
+ cursor: pointer;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ position: relative;
+ overflow: hidden;
+
+ &::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 0;
+ height: 0;
+ background: ${props => props.theme['red-500']};
+ border-radius: 50%;
+ transform: translate(-50%, -50%);
+ transition: width 0.4s, height 0.4s;
+ z-index: -1;
+ }
+
+ &:hover::before {
+ width: 300%;
+ height: 300%;
+ }
+
+ &:hover {
+ color: ${props => props.theme.white};
+ border-color: ${props => props.theme['red-500']};
+ box-shadow: 0 4px 16px ${props => props.theme['red-500']}40;
+ transform: translateY(-2px);
+ }
+
+ &:active {
+ transform: translateY(0);
+ }
+
+ > * {
+ position: relative;
+ z-index: 1;
+ }
+`;
+
+export const OptionsButton = styled.button`
+ width: 48px;
+ height: 48px;
+ border-radius: 12px;
+ border: 2px solid ${props => props.theme.button};
+ background: ${props => props.theme.white};
+ color: ${props => props.theme.button};
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ font-size: 1.3rem;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+
+ &:hover {
+ background-color: ${props => props.theme.button};
+ color: ${props => props.theme.white};
+ transform: scale(1.1) rotate(90deg);
+ box-shadow: 0 4px 16px ${props => props.theme.button}30;
+ }
+
+ &:active {
+ transform: scale(1.05) rotate(90deg);
+ }
+`;
+
+// --- Grid de Conteúdo (Feed + Info Lateral) ---
+
+export const ContentGrid = styled.div`
+ display: grid;
+ grid-template-columns: 1fr 340px;
+ gap: 28px;
+ padding: 0 40px 40px 40px;
+ max-width: 1400px;
+ width: 100%;
+ box-sizing: border-box;
+
+ @media (max-width: 900px) {
+ grid-template-columns: 1fr;
+ }
+`;
+
+export const FeedColumn = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+
+ animation: slideInLeft 0.5s ease-out;
+
+ @keyframes slideInLeft {
+ from {
+ opacity: 0;
+ transform: translateX(-20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+ }
+`;
+
+export const FeedCardWrapper = styled.div`
+ background: linear-gradient(135deg, ${props => props.theme.background} 0%, ${props => props.theme['gray-100'] || props.theme.background} 100%);
+ border-radius: 18px;
+ padding: 10px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
+ transition: all 0.3s ease;
+
+ &:hover {
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
+ transform: translateY(-2px);
+ }
+`;
+
+// --- Sidebar de Informações (Direita) ---
+
+export const InfoSidebar = styled.aside`
+ background: ${props => props.theme.white};
+ border: 2px solid ${props => props.theme.button}25;
+ border-radius: 20px;
+ padding: 28px;
+ height: fit-content;
+ position: sticky;
+ top: 20px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
+ overflow-wrap: break-word;
+ word-wrap: break-word;
+ word-break: break-word;
+
+ animation: slideInRight 0.5s ease-out;
+
+ @keyframes slideInRight {
+ from {
+ opacity: 0;
+ transform: translateX(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+ }
+
+ h3 {
+ color: ${props => props.theme.title};
+ font-size: 0.85rem;
+ font-weight: 800;
+ letter-spacing: 1.5px;
+ text-transform: uppercase;
+ margin-bottom: 16px;
+ margin-top: 28px;
+ position: relative;
+ padding-left: 12px;
+
+ /* Barra lateral decorativa */
+ &::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 4px;
+ height: 18px;
+ background: linear-gradient(180deg, ${props => props.theme.button} 0%, ${props => props.theme['hover-button'] || props.theme.button} 100%);
+ border-radius: 2px;
+ }
+ }
+
+ h3:first-child {
+ margin-top: 0;
+ }
+
+ p {
+ color: ${props => props.theme['gray-700'] || props.theme.black};
+ font-size: 0.95rem;
+ line-height: 1.7;
+ margin-bottom: 16px;
+ padding-left: 12px;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ word-break: break-word;
+ white-space: pre-wrap;
+ }
+`;
+
+export const KeywordsContainer = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ padding-left: 12px;
+`;
+
+export const KeywordTag = styled.span`
+ border: 2px solid ${props => props.theme.keyword};
+ color: ${props => props.theme.keyword};
+ border-radius: 10px;
+ padding: 8px 18px;
+ font-size: 0.85rem;
+ font-weight: 700;
+ background: ${props => props.theme.keyword}10;
+ transition: all 0.2s ease;
+ cursor: default;
+
+ &:hover {
+ background: ${props => props.theme.keyword}20;
+ transform: translateY(-2px);
+ box-shadow: 0 4px 8px ${props => props.theme.keyword}20;
+ }
+`;
+
+export const MenuWrapper = styled.div`
+ position: relative;
+ display: flex;
+ align-items: center;
+ z-index: 10;
+`; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 81.81% | +27/33 | +90.9% | +20/22 | +71.42% | +5/7 | +81.81% | +27/33 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 | + + + + + + + + + + + + + +7x +7x + +7x +7x + +7x + + + + + + + + + +7x +7x +7x +7x +7x + +7x + +2x +2x +2x + + + + +2x + + +7x +2x +2x + +1x +1x +1x + + +1x +1x +1x + + +2x + + + + + + + + + + + + +7x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +10x + + + + + + + + + + + + + + + + + | import {useState, useEffect} from 'react';
+import { useNavigate, useLocation } from 'react-router-dom';
+import { useForm, Controller } from 'react-hook-form';
+import Sidebar from '../../components/layout/Sidebar';
+import { PageWrapper, ContentWrapper } from '../Feed/styles';
+import * as S from '../../components/domain/CreationForm/styles';
+import TagInput from '../../components/domain/TagInput';
+import type { NotificationState } from '../../components/common/Toast';
+import { GetKeywords } from '../../API/Keywords';
+import Toast from '../../components/common/Toast';
+import type { CommunityProps } from '../../API/Community';
+import { NewCommunity, UpdateCommunity } from '../../API/Community';
+
+export default function CreateCommunity() {
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ const communityToEdit = location.state?.communityToEdit as CommunityProps | undefined;
+ const isEditMode = !!communityToEdit;
+
+ const { register, handleSubmit, control, watch } = useForm<CommunityProps>({
+ // Preenche os valores iniciais com os dados da comunidade se estiver editando
+ defaultValues: {
+ communityID: communityToEdit?.communityID || "",
+ name: communityToEdit?.name || "",
+ description: communityToEdit?.description || "",
+ technologies: communityToEdit?.technologies || []
+ }
+ });
+
+ const descriptionValue = watch('description');
+ const descriptionLength = descriptionValue ? descriptionValue.length : 0;
+ const MAX_CHARS = 500;
+ const [notification, setNotification] = useState<NotificationState | null>(null);
+ const [keywords, setKeywords] = useState<string[]>([]);
+
+ useEffect(() => {
+ async function loadTechs() {
+ try {
+ const techsFromDB = await GetKeywords();
+ setKeywords(techsFromDB);
+ } catch (error) {
+ console.error("Falha ao carregar tecnologias:", error);
+ }
+ }
+ loadTechs();
+ }, []);
+
+ const onSubmit = (data: CommunityProps) => {
+ try{
+ if (isEditMode && communityToEdit) {
+ // --- MODO EDIÇÃO ---
+ console.log("Atualizando Comunidade:", communityToEdit.communityID, data);
+ UpdateCommunity(communityToEdit.communityID, data);
+ setNotification({ message: 'Comunidade atualizada com sucesso!', type: 'success' });
+ } else {
+ // --- MODO CRIAÇÃO ---
+ console.log("Criando Comunidade:", data);
+ NewCommunity(data);
+ setNotification({ message: 'Comunidade criada com sucesso!', type: 'success' });
+ }
+
+ setTimeout(() => {
+ navigate('/feed');
+ }, 1000);
+
+ } catch (error) {
+ console.error('Erro ao criar comunidade:', error);
+ if (error instanceof Error){
+ setNotification({ message: error.message, type: 'error' });
+ }
+ }
+
+ };
+
+ return (
+ <PageWrapper>
+ <Sidebar />
+
+ {notification && (
+ <Toast
+ message={notification.message}
+ type={notification.type}
+ onClose={() => setNotification(null)}
+ />
+ )}
+
+ <ContentWrapper>
+ <S.FormContainer onSubmit={handleSubmit(onSubmit)}>
+
+ <h2>{isEditMode ? 'Editar Comunidade' : 'Criar Comunidade'}</h2>
+
+ <S.InputGroup>
+ <S.Label htmlFor="name">Nome da Comunidade</S.Label>
+ <S.Input id="name" {...register('name', { required: true })} />
+ </S.InputGroup>
+
+ <S.InputGroup>
+ <S.Label htmlFor="description">Descrição do Projeto</S.Label>
+ <S.TextArea
+ id="description"
+ placeholder="Descreva sua comunidade..."
+ maxLength={MAX_CHARS}
+ {...register('description', {
+ maxLength: {
+ value: MAX_CHARS,
+ message: `A descrição não pode exceder ${MAX_CHARS} caracteres`
+ }
+ })}
+ />
+ <S.CharacterCount>
+ {descriptionLength} / {MAX_CHARS}
+ </S.CharacterCount>
+ </S.InputGroup>
+
+ <S.InputGroup>
+ <S.Label htmlFor="keywords">Palavras-chave</S.Label>
+
+ <Controller
+ name="technologies"
+ control={control}
+ render={({ field }) => (
+ <TagInput
+ value={field.value}
+ onChange={field.onChange}
+ searchList={keywords}
+ limit={10}
+ placeholder="Adicione até 10 palavras-chave..."
+ />
+ )}
+ />
+ </S.InputGroup>
+
+ <S.SubmitButton type="submit">{isEditMode ? 'Salvar Alterações' : 'Criar Comunidade'}</S.SubmitButton>
+
+ </S.FormContainer>
+ </ContentWrapper>
+ </PageWrapper>
+ );
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 84.44% | +38/45 | +87.09% | +27/31 | +80% | +8/10 | +86.04% | +37/43 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 | + + + + + + + + + + + + + + + + + + + +13x +13x +13x + +13x + + +13x + +13x + +13x + + + +13x +13x +6x + +6x +6x + + +13x + + + + + + + + + + +13x +13x +13x +13x + + +13x + +4x +4x +4x + + + + +4x + + + +13x + +2x + + + + +2x +2x + +1x +1x +1x + + +1x +1x +1x + + + +2x + + + + + + + + + + + +13x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +15x + + + + + +2x + + + + + + + + + + + + +15x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { useState, useEffect } from 'react';
+import { useForm, Controller } from 'react-hook-form';
+import Sidebar from '../../components/layout/Sidebar';
+import { PageWrapper, ContentWrapper } from '../Feed/styles';
+import * as S from '../../components/domain/CreationForm/styles';
+import TagInput from '../../components/domain/TagInput';
+import { IMaskInput } from 'react-imask';
+import { NewProject, UpdateProject } from '../../API/Project';
+import type { ProjectProps } from '../../API/Project';
+import Toast from '../../components/common/Toast';
+import { useNavigate, useLocation, useParams } from 'react-router-dom';
+import type { NotificationState } from '../../components/common/Toast';
+import { GetKeywords } from '../../API/Keywords';
+import { parseDate } from '../../API/Project';
+
+interface ProjectFormProps extends Omit<ProjectProps, 'startDate'> {
+ startDate: string;
+}
+
+export default function CreateProject() {
+ const navigate = useNavigate();
+ const location = useLocation(); // Hook para ler o state
+ const [keywords, setKeywords] = useState<string[]>([]);
+
+ const { projectId: paramId } = useParams<{ projectId?: string }>();
+
+ // Recupera o projeto do estado
+ const projectToEdit = location.state?.projectToEdit as (ProjectProps & { id?: string; projectID?: string }) | undefined;
+ // Define se é modo edição
+ const isEditMode = !!projectToEdit;
+
+ const validProjectId = projectToEdit?.id
+ || projectToEdit?.projectID
+ || (paramId !== 'undefined' ? paramId : undefined);
+
+ const formatDateToString = (date?: Date | string) => {
+ if (!date) return "";
+ const d = new Date(date);
+ // Verifica se é data válida
+ Iif (isNaN(d.getTime())) return "";
+ return d.toLocaleDateString('pt-BR'); // Retorna "20/11/2025"
+ };
+
+ const { register, handleSubmit, control, watch } = useForm<ProjectFormProps>({
+ // Preenche os valores padrão se estiver em modo de edição
+ defaultValues: {
+ title: projectToEdit?.title || "",
+ description: projectToEdit?.description || "",
+ technologies: projectToEdit?.technologies || [],
+ status: projectToEdit?.status || "",
+ startDate: formatDateToString(projectToEdit?.startDate)
+ }
+ });
+
+ const descriptionValue = watch('description');
+ const descriptionLength = descriptionValue ? descriptionValue.length : 0;
+ const MAX_CHARS = 2500;
+ const [notification, setNotification] = useState<NotificationState | null>(null);
+
+ // Carrega as tecnologias disponíveis do backend
+ useEffect(() => {
+ async function loadTechs() {
+ try {
+ const techsFromDB = await GetKeywords();
+ setKeywords(techsFromDB);
+ } catch (error) {
+ console.error("Falha ao carregar tecnologias:", error);
+ }
+ }
+ loadTechs();
+ }, []);
+
+ // Lida com CRIAR ou ATUALIZAR
+ const onSubmit = (data: ProjectFormProps) => {
+
+ const finalData: ProjectProps = {
+ ...data,
+ startDate: parseDate(data.startDate)
+ };
+
+ try {
+ if (isEditMode && validProjectId) {
+ // MODO DE EDIÇÃO
+ console.log("Atualizando Projeto:", validProjectId, finalData);
+ UpdateProject(validProjectId, finalData);
+ setNotification({ message: 'Projeto atualizado com sucesso!', type: 'success' });
+ } else {
+ // MODO DE CRIAÇÃO
+ console.log("Criando Projeto:", finalData);
+ NewProject(finalData);
+ setNotification({ message: 'Projeto criado com sucesso!', type: 'success' });
+ }
+
+ // Redireciona após o sucesso
+ setTimeout(() => {
+ navigate('/feed');
+ }, 1000);
+
+ } catch(error) {
+ console.error('Erro ao salvar projeto:', error);
+ if (error instanceof Error){
+ setNotification({ message: error.message, type: 'error' });
+ }
+ }
+ };
+
+ return (
+ <PageWrapper>
+ <Sidebar />
+ {notification && (
+ <Toast
+ message={notification.message}
+ type={notification.type}
+ onClose={() => setNotification(null)}
+ />
+ )}
+
+ <ContentWrapper>
+ <S.FormContainer onSubmit={handleSubmit(onSubmit)}>
+
+ <h2>{isEditMode ? 'Editar Projeto' : 'Criar Projeto'}</h2>
+
+ <S.InputGroup>
+ <S.Label htmlFor="title">Nome do Projeto</S.Label>
+ <S.Input id="title" {...register('title', { required: true })} />
+ </S.InputGroup>
+
+ <S.InputGroup>
+ <S.Label htmlFor="description">Descrição do Projeto</S.Label>
+ <S.TextArea
+ id="description"
+ placeholder="Descreva seu projeto..."
+ maxLength={MAX_CHARS}
+ {...register('description', {
+ maxLength: {
+ value: MAX_CHARS,
+ message: `A descrição não pode exceder ${MAX_CHARS} caracteres`
+ }
+ })}
+ />
+ <S.CharacterCount>
+ {descriptionLength} / {MAX_CHARS}
+ </S.CharacterCount>
+ </S.InputGroup>
+
+ <S.InputGroup>
+ <S.Label htmlFor="startDate">Data de Início</S.Label>
+ <Controller
+ name="startDate"
+ control={control}
+ render={({ field: { onChange, value } }) => (
+ <S.Input
+ as={IMaskInput}
+ mask="00/00/0000"
+ id="startDate"
+ placeholder="DD/MM/AAAA"
+ value={value}
+ onAccept={(value: string) => onChange(value)}
+ disabled={isEditMode}
+ />
+ )}
+ />
+ </S.InputGroup>
+
+ <S.InputGroup>
+ <S.Label htmlFor="technologies">Tecnologias (Palavras-chave)</S.Label>
+ <Controller
+ name="technologies"
+ control={control}
+ render={({ field }) => (
+ <TagInput
+ value={field.value}
+ onChange={field.onChange}
+ searchList={keywords}
+ limit={6}
+ placeholder="Adicione até 6 tecnologias..."
+ />
+ )}
+ />
+ </S.InputGroup>
+
+ <S.InputGroup>
+ <S.Label htmlFor="status">Status</S.Label>
+ <S.SelectWrapper>
+ <S.Select
+ id="status"
+ {...register('status', { required: true })}
+ required
+ >
+ <option value="" disabled>Selecione um status...</option>
+ <option value="em-andamento">Em andamento</option>
+ <option value="pausado">Pausado</option>
+ <option value="finalizado">Finalizado</option>
+ </S.Select>
+ </S.SelectWrapper>
+ </S.InputGroup>
+
+ {/* 10. Botão de submit dinâmico */}
+ <S.SubmitButton type="submit">
+ {isEditMode ? 'Atualizar Projeto' : 'Criar Projeto'}
+ </S.SubmitButton>
+
+ </S.FormContainer>
+ </ContentWrapper>
+ </PageWrapper>
+ );
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 87.09% | +27/31 | +55.55% | +10/18 | +66.66% | +4/6 | +93.1% | +27/29 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 | + + + + + + + + + + + + +8x +8x +8x + + +8x +3x +3x +3x +3x + + +8x + + +8x +3x +3x +3x +3x +3x +3x + + + +8x +2x +2x +2x + +1x + +1x + + +1x + + + + +1x +1x +1x + + + + +8x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { useState, useEffect } from 'react';
+import { useForm } from 'react-hook-form';
+import { useNavigate } from 'react-router-dom';
+import Sidebar from '../../components/layout/Sidebar';
+import { PageWrapper, ContentWrapper } from '../Feed/styles';
+import * as S from '../../components/domain/CreationForm/styles';
+import Toast from '../../components/common/Toast';
+import type { NotificationState } from '../../components/common/Toast';
+import { useAuth } from '../../API/AuthContext';
+import { UpdateProfile } from '../../API/User';
+import type { UserProfileData } from '../../API/User';
+
+export default function EditProfile() {
+ const { currentUser, updateUser } = useAuth();
+ const navigate = useNavigate();
+ const [notification, setNotification] = useState<NotificationState | null>(null);
+
+ // Formata a data para o input (YYYY-MM-DD)
+const formatDateForInput = (dateString?: string) => {
+ Iif (!dateString) return "";
+ const date = new Date(dateString);
+ Iif (isNaN(date.getTime())) return "";
+ return date.toISOString().split('T')[0];
+};
+
+ const { register, handleSubmit, setValue } = useForm<UserProfileData>();
+
+ // Preenche o formulário com os dados atuais
+ useEffect(() => {
+ Eif (currentUser) {
+ setValue('nomeCompleto', currentUser.nomeCompleto || '');
+ setValue('username', currentUser.username || '');
+ setValue('email', currentUser.email || '');
+ setValue('telefone', (currentUser as any).phone || '');
+ setValue('dataNascimento', formatDateForInput((currentUser as any).birthDate));
+ }
+ }, [currentUser, setValue]);
+
+ const onSubmit = async (data: UserProfileData) => {
+ try {
+ console.log("Atualizando perfil:", data);
+ await UpdateProfile(data);
+
+ setNotification({ message: 'Perfil atualizado com sucesso!', type: 'success' });
+
+ updateUser(data);
+
+ // Redireciona
+ setTimeout(() => {
+ navigate('/profile');
+ }, 1000);
+
+ } catch (error) {
+ console.error(error);
+ Eif (error instanceof Error) {
+ setNotification({ message: error.message, type: 'error' });
+ }
+ }
+ };
+
+ return (
+ <PageWrapper>
+ <Sidebar />
+ {notification && (
+ <Toast
+ message={notification.message}
+ type={notification.type}
+ onClose={() => setNotification(null)}
+ />
+ )}
+
+ <ContentWrapper>
+ <S.FormContainer onSubmit={handleSubmit(onSubmit)}>
+ <h2>Editar Perfil</h2>
+
+ <S.InputGroup>
+ <S.Label htmlFor="nomeCompleto">Nome Completo</S.Label>
+ <S.Input
+ id="nomeCompleto"
+ {...register('nomeCompleto', { required: true })}
+ />
+ </S.InputGroup>
+
+ <S.InputGroup>
+ <S.Label htmlFor="username">Nome de Usuário</S.Label>
+ <S.Input
+ id="username"
+ {...register('username', { required: true })}
+ />
+ </S.InputGroup>
+
+ <S.InputGroup>
+ <S.Label htmlFor="email">Email</S.Label>
+ <S.Input
+ id="email"
+ type="email"
+ {...register('email', { required: true })}
+ />
+ </S.InputGroup>
+
+ <S.InputGroup>
+ <S.Label htmlFor="telefone">Telefone</S.Label>
+ <S.Input
+ id="telefone"
+ {...register('telefone')}
+ />
+ </S.InputGroup>
+
+ <S.InputGroup>
+ <S.Label htmlFor="dataNascimento">Data de Nascimento</S.Label>
+ <S.Input
+ id="dataNascimento"
+ type="date"
+ {...register('dataNascimento')}
+ />
+ </S.InputGroup>
+
+ <S.SubmitButton type="submit">
+ Salvar Alterações
+ </S.SubmitButton>
+
+ </S.FormContainer>
+ </ContentWrapper>
+ </PageWrapper>
+ );
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ ++ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 | + + + + + + + + + + + + + +18x +18x +18x +18x +18x + +18x + +6x +6x +6x +5x + +1x +1x +1x + + +6x + + +6x + + +18x +1x +1x + + +18x +1x +1x + + +18x + + + + + + + + + +2x + + + + + + + + + + + + +2x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import { useState, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import Modal from '../../components/common/Modal';
+import Postcard from '../../components/domain/Postcard';
+import Header from '../../components/layout/Header';
+import Sidebar from '../../components/layout/Sidebar';
+import * as S from './styles';
+import * as ModalS from '../../components/common/Modal/styles';
+import { GetFeedProjects } from '../../API/Project';
+import type { ProjectProps } from '../../API/Project';
+import Toast from '../../components/common/Toast';
+import type { NotificationState } from '../../components/common/Toast';
+
+export default function Feed() {
+ const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
+ const [isLoading, setIsLoading] = useState(true);
+ const navigate = useNavigate();
+ const [posts, setPosts] = useState<ProjectProps[]>([]);
+ const [notification, setNotification] = useState<NotificationState | null>(null);
+
+ useEffect(() => {
+ async function loadFeed() {
+ setIsLoading(true);
+ try {
+ const feedData = await GetFeedProjects();
+ setPosts(feedData || []);
+ } catch (error) {
+ console.error("Erro ao carregar feed:", error);
+ Eif (error instanceof Error) {
+ setNotification({ message: "Não foi possível carregar o feed.", type: 'error' });
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ }
+ loadFeed();
+ }, []);
+
+ const handleCreateProject = () => {
+ setIsCreateModalOpen(false);
+ navigate('/createProject');
+ };
+
+ const handleCreateCommunity = () => {
+ setIsCreateModalOpen(false);
+ navigate('/createCommunity');
+ };
+
+ return (
+ <S.PageWrapper>
+ {notification && (
+ <Toast
+ message={notification.message}
+ type={notification.type}
+ onClose={() => setNotification(null)}
+ />
+ )}
+
+ <Header onCreateClick={() => setIsCreateModalOpen(true)}/>
+ <Sidebar />
+
+ <S.ContentWrapper>
+ <S.FeedContainer>
+ <S.PostList>
+ {isLoading ? (
+ <S.LoadingContainer>
+ <div className="spinner"></div>
+ <p>Carregando feed...</p>
+ </S.LoadingContainer>
+ ) : posts.length > 0 ? (
+ posts.map((post, index) => (
+ <Postcard
+ key={(post as unknown as ProjectProps).id || (post as any).projectID || index}
+ post={post}
+ showMenu={false}
+ />
+ ))
+ ) : (
+ <S.EmptyFeedMessage>
+ <svg
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
+ <line x1="9" y1="9" x2="15" y2="15"/>
+ <line x1="15" y1="9" x2="9" y2="15"/>
+ </svg>
+ <h3>Nenhum post encontrado</h3>
+ <p style={{ color: '#ccc', textAlign: 'center', marginTop: '20px' }}>
+ Nenhum post encontrado. Entre em comunidades para ver atualizações!
+ </p>
+ </S.EmptyFeedMessage>
+ )}
+ </S.PostList>
+ </S.FeedContainer>
+ </S.ContentWrapper>
+
+ <Modal
+ isOpen={isCreateModalOpen}
+ onClose={() => setIsCreateModalOpen(false)}
+ title="O que você deseja criar?"
+ >
+ <ModalS.ModalActions>
+ <ModalS.ChoiceButton onClick={handleCreateProject}>
+ Criar Projeto
+ </ModalS.ChoiceButton>
+ <ModalS.ChoiceButton onClick={handleCreateCommunity}>
+ Criar Comunidade
+ </ModalS.ChoiceButton>
+ </ModalS.ModalActions>
+ </Modal>
+ </S.PageWrapper>
+ );
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 | + + +4x +44x + + + + + + + + + + +44x +44x + + + + + + +4x + + + + + + + + + + + + + + + + + + + + + + + + + + +4x + + + +16x + + + + + + +16x + + + + + + + + + + + + + + + + + + + + + + + + +16x + + + + + + + + + +4x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +4x + + +5x + + + + + + + + + + + + + +5x + + + + + + +5x + + + + + + + + +4x + + + + + + + + + + + + + + + + + + + + + + + +4x + + + + + + + + + + +10x +10x + + + + + + + + + +10x + + + | import styled from 'styled-components';
+
+// Wrapper para a página inteira
+export const PageWrapper = styled.div`
+ background: linear-gradient(135deg, ${props => props.theme['gray-100']} 0%, ${props => props.theme['gray-100']}f0 100%);
+ min-height: 100vh;
+ position: relative;
+
+ &::before {
+ content: '';
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: radial-gradient(circle at 20% 50%, ${props => props.theme.keyword}08 0%, transparent 50%),
+ radial-gradient(circle at 80% 80%, ${props => props.theme.button}08 0%, transparent 50%);
+ pointer-events: none;
+ z-index: 0;
+ }
+`;
+
+// Wrapper para o conteúdo principal
+export const ContentWrapper = styled.main`
+ margin-left: 250px;
+ margin-top: 60px;
+ padding: 32px 24px;
+ box-sizing: border-box;
+ position: relative;
+ z-index: 1;
+ animation: fadeIn 0.5s ease-out;
+
+ @keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+
+ @media (max-width: 768px) {
+ margin-left: 0;
+ padding: 20px 16px;
+ }
+`;
+
+// Container principal do feed com posts
+export const FeedContainer = styled.main`
+ width: 100%;
+ max-width: 800px;
+ margin: 0 auto;
+ background: ${props => props.theme.background};
+ backdrop-filter: blur(10px);
+ border-radius: 20px;
+ padding: 28px;
+ box-sizing: border-box;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
+ 0 2px 8px rgba(0, 0, 0, 0.08),
+ 0 0 0 1px ${props => props.theme.placeholder}15;
+ position: relative;
+ transition: all 0.3s ease;
+
+ &::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 3px;
+
+ background-size: 200% 100%;
+ border-radius: 20px 20px 0 0;
+ animation: gradientShift 3s ease infinite;
+ }
+
+ @keyframes gradientShift {
+ 0%, 100% { background-position: 0% 50%; }
+ 50% { background-position: 100% 50%; }
+ }
+
+ &:hover {
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15),
+ 0 4px 12px rgba(0, 0, 0, 0.1),
+ 0 0 0 1px ${props => props.theme.placeholder}25;
+ }
+
+ @media (max-width: 768px) {
+ padding: 20px;
+ border-radius: 16px;
+ }
+`;
+
+// Lista que agrupa os posts
+export const PostList = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ position: relative;
+
+ /* Animação de entrada para cada post */
+ & > * {
+ animation: slideInUp 0.4s ease-out backwards;
+ }
+
+ & > *:nth-child(1) { animation-delay: 0.05s; }
+ & > *:nth-child(2) { animation-delay: 0.1s; }
+ & > *:nth-child(3) { animation-delay: 0.15s; }
+ & > *:nth-child(4) { animation-delay: 0.2s; }
+ & > *:nth-child(5) { animation-delay: 0.25s; }
+
+ @keyframes slideInUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+`;
+
+// Mensagem de feed vazio
+export const EmptyFeedMessage = styled.div`
+ text-align: center;
+ padding: 60px 24px;
+ color: ${props => props.theme.placeholder};
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+
+ svg {
+ width: 80px;
+ height: 80px;
+ opacity: 0.5;
+ margin-bottom: 8px;
+ }
+
+ h3 {
+ color: ${props => props.theme.subtitle};
+ font-size: 1.3rem;
+ font-weight: 600;
+ margin: 0 0 8px 0;
+ }
+
+ p {
+ color: ${props => props.theme.placeholder};
+ font-size: 0.95rem;
+ line-height: 1.5;
+ max-width: 400px;
+ margin: 0;
+ }
+`;
+
+// Header do feed
+export const FeedHeader = styled.div`
+ margin-bottom: 24px;
+ padding-bottom: 20px;
+ border-bottom: 1px solid ${props => props.theme.placeholder}20;
+
+ h1 {
+ color: ${props => props.theme.white};
+ font-size: 1.8rem;
+ font-weight: 700;
+ margin: 0 0 8px 0;
+ background: linear-gradient(135deg, ${props => props.theme.white}, ${props => props.theme.subtitle});
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ }
+
+ p {
+ color: ${props => props.theme.placeholder};
+ font-size: 0.9rem;
+ margin: 0;
+ }
+`;
+
+// Container para loading state
+export const LoadingContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 60px 24px;
+ gap: 16px;
+
+ .spinner {
+ width: 40px;
+ height: 40px;
+ border: 3px solid ${props => props.theme.placeholder}30;
+ border-top-color: ${props => props.theme.keyword};
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+ }
+
+ @keyframes spin {
+ to { transform: rotate(360deg); }
+ }
+
+ p {
+ color: ${props => props.theme.placeholder};
+ font-size: 0.9rem;
+ }
+`; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 100% | +1/1 | +100% | +0/0 | +100% | +1/1 | +100% | +1/1 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 | + + + +1x + + + + + + + + + | import HeaderHome from '../../components/layout/HeaderHome';
+import * as S from './styles';
+
+export default function Home() {
+ return (
+ <S.HomePageContainer>
+ <HeaderHome />
+ <S.ContentWrapper>
+ <S.SiteTitle>CTable</S.SiteTitle>
+ <S.Tagline>Descubra, compartilhe, aprenda, converse: um novo mundo se abre para os amantes de computação.</S.Tagline>
+ </S.ContentWrapper>
+ </S.HomePageContainer>
+ );
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 87.5% | +14/16 | +75% | +3/4 | +50% | +2/4 | +87.5% | +14/16 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 | + + + + + + + + + + + + + + + + + +10x +10x + +10x +10x + + +2x +2x +2x + +1x + + +1x + +1x + + + + +1x + + +1x +1x + + + + +10x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | import {useState} from 'react';
+import {
+ FormPageContainer,
+ FormWrapper,
+ FormTitle,
+ StyledForm,
+ StyledInput,
+ SubmitButton,
+ RedirectLink
+} from '../../components/domain/Form/styles';
+import { useForm} from 'react-hook-form';
+import Toast from '../../components/common/Toast';
+import { useNavigate } from 'react-router-dom';
+import type { LoginProps } from '../../API/Auth';
+import { useAuth } from '../../API/AuthContext';
+import type { NotificationState } from '../../components/common/Toast';
+
+export default function LoginPage() {
+ const [notification, setNotification] = useState<NotificationState | null>(null);
+ const { login } = useAuth();
+
+ const { register, handleSubmit, formState: {isSubmitting} } = useForm<LoginProps>();
+ const navigate = useNavigate();
+
+ async function onSubmit(data: LoginProps) {
+ console.log(data);
+ try{
+ await login(data);
+
+ console.log('Usuário registrado com sucesso');
+
+ // Define estado para mostrar notificação de sucesso
+ setNotification({ message: 'Usuário registrado com sucesso!', type: 'success' });
+
+ setTimeout(() => {
+ navigate('/feed'); // Navega para a página de feed
+ }, 1000);
+
+ } catch (error) {
+ console.error('Erro ao registrar usuário:', error);
+
+ // Define estado para mostrar notificação de erro
+ Eif (error instanceof Error){
+ setNotification({ message: error.message, type: 'error' });
+ }
+ }
+ }
+
+ return (
+ <FormPageContainer>
+ {notification && (
+ <Toast
+ message={notification.message}
+ type={notification.type}
+ onClose={() => setNotification(null)}
+ />
+ )}
+ <FormWrapper>
+ <FormTitle>Login</FormTitle>
+
+ <StyledForm onSubmit={handleSubmit(onSubmit)}>
+ <StyledInput
+ type="text"
+ placeholder="Email ou usuário"
+ required
+ {...register('username')}
+ />
+ <StyledInput
+ type="password"
+ placeholder="Senha"
+ required
+ {...register('senha')}
+ />
+
+ <SubmitButton disabled = {isSubmitting} type="submit">Entrar</SubmitButton>
+ </StyledForm>
+
+ <RedirectLink>
+ Não tem uma conta? <a href="/register">Cadastre-se</a>
+ </RedirectLink>
+ </FormWrapper>
+ </FormPageContainer>
+ );
+}
+
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ ++ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 | + + + + + + + + + + + + + + + + +1x +40x + + + + +1x +40x + + + +1x +40x + + + + + + + + +42x +42x +42x +42x + +42x + + +42x +42x + +42x +42x +42x + +42x + +40x +40x +40x + +40x +40x + +39x + +39x +39x + + +1x + + + +40x +40x + + + +42x + +1x +1x + + +25x +7x + +25x +25x + + + +42x +1x +1x + + +42x +1x + + +1x +1x + + + +42x +1x +1x + + +42x + +3x +3x + +3x +3x +1x + +1x + +1x + + + + + +2x +2x +1x +1x +1x + +1x + +1x + +3x +3x + + + + +42x +42x +37x +35x + + +2x +2x + + + + + +5x + +5x +2x + + + + + + + + + + + + + +1x + + + + + + + + +42x + + + + + + + + + + + + + + + + + + + + + + + + + + +2x + + + + + +7x + + + + + + + + + + + +4x +4x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + | import { useState, useRef, useEffect } from 'react';
+import Sidebar from '../../components/layout/Sidebar';
+import Postcard from '../../components/domain/Postcard';
+import * as S from './styles';
+import * as D from '../../components/common/Dropdown/styles';
+import type { ProjectProps } from '../../API/Project';
+import type { CommentProps } from '../../API/Comment';
+import { GetUserProjects } from '../../API/Project';
+import { GetUserComments, DeleteComment } from '../../API/Comment';
+import { useAuth } from '../../API/AuthContext';
+import { useNavigate } from 'react-router-dom';
+import { DeleteProfile } from '../../API/User';
+import Toast from '../../components/common/Toast';
+import Modal from '../../components/common/Modal';
+import * as ModalS from '../../components/common/Modal/styles';
+
+// --- ÍCONES ---
+const PostsIcon = () => (
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+ <path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
+ <polyline points="14 2 14 8 20 8" /><line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" /><line x1="10" y1="9" x2="8" y2="9" />
+ </svg>
+);
+const CommentsIcon = () => (
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
+ </svg>
+);
+const SettingsIcon = () => (
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+ <circle cx="12" cy="12" r="1" /><circle cx="19" cy="12" r="1" /><circle cx="5" cy="12" r="1" />
+ </svg>
+);
+// -----------------------
+
+type ViewState = 'posts' | 'comments';
+
+export default function Profile() {
+ const { currentUser, logout } = useAuth();
+ const [view, setView] = useState<ViewState>('posts');
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+ const menuRef = useRef<HTMLDivElement>(null);
+
+ const navigate = useNavigate();
+
+ // Estados para os dados da API
+ const [userPosts, setUserPosts] = useState<ProjectProps[]>([]);
+ const [userComments, setUserComments] = useState<CommentProps[]>([]);
+
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [isDeleteProfileModalOpen, setIsDeleteProfileModalOpen] = useState(false);
+ const [notification, setNotification] = useState<{message: string, type: 'success' | 'error'} | null>(null);
+
+ useEffect(() => {
+ // Função assíncrona para buscar todos os dados
+ console.log("Efeito de busca de dados do perfil disparado.");
+ const fetchProfileData = async () => {
+ try {;
+
+ console.log("Usuário atual no Profile:", currentUser);
+ const apiUserPosts = await GetUserProjects();
+
+ const apiUserComments: CommentProps[] = await GetUserComments();
+
+ setUserPosts(apiUserPosts);
+ setUserComments(apiUserComments);
+
+ } catch (error) {
+ console.error("Falha ao buscar dados do perfil:", error);
+ }
+
+ };
+ Eif(currentUser)
+ fetchProfileData();
+ }, [currentUser]);
+
+ // Lógica para fechar o menu ao clicar fora
+ useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ Eif (menuRef.current && !menuRef.current.contains(event.target as Node)) {
+ setIsMenuOpen(false);
+ }
+ }
+ if (isMenuOpen) {
+ document.addEventListener('mousedown', handleClickOutside);
+ }
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [isMenuOpen]);
+
+ const handleLogout = () => {
+ logout(); // Limpa o estado e o localStorage
+ navigate('/login'); // Redireciona para a tela de login
+ };
+
+ const handleDeleteComment = async (commentId: string) => {
+ await DeleteComment(commentId);
+
+ // Remove o comentário deletado do estado local para sumir da tela instantaneamente
+ setUserComments((prevComments) =>
+ prevComments.filter(comment => comment.commentID !== commentId)
+ );
+ };
+
+ const handleEditProfile = () => {
+ setIsMenuOpen(false);
+ navigate('/editProfile');
+ };
+
+ const handleDeleteProfile = async () => {
+
+ Iif (isDeleting) return;
+ setIsDeleting(true);
+
+ try {
+ await DeleteProfile();
+ setNotification({ message: "Perfil excluído com sucesso. Até logo!", type: 'success' });
+
+ setIsDeleteProfileModalOpen(false);
+
+ setTimeout(() => {
+ logout(); // Desloga o usuário e limpa o storage
+ navigate('/login'); // Manda para login
+ }, 2000);
+
+ } catch (error) {
+ Eif (error instanceof Error) {
+ if (error.message.includes("não encontrado") || error.message.includes("not found")) {
+ setNotification({ message: "Conta já encerrada. Redirecionando...", type: 'success' });
+ setTimeout(() => { logout(); navigate('/login'); }, 2000);
+ return;
+ }
+ setNotification({ message: error.message, type: 'error' });
+ }
+ setIsDeleteProfileModalOpen(false);
+ } finally {
+ setIsDeleting(false);
+ setIsDeleteProfileModalOpen(false);
+ }
+ };
+
+ // Função para renderizar o feed (posts ou comentários)
+ const renderFeed = () => {
+ if (view === 'posts') {
+ if (userPosts.length === 0) {
+ return <p style={{ color: '#fff', padding: '20px' }}>Nenhum projeto encontrado.</p>;
+ }
+
+ return userPosts.map((post, index) => (
+ <S.PostContainer key={index}>
+ <Postcard post={post} showMenu={true} />
+ </S.PostContainer>
+ ));
+ }
+
+ Eif (view === 'comments') {
+ // Mapeia 'userComments'
+ return userComments.map(comment => (
+ <S.PostContainer key={comment.commentID || Math.random()}>
+ <Postcard
+ post={{
+ id: comment.commentID,
+ title: `Comentou em: ${comment.projectTitle || 'Projeto'}`,
+ description: comment.content,
+ technologies: [],
+ status: '',
+ startDate: comment.createdAt,
+ // Passa o usuário atual como autor para o cabeçalho do card
+ author: { title: currentUser?.username || 'Você' }
+ } as unknown as ProjectProps}
+ showMenu={true}
+ deleteLabel="Comentário"
+ onDelete={() => handleDeleteComment(comment.commentID!)}
+ />
+ </S.PostContainer>
+ ));
+ }
+
+ return null;
+ };
+
+ return (
+ <S.PageWrapper>
+ <Sidebar />
+
+ {notification && (
+ <Toast
+ message={notification.message}
+ type={notification.type}
+ onClose={() => setNotification(null)}
+ />
+ )}
+
+ <S.ContentWrapper>
+
+ <S.ProfileHeader>
+ <S.ActionsWrapper ref={menuRef}>
+ <S.ProfileActions>
+ <S.IconButton
+ title="Ver publicações"
+ $active={view === 'posts'}
+ onClick={() => setView('posts')}
+ >
+ <PostsIcon />
+ </S.IconButton>
+ <S.IconButton
+ title="Ver comentários"
+ $active={view === 'comments'}
+ onClick={() => setView('comments')}
+ >
+ <CommentsIcon />
+ </S.IconButton>
+ <S.IconButton
+ title="Configurações"
+ onClick={() => setIsMenuOpen(prev => !prev)}
+ >
+ <SettingsIcon />
+ </S.IconButton>
+ </S.ProfileActions>
+
+ {isMenuOpen && (
+ <D.DropdownMenu>
+ <D.MenuItem onClick={handleEditProfile}>Editar Perfil</D.MenuItem>
+ <D.MenuItem onClick={handleLogout}>Sair</D.MenuItem>
+ <D.Separator />
+ <D.DangerMenuItem onClick={() => {
+ setIsMenuOpen(false);
+ setIsDeleteProfileModalOpen(true);
+ }}>
+ Excluir Perfil
+ </D.DangerMenuItem>
+ </D.DropdownMenu>
+ )}
+
+ </S.ActionsWrapper>
+ </S.ProfileHeader>
+
+ <S.ProfileInfo>
+ <S.ProfileAvatar
+ style={{
+ backgroundImage: undefined
+ }}
+ />
+ <S.Username>{currentUser?.username || 'Carregando...'}</S.Username>
+ </S.ProfileInfo>
+
+ <S.PostList>
+ {renderFeed()}
+ </S.PostList>
+
+ </S.ContentWrapper>
+
+ <Modal
+ isOpen={isDeleteProfileModalOpen}
+ onClose={() => !isDeleting && setIsDeleteProfileModalOpen(false)}
+ title="Excluir Conta"
+ >
+ <div style={{ textAlign: 'center' }}>
+ <p style={{ marginBottom: '24px', color: '#555' }}>
+ Tem certeza que deseja excluir sua conta? <br/>
+ <strong>Todos os seus projetos, comunidades e comentários serão apagados permanentemente.</strong>
+ </p>
+ <ModalS.ModalActions>
+ <ModalS.ChoiceButton
+ onClick={() => setIsDeleteProfileModalOpen(false)}
+ disabled={isDeleting}>
+ Cancelar
+ </ModalS.ChoiceButton>
+ <ModalS.ChoiceButton
+ onClick={handleDeleteProfile}
+ style={{ backgroundColor: '#e74c3c' }}
+ disabled={isDeleting}
+ >
+ {isDeleting ? 'Excluindo...' : 'Excluir Conta'}
+ </ModalS.ChoiceButton>
+ </ModalS.ModalActions>
+ </div>
+ </Modal>
+
+ </S.PageWrapper>
+ );
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 | + + +1x +40x + + + + +1x + + + + + + +1x + + + + +3x + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + +1x +40x + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + + +40x +40x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1x + + +40x + + +40x + + + + + + + + + + + + + + + +40x + + + + + +1x + + + + + + + + + + + + + + + +1x + + + + + + + + + + + +120x + + +120x + + + +120x + + + + + + + + + + + + + + +120x + + + + + + + + + + + + + +120x + + +120x + + + + + + + + + + + + + + + + + +120x + + + + +1x + + + | import styled from 'styled-components';
+
+// Wrapper para a página inteira
+export const PageWrapper = styled.div`
+ background: linear-gradient(135deg, ${props => props.theme['gray-100']} 0%, ${props => props.theme['gray-100'] || props.theme['gray-100']} 100%);
+ min-height: 100vh;
+`;
+
+// Wrapper para o conteúdo principal
+export const ContentWrapper = styled.main`
+ margin-left: 250px;
+ box-sizing: border-box;
+ padding-bottom: 60px;
+`;
+
+// Container principal com posts
+export const PostContainer = styled.main`
+ width: 100%;
+ max-width: 800px;
+ margin: 0 auto;
+
+ background-color: ${props => props.theme.background};
+ border-radius: 16px;
+ padding: 24px;
+ box-sizing: border-box;
+
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04),
+ 0 1px 2px rgba(0, 0, 0, 0.06);
+
+ transition: all 0.3s ease;
+
+ &:hover {
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08),
+ 0 2px 4px rgba(0, 0, 0, 0.06);
+ transform: translateY(-2px);
+ }
+`;
+
+// Lista que agrupa os posts do perfil
+export const PostList = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ padding: 32px 24px;
+
+ animation: fadeIn 0.4s ease-out;
+
+ @keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+`;
+
+// Cabeçalho do perfil
+export const ProfileHeader = styled.header`
+ background: linear-gradient(135deg, ${props => props.theme.button} 0%, ${props => props.theme['hover-button'] || props.theme.button} 100%);
+ height: 160px;
+ position: relative;
+ z-index: 0;
+
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
+
+ /* Padrão decorativo sutil */
+ &::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-image:
+ radial-gradient(circle at 20% 50%, rgba(255, 255, 255, 0.1) 0%, transparent 50%),
+ radial-gradient(circle at 80% 80%, rgba(255, 255, 255, 0.1) 0%, transparent 50%);
+ pointer-events: none;
+ }
+
+ /* Posiciona os botões de ação */
+ display: flex;
+ justify-content: flex-end;
+ align-items: flex-end;
+ padding: 20px 32px;
+`;
+
+// Container da foto + nome
+export const ProfileInfo = styled.div`
+ display: flex;
+ align-items: flex-end;
+ gap: 20px;
+
+ margin-top: -70px;
+ margin-left: 32px;
+ padding-bottom: 24px;
+ position: relative;
+ z-index: 1;
+ pointer-events: none;
+
+ animation: slideUp 0.5s ease-out;
+
+ @keyframes slideUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+
+ /* Reativa pointer-events apenas nos elementos internos */
+ > * {
+ pointer-events: auto;
+ }
+`;
+
+// A foto de perfil circular
+export const ProfileAvatar = styled.div`
+ width: 120px;
+ height: 120px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, ${props => props.theme.sidebar} 0%, ${props => props.theme.button} 100%);
+ border: 5px solid ${props => props.theme.background};
+ background-size: cover;
+ background-position: center;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15),
+ 0 2px 8px rgba(0, 0, 0, 0.1);
+
+ position: relative;
+ flex-shrink: 0;
+
+ /* Efeito de brilho sutil */
+ &::after {
+ content: '';
+ position: absolute;
+ top: 10%;
+ left: 10%;
+ width: 40%;
+ height: 40%;
+ background: radial-gradient(circle, rgba(255, 255, 255, 0.3) 0%, transparent 70%);
+ border-radius: 50%;
+ }
+
+ transition: transform 0.3s ease, box-shadow 0.3s ease;
+
+ &:hover {
+ transform: scale(1.05);
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.2),
+ 0 4px 12px rgba(0, 0, 0, 0.15);
+ }
+`;
+
+// O nome do usuário
+export const Username = styled.h1`
+ font-size: 2.5em;
+ font-weight: 700;
+ color: ${props => props.theme.title};
+ margin: 0 0 10px 0; /* Margem inferior para alinhar com a base do avatar */
+
+ background: linear-gradient(135deg, ${props => props.theme.title} 0%, ${props => props.theme.subtitle} 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+
+ text-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
+
+ position: relative;
+
+ &::after {
+ content: '';
+ position: absolute;
+ bottom: -8px;
+ left: 0;
+ width: 60px;
+ height: 4px;
+ background: linear-gradient(90deg, ${props => props.theme.button} 0%, transparent 100%);
+ border-radius: 2px;
+ }
+`;
+
+// Container dos 3 botões de ação (dentro do banner)
+export const ProfileActions = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ position: relative;
+ z-index: 3;
+
+ /* Backdrop blur para destacar os botões */
+ background: rgba(255, 255, 255, 0.1);
+ backdrop-filter: blur(10px);
+ padding: 8px;
+ border-radius: 50px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+`;
+
+// Botão de ícone
+export const IconButton = styled.button<{ $active?: boolean }>`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ border: none;
+ cursor: pointer;
+ position: relative;
+ overflow: hidden;
+
+ background-color: ${props => props.$active
+ ? props.theme['hover-button'] || props.theme.button
+ : props.theme.background};
+ color: ${props => props.$active
+ ? '#FFFFFF'
+ : props.theme.button};
+
+ box-shadow: ${props => props.$active
+ ? '0 4px 12px rgba(0, 0, 0, 0.15), inset 0 1px 2px rgba(255, 255, 255, 0.1)'
+ : '0 2px 8px rgba(0, 0, 0, 0.08)'};
+
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+
+ /* Efeito ripple */
+ &::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 0;
+ height: 0;
+ border-radius: 50%;
+ background: ${props => props.$active
+ ? 'rgba(255, 255, 255, 0.3)'
+ : 'rgba(0, 0, 0, 0.1)'};
+ transform: translate(-50%, -50%);
+ transition: width 0.6s, height 0.6s;
+ }
+
+ &:hover::before {
+ width: 100%;
+ height: 100%;
+ }
+
+ &:hover {
+ transform: translateY(-3px) scale(1.05);
+ box-shadow: ${props => props.$active
+ ? '0 6px 20px rgba(0, 0, 0, 0.2)'
+ : '0 4px 16px rgba(0, 0, 0, 0.12)'};
+ background-color: ${props => props.$active
+ ? props.theme['hover-button'] || props.theme.button
+ : props.theme['gray-500'] || props.theme.background};
+ }
+
+ &:active {
+ transform: translateY(-1px) scale(1.02);
+ }
+
+ svg {
+ width: 22px;
+ height: 22px;
+ position: relative;
+ z-index: 1;
+ transition: transform 0.2s ease;
+ }
+
+ &:hover svg {
+ transform: ${props => props.$active ? 'rotate(5deg)' : 'scale(1.1)'};
+ }
+`;
+
+// Menus de ações flutuantes
+export const ActionsWrapper = styled.div`
+ position: relative;
+ z-index: 5; /* Aumentado para ficar acima de tudo */
+`; |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 90% | +18/20 | +75% | +6/8 | +100% | +4/4 | +94.11% | +16/17 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 | + + + + + + + + +4x +4x +4x + +4x + +2x +2x +2x +2x +2x + + + +2x + + +2x + + +4x +2x + + +1x + +1x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +2x + + + + + + + + + + + | import { useEffect, useState } from 'react';
+import { useParams } from 'react-router-dom';
+import Sidebar from '../../components/layout/Sidebar';
+import * as S from './styles';
+import { GetProjectById } from '../../API/Project';
+import type { ProjectProps } from '../../API/Project';
+import { FiCalendar, FiUser } from 'react-icons/fi';
+
+export default function ProjectPage() {
+ const { projectId } = useParams<{ projectId: string }>();
+ const [project, setProject] = useState<ProjectProps | null>(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ async function loadData() {
+ Iif (!projectId) return;
+ try {
+ setLoading(true);
+ const data = await GetProjectById(projectId);
+ setProject(data);
+ } catch (error) {
+ console.error("Erro ao carregar projeto:", error);
+ } finally {
+ setLoading(false);
+ }
+ }
+ loadData();
+ }, [projectId]);
+
+ if (loading) return <div>Carregando...</div>;
+ if (!project) return <div>Projeto não encontrado</div>;
+
+ // Formata data
+ const startDate = new Date(project.startDate).toLocaleDateString('pt-BR');
+
+ return (
+ <S.PageWrapper>
+ <Sidebar />
+
+ <S.MainContent>
+ <S.Banner />
+
+ <S.HeaderContainer>
+ {/* Ícone com a inicial do projeto */}
+ <S.ProjectIcon>
+ {project.title.charAt(0).toUpperCase()}
+ </S.ProjectIcon>
+
+ <S.HeaderInfo>
+ <h1>{project.title}</h1>
+ <span>
+ <FiUser style={{ marginRight: 5, verticalAlign: 'middle' }}/>
+ Criado por <strong>{project.authorUsername || project.authorName}</strong>
+ </span>
+ </S.HeaderInfo>
+ </S.HeaderContainer>
+
+ <S.ContentGrid>
+
+ {/* Coluna Principal: Descrição */}
+ <S.MainColumn>
+ <h2>Sobre o Projeto</h2>
+ <S.DescriptionBox>
+ {project.description}
+ </S.DescriptionBox>
+
+ </S.MainColumn>
+
+ <S.InfoSidebar>
+ <h3>Status</h3>
+ <S.StatusBadge status={project.status}>
+ {project.status.replace('-', ' ')}
+ </S.StatusBadge>
+
+ <h3>Início</h3>
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#555' }}>
+ <FiCalendar />
+ <span>{startDate}</span>
+ </div>
+
+ <h3>Tecnologias</h3>
+ <S.KeywordsContainer>
+ {project.technologies?.map((tech) => (
+ <S.KeywordTag key={tech}>
+ {tech}
+ </S.KeywordTag>
+ ))}
+ </S.KeywordsContainer>
+ </S.InfoSidebar>
+
+ </S.ContentGrid>
+ </S.MainContent>
+ </S.PageWrapper>
+ );
+} |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| index.tsx | +
+
+ |
+ 87.5% | +14/16 | +75% | +3/4 | +60% | +3/5 | +87.5% | +14/16 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 | + + + + + + + + + + + +8x + + +8x +8x + + +2x +2x +2x + +1x + + +1x +1x + + + + +1x + + +1x +1x + + + + +8x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +10x + + + + + + + + + + + + + + + + + + + + + | import { useState } from 'react';
+import { FormPageContainer, FormWrapper, FormTitle, StyledForm, StyledInput, SubmitButton, RedirectLink } from '../../components/domain/Form/styles';
+import { useForm, Controller} from 'react-hook-form';
+import { IMaskInput } from 'react-imask';
+import Toast from '../../components/common/Toast';
+import { useNavigate } from 'react-router-dom';
+import {Register as RegisterAPI} from '../../API/Auth'
+import type { RegisterProps } from '../../API/Auth';
+import type { NotificationState } from '../../components/common/Toast';
+
+export default function Register() {
+ // Estado para a notificação - usado no Toast
+ const [notification, setNotification] = useState<NotificationState | null>(null);
+
+
+ const { register, handleSubmit, formState: {isSubmitting}, control } = useForm<RegisterProps>();
+ const navigate = useNavigate();
+
+ async function onSubmit(data: RegisterProps) {
+ console.log(data);
+ try{
+ await RegisterAPI(data);
+
+ console.log('Usuário registrado com sucesso:');
+
+ // Define estado para mostrar notificação de sucesso
+ setNotification({ message: 'Usuário registrado com sucesso!', type: 'success' });
+ setTimeout(() => {
+ navigate('/feed'); // Navega para a página de feed
+ }, 1000);
+
+ } catch (error) {
+ console.error('Erro ao registrar usuário:', error);
+
+ // Define estado para mostrar notificação de erro
+ Eif (error instanceof Error){
+ setNotification({ message: error.message, type: 'error' });
+ }
+ }
+ }
+
+ return (
+ <FormPageContainer>
+
+ {notification && (
+ <Toast
+ message={notification.message}
+ type={notification.type}
+ onClose={() => setNotification(null)}
+ />
+ )}
+
+ <FormWrapper>
+ <FormTitle>Criar conta</FormTitle>
+
+ <StyledForm onSubmit={handleSubmit(onSubmit)}>
+ <StyledInput
+ type="text"
+ placeholder="Nome completo"
+ required
+ {...register('nomeCompleto')}
+ />
+ <StyledInput
+ type="text"
+ placeholder="Nome de usuário (user)"
+ required
+ {...register('username')}
+ />
+ <StyledInput
+ type="email"
+ placeholder="Email"
+ required
+ {...register('email')}
+ />
+ <StyledInput
+ type="password"
+ placeholder="Senha"
+ required
+ {...register('senha')}
+ />
+ <StyledInput
+ type="tel"
+ placeholder="Telefone"
+ required
+ {...register('telefone')}
+ />
+ <Controller
+ name="dataNascimento"
+ control={control}
+ rules={{ required: true }}
+ render={({ field }) => (
+ <StyledInput
+ {...field}
+ as={IMaskInput}
+ mask="00/00/0000"
+ placeholder="Data de nascimento"
+ required
+ />
+ )}
+ />
+
+ <SubmitButton disabled = {isSubmitting} type="submit">Cadastrar</SubmitButton>
+ </StyledForm>
+
+ <RedirectLink>
+ Já tem uma conta? <a href="/login">Faça login</a>
+ </RedirectLink>
+ </FormWrapper>
+ </FormPageContainer>
+ );
+}
+
+ |