From f54f237dd981b322de0bf5c73d94566f01704d68 Mon Sep 17 00:00:00 2001 From: Thibault Pouch Date: Tue, 3 Mar 2026 09:48:07 +0100 Subject: [PATCH 01/24] feat: integrate API calls for forum, bug, and event pages; replace mock data with dynamic data fetching --- nest-front/src/pages/public/AccountPage.tsx | 49 +++-- nest-front/src/pages/public/BugDetailPage.tsx | 97 +++++---- nest-front/src/pages/public/BugReportPage.tsx | 199 ++++++++++-------- nest-front/src/pages/public/EventsPage.tsx | 103 ++++----- nest-front/src/pages/public/ForumPage.tsx | 68 ++++-- nest-front/src/pages/public/StudioPage.tsx | 14 +- nest-front/src/pages/public/ThreadPage.tsx | 74 +++++-- 7 files changed, 363 insertions(+), 241 deletions(-) diff --git a/nest-front/src/pages/public/AccountPage.tsx b/nest-front/src/pages/public/AccountPage.tsx index 6f89be0..ac11370 100644 --- a/nest-front/src/pages/public/AccountPage.tsx +++ b/nest-front/src/pages/public/AccountPage.tsx @@ -1,17 +1,29 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { useAuth } from '../../contexts/AuthContext'; -import { MOCK_THREADS, MOCK_BUGS } from '../../data/mockData'; +import { bugsApi, forumApi, usersApi } from '../../utils/api'; import { formatDate } from '../../utils/format'; import { Link } from 'react-router-dom'; +import type { BugReport, ForumThread } from '../../types'; type Tab = 'profile' | 'threads' | 'bugs' | 'password'; export default function AccountPage() { const { user, updateUsername } = useAuth(); const [activeTab, setActiveTab] = useState('profile'); + const [userThreads, setUserThreads] = useState([]); + const [userBugs, setUserBugs] = useState([]); - const userThreads = MOCK_THREADS.filter((t) => t.authorId === user?.id); - const userBugs = MOCK_BUGS.filter((b) => b.submittedById === user?.id); + useEffect(() => { + if (!user) return; + + forumApi.getThreads({ limit: 200 }) + .then((res) => setUserThreads(res.data.filter((t) => t.authorId === user.id))) + .catch(() => setUserThreads([])); + + bugsApi.getBugs({ limit: 200 }) + .then((res) => setUserBugs(res.data.filter((b) => b.submittedById === user.id))) + .catch(() => setUserBugs([])); + }, [user]); const tabs: { id: Tab; label: string }[] = [ { id: 'profile', label: 'Profile' }, @@ -121,19 +133,23 @@ export default function AccountPage() { // ── Profile Tab ──────────────────────────────────────────────────────────────── -function ProfileTab({ user, updateUsername }: { user: NonNullable['user']>; updateUsername: (u: string) => void }) { +function ProfileTab({ user, updateUsername }: { user: NonNullable['user']>; updateUsername: (u: string) => Promise<{ success: boolean; error?: string }> }) { const [editing, setEditing] = useState(false); const [username, setUsername] = useState(user.username); const [error, setError] = useState(''); const [saved, setSaved] = useState(false); - const handleSave = useCallback(() => { + const handleSave = useCallback(async () => { if (!username.trim()) { setError('Username cannot be empty.'); return; } if (username.length < 3) { setError('Must be at least 3 characters.'); return; } - updateUsername(username.trim()); - setEditing(false); - setSaved(true); - setTimeout(() => setSaved(false), 3000); + const result = await updateUsername(username.trim()); + if (result.success) { + setEditing(false); + setSaved(true); + setTimeout(() => setSaved(false), 3000); + } else { + setError(result.error ?? 'Failed to update username.'); + } }, [username, updateUsername]); return ( @@ -210,10 +226,15 @@ function ChangePasswordForm() { if (Object.keys(next).length > 0) return; setLoading(true); - await new Promise((r) => setTimeout(r, 400)); - setLoading(false); - setForm({ current: '', next: '', confirm: '' }); - setErrors({ success: 'Password changed successfully.' }); + try { + await usersApi.changePassword(form.current, form.next); + setForm({ current: '', next: '', confirm: '' }); + setErrors({ success: 'Password changed successfully.' }); + } catch (err) { + setErrors({ current: err instanceof Error ? err.message : 'Failed to change password.' }); + } finally { + setLoading(false); + } }, [form]); return ( diff --git a/nest-front/src/pages/public/BugDetailPage.tsx b/nest-front/src/pages/public/BugDetailPage.tsx index 96417dc..9e03630 100644 --- a/nest-front/src/pages/public/BugDetailPage.tsx +++ b/nest-front/src/pages/public/BugDetailPage.tsx @@ -1,6 +1,6 @@ -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback, useEffect, useMemo } from 'react'; import { Link, Navigate, useParams } from 'react-router-dom'; -import { MOCK_BUGS, MOCK_BUG_COMMENTS } from '../../data/mockData'; +import { bugsApi, ApiError } from '../../utils/api'; import { useAuth } from '../../contexts/AuthContext'; import { formatDate, formatDateTime } from '../../utils/format'; import type { BugComment, BugReport, BugSeverity, BugStatus } from '../../types'; @@ -57,21 +57,36 @@ export default function BugDetailPage() { const { id } = useParams<{ id: string }>(); const { user, isAuthenticated } = useAuth(); - // Local state — mirrors the global bug list in memory - const [bugs, setBugs] = useState(MOCK_BUGS); - const [comments, setComments] = useState(MOCK_BUG_COMMENTS); + const [bug, setBug] = useState(null); + const [comments, setComments] = useState([]); + const [loading, setLoading] = useState(true); + const [notFound, setNotFound] = useState(false); + const [newComment, setNewComment] = useState(''); const [commentError, setCommentError] = useState(''); const [submitting, setSubmitting] = useState(false); - const bug = useMemo(() => bugs.find((b) => b.id === id), [bugs, id]); + useEffect(() => { + if (!id) return; + let cancelled = false; + setLoading(true); - const bugComments = useMemo( - () => comments.filter((c) => c.bugReportId === id).sort((a, b) => a.createdAt.localeCompare(b.createdAt)), - [comments, id] - ); + bugsApi.getBug(id) + .then((data) => { + if (cancelled) return; + const { comments: bugComments, ...bugData } = data; + setBug(bugData); + setComments(bugComments.sort((a, b) => a.createdAt.localeCompare(b.createdAt))); + }) + .catch((err) => { + if (cancelled) return; + if (err instanceof ApiError && err.status === 404) setNotFound(true); + }) + .finally(() => { if (!cancelled) setLoading(false); }); + + return () => { cancelled = true; }; + }, [id]); - // "I have this too" logic const alreadyVoted = useMemo( () => !!user && !!bug && bug.meTooBugs.includes(user.id), [user, bug] @@ -81,38 +96,44 @@ export default function BugDetailPage() { [user, bug] ); - const handleMeToo = useCallback(() => { + const handleMeToo = useCallback(async () => { if (!user || !bug || alreadyVoted || isOwnReport) return; - setBugs((prev) => - prev.map((b) => - b.id === bug.id ? { ...b, meTooBugs: [...b.meTooBugs, user.id] } : b - ) - ); + try { + await bugsApi.toggleMeToo(bug.id); + setBug((prev) => prev ? { ...prev, meTooBugs: [...prev.meTooBugs, user.id] } : prev); + } catch { + // silently ignore + } }, [user, bug, alreadyVoted, isOwnReport]); const handleComment = useCallback(async () => { - if (!user) return; + if (!user || !bug) return; if (!newComment.trim()) { setCommentError('Comment cannot be empty.'); return; } if (newComment.trim().length < 5) { setCommentError('Comment must be at least 5 characters.'); return; } setCommentError(''); setSubmitting(true); - await new Promise((r) => setTimeout(r, 250)); - const comment: BugComment = { - id: `bc${Date.now()}`, - bugReportId: id!, - authorId: user.id, - authorName: user.username, - content: newComment.trim(), - createdAt: new Date().toISOString(), - }; - setComments((prev) => [...prev, comment]); - setNewComment(''); - setSubmitting(false); - }, [user, newComment, id]); + try { + const comment = await bugsApi.addComment(bug.id, newComment.trim()); + setComments((prev) => [...prev, comment]); + setNewComment(''); + } catch (err) { + setCommentError(err instanceof Error ? err.message : 'Failed to post comment.'); + } finally { + setSubmitting(false); + } + }, [user, bug, newComment]); - if (!bug) { + if (loading) { + return ( +
+ Loading... +
+ ); + } + + if (notFound || !bug) { return ; } @@ -129,7 +150,6 @@ export default function BugDetailPage() { {/* Header */}
- {/* Badges */}
{bug.uniqueCode} @@ -138,7 +158,6 @@ export default function BugDetailPage() {
- {/* Title */}

- {/* Count */}
{metooCount}{' '} {metooCount === 1 ? 'user has' : 'users have'} this issue
- {/* Button logic */} {!isAuthenticated ? (
Login to confirm you have this issue @@ -285,22 +302,20 @@ export default function BugDetailPage() { Discussion - {bugComments.length} + {comments.length}
- {/* Comment list */} - {bugComments.length === 0 ? ( + {comments.length === 0 ? (
No comments yet. Be the first to comment.
) : ( - bugComments.map((comment) => ( + comments.map((comment) => ( )) )} - {/* Add comment */}
{isAuthenticated ? (
diff --git a/nest-front/src/pages/public/BugReportPage.tsx b/nest-front/src/pages/public/BugReportPage.tsx index 79caba8..bd78f6e 100644 --- a/nest-front/src/pages/public/BugReportPage.tsx +++ b/nest-front/src/pages/public/BugReportPage.tsx @@ -1,6 +1,6 @@ -import { useState, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback, useEffect } from 'react'; import { Link, useNavigate } from 'react-router-dom'; -import { MOCK_BUGS } from '../../data/mockData'; +import { bugsApi } from '../../utils/api'; import { useAuth } from '../../contexts/AuthContext'; import { timeAgo } from '../../utils/format'; import type { BugReport, BugSeverity, BugStatus, BugReportFormData } from '../../types'; @@ -66,7 +66,6 @@ function BugCard({ bug, highlight }: BugCardProps) { - {/* MeToo count */} void }) { +function SubmitBugForm({ onSubmit }: { onSubmit: (data: BugReportFormData) => Promise }) { const [form, setForm] = useState({ title: '', description: '', @@ -128,6 +127,8 @@ function SubmitBugForm({ onSubmit }: { onSubmit: (data: BugReportFormData) => vo }); const [errors, setErrors] = useState>>({}); const [submitted, setSubmitted] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(''); const set = useCallback((key: K, value: BugReportFormData[K]) => { setForm((prev) => ({ ...prev, [key]: value })); @@ -148,9 +149,16 @@ function SubmitBugForm({ onSubmit }: { onSubmit: (data: BugReportFormData) => vo const handleSubmit = useCallback(async (e: React.FormEvent) => { e.preventDefault(); if (!validate()) return; - await new Promise((r) => setTimeout(r, 400)); - onSubmit(form); - setSubmitted(true); + setSubmitting(true); + setSubmitError(''); + try { + await onSubmit(form); + setSubmitted(true); + } catch (err) { + setSubmitError(err instanceof Error ? err.message : 'Failed to submit report.'); + } finally { + setSubmitting(false); + } }, [form, onSubmit]); const labelStyle: React.CSSProperties = { @@ -186,6 +194,12 @@ function SubmitBugForm({ onSubmit }: { onSubmit: (data: BugReportFormData) => vo ▶ Submit a Bug Report
+ {submitError && ( +
+ [ERROR] {submitError} +
+ )} +
vo />
-
@@ -262,44 +276,37 @@ function SubmitBugForm({ onSubmit }: { onSubmit: (data: BugReportFormData) => vo export default function BugReportPage() { const { user, isAuthenticated } = useAuth(); - const [bugs, setBugs] = useState(MOCK_BUGS); + const [bugs, setBugs] = useState([]); + const [loading, setLoading] = useState(true); const [statusFilter, setStatusFilter] = useState('all'); const [severityFilter, setSeverityFilter] = useState('all'); const [showForm, setShowForm] = useState(false); - // Separate: user's own bugs and all others, both filtered + const fetchBugs = useCallback(() => { + setLoading(true); + bugsApi.getBugs({ status: statusFilter, severity: severityFilter, limit: 100 }) + .then((res) => setBugs(res.data)) + .catch(() => setBugs([])) + .finally(() => setLoading(false)); + }, [statusFilter, severityFilter]); + + useEffect(() => { fetchBugs(); }, [fetchBugs]); + const { myBugs, otherBugs } = useMemo(() => { - const passes = (b: BugReport) => { - if (statusFilter !== 'all' && b.status !== statusFilter) return false; - if (severityFilter !== 'all' && b.severity !== severityFilter) return false; - return true; - }; const my: BugReport[] = []; const other: BugReport[] = []; bugs.forEach((b) => { - if (!passes(b)) return; if (user && b.submittedById === user.id) my.push(b); else other.push(b); }); return { myBugs: my, otherBugs: other }; - }, [bugs, statusFilter, severityFilter, user]); + }, [bugs, user]); - const handleNewReport = useCallback((data: BugReportFormData) => { - const newBug: BugReport = { - id: `bug${Date.now()}`, - uniqueCode: `HH-${String(bugs.length + 1).padStart(4, '0')}`, - ...data, - status: 'open', - submittedById: user?.id ?? 'unknown', - submittedByName: user?.username ?? 'You', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - notes: [], - meTooBugs: [], - }; + const handleNewReport = useCallback(async (data: BugReportFormData) => { + const newBug = await bugsApi.createBug(data); setBugs((prev) => [newBug, ...prev]); setShowForm(false); - }, [bugs.length, user]); + }, []); const openCount = bugs.filter((b) => b.status === 'open').length; const inProgressCount = bugs.filter((b) => b.status === 'in_progress').length; @@ -373,68 +380,78 @@ export default function BugReportPage() {

- {/* "Your Reports" section — only for logged-in users with their own bugs */} - {isAuthenticated && myBugs.length > 0 && ( -
-
- - ▶ Your Reports - - - {myBugs.length} - -
- {myBugs.map((bug) => ( - - ))} -
+ {loading && ( +
+ Loading reports... +
)} - {/* All other reports */} -
- {isAuthenticated && myBugs.length > 0 && ( -
- - All Reports - - - {otherBugs.length} - -
- )} + {!loading && ( + <> + {/* "Your Reports" section */} + {isAuthenticated && myBugs.length > 0 && ( +
+
+ + ▶ Your Reports + + + {myBugs.length} + +
+ {myBugs.map((bug) => ( + + ))} +
+ )} - {otherBugs.length === 0 && myBugs.length === 0 ? ( -
- No bug reports match the selected filters. -
- ) : otherBugs.length === 0 && isAuthenticated ? ( -
- No other reports match the selected filters. -
- ) : ( - otherBugs.map((bug) => ( - - )) - )} -
+ {/* All other reports */} +
+ {isAuthenticated && myBugs.length > 0 && ( +
+ + All Reports + + + {otherBugs.length} + +
+ )} + + {otherBugs.length === 0 && myBugs.length === 0 ? ( +
+ No bug reports match the selected filters. +
+ ) : otherBugs.length === 0 && isAuthenticated ? ( +
+ No other reports match the selected filters. +
+ ) : ( + otherBugs.map((bug) => ( + + )) + )} +
+ + )} ); } diff --git a/nest-front/src/pages/public/EventsPage.tsx b/nest-front/src/pages/public/EventsPage.tsx index cd2ae91..d7c297a 100644 --- a/nest-front/src/pages/public/EventsPage.tsx +++ b/nest-front/src/pages/public/EventsPage.tsx @@ -1,5 +1,5 @@ -import { useState, useCallback } from 'react'; -import { MOCK_EVENTS, MOCK_POLLS } from '../../data/mockData'; +import { useState, useCallback, useEffect } from 'react'; +import { eventsApi } from '../../utils/api'; import { useAuth } from '../../contexts/AuthContext'; import { formatDateTime } from '../../utils/format'; import type { EventPost, EventType, Poll, UserRole } from '../../types'; @@ -54,7 +54,7 @@ function PollCard({ poll, onVote }: { poll: Poll; onVote: (pollId: string, optio
{poll.options.map((option) => { const percentage = totalVotes > 0 ? Math.round((option.votes / totalVotes) * 100) : 0; - const userVoted = option.votedUserIds.includes(user?.id || ''); + const userVoted = option.votedUserIds.includes(user?.id ?? ''); return (
void; + onVote: (eventId: string, pollId: string, optionId: string) => void; }) { return (
{/* Poll if exists */} - {poll && } + {event.poll && ( + onVote(event.id, pollId, optionId)} + /> + )}
); } @@ -249,48 +252,28 @@ function EventCard({ export default function EventsPage() { const { user } = useAuth(); - // Filter to show only public events - const publicEvents = MOCK_EVENTS.filter((event) => event.isPublic); - const [events] = useState(publicEvents); - const [polls, setPolls] = useState(MOCK_POLLS); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + eventsApi.getEvents(true) + .then((res) => setEvents(res.data)) + .catch(() => setEvents([])) + .finally(() => setLoading(false)); + }, []); const handleVote = useCallback( - (pollId: string, optionId: string) => { + async (eventId: string, _pollId: string, optionId: string) => { if (!user) return; - setPolls((prevPolls) => - prevPolls.map((poll) => { - if (poll.id !== pollId) return poll; - - const hasVotedForOption = poll.options.some((opt) => - opt.votedUserIds.includes(user.id) - ); - - return { - ...poll, - options: poll.options.map((opt) => { - if (opt.id === optionId) { - // Add vote to this option - return { - ...opt, - votes: opt.votedUserIds.includes(user.id) ? opt.votes : opt.votes + 1, - votedUserIds: opt.votedUserIds.includes(user.id) - ? opt.votedUserIds - : [...opt.votedUserIds, user.id], - }; - } else if (!poll.allowMultipleVotes && hasVotedForOption) { - // Remove vote from other options if single vote - return { - ...opt, - votes: opt.votedUserIds.includes(user.id) ? opt.votes - 1 : opt.votes, - votedUserIds: opt.votedUserIds.filter((id) => id !== user.id), - }; - } - return opt; - }), - }; - }) - ); + try { + const updatedEvent = await eventsApi.vote(eventId, [optionId]); + setEvents((prev) => + prev.map((e) => (e.id === updatedEvent.id ? updatedEvent : e)) + ); + } catch { + // silently ignore + } }, [user] ); @@ -334,7 +317,7 @@ export default function EventsPage() { {/* Events Grid */}
- {events.length === 0 ? ( + {loading ? (
-
+
+ Loading events... +
+
+ ) : events.length === 0 ? ( +
+
No events available at the moment. Check back soon!
) : ( - events.map((event) => { - const poll = event.pollId ? polls.find((p) => p.id === event.pollId) : undefined; - return ; - }) + events.map((event) => ( + + )) )}
diff --git a/nest-front/src/pages/public/ForumPage.tsx b/nest-front/src/pages/public/ForumPage.tsx index 9229038..530f5d2 100644 --- a/nest-front/src/pages/public/ForumPage.tsx +++ b/nest-front/src/pages/public/ForumPage.tsx @@ -1,6 +1,6 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; -import { MOCK_CATEGORIES, MOCK_THREADS } from '../../data/mockData'; +import { forumApi } from '../../utils/api'; import { timeAgo } from '../../utils/format'; import type { ForumCategory, ForumThread } from '../../types'; @@ -128,15 +128,43 @@ function CategoryCard({ category, threads }: { category: ForumCategory; threads: export default function ForumPage() { const [search, setSearch] = useState(''); + const [categories, setCategories] = useState([]); + const [threads, setThreads] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + let cancelled = false; + setLoading(true); + + Promise.all([ + forumApi.getCategories(), + forumApi.getThreads({ limit: 200 }), + ]) + .then(([cats, threadRes]) => { + if (cancelled) return; + setCategories(cats); + setThreads(threadRes.data); + }) + .catch(() => { + if (cancelled) return; + setError('Failed to load forum. Please try again.'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { cancelled = true; }; + }, []); const filteredCategories = useMemo(() => { - if (!search.trim()) return MOCK_CATEGORIES; + if (!search.trim()) return categories; const q = search.toLowerCase(); - return MOCK_CATEGORIES.filter((cat) => + return categories.filter((cat) => cat.name.toLowerCase().includes(q) || - MOCK_THREADS.some((t) => t.categoryId === cat.id && t.title.toLowerCase().includes(q)) + threads.some((t) => t.categoryId === cat.id && t.title.toLowerCase().includes(q)) ); - }, [search]); + }, [search, categories, threads]); return (
@@ -173,15 +201,29 @@ export default function ForumPage() {
- {/* Categories */} - {filteredCategories.length === 0 ? ( + {loading && (
- No results found for "{search}" + Loading forum...
- ) : ( - filteredCategories.map((cat) => ( - - )) + )} + + {error && !loading && ( +
+ {error} +
+ )} + + {/* Categories */} + {!loading && !error && ( + filteredCategories.length === 0 ? ( +
+ No results found for "{search}" +
+ ) : ( + filteredCategories.map((cat) => ( + + )) + ) )}
); diff --git a/nest-front/src/pages/public/StudioPage.tsx b/nest-front/src/pages/public/StudioPage.tsx index b398088..55afe61 100644 --- a/nest-front/src/pages/public/StudioPage.tsx +++ b/nest-front/src/pages/public/StudioPage.tsx @@ -1,6 +1,16 @@ -import { TEAM_MEMBERS } from '../../data/mockData'; +import { useEffect, useState } from 'react'; +import { teamApi } from '../../utils/api'; +import type { TeamMember } from '../../types'; export default function StudioPage() { + const [members, setMembers] = useState([]); + + useEffect(() => { + teamApi.getMembers() + .then(setMembers) + .catch(() => { /* show empty state */ }); + }, []); + return (
{/* Header */} @@ -127,7 +137,7 @@ export default function StudioPage() { gap: '1.25rem', }} > - {TEAM_MEMBERS.map((member) => ( + {members.map((member) => (
{/* Avatar */} diff --git a/nest-front/src/pages/public/ThreadPage.tsx b/nest-front/src/pages/public/ThreadPage.tsx index 1c521ca..ed24d0e 100644 --- a/nest-front/src/pages/public/ThreadPage.tsx +++ b/nest-front/src/pages/public/ThreadPage.tsx @@ -1,23 +1,48 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { Link, useParams, Navigate } from 'react-router-dom'; -import { MOCK_THREADS, MOCK_REPLIES } from '../../data/mockData'; +import { forumApi, ApiError } from '../../utils/api'; import { useAuth } from '../../contexts/AuthContext'; import { formatDateTime, timeAgo } from '../../utils/format'; +import type { ForumThread, ForumReply } from '../../types'; export default function ThreadPage() { const { id } = useParams<{ id: string }>(); const { user, isAuthenticated } = useAuth(); - const thread = MOCK_THREADS.find((t) => t.id === id); + const [thread, setThread] = useState(null); + const [replies, setReplies] = useState([]); + const [loading, setLoading] = useState(true); + const [notFound, setNotFound] = useState(false); - // Local state for new reply (stored in memory, not persisted) - const [replies, setReplies] = useState( - MOCK_REPLIES.filter((r) => r.threadId === id) - ); const [newReply, setNewReply] = useState(''); const [replyError, setReplyError] = useState(''); const [submitting, setSubmitting] = useState(false); + useEffect(() => { + if (!id) return; + let cancelled = false; + setLoading(true); + + forumApi.getThread(id) + .then((data) => { + if (cancelled) return; + const { replies: threadReplies, ...threadData } = data; + setThread(threadData); + setReplies(threadReplies); + }) + .catch((err) => { + if (cancelled) return; + if (err instanceof ApiError && err.status === 404) { + setNotFound(true); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { cancelled = true; }; + }, [id]); + const handleReply = useCallback(async () => { if (!newReply.trim()) { setReplyError('Reply cannot be empty.'); @@ -30,23 +55,26 @@ export default function ThreadPage() { setReplyError(''); setSubmitting(true); - await new Promise((r) => setTimeout(r, 300)); + try { + const reply = await forumApi.createReply(id!, newReply.trim()); + setReplies((prev) => [...prev, reply]); + setNewReply(''); + } catch (err) { + setReplyError(err instanceof Error ? err.message : 'Failed to post reply.'); + } finally { + setSubmitting(false); + } + }, [newReply, id]); - const reply = { - id: `r${Date.now()}`, - content: newReply.trim(), - authorId: user!.id, - authorName: user!.username, - threadId: id!, - createdAt: new Date().toISOString(), - }; + if (loading) { + return ( +
+ Loading thread... +
+ ); + } - setReplies((prev) => [...prev, reply]); - setNewReply(''); - setSubmitting(false); - }, [newReply, user, id]); - - if (!thread) { + if (notFound || !thread) { return ; } @@ -203,7 +231,7 @@ export default function ThreadPage() { - ))} -
-
); diff --git a/nest-intra/src/types/index.ts b/nest-intra/src/types/index.ts index db9ca04..472ee22 100644 --- a/nest-intra/src/types/index.ts +++ b/nest-intra/src/types/index.ts @@ -133,7 +133,8 @@ export interface EventPost { createdAt: string; updatedAt?: string; isPublic: boolean; // whether visible to community - pollId?: string; // reference to poll if type is 'poll' + pollId?: string | null; // reference to poll if type is 'poll' + poll?: Poll | null; // embedded poll data from API } export interface PollOption { From b6b3d94fac0abd9b0af840c9c450aa9010db17de Mon Sep 17 00:00:00 2001 From: Thibault Pouch Date: Tue, 3 Mar 2026 10:24:47 +0100 Subject: [PATCH 10/24] feat: add dependencies for front and intra services to ensure API availability --- docker-compose.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index acd6a66..a0ac73b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,12 +39,16 @@ services: restart: unless-stopped ports: - "80:80" + depends_on: + - api intra: build: ./nest-intra restart: unless-stopped ports: - "5174:5174" + depends_on: + - api volumes: db_data: From c2135bbb5d167fa1f9d06a772c8838ac5c166436 Mon Sep 17 00:00:00 2001 From: Thibault Pouch Date: Thu, 12 Mar 2026 11:30:51 +0100 Subject: [PATCH 11/24] feat: implement fallback team members and enhance StudioPage layout with team information --- nest-front/src/pages/public/StudioPage.tsx | 94 +++++++++++++++++++--- 1 file changed, 83 insertions(+), 11 deletions(-) diff --git a/nest-front/src/pages/public/StudioPage.tsx b/nest-front/src/pages/public/StudioPage.tsx index 55afe61..3bd4031 100644 --- a/nest-front/src/pages/public/StudioPage.tsx +++ b/nest-front/src/pages/public/StudioPage.tsx @@ -1,15 +1,52 @@ -import { useEffect, useState } from 'react'; -import { teamApi } from '../../utils/api'; import type { TeamMember } from '../../types'; -export default function StudioPage() { - const [members, setMembers] = useState([]); +const FALLBACK_MEMBERS: TeamMember[] = [ + { + id: 'studio-1', + name: 'Thibault Pouch', + role: 'Game Dev • Lore / CI-CD', + bio: 'Works on game dev, game lore, CI/CD, assets, and the web platform.', + avatarInitials: 'TP', + }, + { + id: 'studio-2', + name: 'Pierre Ryssen', + role: 'Game Dev • Assets / Web', + bio: 'Works on game dev, assets, and the web platform.', + avatarInitials: 'PR', + }, + { + id: 'studio-3', + name: 'Antoine Papillon', + role: 'Game Dev • Gameplay', + bio: 'Focused on core game development for the project.', + avatarInitials: 'AP', + }, + { + id: 'studio-4', + name: 'Clement Augustinowick', + role: 'Game Dev • Gameplay', + bio: 'Focused on core game development for the project.', + avatarInitials: 'CA', + }, + { + id: 'studio-5', + name: 'Dany Lhoir', + role: 'Game Dev • Multiplayer / Security', + bio: 'Works on game dev, multiplayer systems, and cybersecurity.', + avatarInitials: 'DL', + }, + { + id: 'studio-6', + name: 'Timote Koenig', + role: 'Game Dev • Assets / Planning', + bio: 'Works on game dev, assets, and project planning.', + avatarInitials: 'TK', + }, +]; - useEffect(() => { - teamApi.getMembers() - .then(setMembers) - .catch(() => { /* show empty state */ }); - }, []); +export default function StudioPage() { + const members = FALLBACK_MEMBERS; return (
@@ -42,8 +79,8 @@ export default function StudioPage() { }} > CrowMate Studio is an independent game studio founded in 2023 by a team of six developers - united by a shared obsession: games that are strange, atmospheric, and actually interesting. - We are headquartered somewhere in Europe and operate fully remote. + who are all new to game development and learning by building together. We are headquartered + somewhere in Europe and operate fully remote.

+ +
+ {[ + { label: 'TEAM SIZE', value: '6 PEOPLE' }, + { label: 'FOUNDED', value: '2026' }, + { label: 'WORK MODE', value: 'REMOTE' }, + { label: 'CURRENT GAME', value: 'HEADLESS HAZARD' }, + ].map(({ label, value }) => ( +
+
+ {label} +
+
+ {value} +
+
+ ))} +
{/* History & Vision */} From 513bfbda96199e9f1645ee1e859fd3fac2209768 Mon Sep 17 00:00:00 2001 From: Thibault Pouch Date: Thu, 12 Mar 2026 11:30:55 +0100 Subject: [PATCH 12/24] feat: update front service port mapping from 5173 to 80 --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 154f957..f4f46d6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,7 +33,7 @@ services: build: ./nest-front restart: unless-stopped ports: - - "5173:5173" + - "80:80" environment: API_URL: http://api:3000 depends_on: From f926951e227b788adc5467c5068f1a8cb263cdee Mon Sep 17 00:00:00 2001 From: Thibault Pouch Date: Tue, 17 Mar 2026 11:43:41 +0100 Subject: [PATCH 13/24] feat: add SiteSettings model to manage forum and bug feature toggles --- nest-backend/prisma/schema.prisma | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/nest-backend/prisma/schema.prisma b/nest-backend/prisma/schema.prisma index 516a922..4702f78 100644 --- a/nest-backend/prisma/schema.prisma +++ b/nest-backend/prisma/schema.prisma @@ -221,6 +221,14 @@ model PollVote { @@id([userId, pollOptionId]) } +// ── Site Settings ────────────────────────────────────────────────────────────── + +model SiteSettings { + id Int @id @default(1) + forumEnabled Boolean @default(true) + bugsEnabled Boolean @default(true) +} + // ── Team Members ─────────────────────────────────────────────────────────────── model TeamMember { From f481a6fc4e4f67bcc646178b34a7ee2f7315ed24 Mon Sep 17 00:00:00 2001 From: Thibault Pouch Date: Tue, 17 Mar 2026 11:43:50 +0100 Subject: [PATCH 14/24] feat: add settings route for managing forum and bug feature toggles --- nest-backend/src/app.ts | 2 ++ nest-backend/src/routes/settings.ts | 44 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 nest-backend/src/routes/settings.ts diff --git a/nest-backend/src/app.ts b/nest-backend/src/app.ts index 9cca0aa..67c430c 100644 --- a/nest-backend/src/app.ts +++ b/nest-backend/src/app.ts @@ -7,6 +7,7 @@ import bugsRouter from './routes/bugs.js'; import feedRouter from './routes/feed.js'; import eventsRouter from './routes/events.js'; import teamRouter from './routes/team.js'; +import settingsRouter from './routes/settings.js'; const app = express(); @@ -122,6 +123,7 @@ app.use('/api/bugs', bugsRouter); app.use('/api/feed', feedRouter); app.use('/api/events', eventsRouter); app.use('/api/team', teamRouter); +app.use('/api/settings', settingsRouter); // 404 app.use((_req, res) => res.status(404).json({ error: 'Not found' })); diff --git a/nest-backend/src/routes/settings.ts b/nest-backend/src/routes/settings.ts new file mode 100644 index 0000000..684fd3f --- /dev/null +++ b/nest-backend/src/routes/settings.ts @@ -0,0 +1,44 @@ +import { Router } from 'express'; +import type { Request, Response } from 'express'; +import prisma from '../lib/prisma.js'; +import { authenticate, requireAdmin } from '../middleware/auth.js'; + +const router = Router(); + +function getOrCreateSettings() { + return prisma.siteSettings.upsert({ + where: { id: 1 }, + update: {}, + create: { id: 1 }, + }); +} + +// GET /api/settings — public +router.get('/', async (_req: Request, res: Response): Promise => { + const settings = await getOrCreateSettings(); + res.json({ forumEnabled: settings.forumEnabled, bugsEnabled: settings.bugsEnabled }); +}); + +// PATCH /api/settings — admin only +router.patch('/', authenticate, requireAdmin, async (req: Request, res: Response): Promise => { + const { forumEnabled, bugsEnabled } = req.body as { forumEnabled?: unknown; bugsEnabled?: unknown }; + + const data: { forumEnabled?: boolean; bugsEnabled?: boolean } = {}; + if (typeof forumEnabled === 'boolean') data.forumEnabled = forumEnabled; + if (typeof bugsEnabled === 'boolean') data.bugsEnabled = bugsEnabled; + + if (Object.keys(data).length === 0) { + res.status(400).json({ error: 'No valid fields to update' }); + return; + } + + const settings = await prisma.siteSettings.upsert({ + where: { id: 1 }, + update: data, + create: { id: 1, ...data }, + }); + + res.json({ forumEnabled: settings.forumEnabled, bugsEnabled: settings.bugsEnabled }); +}); + +export default router; From f9012bd12388826015630aca324ffdb3134e1587 Mon Sep 17 00:00:00 2001 From: Thibault Pouch Date: Tue, 17 Mar 2026 11:44:05 +0100 Subject: [PATCH 15/24] feat: integrate settings API to manage forum and bug reporting availability --- nest-front/src/pages/public/BugReportPage.tsx | 21 ++++++++++++++++++- nest-front/src/pages/public/ForumPage.tsx | 21 +++++++++++++++++-- nest-front/src/pages/public/StudioPage.tsx | 5 ++--- nest-front/src/utils/api.ts | 8 +++++++ 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/nest-front/src/pages/public/BugReportPage.tsx b/nest-front/src/pages/public/BugReportPage.tsx index bd78f6e..2cf538b 100644 --- a/nest-front/src/pages/public/BugReportPage.tsx +++ b/nest-front/src/pages/public/BugReportPage.tsx @@ -1,6 +1,6 @@ import { useState, useMemo, useCallback, useEffect } from 'react'; import { Link, useNavigate } from 'react-router-dom'; -import { bugsApi } from '../../utils/api'; +import { bugsApi, settingsApi } from '../../utils/api'; import { useAuth } from '../../contexts/AuthContext'; import { timeAgo } from '../../utils/format'; import type { BugReport, BugSeverity, BugStatus, BugReportFormData } from '../../types'; @@ -278,10 +278,15 @@ export default function BugReportPage() { const { user, isAuthenticated } = useAuth(); const [bugs, setBugs] = useState([]); const [loading, setLoading] = useState(true); + const [bugsEnabled, setBugsEnabled] = useState(true); const [statusFilter, setStatusFilter] = useState('all'); const [severityFilter, setSeverityFilter] = useState('all'); const [showForm, setShowForm] = useState(false); + useEffect(() => { + settingsApi.get().then((s) => setBugsEnabled(s.bugsEnabled)).catch(() => {}); + }, []); + const fetchBugs = useCallback(() => { setLoading(true); bugsApi.getBugs({ status: statusFilter, severity: severityFilter, limit: 100 }) @@ -312,6 +317,20 @@ export default function BugReportPage() { const inProgressCount = bugs.filter((b) => b.status === 'in_progress').length; const resolvedCount = bugs.filter((b) => b.status === 'resolved').length; + if (!bugsEnabled) { + return ( +
+
Issue Tracker
+

+ BUG REPORTS UNAVAILABLE +

+

+ Bug reporting has been temporarily disabled by an administrator. +

+
+ ); + } + return (
{/* Header */} diff --git a/nest-front/src/pages/public/ForumPage.tsx b/nest-front/src/pages/public/ForumPage.tsx index 530f5d2..705499c 100644 --- a/nest-front/src/pages/public/ForumPage.tsx +++ b/nest-front/src/pages/public/ForumPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; -import { forumApi } from '../../utils/api'; +import { forumApi, settingsApi } from '../../utils/api'; import { timeAgo } from '../../utils/format'; import type { ForumCategory, ForumThread } from '../../types'; @@ -132,17 +132,20 @@ export default function ForumPage() { const [threads, setThreads] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const [forumEnabled, setForumEnabled] = useState(true); useEffect(() => { let cancelled = false; setLoading(true); Promise.all([ + settingsApi.get(), forumApi.getCategories(), forumApi.getThreads({ limit: 200 }), ]) - .then(([cats, threadRes]) => { + .then(([settings, cats, threadRes]) => { if (cancelled) return; + setForumEnabled(settings.forumEnabled); setCategories(cats); setThreads(threadRes.data); }) @@ -166,6 +169,20 @@ export default function ForumPage() { ); }, [search, categories, threads]); + if (!loading && !forumEnabled) { + return ( +
+
Community
+

+ FORUM UNAVAILABLE +

+

+ The forum has been temporarily disabled by an administrator. +

+
+ ); + } + return (
{/* Header */} diff --git a/nest-front/src/pages/public/StudioPage.tsx b/nest-front/src/pages/public/StudioPage.tsx index 3bd4031..1f726ac 100644 --- a/nest-front/src/pages/public/StudioPage.tsx +++ b/nest-front/src/pages/public/StudioPage.tsx @@ -78,9 +78,9 @@ export default function StudioPage() { marginBottom: '1rem', }} > - CrowMate Studio is an independent game studio founded in 2023 by a team of six developers + CrowMate Studio is an independent game studio founded in 2026 by a team of six developers who are all new to game development and learning by building together. We are headquartered - somewhere in Europe and operate fully remote. + somewhere in France and operate arround the globe.

(

diff --git a/nest-front/src/utils/api.ts b/nest-front/src/utils/api.ts index 390dcb8..deb4a1a 100644 --- a/nest-front/src/utils/api.ts +++ b/nest-front/src/utils/api.ts @@ -190,3 +190,11 @@ export const eventsApi = { export const teamApi = { getMembers: () => apiFetch('/team'), }; + +// ── Settings API ────────────────────────────────────────────────────────────── + +export type SiteSettings = { forumEnabled: boolean; bugsEnabled: boolean }; + +export const settingsApi = { + get: () => apiFetch('/settings'), +}; From 53740dc6948bf75381fd0b5038670e162c96b3dd Mon Sep 17 00:00:00 2001 From: Thibault Pouch Date: Tue, 17 Mar 2026 11:44:14 +0100 Subject: [PATCH 16/24] feat: implement settings API for toggling forum and bug reporting features --- nest-intra/nginx.conf | 2 +- .../src/components/layout/IntranetLayout.tsx | 2 +- .../src/pages/intranet/IntranetBugs.tsx | 28 ++++++++++++--- .../src/pages/intranet/IntranetModeration.tsx | 28 ++++++++++++--- nest-intra/src/utils/api.ts | 35 +++++++++++++++++++ 5 files changed, 83 insertions(+), 12 deletions(-) create mode 100644 nest-intra/src/utils/api.ts diff --git a/nest-intra/nginx.conf b/nest-intra/nginx.conf index bf22cad..471887c 100644 --- a/nest-intra/nginx.conf +++ b/nest-intra/nginx.conf @@ -8,7 +8,7 @@ server { set $api_upstream http://api:3000; location /api/ { - proxy_pass $api_upstream/api/; + proxy_pass $api_upstream; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/nest-intra/src/components/layout/IntranetLayout.tsx b/nest-intra/src/components/layout/IntranetLayout.tsx index f9067bf..ca5973e 100644 --- a/nest-intra/src/components/layout/IntranetLayout.tsx +++ b/nest-intra/src/components/layout/IntranetLayout.tsx @@ -8,7 +8,7 @@ const INTRANET_LINKS = [ { to: '/intranet/feed', label: 'Team Feed', icon: '[~]', end: false }, { to: '/intranet/events', label: 'Events', icon: '[E]', end: false }, { to: '/intranet/users', label: 'Users', icon: '[U]', end: false }, - { to: '/intranet/moderation', label: 'Moderation', icon: '[M]', end: false }, + { to: '/intranet/moderation', label: 'Forum Mod', icon: '[M]', end: false }, { to: '/intranet/services', label: 'Services', icon: '[S]', end: false }, ]; diff --git a/nest-intra/src/pages/intranet/IntranetBugs.tsx b/nest-intra/src/pages/intranet/IntranetBugs.tsx index 8894848..e2392dc 100644 --- a/nest-intra/src/pages/intranet/IntranetBugs.tsx +++ b/nest-intra/src/pages/intranet/IntranetBugs.tsx @@ -1,6 +1,7 @@ -import { useState, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback, useEffect } from 'react'; import { useAuth } from '../../contexts/AuthContext'; import { formatDate, formatDateTime } from '../../utils/format'; +import { settingsApi } from '../../utils/api'; import type { BugReport, BugSeverity, BugStatus, BugReportNote } from '../../types'; function StatusBadge({ status }: { status: BugStatus }) { @@ -27,6 +28,19 @@ export default function IntranetBugs() { const [assignedFilter, setAssignedFilter] = useState('all'); const [noteText, setNoteText] = useState(''); const [isEnabled, setIsEnabled] = useState(true); + const [toggling, setToggling] = useState(false); + + useEffect(() => { + settingsApi.get().then((s) => setIsEnabled(s.bugsEnabled)).catch(() => {}); + }, []); + + const handleToggle = useCallback((enabled: boolean) => { + setToggling(true); + settingsApi.update({ bugsEnabled: enabled }) + .then(() => setIsEnabled(enabled)) + .catch(() => {}) + .finally(() => setToggling(false)); + }, []); const openCount = bugs.filter((b) => b.status === 'open').length; const criticalCount = bugs.filter((b) => b.severity === 'critical').length; @@ -82,7 +96,8 @@ export default function IntranetBugs() {

FUNCTIONALITY DISABLED

Bug Reports feature is currently disabled

    {[ - { to: '/', label: 'Home' }, - { to: '/studio', label: 'Studio' }, - { to: '/forum', label: 'Forum' }, - { to: '/bugs', label: 'Bug Reports' }, - ].map(({ to, label }) => ( + { to: '/', label: 'Home', show: true }, + { to: '/studio', label: 'Studio', show: true }, + { to: '/forum', label: 'Forum', show: forumEnabled }, + { to: '/bugs', label: 'Bug Reports', show: bugsEnabled }, + ].filter((item) => item.show).map(({ to, label }) => (
  • { + if (feature === 'forum') return forumEnabled; + if (feature === 'bugs') return bugsEnabled; + return true; + }); + const handleLogout = useCallback(() => { logout(); setMenuOpen(false); @@ -85,7 +93,7 @@ export function Navbar() { {/* Desktop Nav */}
    - {NAV_LINKS.map(({ to, label, end }) => ( + {navLinks.map(({ to, label, end }) => ( {label} @@ -148,7 +156,7 @@ export function Navbar() { }} >
    - {NAV_LINKS.map(({ to, label, end }) => ( + {navLinks.map(({ to, label, end }) => ( Date: Tue, 17 Mar 2026 16:43:45 +0100 Subject: [PATCH 19/24] feat: add SettingsContext for managing forum and bug reporting settings --- nest-front/src/contexts/SettingsContext.tsx | 31 +++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 nest-front/src/contexts/SettingsContext.tsx diff --git a/nest-front/src/contexts/SettingsContext.tsx b/nest-front/src/contexts/SettingsContext.tsx new file mode 100644 index 0000000..6fbd450 --- /dev/null +++ b/nest-front/src/contexts/SettingsContext.tsx @@ -0,0 +1,31 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; +import { settingsApi } from '../utils/api'; + +interface SettingsContextValue { + forumEnabled: boolean; + bugsEnabled: boolean; + loaded: boolean; +} + +const SettingsContext = createContext({ + forumEnabled: true, + bugsEnabled: true, + loaded: false, +}); + +export function SettingsProvider({ children }: { children: React.ReactNode }) { + const [value, setValue] = useState({ forumEnabled: true, bugsEnabled: true, loaded: false }); + + useEffect(() => { + settingsApi + .get() + .then((s) => setValue({ forumEnabled: s.forumEnabled, bugsEnabled: s.bugsEnabled, loaded: true })) + .catch(() => setValue({ forumEnabled: true, bugsEnabled: true, loaded: true })); + }, []); + + return {children}; +} + +export function useSettings(): SettingsContextValue { + return useContext(SettingsContext); +} From 792816c6c8da0a57d185f2776db263cd00da3751 Mon Sep 17 00:00:00 2001 From: Thibault Pouch Date: Tue, 17 Mar 2026 16:44:06 +0100 Subject: [PATCH 20/24] refactor: connect frontend to backend by integrating SettingsContext and updating route handling for forum and bug reporting features --- nest-front/src/App.tsx | 64 +++++++++++-------- nest-front/src/pages/public/BugDetailPage.tsx | 6 +- nest-front/src/pages/public/BugReportPage.tsx | 27 ++------ nest-front/src/pages/public/ForumPage.tsx | 21 +----- 4 files changed, 48 insertions(+), 70 deletions(-) diff --git a/nest-front/src/App.tsx b/nest-front/src/App.tsx index 8af3f33..751d5ec 100644 --- a/nest-front/src/App.tsx +++ b/nest-front/src/App.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense } from 'react'; import { Routes, Route } from 'react-router-dom'; import { AuthProvider } from './contexts/AuthContext'; +import { SettingsProvider, useSettings } from './contexts/SettingsContext'; import { ProtectedRoute } from './components/shared/ProtectedRoute'; import { PublicLayout } from './components/layout/PublicLayout'; import { PageLoader } from './components/shared/PageLoader'; @@ -19,36 +20,49 @@ const LoginPage = lazy(() => import('./pages/public/LoginPage')); const RegisterPage = lazy(() => import('./pages/public/RegisterPage')); const NotFoundPage = lazy(() => import('./pages/public/NotFoundPage')); +// ── Routes (needs SettingsContext) ──────────────────────────────────────────── + +function AppRoutes() { + const { forumEnabled, bugsEnabled, loaded } = useSettings(); + + if (!loaded) return ; + + return ( + }> + + }> + } /> + } /> + } /> + : } /> + : } /> + : } /> + : } /> + + + + } + /> + } /> + } /> + } /> + + + + ); +} + // ── App ──────────────────────────────────────────────────────────────────────── export default function App() { return ( - }> - - {/* Public Routes */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - } - /> - } /> - } /> - } /> - - - + + + ); } diff --git a/nest-front/src/pages/public/BugDetailPage.tsx b/nest-front/src/pages/public/BugDetailPage.tsx index 9e03630..3908774 100644 --- a/nest-front/src/pages/public/BugDetailPage.tsx +++ b/nest-front/src/pages/public/BugDetailPage.tsx @@ -88,7 +88,7 @@ export default function BugDetailPage() { }, [id]); const alreadyVoted = useMemo( - () => !!user && !!bug && bug.meTooBugs.includes(user.id), + () => !!user && !!bug && (bug.meTooBugs ?? []).includes(user.id), [user, bug] ); const isOwnReport = useMemo( @@ -100,7 +100,7 @@ export default function BugDetailPage() { if (!user || !bug || alreadyVoted || isOwnReport) return; try { await bugsApi.toggleMeToo(bug.id); - setBug((prev) => prev ? { ...prev, meTooBugs: [...prev.meTooBugs, user.id] } : prev); + setBug((prev) => prev ? { ...prev, meTooBugs: [...(prev.meTooBugs ?? []), user.id] } : prev); } catch { // silently ignore } @@ -137,7 +137,7 @@ export default function BugDetailPage() { return ; } - const metooCount = bug.meTooBugs.length; + const metooCount = (bug.meTooBugs ?? []).length; return (
    diff --git a/nest-front/src/pages/public/BugReportPage.tsx b/nest-front/src/pages/public/BugReportPage.tsx index 2cf538b..2604b52 100644 --- a/nest-front/src/pages/public/BugReportPage.tsx +++ b/nest-front/src/pages/public/BugReportPage.tsx @@ -1,6 +1,6 @@ import { useState, useMemo, useCallback, useEffect } from 'react'; import { Link, useNavigate } from 'react-router-dom'; -import { bugsApi, settingsApi } from '../../utils/api'; +import { bugsApi } from '../../utils/api'; import { useAuth } from '../../contexts/AuthContext'; import { timeAgo } from '../../utils/format'; import type { BugReport, BugSeverity, BugStatus, BugReportFormData } from '../../types'; @@ -78,7 +78,7 @@ function BugCard({ bug, highlight }: BugCardProps) { borderRadius: '3px', }} > - ▶ {bug.meTooBugs.length} {bug.meTooBugs.length === 1 ? 'user' : 'users'} have this + ▶ {(bug.meTooBugs ?? []).length} {(bug.meTooBugs ?? []).length === 1 ? 'user' : 'users'} have this
    @@ -278,19 +278,14 @@ export default function BugReportPage() { const { user, isAuthenticated } = useAuth(); const [bugs, setBugs] = useState([]); const [loading, setLoading] = useState(true); - const [bugsEnabled, setBugsEnabled] = useState(true); const [statusFilter, setStatusFilter] = useState('all'); const [severityFilter, setSeverityFilter] = useState('all'); const [showForm, setShowForm] = useState(false); - useEffect(() => { - settingsApi.get().then((s) => setBugsEnabled(s.bugsEnabled)).catch(() => {}); - }, []); - const fetchBugs = useCallback(() => { setLoading(true); bugsApi.getBugs({ status: statusFilter, severity: severityFilter, limit: 100 }) - .then((res) => setBugs(res.data)) + .then((res) => setBugs(Array.isArray(res?.data) ? res.data : [])) .catch(() => setBugs([])) .finally(() => setLoading(false)); }, [statusFilter, severityFilter]); @@ -300,7 +295,7 @@ export default function BugReportPage() { const { myBugs, otherBugs } = useMemo(() => { const my: BugReport[] = []; const other: BugReport[] = []; - bugs.forEach((b) => { + (bugs ?? []).forEach((b) => { if (user && b.submittedById === user.id) my.push(b); else other.push(b); }); @@ -317,20 +312,6 @@ export default function BugReportPage() { const inProgressCount = bugs.filter((b) => b.status === 'in_progress').length; const resolvedCount = bugs.filter((b) => b.status === 'resolved').length; - if (!bugsEnabled) { - return ( -
    -
    Issue Tracker
    -

    - BUG REPORTS UNAVAILABLE -

    -

    - Bug reporting has been temporarily disabled by an administrator. -

    -
    - ); - } - return (
    {/* Header */} diff --git a/nest-front/src/pages/public/ForumPage.tsx b/nest-front/src/pages/public/ForumPage.tsx index 705499c..530f5d2 100644 --- a/nest-front/src/pages/public/ForumPage.tsx +++ b/nest-front/src/pages/public/ForumPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; -import { forumApi, settingsApi } from '../../utils/api'; +import { forumApi } from '../../utils/api'; import { timeAgo } from '../../utils/format'; import type { ForumCategory, ForumThread } from '../../types'; @@ -132,20 +132,17 @@ export default function ForumPage() { const [threads, setThreads] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - const [forumEnabled, setForumEnabled] = useState(true); useEffect(() => { let cancelled = false; setLoading(true); Promise.all([ - settingsApi.get(), forumApi.getCategories(), forumApi.getThreads({ limit: 200 }), ]) - .then(([settings, cats, threadRes]) => { + .then(([cats, threadRes]) => { if (cancelled) return; - setForumEnabled(settings.forumEnabled); setCategories(cats); setThreads(threadRes.data); }) @@ -169,20 +166,6 @@ export default function ForumPage() { ); }, [search, categories, threads]); - if (!loading && !forumEnabled) { - return ( -
    -
    Community
    -

    - FORUM UNAVAILABLE -

    -

    - The forum has been temporarily disabled by an administrator. -

    -
    - ); - } - return (
    {/* Header */} From e7d1cda35613c3d71be738eab9371f5861b0b118 Mon Sep 17 00:00:00 2001 From: Thibault Pouch Date: Wed, 18 Mar 2026 10:58:55 +0100 Subject: [PATCH 21/24] refactor: enhance authentication middleware to validate user existence and status before proceeding --- nest-backend/src/middleware/auth.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/nest-backend/src/middleware/auth.ts b/nest-backend/src/middleware/auth.ts index c1957cc..1c3f27a 100644 --- a/nest-backend/src/middleware/auth.ts +++ b/nest-backend/src/middleware/auth.ts @@ -1,5 +1,6 @@ import { Request, Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken'; +import prisma from '../lib/prisma.js'; export interface JwtPayload { userId: string; @@ -15,7 +16,7 @@ declare global { } } -export function authenticate(req: Request, res: Response, next: NextFunction): void { +export async function authenticate(req: Request, res: Response, next: NextFunction): Promise { const header = req.headers.authorization; if (!header?.startsWith('Bearer ')) { res.status(401).json({ error: 'Missing or invalid Authorization header' }); @@ -25,7 +26,21 @@ export function authenticate(req: Request, res: Response, next: NextFunction): v const token = header.slice(7); try { const payload = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload; - req.user = payload; + const user = await prisma.user.findUnique({ + where: { id: payload.userId }, + select: { id: true, role: true, isAdmin: true, isBanned: true }, + }); + + if (!user || user.isBanned) { + res.status(401).json({ error: 'Token user no longer exists or is banned. Please login again.' }); + return; + } + + req.user = { + userId: user.id, + role: user.role, + isAdmin: user.isAdmin, + }; next(); } catch { res.status(401).json({ error: 'Token expired or invalid' }); From bc9d93fe900df3b68c8d63831b864ab52d296a95 Mon Sep 17 00:00:00 2001 From: Thibault Pouch Date: Wed, 18 Mar 2026 10:59:01 +0100 Subject: [PATCH 22/24] refactor: improve error handling and response structure for getThreads API function --- nest-front/src/pages/public/ForumPage.tsx | 2 +- nest-front/src/utils/api.ts | 20 ++++++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/nest-front/src/pages/public/ForumPage.tsx b/nest-front/src/pages/public/ForumPage.tsx index 530f5d2..b3171ba 100644 --- a/nest-front/src/pages/public/ForumPage.tsx +++ b/nest-front/src/pages/public/ForumPage.tsx @@ -217,7 +217,7 @@ export default function ForumPage() { {!loading && !error && ( filteredCategories.length === 0 ? (
    - No results found for "{search}" + {search.trim() ? `No results found for "${search}"` : 'No forum categories available yet.'}
    ) : ( filteredCategories.map((cat) => ( diff --git a/nest-front/src/utils/api.ts b/nest-front/src/utils/api.ts index deb4a1a..6fdbf68 100644 --- a/nest-front/src/utils/api.ts +++ b/nest-front/src/utils/api.ts @@ -114,14 +114,26 @@ export const forumApi = { getCategories: () => apiFetch('/forum/categories'), - getThreads: (params?: { categoryId?: string; page?: number; limit?: number }) => { + getThreads: async (params?: { categoryId?: string; page?: number; limit?: number }) => { const q = new URLSearchParams(); if (params?.categoryId) q.set('categoryId', params.categoryId); q.set('page', String(params?.page ?? 1)); q.set('limit', String(params?.limit ?? 100)); - return apiFetch<{ data: ForumThread[]; total: number; page: number; pages: number }>( - `/forum/threads?${q}` - ); + + const result = await apiFetch<{ + data?: ForumThread[]; + threads?: ForumThread[]; + total: number; + page: number; + pages: number; + }>(`/forum/threads?${q}`); + + return { + data: result.data ?? result.threads ?? [], + total: result.total, + page: result.page, + pages: result.pages, + }; }, getThread: (id: string) => From 032b08bfb5c2555afbac806ad5311b81851688b0 Mon Sep 17 00:00:00 2001 From: Thibault Pouch Date: Wed, 18 Mar 2026 10:59:07 +0100 Subject: [PATCH 23/24] feat: implement forum moderation features with category management and thread operations --- .../src/pages/intranet/IntranetModeration.tsx | 443 +++++++++++++++++- nest-intra/src/utils/api.ts | 79 ++++ 2 files changed, 509 insertions(+), 13 deletions(-) diff --git a/nest-intra/src/pages/intranet/IntranetModeration.tsx b/nest-intra/src/pages/intranet/IntranetModeration.tsx index 02c4fc0..4f9a40d 100644 --- a/nest-intra/src/pages/intranet/IntranetModeration.tsx +++ b/nest-intra/src/pages/intranet/IntranetModeration.tsx @@ -1,21 +1,80 @@ import { useState, useMemo, useCallback, useEffect } from 'react'; import { formatDateTime } from '../../utils/format'; -import { settingsApi } from '../../utils/api'; -import type { ForumThread, ForumReply } from '../../types'; +import { forumApi, settingsApi } from '../../utils/api'; +import type { ForumCategory, ForumReply, ForumThread } from '../../types'; export default function IntranetModeration() { + const [categories, setCategories] = useState([]); const [threads, setThreads] = useState([]); const [replies, setReplies] = useState([]); const [selectedThreadId, setSelectedThreadId] = useState(null); + const [createTitle, setCreateTitle] = useState(''); + const [createContent, setCreateContent] = useState(''); + const [createCategoryId, setCreateCategoryId] = useState(''); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false); + const [editingCategoryId, setEditingCategoryId] = useState(null); + const [categoryName, setCategoryName] = useState(''); + const [categoryDescription, setCategoryDescription] = useState(''); + const [categoryIcon, setCategoryIcon] = useState('📁'); const [search, setSearch] = useState(''); - const [activeTab, setActiveTab] = useState<'threads' | 'replies'>('threads'); + const [activeTab, setActiveTab] = useState<'threads' | 'replies' | 'categories'>('threads'); const [isEnabled, setIsEnabled] = useState(true); const [toggling, setToggling] = useState(false); + const [loading, setLoading] = useState(true); + const [creating, setCreating] = useState(false); + const [savingCategory, setSavingCategory] = useState(false); + const [error, setError] = useState(''); + + const loadModerationData = useCallback(async () => { + setLoading(true); + setError(''); + + try { + const [cats, threadRes] = await Promise.all([ + forumApi.getCategories(), + forumApi.getThreads({ limit: 200 }), + ]); + + const loadedThreads = threadRes.data; + setCategories(cats); + setThreads(loadedThreads); + + const detailed = await Promise.all( + loadedThreads.map((thread) => forumApi.getThread(thread.id).catch(() => null)) + ); + + const allReplies = detailed + .filter((thread): thread is ForumThread & { replies: ForumReply[] } => Boolean(thread)) + .flatMap((thread) => thread.replies); + setReplies(allReplies); + } catch { + setError('Failed to load moderation data.'); + } finally { + setLoading(false); + } + }, []); useEffect(() => { settingsApi.get().then((s) => setIsEnabled(s.forumEnabled)).catch(() => {}); }, []); + useEffect(() => { + void loadModerationData(); + }, [loadModerationData]); + + useEffect(() => { + if (categories.length === 0) { + setCreateCategoryId(''); + return; + } + + const exists = categories.some((category) => category.id === createCategoryId); + if (!exists) { + setCreateCategoryId(categories[0].id); + } + }, [categories, createCategoryId]); + const handleToggle = useCallback((enabled: boolean) => { setToggling(true); settingsApi.update({ forumEnabled: enabled }) @@ -36,23 +95,151 @@ export default function IntranetModeration() { }, [replies, selectedThreadId]); const deleteThread = useCallback((id: string) => { - setThreads((prev) => prev.filter((t) => t.id !== id)); - setReplies((prev) => prev.filter((r) => r.threadId !== id)); - if (selectedThreadId === id) setSelectedThreadId(null); + forumApi.deleteThread(id) + .then(() => { + setThreads((prev) => prev.filter((t) => t.id !== id)); + setReplies((prev) => prev.filter((r) => r.threadId !== id)); + if (selectedThreadId === id) setSelectedThreadId(null); + }) + .catch(() => { + setError('Failed to delete thread.'); + }); }, [selectedThreadId]); const togglePin = useCallback((id: string) => { - setThreads((prev) => prev.map((t) => t.id === id ? { ...t, isPinned: !t.isPinned } : t)); - }, []); + const thread = threads.find((t) => t.id === id); + if (!thread) return; + + forumApi.updateThread(id, { isPinned: !thread.isPinned }) + .then((updated) => { + setThreads((prev) => prev.map((t) => (t.id === id ? updated : t))); + }) + .catch(() => { + setError('Failed to update pin state.'); + }); + }, [threads]); const toggleLock = useCallback((id: string) => { - setThreads((prev) => prev.map((t) => t.id === id ? { ...t, isLocked: !t.isLocked } : t)); - }, []); + const thread = threads.find((t) => t.id === id); + if (!thread) return; + + forumApi.updateThread(id, { isLocked: !thread.isLocked }) + .then((updated) => { + setThreads((prev) => prev.map((t) => (t.id === id ? updated : t))); + }) + .catch(() => { + setError('Failed to update lock state.'); + }); + }, [threads]); const deleteReply = useCallback((id: string) => { - setReplies((prev) => prev.filter((r) => r.id !== id)); + const removedReply = replies.find((r) => r.id === id); + + forumApi.deleteReply(id) + .then(() => { + setReplies((prev) => prev.filter((r) => r.id !== id)); + setThreads((prev) => prev.map((t) => { + if (!removedReply || removedReply.threadId !== t.id) return t; + return { ...t, replyCount: Math.max(0, t.replyCount - 1) }; + })); + }) + .catch(() => { + setError('Failed to delete reply.'); + }); + }, [replies]); + + const createThread = useCallback(() => { + const title = createTitle.trim(); + const content = createContent.trim(); + + if (!title || !content || !createCategoryId) { + setError('Title, category and content are required.'); + return; + } + + setCreating(true); + setError(''); + + forumApi.createThread({ + title, + content, + categoryId: createCategoryId, + }) + .then((thread) => { + setThreads((prev) => [thread, ...prev]); + setCategories((prev) => prev.map((cat) => ( + cat.id === createCategoryId + ? { ...cat, threadCount: cat.threadCount + 1 } + : cat + ))); + setCreateTitle(''); + setCreateContent(''); + setIsCreateModalOpen(false); + }) + .catch(() => { + setError('Failed to create thread.'); + }) + .finally(() => { + setCreating(false); + }); + }, [createCategoryId, createContent, createTitle]); + + const openCreateCategoryModal = useCallback(() => { + setEditingCategoryId(null); + setCategoryName(''); + setCategoryDescription(''); + setCategoryIcon('📁'); + setIsCategoryModalOpen(true); }, []); + const openEditCategoryModal = useCallback((category: ForumCategory) => { + setEditingCategoryId(category.id); + setCategoryName(category.name); + setCategoryDescription(category.description); + setCategoryIcon(category.icon || '📁'); + setIsCategoryModalOpen(true); + }, []); + + const saveCategory = useCallback(() => { + const name = categoryName.trim(); + const description = categoryDescription.trim(); + const icon = categoryIcon.trim() || '📁'; + + if (!name || !description) { + setError('Category name and description are required.'); + return; + } + + setSavingCategory(true); + setError(''); + + const action = editingCategoryId + ? forumApi.updateCategory(editingCategoryId, { name, description, icon }) + : forumApi.createCategory({ name, description, icon }); + + action + .then(() => loadModerationData()) + .then(() => setIsCategoryModalOpen(false)) + .catch(() => { + setError(editingCategoryId ? 'Failed to update category.' : 'Failed to create category.'); + }) + .finally(() => { + setSavingCategory(false); + }); + }, [categoryDescription, categoryIcon, categoryName, editingCategoryId, loadModerationData]); + + const removeCategory = useCallback((id: string) => { + const confirmed = window.confirm('Delete this category? This can fail if it still has threads.'); + if (!confirmed) return; + + setError(''); + forumApi.deleteCategory(id) + .then(() => loadModerationData()) + .catch(() => { + setError('Failed to delete category. Remove or move threads first.'); + }); + }, [loadModerationData]); + const recentReplies = useMemo(() => { return [...replies].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()).slice(0, 20); }, [replies]); @@ -87,6 +274,16 @@ export default function IntranetModeration() {
    ) : (
    + {loading && ( +
    + Loading moderation data... +
    + )} + {error && ( +
    + {error} +
    + )}
    @@ -123,7 +320,7 @@ export default function IntranetModeration() { {/* Tabs */}
    - {(['threads', 'replies'] as const).map((tab) => ( + {(['threads', 'replies', 'categories'] as const).map((tab) => ( ))}
    @@ -149,6 +350,22 @@ export default function IntranetModeration() {
    {/* Thread list */}
    +
    + + {categories.length === 0 && ( +
    + No categories available. +
    + )} +
    + togglePin(thread.id)} + disabled={loading} > {thread.isPinned ? 'Unpin' : 'Pin'} @@ -199,6 +417,7 @@ export default function IntranetModeration() { className="btn-terminal btn-amber" style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }} onClick={() => toggleLock(thread.id)} + disabled={loading} > {thread.isLocked ? 'Unlock' : 'Lock'} @@ -206,6 +425,7 @@ export default function IntranetModeration() { className="btn-terminal btn-danger" style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }} onClick={() => deleteThread(thread.id)} + disabled={loading} > Delete @@ -292,6 +512,203 @@ export default function IntranetModeration() { )}
    )} + + {activeTab === 'categories' && ( +
    +
    + +
    + +
    + {categories.map((category) => ( +
    +
    +
    + {category.icon} {category.name} +
    + + {category.threadCount} threads + +
    +
    + {category.description} +
    +
    + + +
    +
    + ))} + + {categories.length === 0 && ( +
    + No categories found. +
    + )} +
    +
    + )} + + {isCreateModalOpen && ( +
    +
    +
    +
    + CREATE THREAD +
    + +
    +
    + setCreateTitle(e.target.value)} + /> + +