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: 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 { diff --git a/nest-backend/src/app.ts b/nest-backend/src/app.ts index 5f68658..67c430c 100644 --- a/nest-backend/src/app.ts +++ b/nest-backend/src/app.ts @@ -7,9 +7,101 @@ 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(); +// ── Logger ───────────────────────────────────────────────────────────────────── +const R = '\x1b[0m'; +const BOLD = '\x1b[1m'; +const DIM = '\x1b[2m'; +const RED = '\x1b[31m'; +const GREEN = '\x1b[32m'; +const YELLOW = '\x1b[33m'; +const BLUE = '\x1b[34m'; +const MAGENTA = '\x1b[35m'; +const CYAN = '\x1b[36m'; +const WHITE = '\x1b[37m'; +const BG_RED = '\x1b[41m'; +const BG_GREEN = '\x1b[42m'; +const BG_YELLOW = '\x1b[43m'; +const BG_BLUE = '\x1b[44m'; +const BG_MAGENTA = '\x1b[45m'; +const BG_CYAN = '\x1b[46m'; + +const METHOD_STYLE: Record = { + GET: `${BG_BLUE}${WHITE}${BOLD}`, + POST: `${BG_GREEN}${WHITE}${BOLD}`, + PUT: `${BG_YELLOW}${WHITE}${BOLD}`, + PATCH: `${BG_MAGENTA}${WHITE}${BOLD}`, + DELETE: `${BG_RED}${WHITE}${BOLD}`, +}; + +function methodBadge(method: string): string { + const style = METHOD_STYLE[method] ?? `${BG_CYAN}${WHITE}${BOLD}`; + return `${style} ${method.padEnd(6)} ${R}`; +} + +function statusBadge(code: number): string { + if (code < 300) return `${BG_GREEN}${WHITE}${BOLD} ${code} ${R}`; + if (code < 400) return `${BG_YELLOW}${WHITE}${BOLD} ${code} ${R}`; + return `${BG_RED}${WHITE}${BOLD} ${code} ${R}`; +} + +function prettyJson(value: unknown): string { + return JSON.stringify(value, (k, v) => k === 'password' ? '***' : v, 2) + .split('\n') + .map((line, i) => i === 0 ? line : ` ${DIM} ${line}${R}`) + .join('\n'); +} + +const SEP = `${DIM}${'─'.repeat(60)}${R}`; + +app.use((req, res, next) => { + const start = Date.now(); + const ts = new Date().toISOString().replace('T', ' ').slice(0, 23); + + // Skip health check noise + if (req.originalUrl === '/api/health') { next(); return; } + + const originalJson = res.json.bind(res); + let resBody: unknown; + res.json = (body) => { resBody = body; return originalJson(body); }; + + res.on('finish', () => { + const ms = Date.now() - start; + const userId = req.user?.userId ? `${CYAN}${req.user.userId.slice(0, 8)}…${R}` : `${DIM}anon${R}`; + const role = req.user?.role ? `${MAGENTA}${req.user.role}${R}` : `${DIM}-${R}`; + + const hasBody = ['POST', 'PUT', 'PATCH'].includes(req.method) + && req.body && Object.keys(req.body).length > 0; + + const lines: string[] = [ + SEP, + `${DIM}${ts}${R} ${methodBadge(req.method)} ${BOLD}${req.originalUrl}${R}`, + ` ${DIM}┌ user ${R} ${userId} ${DIM}role:${R} ${role}`, + ` ${DIM}└ status ${R} ${statusBadge(res.statusCode)} ${DIM}${ms}ms${R}`, + ]; + + if (hasBody) { + lines.push(` ${GREEN}↑ REQUEST BODY${R}`); + lines.push(` ${DIM} ${prettyJson(req.body)}${R}`); + } + + if (res.statusCode >= 400 && resBody) { + lines.push(` ${RED}↓ ERROR RESPONSE${R}`); + lines.push(` ${DIM} ${prettyJson(resBody)}${R}`); + } else if (res.statusCode < 300 && resBody && req.method !== 'GET') { + lines.push(` ${GREEN}↓ RESPONSE BODY${R}`); + lines.push(` ${DIM} ${prettyJson(resBody)}${R}`); + } + + console.log(lines.join('\n')); + }); + + next(); +}); + app.use(cors({ origin: [ 'http://localhost:5173', // nest-front dev @@ -31,13 +123,14 @@ 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' })); // Global error handler app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { - console.error(err); + console.error(`${BG_RED}${WHITE}${BOLD} UNHANDLED ERROR ${R}`, err); res.status(500).json({ error: 'Internal server error' }); }); diff --git a/nest-backend/src/lib/prisma.ts b/nest-backend/src/lib/prisma.ts index 4e54f7a..3bc967f 100644 --- a/nest-backend/src/lib/prisma.ts +++ b/nest-backend/src/lib/prisma.ts @@ -1,5 +1,11 @@ import { PrismaClient } from '@prisma/client'; -const prisma = new PrismaClient(); +const prisma = new PrismaClient({ + log: [ + { emit: 'stdout', level: 'query' }, + { emit: 'stdout', level: 'warn' }, + { emit: 'stdout', level: 'error' }, + ], +}); export default prisma; 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' }); diff --git a/nest-backend/src/routes/bugs.ts b/nest-backend/src/routes/bugs.ts index b6f309e..174aea8 100644 --- a/nest-backend/src/routes/bugs.ts +++ b/nest-backend/src/routes/bugs.ts @@ -100,7 +100,7 @@ router.get('/', async (req: Request, res: Response): Promise => { prisma.bugReport.count({ where }), ]); - res.json({ bugs: bugs.map(formatBug), total, page, pages: Math.ceil(total / limit) }); + res.json({ data: bugs.map(formatBug), total, page, pages: Math.ceil(total / limit) }); }); // GET /api/bugs/:id 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; diff --git a/nest-front/Dockerfile b/nest-front/Dockerfile index e8b0c94..b293fdb 100644 --- a/nest-front/Dockerfile +++ b/nest-front/Dockerfile @@ -17,14 +17,8 @@ RUN npm run build FROM nginx:alpine COPY --from=build /app/dist /usr/share/nginx/html -COPY nginx.conf /etc/nginx/nginx.conf.template +COPY nginx.conf /etc/nginx/conf.d/default.conf -EXPOSE 5173 +EXPOSE 80 -# API_URL is the backend's public base URL used by nginx proxy_pass. -# Set this at runtime in Coolify, e.g. API_URL=https://api.crowmate.fr -ENV API_URL=http://localhost:3000 - -# Substitute ${API_URL} in the nginx template at container start, then launch nginx. -# The quoted variable list prevents envsubst from replacing nginx variables like $host. -CMD ["/bin/sh", "-c", "envsubst '${API_URL}' < /etc/nginx/nginx.conf.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'"] +CMD ["nginx", "-g", "daemon off;"] diff --git a/nest-front/docker-compose.yml b/nest-front/docker-compose.yml index 0428972..09edf0e 100644 --- a/nest-front/docker-compose.yml +++ b/nest-front/docker-compose.yml @@ -2,5 +2,5 @@ services: nest-front: build: . ports: - - "5173:5173" + - "80:80" restart: unless-stopped diff --git a/nest-front/nginx.conf b/nest-front/nginx.conf index 3af20f7..54e1ef3 100644 --- a/nest-front/nginx.conf +++ b/nest-front/nginx.conf @@ -1,10 +1,14 @@ server { - listen 5173; + listen 80; root /usr/share/nginx/html; index index.html; + # Use Docker's embedded DNS resolver; defer resolution to request time + resolver 127.0.0.11 valid=30s; + location /api/ { - proxy_pass ${API_URL}/api/; + set $api_upstream http://api:3000; + 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-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/components/shared/DevRoleSwitcher.tsx b/nest-front/src/components/shared/DevRoleSwitcher.tsx index a4627b8..9520c3c 100644 --- a/nest-front/src/components/shared/DevRoleSwitcher.tsx +++ b/nest-front/src/components/shared/DevRoleSwitcher.tsx @@ -1,8 +1,7 @@ import { useAuth } from '../../contexts/AuthContext'; -import type { UserRole } from '../../types'; /** - * Developer-only overlay to quickly switch user roles for testing. + * Developer-only overlay to quickly log in as test accounts. * Only visible in development mode. */ export function DevRoleSwitcher() { @@ -12,9 +11,8 @@ export function DevRoleSwitcher() { } function DevRoleSwitcherInner() { - const { user, isAuthenticated, devSetRole, login, logout } = useAuth(); + const { user, isAuthenticated, login, logout } = useAuth(); - const ROLES: UserRole[] = ['user', 'dev', 'com']; const DEV_ACCOUNTS = [ { label: 'Dev/Admin (Kestrel)', email: 'kestrel@crowmate.dev' }, { label: 'Com Staff (Vesper)', email: 'vesper@crowmate.dev' }, @@ -48,30 +46,10 @@ function DevRoleSwitcherInner() {
Logged as: {user?.username}
-
+
Role: {user?.role}
-
- {ROLES.map((r) => ( - - ))} -
-
    {[ - { 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 }) => ( Promise<{ success: boolean; error?: string }>; register: (username: string, email: string, password: string) => Promise<{ success: boolean; error?: string }>; logout: () => void; - updateUsername: (username: string) => void; - // Dev helper: quickly switch role for testing - devSetRole: (role: UserRole) => void; + updateUsername: (username: string) => Promise<{ success: boolean; error?: string }>; } // ── Context ──────────────────────────────────────────────────────────────────── @@ -31,16 +31,6 @@ const AuthContext = createContext(null); const STORAGE_KEY = 'crowmate_auth_user'; -function loadUserFromStorage(): User | null { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return null; - return JSON.parse(raw) as User; - } catch { - return null; - } -} - function saveUserToStorage(user: User | null): void { if (user) { localStorage.setItem(STORAGE_KEY, JSON.stringify(user)); @@ -50,96 +40,89 @@ function saveUserToStorage(user: User | null): void { } export function AuthProvider({ children }: { children: React.ReactNode }) { - const [user, setUser] = useState(loadUserFromStorage); + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + // On mount: validate stored token and restore session + useEffect(() => { + const token = getToken(); + if (!token) { + setIsLoading(false); + return; + } + + authApi.me() + .then((freshUser) => { + setUser(freshUser); + saveUserToStorage(freshUser); + }) + .catch(() => { + clearToken(); + saveUserToStorage(null); + }) + .finally(() => setIsLoading(false)); + }, []); const isAuthenticated = user !== null; const isStaff = user?.role === 'dev' || user?.role === 'com'; const isAdmin = user?.isAdmin === true; const login = useCallback( - async (email: string, _password: string): Promise<{ success: boolean; error?: string }> => { - // Simulate network delay - await new Promise((r) => setTimeout(r, 400)); - - const found = MOCK_USERS.find( - (u) => u.email.toLowerCase() === email.toLowerCase() - ); - - if (!found) { - return { success: false, error: 'No account found with that email address.' }; + async (email: string, password: string): Promise<{ success: boolean; error?: string }> => { + try { + const { token, user: loggedInUser } = await authApi.login(email, password); + setToken(token); + setUser(loggedInUser); + saveUserToStorage(loggedInUser); + return { success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Login failed.'; + return { success: false, error: message }; } - if (found.isBanned) { - return { success: false, error: 'This account has been suspended.' }; - } - - setUser(found); - saveUserToStorage(found); - return { success: true }; }, [] ); const register = useCallback( - async (username: string, email: string, _password: string): Promise<{ success: boolean; error?: string }> => { - await new Promise((r) => setTimeout(r, 500)); - - const emailTaken = MOCK_USERS.some( - (u) => u.email.toLowerCase() === email.toLowerCase() - ); - if (emailTaken) { - return { success: false, error: 'An account with this email already exists.' }; + async (username: string, email: string, password: string): Promise<{ success: boolean; error?: string }> => { + try { + const { token, user: newUser } = await authApi.register(username, email, password); + setToken(token); + setUser(newUser); + saveUserToStorage(newUser); + return { success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Registration failed.'; + return { success: false, error: message }; } - - const usernameTaken = MOCK_USERS.some( - (u) => u.username.toLowerCase() === username.toLowerCase() - ); - if (usernameTaken) { - return { success: false, error: 'This username is already taken.' }; - } - - const newUser: User = { - id: `u${Date.now()}`, - username, - email, - role: 'user', - isAdmin: false, - isBanned: false, - createdAt: new Date().toISOString(), - }; - - setUser(newUser); - saveUserToStorage(newUser); - return { success: true }; }, [] ); const logout = useCallback(() => { + clearToken(); setUser(null); saveUserToStorage(null); }, []); - const updateUsername = useCallback((username: string) => { - setUser((prev) => { - if (!prev) return prev; - const updated = { ...prev, username }; - saveUserToStorage(updated); - return updated; - }); - }, []); - - const devSetRole = useCallback((role: UserRole) => { - setUser((prev) => { - if (!prev) return prev; - const updated = { ...prev, role, isAdmin: role === 'dev' }; - saveUserToStorage(updated); - return updated; - }); - }, []); + const updateUsername = useCallback( + async (username: string): Promise<{ success: boolean; error?: string }> => { + try { + const updatedUser = await usersApi.updateUsername(username); + setUser(updatedUser); + saveUserToStorage(updatedUser); + return { success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to update username.'; + return { success: false, error: message }; + } + }, + [] + ); const value = useMemo( - () => ({ user, isAuthenticated, isStaff, isAdmin, login, register, logout, updateUsername, devSetRole }), - [user, isAuthenticated, isStaff, isAdmin, login, register, logout, updateUsername, devSetRole] + () => ({ user, isAuthenticated, isStaff, isAdmin, isLoading, login, register, logout, updateUsername }), + [user, isAuthenticated, isStaff, isAdmin, isLoading, login, register, logout, updateUsername] ); return {children}; 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); +} 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..3908774 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,23 +57,38 @@ 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 && (bug.meTooBugs ?? []).includes(user.id), [user, bug] ); const isOwnReport = useMemo( @@ -81,42 +96,48 @@ 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 ; } - const metooCount = bug.meTooBugs.length; + const metooCount = (bug.meTooBugs ?? []).length; 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..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 } 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 */} - ▶ {bug.meTooBugs.length} {bug.meTooBugs.length === 1 ? 'user' : 'users'} have this + ▶ {(bug.meTooBugs ?? []).length} {(bug.meTooBugs ?? []).length === 1 ? 'user' : 'users'} have this
    @@ -118,7 +117,7 @@ function BugCard({ bug, highlight }: BugCardProps) { const GAME_VERSIONS = ['0.9.3-alpha', '0.9.2-alpha', '0.9.1-alpha']; const SEVERITIES: BugSeverity[] = ['low', 'medium', 'high', 'critical']; -function SubmitBugForm({ onSubmit }: { onSubmit: (data: BugReportFormData) => 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(Array.isArray(res?.data) ? 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; + (bugs ?? []).forEach((b) => { 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..34a8017 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.events)) + .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..b3171ba 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 ? ( +
    + {search.trim() ? `No results found for "${search}"` : 'No forum categories available yet.'} +
    + ) : ( + filteredCategories.map((cat) => ( + + )) + ) )}
    ); diff --git a/nest-front/src/pages/public/StudioPage.tsx b/nest-front/src/pages/public/StudioPage.tsx index b398088..1f726ac 100644 --- a/nest-front/src/pages/public/StudioPage.tsx +++ b/nest-front/src/pages/public/StudioPage.tsx @@ -1,6 +1,53 @@ -import { TEAM_MEMBERS } from '../../data/mockData'; +import type { TeamMember } from '../../types'; + +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', + }, +]; export default function StudioPage() { + const members = FALLBACK_MEMBERS; + return (
    {/* Header */} @@ -31,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 - united by a shared obsession: games that are strange, atmospheric, and actually interesting. - We are headquartered somewhere in Europe and operate fully remote. + 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 France and operate arround the globe.

    + +
    + {[ + { label: 'TEAM SIZE', value: '6 PEOPLE' }, + { label: 'FOUNDED', value: '2026' }, + { label: 'CURRENT GAME', value: 'HEADLESS HAZARD' }, + ].map(({ label, value }) => ( +
    +
    + {label} +
    +
    + {value} +
    +
    + ))} +
    {/* History & Vision */} @@ -127,7 +208,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() {
    ) : (
    + {loading && ( +
    + Loading moderation data... +
    + )} + {error && ( +
    + {error} +
    + )}
    @@ -83,7 +296,8 @@ export default function IntranetModeration() {

    ))}
    @@ -131,6 +350,22 @@ export default function IntranetModeration() {
    {/* Thread list */}
    +
    + + {categories.length === 0 && ( +
    + No categories available. +
    + )} +
    + togglePin(thread.id)} + disabled={loading} > {thread.isPinned ? 'Unpin' : 'Pin'} @@ -181,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'} @@ -188,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 @@ -274,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)} + /> + +