From e67574425de3a507181cfac2d676e4d392716508 Mon Sep 17 00:00:00 2001 From: arpit2006 Date: Wed, 27 May 2026 17:40:16 +0530 Subject: [PATCH 1/6] Add community discussions feature --- backend/data/discussions.json | 20 + backend/data/users.json | 22 + backend/models/Discussion.js | 72 +++ backend/routes/auth.js | 131 ++++- backend/routes/discussions.js | 324 +++++++++++ backend/server.js | 50 +- backend/validators/discussionValidator.js | 30 + src/App.tsx | 2 +- src/Routes/Router.tsx | 3 + src/components/Navbar.tsx | 12 + src/pages/Community/Community.tsx | 640 ++++++++++++++++++++++ src/pages/Login/Login.tsx | 4 +- src/pages/Signup/Signup.tsx | 8 +- 13 files changed, 1301 insertions(+), 17 deletions(-) create mode 100644 backend/data/discussions.json create mode 100644 backend/data/users.json create mode 100644 backend/models/Discussion.js create mode 100644 backend/routes/discussions.js create mode 100644 backend/validators/discussionValidator.js create mode 100644 src/pages/Community/Community.tsx diff --git a/backend/data/discussions.json b/backend/data/discussions.json new file mode 100644 index 00000000..1e3283cf --- /dev/null +++ b/backend/data/discussions.json @@ -0,0 +1,20 @@ +{ + "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": [], + "comments": [], + "createdAt": "2026-05-27T11:55:11.307Z", + "updatedAt": "2026-05-27T12:08:46.911Z" + } + ] +} \ No newline at end of file diff --git a/backend/data/users.json b/backend/data/users.json new file mode 100644 index 00000000..14f6be25 --- /dev/null +++ b/backend/data/users.json @@ -0,0 +1,22 @@ +{ + "users": [ + { + "id": "a95e96b8-c655-4122-b949-9a9a2e166e96", + "username": "testuser_api", + "email": "testuser_api@example.com", + "password": "$2a$10$.jtIXdJ7BOg4Sdg2YZaVD.UcsOJYXYkvjXDcur6Duk6Ds74yC2XjO" + }, + { + "id": "7a565adc-7c6b-4254-bd38-874c22d88892", + "username": "arpitshirbhate", + "email": "arpitshirbhate2@gmail.com", + "password": "$2a$10$xFIsrIOJYpWpaNcm.OiKJesWnGy5nsWT11v9kVPepkT7HfvkNMpDK" + }, + { + "id": "a6775248-35ef-410c-b529-220e350210f2", + "username": "arpit", + "email": "arpitshirbhate5@gmail.com", + "password": "$2a$10$OZ.3v4/LRDIB0Z1mMTvR8uL97b9UODDje1GJmBcfnio4gdw9pCXEu" + } + ] +} \ No newline at end of file diff --git a/backend/models/Discussion.js b/backend/models/Discussion.js new file mode 100644 index 00000000..87dc9126 --- /dev/null +++ b/backend/models/Discussion.js @@ -0,0 +1,72 @@ +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: String, + trim: true, + maxlength: 30, + }, + ], + authorId: { + type: String, + required: true, + }, + authorName: { + type: String, + required: true, + trim: true, + }, + likes: [ + { + type: String, + }, + ], + comments: [CommentSchema], + }, + { 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 7c2cda78..4177b911 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -1,15 +1,88 @@ 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 useMongoAuth = Boolean(process.env.MONGO_URI); +const dataDir = path.join(__dirname, "..", "data"); +const usersFile = path.join(dataDir, "users.json"); + +const ensureUsersFile = async () => { + await fs.mkdir(dataDir, { recursive: true }); + + try { + await fs.access(usersFile); + } catch { + await fs.writeFile(usersFile, JSON.stringify({ users: [] }, null, 2), "utf8"); + } +}; + +const readUsers = async () => { + await ensureUsersFile(); + const raw = await fs.readFile(usersFile, "utf8"); + + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed.users) ? parsed.users : []; + } catch { + return []; + } +}; + +const writeUsers = async (users) => { + await ensureUsersFile(); + await fs.writeFile(usersFile, JSON.stringify({ users }, null, 2), "utf8"); +}; + +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 (!useMongoAuth) { + try { + const users = await readUsers(); + const existingUser = users.find((user) => user.email === email || user.username === username); + + if (existingUser) { + return res.status(400).json({ message: 'User already exists' }); + } + + const salt = await bcrypt.genSalt(10); + const hashedPassword = await bcrypt.hash(password, salt); + + const newUser = { + id: crypto.randomUUID(), + username, + email, + password: hashedPassword, + }; + + users.push(newUser); + await writeUsers(users); + + 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 }], @@ -31,13 +104,67 @@ router.post("/signup", validateRequest(signupSchema), async (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 (!useMongoAuth) { + try { + const { email, password } = req.body; + const users = await readUsers(); + const user = users.find((item) => item.email === email); + + if (!user) { + return res.status(401).json({ message: 'Email is invalid ' }); + } + + const isMatch = await bcrypt.compare(password, user.password); + if (!isMatch) { + return res.status(401).json({ message: 'Invalid password' }); + } + + 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 (!useMongoAuth) { + 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..809fd77f --- /dev/null +++ b/backend/routes/discussions.js @@ -0,0 +1,324 @@ +const express = require('express'); +const fs = require('fs/promises'); +const path = require('path'); +const crypto = require('crypto'); + +const router = express.Router(); + +const dataDir = path.join(__dirname, '..', 'data'); +const dataFile = path.join(dataDir, 'discussions.json'); + +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 { + return []; + } +}; + +const writeStore = async (discussions) => { + await ensureDataFile(); + await fs.writeFile(dataFile, JSON.stringify({ discussions }, null, 2), 'utf8'); +}; + +const normalizeTags = (tags = []) => + tags + .map((tag) => String(tag).trim()) + .filter(Boolean) + .map((tag) => tag.replace(/^#?/, '').toLowerCase()); + +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, +}); + +const validatePayload = (payload) => { + const errors = []; + + if (!payload.title || String(payload.title).trim().length < 4) { + errors.push({ field: 'title', message: 'Title must be at least 4 characters long' }); + } + + if (!payload.body || String(payload.body).trim().length < 20) { + errors.push({ field: 'body', message: 'Post body must be at least 20 characters long' }); + } + + if (!payload.category || String(payload.category).trim().length < 2) { + errors.push({ field: 'category', message: 'Category is required' }); + } + + if (payload.tags && !Array.isArray(payload.tags)) { + errors.push({ field: 'tags', message: 'Tags must be an array of strings' }); + } + + return errors; +}; + +router.get('/', async (req, res) => { + try { + const discussions = await readStore(); + const { search = '', category = '', tag = '', sort = 'recent' } = req.query; + const currentUserId = getCommunityIdentity(req).id; + + 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(), + }); + } catch (error) { + return res.status(500).json({ message: 'Unable to load discussions', error: error.message }); + } +}); + +router.post('/', async (req, res) => { + try { + const errors = validatePayload(req.body); + if (errors.length > 0) { + return res.status(400).json({ success: false, message: 'Validation failed', errors }); + } + + const discussions = await readStore(); + const identity = getCommunityIdentity(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, + }; + + discussions.unshift(discussion); + await writeStore(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', async (req, res) => { + try { + const errors = validatePayload(req.body); + if (errors.length > 0) { + return res.status(400).json({ success: false, message: 'Validation failed', errors }); + } + + const discussions = await readStore(); + const identity = getCommunityIdentity(req); + const discussion = discussions.find((item) => item.id === req.params.id); + + if (!discussion) { + return res.status(404).json({ message: 'Discussion not found' }); + } + + if (String(discussion.authorId) !== String(identity.id)) { + return res.status(403).json({ message: 'You can only edit your own discussion' }); + } + + 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(); + + await writeStore(discussions); + + return res.status(200).json({ + message: 'Discussion updated successfully', + discussion: toPublicDiscussion(discussion, identity.id), + }); + } catch (error) { + return res.status(500).json({ message: 'Unable to update discussion', error: error.message }); + } +}); + +router.delete('/:id', async (req, res) => { + try { + const discussions = await readStore(); + const identity = getCommunityIdentity(req); + const discussionIndex = discussions.findIndex((item) => item.id === req.params.id); + + if (discussionIndex === -1) { + return res.status(404).json({ message: 'Discussion not found' }); + } + + if (String(discussions[discussionIndex].authorId) !== String(identity.id)) { + return res.status(403).json({ message: 'You can only delete your own discussion' }); + } + + discussions.splice(discussionIndex, 1); + await writeStore(discussions); + + 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', async (req, res) => { + try { + const discussions = await readStore(); + const identity = getCommunityIdentity(req); + const discussion = discussions.find((item) => item.id === req.params.id); + + if (!discussion) { + return res.status(404).json({ message: 'Discussion not found' }); + } + + 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(); + + await writeStore(discussions); + + return res.status(200).json({ + message: alreadyLiked ? 'Discussion unliked' : 'Discussion liked', + discussion: toPublicDiscussion(discussion, identity.id), + }); + } catch (error) { + return res.status(500).json({ message: 'Unable to update like state', error: error.message }); + } +}); + +router.post('/:id/comments', async (req, res) => { + try { + const commentText = String(req.body?.text || '').trim(); + if (commentText.length < 2) { + return res.status(400).json({ + success: false, + message: 'Validation failed', + errors: [{ field: 'text', message: 'Comment must be at least 2 characters long' }], + }); + } + + const discussions = await readStore(); + const identity = getCommunityIdentity(req); + const discussion = discussions.find((item) => item.id === req.params.id); + + if (!discussion) { + return res.status(404).json({ message: 'Discussion not found' }); + } + + discussion.comments.push({ + id: crypto.randomUUID(), + text: commentText, + authorId: identity.id, + authorName: identity.name, + createdAt: new Date().toISOString(), + }); + discussion.updatedAt = new Date().toISOString(); + + await writeStore(discussions); + + return res.status(201).json({ + message: 'Comment added successfully', + discussion: toPublicDiscussion(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 e9b43f83..465a2056 100644 --- a/backend/server.js +++ b/backend/server.js @@ -14,7 +14,11 @@ const logger = require('./logger'); const app = express(); // CORS configuration -app.use(cors('*')); +const clientOrigin = process.env.CLIENT_URL || 'http://localhost:5173'; +app.use(cors({ + origin: clientOrigin, + credentials: true, +})); // Middleware app.use(bodyParser.json()); @@ -26,16 +30,44 @@ app.use(session({ app.use(passport.initialize()); app.use(passport.session()); +// When MongoDB is unavailable, auth routes use a file-backed session user. +if (!process.env.MONGO_URI) { + app.use((req, res, next) => { + 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); + +const startServer = () => { + const port = process.env.PORT || 5000; -// Connect to MongoDB -mongoose.connect(process.env.MONGO_URI, {}).then(() => { - logger.info('Connected to MongoDB'); - app.listen(process.env.PORT, () => { - logger.info(`Server running on port ${process.env.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'); + startServer(); + }) + .catch((err) => { + logger.error('MongoDB connection error', err); + logger.warn('Starting without MongoDB; auth routes may fail, but the discussion backend remains available'); + startServer(); + }); +} else { + logger.warn('MONGO_URI is not set; starting without MongoDB'); + 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 8eafb448..f7924f31 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,7 +17,7 @@ function App() { {!isFullscreen && } -
+
diff --git a/src/Routes/Router.tsx b/src/Routes/Router.tsx index 51eebc32..6e401188 100644 --- a/src/Routes/Router.tsx +++ b/src/Routes/Router.tsx @@ -8,6 +8,7 @@ import Login from "../pages/Login/Login.tsx"; import ContributorProfile from "../pages/ContributorProfile/ContributorProfile.tsx"; import Home from "../pages/Home/Home.tsx"; import Activity from "../pages/Activity.tsx"; +import Community from "../pages/Community/Community.tsx"; const Router = () => { return ( @@ -23,6 +24,8 @@ const Router = () => { {/* ✅ new route */} } /> + } /> + } /> ); }; diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index fd5eac86..2a414432 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -53,6 +53,10 @@ const Navbar: React.FC = () => { Contributors + + Community + + Login @@ -131,6 +135,14 @@ const Navbar: React.FC = () => { Contributors + + Community + + + 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); + +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)); + } 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 payload = { + title: form.title.trim(), + body: form.body.trim(), + category: form.category.trim(), + tags: stripTags(form.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; + } + + const responseMessage = axios.isAxiosError(requestError) + ? requestError.response?.data?.message + : "Unable to save discussion."; + setMessage(responseMessage || "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) { + 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("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" + /> + +