diff --git a/backend/config/passportConfig.js b/backend/config/passportConfig.js index 59ac9e51..50db1f60 100644 --- a/backend/config/passportConfig.js +++ b/backend/config/passportConfig.js @@ -9,12 +9,12 @@ passport.use( try { const user = await User.findOne( {email} ); if (!user) { - return done(null, false, { message: 'Email is invalid '}); + return done(null, false, { message: 'Invalid email or password' }); } const isMatch = await user.comparePassword(password); if (!isMatch) { - return done(null, false, { message: 'Invalid password' }); + return done(null, false, { message: 'Invalid email or password' }); } return done(null, { diff --git a/backend/data/discussions.json b/backend/data/discussions.json new file mode 100644 index 00000000..e474e8ee --- /dev/null +++ b/backend/data/discussions.json @@ -0,0 +1,30 @@ +{ + "discussions": [ + { + "id": "b3808b42-91e4-4d81-991a-07a96d7a549f", + "title": "Test discussion from shell", + "body": "This is a sufficiently long discussion body to pass validation and save.", + "category": "Help", + "tags": [ + "test", + "api" + ], + "authorId": "8d706914-51d9-40ca-981c-ac9dcb6a1881", + "authorName": "Guest", + "likes": [ + "00000000-0000-0000-0000-000000000000" + ], + "comments": [ + { + "id": "d1b1bcad-6989-49ff-a587-667f8426198e", + "text": "Yes", + "authorId": "00000000-0000-0000-0000-000000000000", + "authorName": "demo-user", + "createdAt": "2026-05-27T12:56:14.816Z" + } + ], + "createdAt": "2026-05-27T11:55:11.307Z", + "updatedAt": "2026-05-27T13:00:24.816Z" + } + ] +} \ No newline at end of file diff --git a/backend/data/users.json b/backend/data/users.json new file mode 100644 index 00000000..b4d801da --- /dev/null +++ b/backend/data/users.json @@ -0,0 +1,10 @@ +{ + "users": [ + { + "id": "00000000-0000-0000-0000-000000000000", + "username": "demo-user", + "email": "user@example.com", + "password": "$2a$10$00000000000000000000000000000000000000000000000000000" + } + ] +} \ No newline at end of file diff --git a/backend/models/Discussion.js b/backend/models/Discussion.js new file mode 100644 index 00000000..94705f09 --- /dev/null +++ b/backend/models/Discussion.js @@ -0,0 +1,81 @@ +const mongoose = require('mongoose'); + +const CommentSchema = new mongoose.Schema( + { + text: { + type: String, + required: true, + trim: true, + maxlength: 1000, + }, + authorId: { + type: String, + required: true, + }, + authorName: { + type: String, + required: true, + trim: true, + }, + }, + { timestamps: true } +); + +const DiscussionSchema = new mongoose.Schema( + { + title: { + type: String, + required: true, + trim: true, + minlength: 4, + maxlength: 140, + }, + body: { + type: String, + required: true, + trim: true, + minlength: 20, + maxlength: 4000, + }, + category: { + type: String, + required: true, + trim: true, + maxlength: 60, + }, + tags: { + type: [ + { + type: String, + trim: true, + maxlength: 30, + }, + ], + default: [], + }, + authorId: { + type: String, + required: true, + }, + authorName: { + type: String, + required: true, + trim: true, + }, + likes: { + type: [ + { + type: String, + }, + ], + default: [], + }, + comments: { + type: [CommentSchema], + default: [], + }, + }, + { timestamps: true } +); + +module.exports = mongoose.model('Discussion', DiscussionSchema); \ No newline at end of file diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 1c1cf764..b47213cc 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -1,15 +1,137 @@ const express = require("express"); const passport = require("passport"); +const fs = require("fs/promises"); +const path = require("path"); +const bcrypt = require("bcryptjs"); +const crypto = require("crypto"); const User = require("../models/User"); const { signupSchema, loginSchema } = require("../validators/authValidator"); const { validateRequest } = require("../validators/validationRequest"); const router = express.Router(); +const getUseMongoAuth = () => typeof global.mongooseConnected !== "undefined" ? global.mongooseConnected : Boolean(process.env.MONGO_URI); +const dataDir = path.join(__dirname, "..", "data"); +const usersFile = path.join(dataDir, "users.json"); +const usersLockFile = `${usersFile}.lock`; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const acquireUsersLock = async () => { + const retries = 80; + const delayMs = 25; + + for (let attempt = 0; attempt < retries; attempt += 1) { + try { + const handle = await fs.open(usersLockFile, "wx"); + return handle; + } catch (error) { + if (error.code !== "EEXIST") { + throw error; + } + + await sleep(delayMs); + } + } + + throw new Error("Could not acquire users file lock"); +}; + +const withUsersLock = async (callback) => { + const lockHandle = await acquireUsersLock(); + + try { + return await callback(); + } finally { + await lockHandle.close(); + await fs.unlink(usersLockFile).catch(() => {}); + } +}; + +const ensureUsersFileUnlocked = async () => { + await fs.mkdir(dataDir, { recursive: true }); + + try { + await fs.access(usersFile); + } catch { + await fs.writeFile(usersFile, JSON.stringify({ users: [] }, null, 2), "utf8"); + } +}; + +const readUsersUnlocked = async () => { + await ensureUsersFileUnlocked(); + const raw = await fs.readFile(usersFile, "utf8"); + + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed.users) ? parsed.users : []; + } catch (error) { + throw new Error(`Failed to parse users file at ${usersFile}: ${error.message}`); + } +}; + +const writeUsersUnlocked = async (users) => { + await ensureUsersFileUnlocked(); + const tempFile = `${usersFile}.${process.pid}.${Date.now()}.tmp`; + + // Write to a temporary file first, then atomically replace the target file. + await fs.writeFile(tempFile, JSON.stringify({ users }, null, 2), "utf8"); + await fs.rename(tempFile, usersFile); +}; + +const readUsersWithLock = async () => withUsersLock(readUsersUnlocked); + +const createUserIfNotExistsWithLock = async (newUser) => withUsersLock(async () => { + const users = await readUsersUnlocked(); + const existingUser = users.find((user) => user.email === newUser.email || user.username === newUser.username); + + if (existingUser) { + return false; + } + + users.push(newUser); + await writeUsersUnlocked(users); + return true; +}); + +const createSessionUser = (req, user) => { + req.session.authUser = { + id: user.id, + username: user.username, + email: user.email, + }; + + req.user = req.session.authUser; +}; + // Signup route router.post("/signup", validateRequest(signupSchema), async (req, res) => { const { username, email, password } = req.body; - + + if (!getUseMongoAuth()) { + try { + const salt = await bcrypt.genSalt(10); + const hashedPassword = await bcrypt.hash(password, salt); + + const newUser = { + id: crypto.randomUUID(), + username, + email, + password: hashedPassword, + }; + + const created = await createUserIfNotExistsWithLock(newUser); + + if (!created) { + return res.status(400).json({ message: 'User already exists' }); + } + + return res.status(201).json({ message: 'User created successfully' }); + } catch (err) { + return res.status(500).json({ message: 'Error creating user', error: err.message }); + } + } + try { const existingUser = await User.findOne({ $or: [{ email }, { username }], @@ -42,13 +164,73 @@ router.get("/me", (req, res) => { }); // Login route -router.post("/login", validateRequest(loginSchema), passport.authenticate('local'), (req, res) => { - res.status(200).json( { message: 'Login successful', user: req.user } ); +router.post("/login", validateRequest(loginSchema), async (req, res, next) => { + if (!getUseMongoAuth()) { + try { + const { email, password } = req.body; + const users = await readUsersWithLock(); + const user = users.find((item) => item.email === email); + + if (!user) { + return res.status(401).json({ message: 'Invalid email or password' }); + } + + const isMatch = await bcrypt.compare(password, user.password); + if (!isMatch) { + return res.status(401).json({ message: 'Invalid email or password' }); + } + + req.session.regenerate((err) => { + if (err) { + return res.status(500).json({ message: 'Login failed', error: err.message }); + } + + createSessionUser(req, user); + + return res.status(200).json({ + message: 'Login successful', + user: req.user, + }); + }); + } catch (err) { + return res.status(500).json({ message: 'Login failed', error: err.message }); + } + } + + return passport.authenticate('local', (err, user, info) => { + if (err) { + return next(err); + } + + if (!user) { + return res.status(401).json({ message: info?.message || 'Invalid credentials' }); + } + + req.logIn(user, (loginErr) => { + if (loginErr) { + return next(loginErr); + } + + return res.status(200).json({ message: 'Login successful', user: req.user }); + }); + })(req, res, next); }); // Logout route router.get("/logout", (req, res) => { + if (!getUseMongoAuth()) { + req.session.destroy((err) => { + if (err) { + return res.status(500).json({ message: 'Logout failed', error: err.message }); + } + + return res.status(200).json({ message: 'Logged out successfully' }); + }); + + return; + } + req.logout((err) => { if (err) { diff --git a/backend/routes/discussions.js b/backend/routes/discussions.js new file mode 100644 index 00000000..8c981697 --- /dev/null +++ b/backend/routes/discussions.js @@ -0,0 +1,351 @@ +const express = require('express'); +const fs = require('fs/promises'); +const path = require('path'); +const crypto = require('crypto'); +const { discussionSchema, commentSchema } = require('../validators/discussionValidator'); +const { validateRequest } = require('../validators/validationRequest'); + +const router = express.Router(); + +const dataDir = path.join(__dirname, '..', 'data'); +const dataFile = path.join(dataDir, 'discussions.json'); +let storeMutex = Promise.resolve(); + +const ensureDataFile = async () => { + await fs.mkdir(dataDir, { recursive: true }); + + try { + await fs.access(dataFile); + } catch { + await fs.writeFile(dataFile, JSON.stringify({ discussions: [] }, null, 2), 'utf8'); + } +}; + +const readStore = async () => { + await ensureDataFile(); + const raw = await fs.readFile(dataFile, 'utf8'); + + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed.discussions) ? parsed.discussions : []; + } catch (error) { + throw new Error(`Invalid discussions store JSON in ${dataFile}: ${error.message}`); + } +}; + +const writeStore = async (discussions) => { + await ensureDataFile(); + const tempFile = `${dataFile}.${process.pid}.${Date.now()}.tmp`; + + // Write to a temporary file first, then atomically replace the target file. + await fs.writeFile(tempFile, JSON.stringify({ discussions }, null, 2), 'utf8'); + await fs.rename(tempFile, dataFile); +}; + +const updateStore = async (updater) => { + let releaseLock; + const waitForTurn = storeMutex; + storeMutex = new Promise((resolve) => { + releaseLock = resolve; + }); + + await waitForTurn; + + try { + const discussions = await readStore(); + const result = await updater(discussions); + const nextDiscussions = Array.isArray(result?.discussions) ? result.discussions : discussions; + + if (result?.persist !== false) { + await writeStore(nextDiscussions); + } + + return { ...result, discussions: nextDiscussions }; + } finally { + releaseLock(); + } +}; + +const normalizeTags = (tags = []) => + tags + .map((tag) => String(tag).trim()) + .filter(Boolean) + .map((tag) => tag.replace(/^#?/, '').toLowerCase()); + +const requireAuth = (req, res, next) => { + if (!req.user) { + return res.status(401).json({ message: 'Authentication required' }); + } + + return next(); +}; + +const getAuthenticatedIdentity = (req) => ({ + id: req.user._id?.toString?.() || req.user.id || req.user.email || 'user', + name: req.user.username || req.user.email || 'Member', +}); + +const getCommunityIdentity = (req) => { + if (req.user) { + return { + id: req.user._id?.toString?.() || req.user.id || req.user.email || 'user', + name: req.user.username || req.user.email || 'Member', + }; + } + + if (!req.session.communityUserId) { + req.session.communityUserId = crypto.randomUUID(); + } + + if (!req.session.communityUserName) { + req.session.communityUserName = 'Guest'; + } + + return { + id: req.session.communityUserId, + name: req.session.communityUserName, + }; +}; + +const toPublicDiscussion = (discussion, currentUserId) => ({ + id: discussion.id, + title: discussion.title, + body: discussion.body, + category: discussion.category, + tags: discussion.tags, + author: { + id: discussion.authorId, + name: discussion.authorName, + }, + likesCount: discussion.likes.length, + commentsCount: discussion.comments.length, + likedByCurrentUser: currentUserId ? discussion.likes.includes(currentUserId) : false, + canEdit: currentUserId ? String(discussion.authorId) === String(currentUserId) : false, + comments: discussion.comments.map((comment) => ({ + id: comment.id, + text: comment.text, + author: { + id: comment.authorId, + name: comment.authorName, + }, + createdAt: comment.createdAt, + })), + createdAt: discussion.createdAt, + updatedAt: discussion.updatedAt, +}); + +router.get('/', async (req, res) => { + try { + const discussions = await readStore(); + const { search = '', category = '', tag = '', sort = 'recent' } = req.query; + const currentUserId = req.user ? getCommunityIdentity(req).id : (req.session?.communityUserId || null); + + let filtered = discussions; + + if (category) { + filtered = filtered.filter((discussion) => discussion.category === category); + } + + if (tag) { + const searchTag = String(tag).toLowerCase(); + filtered = filtered.filter((discussion) => discussion.tags.includes(searchTag)); + } + + if (search) { + const term = String(search).toLowerCase(); + filtered = filtered.filter((discussion) => { + const haystack = [discussion.title, discussion.body, discussion.category, discussion.tags.join(' ')].join(' ').toLowerCase(); + return haystack.includes(term); + }); + } + + filtered = filtered.sort((left, right) => { + if (sort === 'trending') { + const leftScore = left.likes.length * 2 + left.comments.length; + const rightScore = right.likes.length * 2 + right.comments.length; + + if (rightScore !== leftScore) { + return rightScore - leftScore; + } + } + + return new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(); + }); + + return res.status(200).json({ + items: filtered.map((discussion) => toPublicDiscussion(discussion, currentUserId)), + categories: Array.from(new Set(filtered.map((discussion) => discussion.category))).sort(), + isAuthenticated: !!req.user, + }); + } catch (error) { + return res.status(500).json({ message: 'Unable to load discussions', error: error.message }); + } +}); + +router.post('/', requireAuth, validateRequest(discussionSchema), async (req, res) => { + try { + const identity = getAuthenticatedIdentity(req); + const now = new Date().toISOString(); + + const discussion = { + id: crypto.randomUUID(), + title: String(req.body.title).trim(), + body: String(req.body.body).trim(), + category: String(req.body.category).trim(), + tags: normalizeTags(req.body.tags || []), + authorId: identity.id, + authorName: identity.name, + likes: [], + comments: [], + createdAt: now, + updatedAt: now, + }; + + await updateStore((discussions) => { + discussions.unshift(discussion); + return { discussions }; + }); + + return res.status(201).json({ + message: 'Discussion created successfully', + discussion: toPublicDiscussion(discussion, identity.id), + }); + } catch (error) { + return res.status(500).json({ message: 'Unable to create discussion', error: error.message }); + } +}); + +router.put('/:id', requireAuth, validateRequest(discussionSchema), async (req, res) => { + try { + const identity = getAuthenticatedIdentity(req); + const result = await updateStore((discussions) => { + const discussion = discussions.find((item) => item.id === req.params.id); + + if (!discussion) { + return { status: 404, message: 'Discussion not found', persist: false }; + } + + if (String(discussion.authorId) !== String(identity.id)) { + return { status: 403, message: 'You can only edit your own discussion', persist: false }; + } + + discussion.title = String(req.body.title).trim(); + discussion.body = String(req.body.body).trim(); + discussion.category = String(req.body.category).trim(); + discussion.tags = normalizeTags(req.body.tags || []); + discussion.updatedAt = new Date().toISOString(); + + return { discussions, discussion }; + }); + + if (result.status) { + return res.status(result.status).json({ message: result.message }); + } + + return res.status(200).json({ + message: 'Discussion updated successfully', + discussion: toPublicDiscussion(result.discussion, identity.id), + }); + } catch (error) { + return res.status(500).json({ message: 'Unable to update discussion', error: error.message }); + } +}); + +router.delete('/:id', requireAuth, async (req, res) => { + try { + const identity = getAuthenticatedIdentity(req); + const result = await updateStore((discussions) => { + const discussionIndex = discussions.findIndex((item) => item.id === req.params.id); + + if (discussionIndex === -1) { + return { status: 404, message: 'Discussion not found', persist: false }; + } + + if (String(discussions[discussionIndex].authorId) !== String(identity.id)) { + return { status: 403, message: 'You can only delete your own discussion', persist: false }; + } + + discussions.splice(discussionIndex, 1); + return { discussions }; + }); + + if (result.status) { + return res.status(result.status).json({ message: result.message }); + } + + return res.status(200).json({ message: 'Discussion deleted successfully' }); + } catch (error) { + return res.status(500).json({ message: 'Unable to delete discussion', error: error.message }); + } +}); + +router.post('/:id/likes', requireAuth, async (req, res) => { + try { + const identity = getAuthenticatedIdentity(req); + const result = await updateStore((discussions) => { + const discussion = discussions.find((item) => item.id === req.params.id); + + if (!discussion) { + return { status: 404, message: 'Discussion not found', persist: false }; + } + + const alreadyLiked = discussion.likes.includes(identity.id); + discussion.likes = alreadyLiked + ? discussion.likes.filter((likeId) => String(likeId) !== String(identity.id)) + : [...discussion.likes, identity.id]; + discussion.updatedAt = new Date().toISOString(); + + return { discussions, discussion, alreadyLiked }; + }); + + if (result.status) { + return res.status(result.status).json({ message: result.message }); + } + + return res.status(200).json({ + message: result.alreadyLiked ? 'Discussion unliked' : 'Discussion liked', + discussion: toPublicDiscussion(result.discussion, identity.id), + }); + } catch (error) { + return res.status(500).json({ message: 'Unable to update like state', error: error.message }); + } +}); + +router.post('/:id/comments', requireAuth, validateRequest(commentSchema), async (req, res) => { + try { + const commentText = req.body.text; + + const identity = getAuthenticatedIdentity(req); + const result = await updateStore((discussions) => { + const discussion = discussions.find((item) => item.id === req.params.id); + + if (!discussion) { + return { status: 404, message: 'Discussion not found', persist: false }; + } + + discussion.comments.push({ + id: crypto.randomUUID(), + text: commentText, + authorId: identity.id, + authorName: identity.name, + createdAt: new Date().toISOString(), + }); + discussion.updatedAt = new Date().toISOString(); + + return { discussions, discussion }; + }); + + if (result.status) { + return res.status(result.status).json({ message: result.message }); + } + + return res.status(201).json({ + message: 'Comment added successfully', + discussion: toPublicDiscussion(result.discussion, identity.id), + }); + } catch (error) { + return res.status(500).json({ message: 'Unable to add comment', error: error.message }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 211416be..88fef719 100644 --- a/backend/server.js +++ b/backend/server.js @@ -45,18 +45,48 @@ app.use(session({ app.use(passport.initialize()); app.use(passport.session()); +global.mongooseConnected = false; + +app.use((req, res, next) => { + if (!global.mongooseConnected) { + if (req.session?.authUser) { + req.user = req.session.authUser; + } + } + + next(); +}); + // Routes const authRoutes = require('./routes/auth'); +const discussionRoutes = require('./routes/discussions'); app.use('/api/auth', authRoutes); +app.use('/api/discussions', discussionRoutes); -// Connect to MongoDB -mongoose.connect(process.env.MONGO_URI, {}).then(() => { - logger.info('Connected to MongoDB'); +const startServer = () => { + const port = process.env.PORT || 5000; - const PORT = process.env.PORT || 5000; - app.listen(PORT, () => { - logger.info(`Server running on port ${PORT}`); + app.listen(port, () => { + logger.info(`Server running on port ${port}`); }); -}).catch((err) => { - logger.error('MongoDB connection error', err); -}); +}; + +// Connect to MongoDB when available, but do not block community discussions if it is not. +if (process.env.MONGO_URI) { + mongoose.connect(process.env.MONGO_URI, {}) + .then(() => { + logger.info('Connected to MongoDB'); + global.mongooseConnected = true; + startServer(); + }) + .catch((err) => { + logger.error('MongoDB connection error', err); + logger.warn('Starting without MongoDB; falling back to JSON file-backed authentication'); + global.mongooseConnected = false; + startServer(); + }); +} else { + logger.warn('MONGO_URI is not set; starting without MongoDB'); + global.mongooseConnected = false; + startServer(); +} diff --git a/backend/validators/discussionValidator.js b/backend/validators/discussionValidator.js new file mode 100644 index 00000000..2beb637a --- /dev/null +++ b/backend/validators/discussionValidator.js @@ -0,0 +1,30 @@ +const { z } = require('zod'); + +const discussionSchema = z.object({ + title: z + .string() + .trim() + .min(4, 'Title must be at least 4 characters long') + .max(140, 'Title must be at most 140 characters long'), + body: z + .string() + .trim() + .min(20, 'Post body must be at least 20 characters long') + .max(4000, 'Post body must be at most 4000 characters long'), + category: z + .string() + .trim() + .min(2, 'Category is required') + .max(60, 'Category must be at most 60 characters long'), + tags: z.array(z.string().trim().min(1).max(30)).max(6).default([]), +}); + +const commentSchema = z.object({ + text: z + .string() + .trim() + .min(2, 'Comment must be at least 2 characters long') + .max(1000, 'Comment must be at most 1000 characters long'), +}); + +module.exports = { discussionSchema, commentSchema }; \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index e26d3330..a771c098 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,7 +19,7 @@ function App() { {!isFullscreen && } -
+
diff --git a/src/Routes/Router.tsx b/src/Routes/Router.tsx index b811e093..79c8f446 100644 --- a/src/Routes/Router.tsx +++ b/src/Routes/Router.tsx @@ -7,9 +7,8 @@ import Signup from "../pages/Signup/Signup.tsx"; import Login from "../pages/Login/Login.tsx"; import ContributorProfile from "../pages/ContributorProfile/ContributorProfile.tsx"; import Home from "../pages/Home/Home.tsx"; -import ProfilePage from "../pages/Profile/ProfilePage.tsx"; -import EditProfilePage from "../pages/Profile/EditProfilePage.tsx"; -import Activity from "../pages/Activity.tsx"; +import Activity from "../pages/Activity.tsx"; +import Community from "../pages/Community/Community.tsx"; import PrivacyPolicy from "../pages/Privacy/PrivacyPolicy.tsx"; // ✅ Updated import path to match your new folder structure import RepoCompare from "../pages/RepoCompare/RepoCompare.tsx"; @@ -27,7 +26,8 @@ const Router = () => { }> }> } /> - } /> + } /> + } /> {/* Privacy Policy page route */} } /> diff --git a/src/components/AuthShell.tsx b/src/components/AuthShell.tsx new file mode 100644 index 00000000..afe9cd3f --- /dev/null +++ b/src/components/AuthShell.tsx @@ -0,0 +1,101 @@ +import React, { ReactNode } from "react"; + +type Highlight = { + title: string; + description: string; +}; + +interface AuthShellProps { + mode: "dark" | "light"; + badge: string; + title: string; + subtitle: string; + highlights: Highlight[]; + children: ReactNode; + footer: ReactNode; +} + +const AuthShell: React.FC = ({ mode, badge, title, subtitle, highlights, children, footer }) => { + const surfaceClass = + mode === "dark" + ? "bg-slate-950 text-white" + : "bg-[radial-gradient(circle_at_top,_rgba(255,255,255,0.92),_rgba(226,232,240,0.92))] text-slate-900"; + + const panelClass = + mode === "dark" + ? "border-white/10 bg-white/10 text-white shadow-[0_24px_80px_rgba(15,23,42,0.45)]" + : "border-slate-200/80 bg-white/85 text-slate-900 shadow-[0_24px_80px_rgba(15,23,42,0.12)]"; + + return ( +
+
+
+
+
+
+
+ +
+ + +
+
+ {children} +
{footer}
+
+
+
+
+ ); +}; + +export default AuthShell; \ No newline at end of file diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index eaf85e29..c604798a 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -72,26 +72,13 @@ const Navbar: React.FC = () => { Contributors - {!isLoading && !isAuthenticated && ( - <> - - Login - - - - Signup - - - )} + + Community + - {!isLoading && isAuthenticated && ( - - )} + + Login + {user && } {/* Theme Toggle */} @@ -157,8 +144,20 @@ const Navbar: React.FC = () => { Compare - - Contributors + + Community + + + + Login {!user && ( + new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + year: "numeric", + }).format(new Date(value)); + +const stripTags = (value: string) => + value + .split(",") + .map((tag) => tag.trim().replace(/^#/, "")) + .filter(Boolean); + +const getApiErrorMessage = (error: unknown, fallback: string) => { + if (!axios.isAxiosError(error)) { + return fallback; + } + + const firstValidationError = error.response?.data?.errors?.[0]?.message; + return firstValidationError || error.response?.data?.message || fallback; +}; + +export default function Community() { + const navigate = useNavigate(); + const themeContext = useContext(ThemeContext) as ThemeContextType; + const { mode } = themeContext; + + const [discussions, setDiscussions] = useState([]); + const [categories, setCategories] = useState(categoryPresets.slice(1)); + const [search, setSearch] = useState(""); + const [selectedCategory, setSelectedCategory] = useState("All"); + const [sortMode, setSortMode] = useState<"recent" | "trending">("trending"); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(""); + const [message, setMessage] = useState(""); + const [isAuthenticated, setIsAuthenticated] = useState(true); + const [form, setForm] = useState(emptyFormState); + const [editingId, setEditingId] = useState(null); + const [commentDrafts, setCommentDrafts] = useState>({}); + const [isSubmitting, setIsSubmitting] = useState(false); + + const debouncedSearch = useDebounce(search, 300); + + const filteredDiscussions = useMemo(() => discussions, [discussions]); + + const trendingDiscussions = useMemo( + () => [...filteredDiscussions] + .sort((left, right) => { + const leftScore = left.likesCount * 2 + left.commentsCount; + const rightScore = right.likesCount * 2 + right.commentsCount; + return rightScore - leftScore; + }) + .slice(0, 3), + [filteredDiscussions] + ); + + const loadDiscussions = useCallback(async () => { + setIsLoading(true); + setError(""); + + try { + const response = await axios.get(`${apiBase}/api/discussions`, { + params: { + search: debouncedSearch, + category: selectedCategory === "All" ? "" : selectedCategory, + sort: sortMode, + }, + withCredentials: true, + }); + + setDiscussions(response.data.items || []); + setCategories(response.data.categories?.length ? response.data.categories : categoryPresets.slice(1)); + setIsAuthenticated(!!response.data.isAuthenticated); + } catch (requestError) { + setError("Unable to load community discussions right now."); + if (axios.isAxiosError(requestError) && requestError.response?.status === 401) { + setIsAuthenticated(false); + } + } finally { + setIsLoading(false); + } + }, [debouncedSearch, selectedCategory, sortMode]); + + useEffect(() => { + void loadDiscussions(); + }, [loadDiscussions]); + + const resetForm = () => { + setForm(emptyFormState); + setEditingId(null); + }; + + const populateForEdit = (discussion: Discussion) => { + setForm({ + title: discussion.title, + body: discussion.body, + category: discussion.category, + tags: discussion.tags.join(", "), + }); + setEditingId(discussion.id); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setIsSubmitting(true); + setMessage(""); + + const tags = stripTags(form.tags); + if (tags.length > 6) { + setMessage("Use up to 6 tags per discussion."); + setIsSubmitting(false); + return; + } + + if (tags.some((tag) => tag.length > 30)) { + setMessage("Each tag must be 30 characters or fewer."); + setIsSubmitting(false); + return; + } + + const payload = { + title: form.title.trim(), + body: form.body.trim(), + category: form.category.trim(), + tags, + }; + + try { + const response = editingId + ? await axios.put(`${apiBase}/api/discussions/${editingId}`, payload, { withCredentials: true }) + : await axios.post(`${apiBase}/api/discussions`, payload, { withCredentials: true }); + + setMessage(response.data.message || "Saved successfully"); + resetForm(); + await loadDiscussions(); + } catch (requestError) { + if (axios.isAxiosError(requestError) && requestError.response?.status === 401) { + setIsAuthenticated(false); + setMessage("Sign in to create and manage discussions."); + return; + } + + setMessage(getApiErrorMessage(requestError, "Unable to save discussion.")); + } finally { + setIsSubmitting(false); + } + }; + + const handleDelete = async (discussionId: string) => { + try { + await axios.delete(`${apiBase}/api/discussions/${discussionId}`, { withCredentials: true }); + await loadDiscussions(); + setMessage("Discussion deleted."); + } catch (requestError) { + if (axios.isAxiosError(requestError) && requestError.response?.status === 401) { + setIsAuthenticated(false); + setMessage("Sign in to delete your discussion."); + return; + } + setMessage("Unable to delete discussion."); + } + }; + + const handleLike = async (discussionId: string) => { + try { + await axios.post(`${apiBase}/api/discussions/${discussionId}/likes`, {}, { withCredentials: true }); + await loadDiscussions(); + } catch (requestError) { + if (axios.isAxiosError(requestError) && requestError.response?.status === 401) { + setIsAuthenticated(false); + setMessage("Sign in to like discussions."); + return; + } + setMessage("Unable to update like state."); + } + }; + + const handleComment = async (discussionId: string) => { + const commentText = commentDrafts[discussionId]?.trim(); + if (!commentText || commentText.length < 2) { + setMessage("Comment must be at least 2 characters long."); + return; + } + + if (commentText.length > 1000) { + setMessage("Comment must be at most 1000 characters long."); + return; + } + + try { + await axios.post( + `${apiBase}/api/discussions/${discussionId}/comments`, + { text: commentText }, + { withCredentials: true } + ); + setCommentDrafts((current) => ({ ...current, [discussionId]: "" })); + await loadDiscussions(); + } catch (requestError) { + if (axios.isAxiosError(requestError) && requestError.response?.status === 401) { + setIsAuthenticated(false); + setMessage("Sign in to comment on discussions."); + return; + } + setMessage(getApiErrorMessage(requestError, "Unable to add comment.")); + } + }; + + const heroTitle = mode === "dark" ? "text-white" : "text-slate-950"; + const heroCopy = mode === "dark" ? "text-slate-300" : "text-slate-600"; + const shell = mode === "dark" ? "bg-slate-950 text-white" : "bg-slate-50 text-slate-950"; + + return ( +
+
+
+
+
+ +
+
+
+
+ + + Community Discussions + +

+ Build ideas, ask questions, and ship together. +

+

+ A dedicated space for GitHub community conversations, product feedback, open-source questions, + and developer collaboration. +

+
+ +
+
+

Posts

+

{discussions.length}

+
+
+

Trending

+

{trendingDiscussions.length}

+
+
+

Categories

+

{categories.length}

+
+
+
+ +
+ + + Authenticated users can create, edit, delete, like, and comment. + +
+
+ +
+
+
+
+
+

Discussion feed

+

+ Search, filter, and follow the most active conversations. +

+
+ +
+ + +
+
+ +
+ + +
+ +
+
+ +
+ {categoryPresets.map((category) => ( + + ))} +
+
+ + {isAuthenticated ? null : ( +
+ You can read discussions without signing in, but posting, editing, commenting, and liking require a logged-in session. +
+ )} + + {error ? ( +
+ {error} +
+ ) : null} + + {message ? ( +
+ {message} +
+ ) : null} + +
+
+
+

{editingId ? "Edit discussion" : "Create a discussion"}

+

+ Share ideas, ask questions, or start a thread around a repository or technology. +

+
+ + {editingId ? ( + + ) : null} +
+ +
+ setForm((current) => ({ ...current, title: event.target.value }))} + placeholder="Discussion title" + className="w-full rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm outline-none transition focus:border-cyan-500 dark:border-slate-800 dark:bg-slate-950" + /> + +