From c1d15b5388c326f6a9f3664f53fa17154c7a5a64 Mon Sep 17 00:00:00 2001 From: RitTheDev <73381025+Ritvik-17@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:50:51 +0530 Subject: [PATCH 01/11] Add Community Module --- backend/package-lock.json | 212 ++++++++++++++++++ backend/package.json | 1 + backend/prisma/schema.prisma | 178 ++++++++++++++- backend/server.js | 30 ++- backend/src/controllers/friends.js | 159 +++++++++++++ backend/src/controllers/messages.js | 128 +++++++++++ backend/src/controllers/posts.js | 193 ++++++++++++++++ backend/src/middleware/error.js | 7 + backend/src/routes/friends.js | 17 ++ backend/src/routes/messages.js | 14 ++ backend/src/routes/posts.js | 17 ++ frontend/package-lock.json | 108 ++++++++- frontend/package.json | 5 +- frontend/src/App.jsx | 26 ++- frontend/src/api/api.js | 76 +++++++ frontend/src/components/Navbar.jsx | 24 ++ frontend/src/components/chat/ChatThread.jsx | 162 +++++++++++++ frontend/src/components/common/UserAvatar.jsx | 20 ++ .../components/community/CreatePostModal.jsx | 137 +++++++++++ .../community/FriendRequestsModal.jsx | 89 ++++++++ .../src/components/community/PostCard.jsx | 201 +++++++++++++++++ .../src/components/community/ReactionBar.jsx | 56 +++++ frontend/src/contexts/ChatContext.jsx | 59 +++++ frontend/src/pages/ApplyForEvent.jsx | 4 +- frontend/src/pages/Community.jsx | 198 ++++++++++++++++ frontend/src/pages/EventDashboard.jsx | 4 +- frontend/src/pages/EventDetails.jsx | 4 +- frontend/src/pages/Events.jsx | 2 +- frontend/src/pages/Home.jsx | 4 +- frontend/src/pages/Inbox.jsx | 199 ++++++++++++++++ frontend/src/pages/OrganiserDashboard.jsx | 2 +- frontend/src/pages/Profile.jsx | 138 +++++++++--- 32 files changed, 2420 insertions(+), 54 deletions(-) create mode 100644 backend/src/controllers/friends.js create mode 100644 backend/src/controllers/messages.js create mode 100644 backend/src/controllers/posts.js create mode 100644 backend/src/routes/friends.js create mode 100644 backend/src/routes/messages.js create mode 100644 backend/src/routes/posts.js create mode 100644 frontend/src/components/chat/ChatThread.jsx create mode 100644 frontend/src/components/common/UserAvatar.jsx create mode 100644 frontend/src/components/community/CreatePostModal.jsx create mode 100644 frontend/src/components/community/FriendRequestsModal.jsx create mode 100644 frontend/src/components/community/PostCard.jsx create mode 100644 frontend/src/components/community/ReactionBar.jsx create mode 100644 frontend/src/contexts/ChatContext.jsx create mode 100644 frontend/src/pages/Community.jsx create mode 100644 frontend/src/pages/Inbox.jsx diff --git a/backend/package-lock.json b/backend/package-lock.json index bb0e6b3..a6b2dfa 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -19,6 +19,7 @@ "express-session": "^1.18.1", "pg": "^8.10.0", "prisma": "^6.0.1", + "socket.io": "^4.8.3", "zod": "^3.22.4" }, "devDependencies": { @@ -120,6 +121,27 @@ "@prisma/debug": "6.0.1" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "25.3.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", + "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -229,6 +251,14 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/bcrypt": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", @@ -592,6 +622,62 @@ "node": ">= 0.8" } }, + "node_modules/engine.io": { + "version": "6.6.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz", + "integrity": "sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1975,6 +2061,107 @@ "semver": "bin/semver.js" } }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", + "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.18.3" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-adapter/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/socket.io-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -2128,6 +2315,11 @@ "dev": true, "license": "MIT" }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -2192,6 +2384,26 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 225bda6..339bc65 100644 --- a/backend/package.json +++ b/backend/package.json @@ -22,6 +22,7 @@ "express-session": "^1.18.1", "pg": "^8.10.0", "prisma": "^6.0.1", + "socket.io": "^4.8.3", "zod": "^3.22.4" }, "devDependencies": { diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 46a1dd6..bd177b6 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -21,9 +21,22 @@ model User { experience UserExperience[] skills UserSkill[] socialProfiles UserSocialProfile[] - applications Application[] - createdEvents Event[] - teamMembers TeamMember[] + applications Application[] + createdEvents Event[] + teamMembers TeamMember[] + + // Community Module Relations + posts Post[] + reactions Reaction[] + comments Comment[] + friendships Friendship[] @relation("UserFriendships") + friendOf Friendship[] @relation("FriendUser") + sentRequests FriendRequest[] @relation("SentRequests") + receivedRequests FriendRequest[] @relation("ReceivedRequests") + followers Follow[] @relation("UserFollowers") + following Follow[] @relation("UserFollowing") + conversations ConversationMember[] + messages Message[] } model UserProfile { @@ -108,6 +121,7 @@ model Event { customQuestions CustomQuestion[] teams Team[] projects ProjectSubmission[] + posts Post[] // For event invite cards embedded in posts } model ApplicationForm { @@ -320,3 +334,161 @@ enum TeamMemberRole { LEADER MEMBER } + +// ========================================== +// Community Module Models +// ========================================== + +model Post { + id String @id @default(cuid()) + content String @db.Text + authorId String + eventId Int? // Optional event link for invites + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull) + images PostImage[] + tags PostTag[] + reactions Reaction[] + comments Comment[] +} + +model PostImage { + id String @id @default(cuid()) + postId String + url String + createdAt DateTime @default(now()) + + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) +} + +model Tag { + id String @id @default(cuid()) + name String @unique + posts PostTag[] +} + +model PostTag { + postId String + tagId String + + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) + + @@id([postId, tagId]) +} + +model Reaction { + id String @id @default(cuid()) + postId String + userId String + type ReactionType + createdAt DateTime @default(now()) + + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([postId, userId]) // User can only react once per post +} + +model Comment { + id String @id @default(cuid()) + content String @db.Text + postId String + userId String + parentCommentId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + parentComment Comment? @relation("ThreadedComments", fields: [parentCommentId], references: [id], onDelete: Cascade) + replies Comment[] @relation("ThreadedComments") +} + +model FriendRequest { + id String @id @default(cuid()) + senderId String + receiverId String + status FriendRequestStatus @default(PENDING) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + sender User @relation("SentRequests", fields: [senderId], references: [id], onDelete: Cascade) + receiver User @relation("ReceivedRequests", fields: [receiverId], references: [id], onDelete: Cascade) + + @@unique([senderId, receiverId]) +} + +model Friendship { + id String @id @default(cuid()) + userId String + friendId String + createdAt DateTime @default(now()) + + user User @relation("UserFriendships", fields: [userId], references: [id], onDelete: Cascade) + friend User @relation("FriendUser", fields: [friendId], references: [id], onDelete: Cascade) + + @@unique([userId, friendId]) +} + +model Follow { + id String @id @default(cuid()) + followerId String + followingId String + createdAt DateTime @default(now()) + + follower User @relation("UserFollowers", fields: [followerId], references: [id], onDelete: Cascade) + following User @relation("UserFollowing", fields: [followingId], references: [id], onDelete: Cascade) + + @@unique([followerId, followingId]) +} + +model Conversation { + id String @id @default(cuid()) + isGroup Boolean @default(false) + name String? // Optional for groups + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + members ConversationMember[] + messages Message[] +} + +model ConversationMember { + id String @id @default(cuid()) + conversationId String + userId String + joinedAt DateTime @default(now()) + + conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([conversationId, userId]) +} + +model Message { + id String @id @default(cuid()) + conversationId String + senderId String + content String @db.Text + isRead Boolean @default(false) + createdAt DateTime @default(now()) + + conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) + sender User @relation(fields: [senderId], references: [id], onDelete: Cascade) +} + +enum ReactionType { + LIKE + CELEBRATE + SUPPORT +} + +enum FriendRequestStatus { + PENDING + ACCEPTED + REJECTED +} diff --git a/backend/server.js b/backend/server.js index cf29271..2720ef9 100644 --- a/backend/server.js +++ b/backend/server.js @@ -5,6 +5,8 @@ import cookieParser from 'cookie-parser'; import errorHandler from './src/middleware/error.js'; import prisma from './src/config/database.js'; import { checkJwt } from './src/middleware/auth.js'; +import { createServer } from 'http'; +import { Server } from 'socket.io'; // Import routes import authRoutes from './src/routes/auth.js'; @@ -12,10 +14,33 @@ import eventRoutes from './src/routes/events.js'; import teamRoutes from './src/routes/teams.js'; import projectRoutes from './src/routes/projects.js'; import profileRoutes from './src/routes/profiles.js'; +import postRoutes from './src/routes/posts.js'; +import friendRoutes from './src/routes/friends.js'; +import messageRoutes from './src/routes/messages.js'; const app = express(); +const httpServer = createServer(app); const PORT = process.env.PORT || 4000; +const io = new Server(httpServer, { + cors: { + origin: process.env.CORS_ORIGIN || 'http://localhost:3000', + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'] + } +}); + +// Make io accessible in our routes +app.set('socketio', io); + +io.on('connection', (socket) => { + console.log('Client connected:', socket.id); + + socket.on('disconnect', () => { + console.log('Client disconnected:', socket.id); + }); +}); + // Middleware app.use(cors({ origin: process.env.CORS_ORIGIN || 'http://localhost:3000', @@ -32,12 +57,15 @@ app.use('/api/events', eventRoutes); // Remove checkJwt from public routes app.use('/api/teams', checkJwt, teamRoutes); app.use('/api/projects', checkJwt, projectRoutes); app.use('/api', checkJwt, profileRoutes); // Changed from '/api/profiles' to '/api' to match frontend calls +app.use('/api/posts', postRoutes); +app.use('/api/friends', friendRoutes); +app.use('/api/messages', messageRoutes); // Error handling app.use(errorHandler); // Start server -app.listen(PORT, () => { +const server = httpServer.listen(PORT, () => { console.log(`Server running on port ${PORT}`); console.log(`- Local: http://localhost:${PORT}`); if (process.env.SERVER_URL) { diff --git a/backend/src/controllers/friends.js b/backend/src/controllers/friends.js new file mode 100644 index 0000000..776d169 --- /dev/null +++ b/backend/src/controllers/friends.js @@ -0,0 +1,159 @@ +import prisma from '../config/database.js'; + +export const sendFriendRequest = async (req, res) => { + try { + const senderAuth0Id = req.auth.payload.sub; + const { receiverId } = req.body; // This is the user ID of the receiver + + const sender = await prisma.user.findUnique({ where: { auth0Id: senderAuth0Id } }); + + // Prevent duplicate requests + const existing = await prisma.friendRequest.findFirst({ + where: { + OR: [ + { senderId: sender.id, receiverId }, + { senderId: receiverId, receiverId: sender.id } + ] + } + }); + + if (existing) { + return res.status(400).json({ error: 'Request already exists or you are already friends.' }); + } + + const request = await prisma.friendRequest.create({ + data: { + senderId: sender.id, + receiverId + } + }); + + res.status(201).json(request); + } catch (error) { + console.error('Send friend request error:', error); + res.status(500).json({ error: 'Failed to send request' }); + } +}; + +export const respondToRequest = async (req, res) => { + try { + const { requestId } = req.params; + const { action } = req.body; // 'ACCEPT' or 'REJECT' + const userAuth0Id = req.auth.payload.sub; + + const user = await prisma.user.findUnique({ where: { auth0Id: userAuth0Id } }); + + const request = await prisma.friendRequest.findUnique({ + where: { id: requestId } + }); + + if (!request || request.receiverId !== user.id) { + return res.status(403).json({ error: 'Not authorized' }); + } + + if (action === 'ACCEPT') { + await prisma.$transaction([ + prisma.friendRequest.update({ + where: { id: requestId }, + data: { status: 'ACCEPTED' } + }), + prisma.friendship.create({ + data: { userId: request.senderId, friendId: request.receiverId } + }), + prisma.friendship.create({ + data: { userId: request.receiverId, friendId: request.senderId } + }) + ]); + } else { + await prisma.friendRequest.update({ + where: { id: requestId }, + data: { status: 'REJECTED' } + }); + } + + res.json({ success: true, action }); + } catch (error) { + console.error('Respond to request error:', error); + res.status(500).json({ error: 'Failed to respond to request' }); + } +}; + +export const getFriends = async (req, res) => { + try { + const userAuth0Id = req.auth.payload.sub; + const user = await prisma.user.findUnique({ where: { auth0Id: userAuth0Id } }); + + const friendships = await prisma.friendship.findMany({ + where: { userId: user.id }, + include: { + friend: { include: { profile: true } } + } + }); + + res.json(friendships.map(f => f.friend)); + } catch (error) { + console.error('Get friends error:', error); + res.status(500).json({ error: 'Failed to fetch friends' }); + } +}; + +export const followOrganizer = async (req, res) => { + // Simplified follow logic + try { + const followerAuth0Id = req.auth.payload.sub; + const { organizerId } = req.body; + + const follower = await prisma.user.findUnique({ where: { auth0Id: followerAuth0Id } }); + + const follow = await prisma.follow.create({ + data: { + followerId: follower.id, + followingId: organizerId + } + }); + + res.status(201).json(follow); + } catch (error) { + console.error('Follow error:', error); + res.status(500).json({ error: 'Failed to follow' }); + } +}; + +export const getPendingRequests = async (req, res) => { + try { + const userAuth0Id = req.auth.payload.sub; + const user = await prisma.user.findUnique({ where: { auth0Id: userAuth0Id } }); + + const requests = await prisma.friendRequest.findMany({ + where: { receiverId: user.id, status: 'PENDING' }, + include: { + sender: { include: { profile: true } } + } + }); + + res.json(requests); + } catch (error) { + console.error('Get pending requests error:', error); + res.status(500).json({ error: 'Failed to fetch requests' }); + } +}; + +export const searchUsers = async (req, res) => { + try { + const { query } = req.query; + if (!query) return res.json([]); + + const users = await prisma.user.findMany({ + where: { + username: { contains: query, mode: 'insensitive' } + }, + take: 10, + include: { profile: true } + }); + + res.json(users); + } catch (error) { + console.error('Search users error:', error); + res.status(500).json({ error: 'Failed to search users' }); + } +}; diff --git a/backend/src/controllers/messages.js b/backend/src/controllers/messages.js new file mode 100644 index 0000000..c132227 --- /dev/null +++ b/backend/src/controllers/messages.js @@ -0,0 +1,128 @@ +import prisma from '../config/database.js'; + +export const getInbox = async (req, res) => { + try { + const auth0Id = req.auth.payload.sub; + const user = await prisma.user.findUnique({ where: { auth0Id } }); + + const conversations = await prisma.conversation.findMany({ + where: { + members: { + some: { userId: user.id } + } + }, + include: { + members: { + include: { user: { include: { profile: true } } } + }, + messages: { + orderBy: { createdAt: 'desc' }, + take: 1 + } + }, + orderBy: { updatedAt: 'desc' } + }); + + res.json(conversations); + } catch (error) { + console.error('Get inbox error:', error); + res.status(500).json({ error: 'Failed to fetch messages' }); + } +}; + +export const getConversation = async (req, res) => { + try { + const { conversationId } = req.params; + const auth0Id = req.auth.payload.sub; + + const messages = await prisma.message.findMany({ + where: { conversationId }, + orderBy: { createdAt: 'asc' }, + include: { + sender: { include: { profile: true } } + } + }); + + // Mark as read + await prisma.message.updateMany({ + where: { + conversationId, + sender: { auth0Id: { not: auth0Id } }, + isRead: false + }, + data: { isRead: true } + }); + + res.json(messages); + } catch (error) { + console.error('Get conversation messages error:', error); + res.status(500).json({ error: 'Failed to fetch conversation' }); + } +}; + +export const sendMessage = async (req, res) => { + try { + const auth0Id = req.auth.payload.sub; + const { conversationId, receiverId, content } = req.body; + + const sender = await prisma.user.findUnique({ where: { auth0Id } }); + let convId = conversationId; + + // If starting a new direct message thread + if (!convId && receiverId) { + // Find existing + const existing = await prisma.conversation.findFirst({ + where: { + isGroup: false, + AND: [ + { members: { some: { userId: sender.id } } }, + { members: { some: { userId: receiverId } } } + ] + } + }); + + if (existing) { + convId = existing.id; + } else { + const newConv = await prisma.conversation.create({ + data: { + members: { + create: [ + { userId: sender.id }, + { userId: receiverId } + ] + } + } + }); + convId = newConv.id; + } + } + + const message = await prisma.message.create({ + data: { + conversationId: convId, + senderId: sender.id, + content + }, + include: { + sender: { include: { profile: true } } + } + }); + + await prisma.conversation.update({ + where: { id: convId }, + data: { updatedAt: new Date() } + }); + + // Emit socket event if io is accessible + const io = req.app.get('socketio'); + if (io) { + io.to(`conversation_${convId}`).emit('message:receive', message); + } + + res.status(201).json(message); + } catch (error) { + console.error('Send message error:', error); + res.status(500).json({ error: 'Failed to send message' }); + } +}; diff --git a/backend/src/controllers/posts.js b/backend/src/controllers/posts.js new file mode 100644 index 0000000..5168535 --- /dev/null +++ b/backend/src/controllers/posts.js @@ -0,0 +1,193 @@ +import prisma from '../config/database.js'; + +export const createPost = async (req, res) => { + try { + const userId = req.auth.payload.sub; + const { content, eventId, tags, images } = req.body; + + const post = await prisma.post.create({ + data: { + content, + author: { connect: { auth0Id: userId } }, + ...(eventId && { event: { connect: { id: eventId } } }), + ...(images && images.length > 0 && { + images: { + create: images.map(url => ({ url })) + } + }), + ...(tags && tags.length > 0 && { + tags: { + create: tags.map(tag => ({ + tag: { + connectOrCreate: { + where: { name: tag }, + create: { name: tag } + } + } + })) + } + }) + }, + include: { + author: { include: { profile: true } }, + images: true, + tags: { include: { tag: true } }, + event: true, + reactions: true, + _count: { select: { comments: true, reactions: true } } + } + }); + + res.status(201).json(post); + } catch (error) { + console.error('Create post error:', error); + res.status(500).json({ error: 'Failed to create post' }); + } +}; + +export const getPosts = async (req, res) => { + try { + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 10; + const skip = (page - 1) * limit; + + const posts = await prisma.post.findMany({ + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + include: { + author: { include: { profile: true } }, + images: true, + tags: { include: { tag: true } }, + event: true, + reactions: true, + _count: { select: { comments: true } } + } + }); + + res.json(posts); + } catch (error) { + console.error('Get posts error:', error); + res.status(500).json({ error: 'Failed to fetch posts' }); + } +}; + +export const reactToPost = async (req, res) => { + try { + const userId = req.auth.payload.sub; + const { id: postId } = req.params; + const { type } = req.body; // LIKE, CELEBRATE, SUPPORT + + const user = await prisma.user.findUnique({ where: { auth0Id: userId } }); + + // Check existing reaction + const existing = await prisma.reaction.findUnique({ + where: { postId_userId: { postId, userId: user.id } } + }); + + if (existing) { + if (existing.type === type) { + // Toggle off + await prisma.reaction.delete({ where: { id: existing.id } }); + return res.json({ message: 'Reaction removed' }); + } else { + // Update type + const updated = await prisma.reaction.update({ + where: { id: existing.id }, + data: { type } + }); + return res.json(updated); + } + } + + const reaction = await prisma.reaction.create({ + data: { + type, + post: { connect: { id: postId } }, + user: { connect: { id: user.id } } + } + }); + + res.json(reaction); + } catch (error) { + console.error('Reaction error:', error); + res.status(500).json({ error: 'Failed to add reaction' }); + } +}; + +export const addComment = async (req, res) => { + try { + const auth0Id = req.auth.payload.sub; + const { id: postId } = req.params; + const { content, parentCommentId } = req.body; + + const comment = await prisma.comment.create({ + data: { + content, + post: { connect: { id: postId } }, + user: { connect: { auth0Id } }, + ...(parentCommentId && { parentComment: { connect: { id: parentCommentId } } }) + }, + include: { + user: { include: { profile: true } } + } + }); + + res.status(201).json(comment); + } catch (error) { + console.error('Add comment error:', error); + res.status(500).json({ error: 'Failed to add comment' }); + } +}; + +export const getComments = async (req, res) => { + try { + const { id: postId } = req.params; + + // Fetch top level comments and their replies (1 level deep) + const comments = await prisma.comment.findMany({ + where: { postId, parentCommentId: null }, + orderBy: { createdAt: 'asc' }, + include: { + user: { include: { profile: true } }, + replies: { + include: { user: { include: { profile: true } } }, + orderBy: { createdAt: 'asc' } + } + } + }); + + res.json(comments); + } catch (error) { + console.error('Get comments error:', error); + res.status(500).json({ error: 'Failed to fetch comments' }); + } +}; + +export const getUserProfile = async (req, res) => { + try { + const { id } = req.params; + const user = await prisma.user.findUnique({ + where: { id }, + include: { + profile: true, + education: true, + experience: true, + skills: true, + socialProfiles: true, + projects: true + } + }); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + // Don't send sensitive info like auth0Id + const { auth0Id, ...publicUser } = user; + res.json(publicUser); + } catch (error) { + console.error('Get user profile error:', error); + res.status(500).json({ error: 'Failed to fetch user profile' }); + } +}; diff --git a/backend/src/middleware/error.js b/backend/src/middleware/error.js index 728768a..c2c1d4e 100644 --- a/backend/src/middleware/error.js +++ b/backend/src/middleware/error.js @@ -8,6 +8,13 @@ const errorHandler = (err, req, res, next) => { }); } + if (err.name === 'UnauthorizedError') { + return res.status(401).json({ + error: 'Unauthorized', + details: err.message + }); + } + if (err.name === 'ValidationError') { return res.status(400).json({ error: 'Validation failed', diff --git a/backend/src/routes/friends.js b/backend/src/routes/friends.js new file mode 100644 index 0000000..37a497d --- /dev/null +++ b/backend/src/routes/friends.js @@ -0,0 +1,17 @@ +import express from 'express'; +import { checkJwt, ensureUser } from '../middleware/auth.js'; +import { sendFriendRequest, respondToRequest, getFriends, followOrganizer, getPendingRequests, searchUsers } from '../controllers/friends.js'; + +const router = express.Router(); + +router.use(checkJwt); +router.use(ensureUser); + +router.post('/request', sendFriendRequest); +router.post('/request/:requestId', respondToRequest); // action in body +router.get('/requests/pending', getPendingRequests); +router.get('/search', searchUsers); +router.get('/', getFriends); +router.post('/follow', followOrganizer); + +export default router; diff --git a/backend/src/routes/messages.js b/backend/src/routes/messages.js new file mode 100644 index 0000000..b66d0a4 --- /dev/null +++ b/backend/src/routes/messages.js @@ -0,0 +1,14 @@ +import express from 'express'; +import { checkJwt, ensureUser } from '../middleware/auth.js'; +import { getInbox, getConversation, sendMessage } from '../controllers/messages.js'; + +const router = express.Router(); + +router.use(checkJwt); +router.use(ensureUser); + +router.get('/inbox', getInbox); +router.get('/:conversationId', getConversation); +router.post('/', sendMessage); + +export default router; diff --git a/backend/src/routes/posts.js b/backend/src/routes/posts.js new file mode 100644 index 0000000..dc33643 --- /dev/null +++ b/backend/src/routes/posts.js @@ -0,0 +1,17 @@ +import express from 'express'; +import { checkJwt, ensureUser } from '../middleware/auth.js'; +import { createPost, getPosts, reactToPost, addComment, getComments, getUserProfile } from '../controllers/posts.js'; + +const router = express.Router(); + +router.use(checkJwt); +router.use(ensureUser); + +router.post('/', createPost); +router.get('/', getPosts); +router.post('/:id/react', reactToPost); +router.post('/:id/comment', addComment); +router.get('/:id/comments', getComments); +router.get('/user/:id', getUserProfile); + +export default router; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 859e6f5..10c8f36 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,12 +12,15 @@ "@supabase/supabase-js": "^2.48.1", "@types/node": "^20.11.0", "axios": "^1.7.9", + "date-fns": "^4.1.0", + "emoji-picker-react": "^4.18.0", "lucide-react": "^0.486.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hot-toast": "^2.5.1", "react-router-dom": "^6.28.0", - "recharts": "^2.15.2" + "recharts": "^2.15.2", + "socket.io-client": "^4.8.3" }, "devDependencies": { "@eslint/js": "^9.15.0", @@ -1383,6 +1386,11 @@ "win32" ] }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" + }, "node_modules/@supabase/auth-js": { "version": "2.67.3", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.67.3.tgz", @@ -2473,12 +2481,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "dev": true, - "license": "MIT", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dependencies": { "ms": "^2.1.3" }, @@ -2615,6 +2630,20 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-picker-react": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.18.0.tgz", + "integrity": "sha512-vLTrLfApXAIciguGE57pXPWs9lPLBspbEpPMiUq03TIli2dHZBiB+aZ0R9/Wat0xmTfcd4AuEzQgSYxEZ8C88Q==", + "dependencies": { + "flairup": "1.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -2622,6 +2651,26 @@ "dev": true, "license": "MIT" }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/es-abstract": { "version": "1.23.5", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.5.tgz", @@ -3178,6 +3227,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flairup": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz", + "integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==" + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -4387,7 +4441,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -5500,6 +5553,32 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", + "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6364,10 +6443,9 @@ } }, "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "license": "MIT", + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "engines": { "node": ">=10.0.0" }, @@ -6384,6 +6462,14 @@ } } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 835eda3..31333d9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,12 +14,15 @@ "@supabase/supabase-js": "^2.48.1", "@types/node": "^20.11.0", "axios": "^1.7.9", + "date-fns": "^4.1.0", + "emoji-picker-react": "^4.18.0", "lucide-react": "^0.486.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hot-toast": "^2.5.1", "react-router-dom": "^6.28.0", - "recharts": "^2.15.2" + "recharts": "^2.15.2", + "socket.io-client": "^4.8.3" }, "devDependencies": { "@eslint/js": "^9.15.0", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3f6f0c4..b318995 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -14,8 +14,11 @@ import EventDetails from './pages/EventDetails'; import EditEvent from './components/EditEvent'; import ApplyForm from "./pages/ApplyForEvent"; // Import the component import { NavigationProvider } from './contexts/NavigationContext'; +import { ChatProvider } from './contexts/ChatContext'; import OrganiserDashboard from './pages/OrganiserDashboard.jsx'; import EventDashboard from './pages/EventDashboard.jsx'; +import Community from './pages/Community.jsx'; +import Inbox from './pages/Inbox.jsx'; const ProtectedRoute = ({ children, requiredRole }) => { const { isAuthenticated, isLoading, user } = useAuth(); @@ -54,14 +57,24 @@ const App = () => { cacheLocation="localstorage" > -
- + +
+
} /> } /> } /> } /> + } /> + + + + } + /> { } /> + + + + } + /> {
+
diff --git a/frontend/src/api/api.js b/frontend/src/api/api.js index a5090cb..548f019 100644 --- a/frontend/src/api/api.js +++ b/frontend/src/api/api.js @@ -262,4 +262,80 @@ export const profileAPI = { api.put('/api/profile', profileData) }; +// Posts API endpoints +export const postsAPI = { + getPosts: async (page = 1, limit = 10) => { + const response = await api.get(`/api/posts?page=${page}&limit=${limit}`); + return response.data; + }, + createPost: async (postData) => { + const response = await api.post('/api/posts', postData); + return response.data; + }, + reactToPost: async (postId, type) => { + const response = await api.post(`/api/posts/${postId}/react`, { type }); + return response.data; + }, + addComment: async (postId, commentData) => { + const response = await api.post(`/api/posts/${postId}/comment`, commentData); + return response.data; + }, + getComments: async (postId) => { + const response = await api.get(`/api/posts/${postId}/comments`); + return response.data; + }, + getUserProfile: async (userId) => { + const response = await api.get(`/api/posts/user/${userId}`); + return response.data; + } +}; + +// Friends API endpoints +export const friendsAPI = { + getFriends: async () => { + const response = await api.get('/api/friends'); + return response.data; + }, + getPendingRequests: async () => { + const response = await api.get('/api/friends/requests/pending'); + return response.data; + }, + searchUsers: async (query) => { + const response = await api.get(`/api/friends/search?query=${query}`); + return response.data; + }, + sendRequest: async (receiverId) => { + const response = await api.post('/api/friends/request', { receiverId }); + return response.data; + }, + followOrganizer: async (organizerId) => { + const response = await api.post('/api/friends/follow', { organizerId }); + return response.data; + }, + respondToRequest: async (requestId, action) => { + const response = await api.post(`/api/friends/request/${requestId}`, { action }); + return response.data; + }, + followOrganizer: async (organizerId) => { + const response = await api.post('/api/friends/follow', { organizerId }); + return response.data; + } +}; + +// Messages API endpoints +export const messagesAPI = { + getInbox: async () => { + const response = await api.get('/api/messages/inbox'); + return response.data; + }, + getConversation: async (conversationId) => { + const response = await api.get(`/api/messages/${conversationId}`); + return response.data; + }, + sendMessage: async (messageData) => { + const response = await api.post('/api/messages', messageData); + return response.data; + } +}; + export default api; diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index be1da38..2c50c21 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -74,6 +74,14 @@ const Navbar = () => { Events + + Community + + {isAuthenticated && ( + + Inbox + + )} Testimonials @@ -118,6 +126,22 @@ const Navbar = () => { > Events + handleMobileMenuClick()} + > + Community + + {isAuthenticated && ( + handleMobileMenuClick()} + > + Inbox + + )} { + const { user } = useAuth(); + const { socket, joinConversation, leaveConversation } = useChat(); + const [messages, setMessages] = useState([]); + const [newMessage, setNewMessage] = useState(''); + const [showEmoji, setShowEmoji] = useState(false); + const bottomRef = useRef(null); + + useEffect(() => { + let mounted = true; + + const fetchMessages = async () => { + if (conversation?.id) { + try { + const res = await messagesAPI.getConversation(conversation.id); + if (mounted) setMessages(res); + } catch (err) { + console.error(err); + } + } else { + setMessages([]); + } + }; + + fetchMessages(); + + if (conversation?.id && socket) { + joinConversation(conversation.id); + + const handleReceive = (msg) => { + setMessages(prev => [...prev, msg]); + }; + + socket.on('message:receive', handleReceive); + + return () => { + mounted = false; + socket.off('message:receive', handleReceive); + leaveConversation(conversation.id); + }; + } + + return () => { mounted = false; }; + }, [conversation?.id, socket]); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + const handleSend = async (e) => { + e.preventDefault(); + if (!newMessage.trim()) return; + + try { + const sentMsg = await messagesAPI.sendMessage({ + conversationId: conversation?.id, + receiverId: selectedUser?.id, + content: newMessage + }); + + setMessages(prev => [...prev, sentMsg]); + setNewMessage(''); + if (onMessageSent) onMessageSent(); + } catch (err) { + console.error(err); + } + }; + + const onEmojiClick = (emojiData) => { + setNewMessage(prev => prev + emojiData.emoji); + setShowEmoji(false); + }; + + const otherUser = selectedUser || conversation?.members?.find(m => m.user.auth0Id !== user?.auth0Id)?.user; + + if (!otherUser) { + return ( +
+ +

Select a conversation to start chatting

+
+ ); + } + + return ( +
+
+ +
+

{otherUser.username}

+

Active Now

+
+
+ +
+ {messages.map((msg, i) => { + const isMe = msg.sender.auth0Id === user?.auth0Id; + return ( +
+
+ {msg.content} +
+ + {formatDistanceToNow(new Date(msg.createdAt), { addSuffix: true })} + +
+ ); + })} +
+
+ +
+
+
+ + {showEmoji && ( +
+ +
+ )} +
+ + setNewMessage(e.target.value)} + placeholder="Type your message..." + className="flex-1 bg-gray-100/50 border border-transparent rounded-xl px-5 py-2.5 text-sm outline-none focus:ring-1 focus:ring-black focus:bg-white transition-all tracking-tight" + /> + + +
+
+
+ ); +}; + +export default ChatThread; diff --git a/frontend/src/components/common/UserAvatar.jsx b/frontend/src/components/common/UserAvatar.jsx new file mode 100644 index 0000000..3415d94 --- /dev/null +++ b/frontend/src/components/common/UserAvatar.jsx @@ -0,0 +1,20 @@ +import React from 'react'; + +const UserAvatar = ({ user, className = 'w-10 h-10', onClick }) => { + const username = user?.username || 'User'; + const avatarUrl = user?.profile?.avatarUrl || `https://ui-avatars.com/api/?name=${encodeURIComponent(username)}&background=EEF2FF&color=4F46E5&bold=true`; + + return ( + {username} { + e.target.src = `https://ui-avatars.com/api/?name=${encodeURIComponent(username)}&background=EEF2FF&color=4F46E5&bold=true`; + }} + /> + ); +}; + +export default UserAvatar; diff --git a/frontend/src/components/community/CreatePostModal.jsx b/frontend/src/components/community/CreatePostModal.jsx new file mode 100644 index 0000000..c346cfe --- /dev/null +++ b/frontend/src/components/community/CreatePostModal.jsx @@ -0,0 +1,137 @@ +import React, { useState } from 'react'; +import { useAuth } from '../../contexts/AuthContext'; +import { postsAPI } from '../../api/api'; +import { Image, Tag, Hash, X } from 'lucide-react'; +import EmojiPicker from 'emoji-picker-react'; +import UserAvatar from '../common/UserAvatar'; + +const CreatePostModal = ({ onClose, onPostCreated }) => { + const { user } = useAuth(); + const [content, setContent] = useState(''); + const [showEmoji, setShowEmoji] = useState(false); + const [tags, setTags] = useState([]); + const [tagInput, setTagInput] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!content.trim()) return; + setSubmitting(true); + try { + await postsAPI.createPost({ + content, + tags + }); + onPostCreated(); + onClose(); + } catch (err) { + console.error(err); + setSubmitting(false); + } + }; + + const handleTagAdd = (e) => { + e.preventDefault(); + if (tagInput.trim() && !tags.includes(tagInput.trim())) { + setTags([...tags, tagInput.trim()]); + setTagInput(''); + } + }; + + const removeTag = (tagToRemove) => { + setTags(tags.filter(t => t !== tagToRemove)); + }; + + const onEmojiClick = (emojiObject) => { + setContent(prevInput => prevInput + emojiObject.emoji); + setShowEmoji(false); + }; + + return ( +
+
+
+

Create Post

+ +
+ +
+
+
+ + {user?.username || 'User'} +
+ +