1 Commits

Author SHA1 Message Date
Thibault Pouch
cc175b6ce6 git: Merge pull request #1 from CrowMate/feat/connect-front-to-backend
Feat/connect front to backend
2026-03-03 14:54:26 +01:00
23 changed files with 99 additions and 980 deletions

View File

@@ -19,10 +19,15 @@ services:
restart: unless-stopped
ports:
- "3000:3000"
env_file:
- ./nest-backend/.env
environment:
DATABASE_URL: postgresql://nest_user:nest_password@db:5432/nest_db
JWT_SECRET: ${JWT_SECRET:-change_me_in_production}
PORT: 3000
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@crowmate.fr}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change_me}
FRONT_ORIGIN: ${FRONT_ORIGIN:-http://localhost:5173}
INTRA_ORIGIN: ${INTRA_ORIGIN:-http://localhost:5174}
depends_on:
db:
condition: service_healthy
@@ -34,8 +39,6 @@ services:
restart: unless-stopped
ports:
- "80:80"
environment:
API_URL: http://api:3000
depends_on:
- api

View File

@@ -221,14 +221,6 @@ 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 {

View File

@@ -7,7 +7,6 @@ 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();
@@ -123,7 +122,6 @@ 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' }));

View File

@@ -1,6 +1,5 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import prisma from '../lib/prisma.js';
export interface JwtPayload {
userId: string;
@@ -16,7 +15,7 @@ declare global {
}
}
export async function authenticate(req: Request, res: Response, next: NextFunction): Promise<void> {
export function authenticate(req: Request, res: Response, next: NextFunction): void {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing or invalid Authorization header' });
@@ -26,21 +25,7 @@ export async function authenticate(req: Request, res: Response, next: NextFuncti
const token = header.slice(7);
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
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,
};
req.user = payload;
next();
} catch {
res.status(401).json({ error: 'Token expired or invalid' });

View File

@@ -100,7 +100,7 @@ router.get('/', async (req: Request, res: Response): Promise<void> => {
prisma.bugReport.count({ where }),
]);
res.json({ data: bugs.map(formatBug), total, page, pages: Math.ceil(total / limit) });
res.json({ bugs: bugs.map(formatBug), total, page, pages: Math.ceil(total / limit) });
});
// GET /api/bugs/:id

View File

@@ -1,44 +0,0 @@
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<void> => {
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<void> => {
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;

View File

@@ -1,4 +1,4 @@
FROM node:25-alpine AS build
FROM node:22-alpine AS build
WORKDIR /app

View File

@@ -1,7 +1,6 @@
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';
@@ -20,49 +19,36 @@ 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 <PageLoader />;
return (
<Suspense fallback={<PageLoader />}>
<Routes>
<Route element={<PublicLayout />}>
<Route index element={<HomePage />} />
<Route path="studio" element={<StudioPage />} />
<Route path="events" element={<EventsPage />} />
<Route path="forum" element={forumEnabled ? <ForumPage /> : <NotFoundPage />} />
<Route path="forum/thread/:id" element={forumEnabled ? <ThreadPage /> : <NotFoundPage />} />
<Route path="bugs" element={bugsEnabled ? <BugReportPage /> : <NotFoundPage />} />
<Route path="bugs/:id" element={bugsEnabled ? <BugDetailPage /> : <NotFoundPage />} />
<Route
path="account"
element={
<ProtectedRoute>
<AccountPage />
</ProtectedRoute>
}
/>
<Route path="login" element={<LoginPage />} />
<Route path="register" element={<RegisterPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</Suspense>
);
}
// ── App ────────────────────────────────────────────────────────────────────────
export default function App() {
return (
<AuthProvider>
<SettingsProvider>
<AppRoutes />
</SettingsProvider>
<Suspense fallback={<PageLoader />}>
<Routes>
{/* Public Routes */}
<Route element={<PublicLayout />}>
<Route index element={<HomePage />} />
<Route path="studio" element={<StudioPage />} />
<Route path="events" element={<EventsPage />} />
<Route path="forum" element={<ForumPage />} />
<Route path="forum/thread/:id" element={<ThreadPage />} />
<Route path="bugs" element={<BugReportPage />} />
<Route path="bugs/:id" element={<BugDetailPage />} />
<Route
path="account"
element={
<ProtectedRoute>
<AccountPage />
</ProtectedRoute>
}
/>
<Route path="login" element={<LoginPage />} />
<Route path="register" element={<RegisterPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</Suspense>
</AuthProvider>
);
}

View File

@@ -1,9 +1,7 @@
import { Link } from 'react-router-dom';
import { useSettings } from '../../contexts/SettingsContext';
export function Footer() {
const year = new Date().getFullYear();
const { forumEnabled, bugsEnabled } = useSettings();
return (
<footer
@@ -39,11 +37,11 @@ export function Footer() {
<div className="section-label" style={{ marginBottom: '0.75rem' }}>Navigate</div>
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
{[
{ 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 }) => (
{ to: '/', label: 'Home' },
{ to: '/studio', label: 'Studio' },
{ to: '/forum', label: 'Forum' },
{ to: '/bugs', label: 'Bug Reports' },
].map(({ to, label }) => (
<li key={to}>
<Link
to={to}

View File

@@ -1,28 +1,20 @@
import { useState, useCallback } from 'react';
import { Link, NavLink, useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { useSettings } from '../../contexts/SettingsContext';
const BASE_NAV_LINKS = [
{ to: '/', label: 'Home', end: true, feature: null as null | 'forum' | 'bugs' },
{ to: '/studio', label: 'Studio', end: false, feature: null },
{ to: '/events', label: 'Events', end: false, feature: null },
{ to: '/forum', label: 'Forum', end: false, feature: 'forum' as const },
{ to: '/bugs', label: 'Bugs', end: false, feature: 'bugs' as const },
const NAV_LINKS = [
{ to: '/', label: 'Home', end: true },
{ to: '/studio', label: 'Studio', end: false },
{ to: '/events', label: 'Events', end: false },
{ to: '/forum', label: 'Forum', end: false },
{ to: '/bugs', label: 'Bugs', end: false },
];
export function Navbar() {
const { user, isAuthenticated, logout } = useAuth();
const { forumEnabled, bugsEnabled } = useSettings();
const navigate = useNavigate();
const [menuOpen, setMenuOpen] = useState(false);
const navLinks = BASE_NAV_LINKS.filter(({ feature }) => {
if (feature === 'forum') return forumEnabled;
if (feature === 'bugs') return bugsEnabled;
return true;
});
const handleLogout = useCallback(() => {
logout();
setMenuOpen(false);
@@ -93,7 +85,7 @@ export function Navbar() {
{/* Desktop Nav */}
<div className="hidden md:flex items-center gap-6">
{navLinks.map(({ to, label, end }) => (
{NAV_LINKS.map(({ to, label, end }) => (
<NavLink key={to} to={to} end={end} style={navLinkStyle}>
{label}
</NavLink>
@@ -156,7 +148,7 @@ export function Navbar() {
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.85rem' }}>
{navLinks.map(({ to, label, end }) => (
{NAV_LINKS.map(({ to, label, end }) => (
<NavLink
key={to}
to={to}

View File

@@ -1,31 +0,0 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { settingsApi } from '../utils/api';
interface SettingsContextValue {
forumEnabled: boolean;
bugsEnabled: boolean;
loaded: boolean;
}
const SettingsContext = createContext<SettingsContextValue>({
forumEnabled: true,
bugsEnabled: true,
loaded: false,
});
export function SettingsProvider({ children }: { children: React.ReactNode }) {
const [value, setValue] = useState<SettingsContextValue>({ 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 <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
}
export function useSettings(): SettingsContextValue {
return useContext(SettingsContext);
}

View File

@@ -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 <Navigate to="/bugs" replace />;
}
const metooCount = (bug.meTooBugs ?? []).length;
const metooCount = bug.meTooBugs.length;
return (
<div style={{ maxWidth: '860px', margin: '0 auto', padding: '3rem 1.5rem' }}>

View File

@@ -78,7 +78,7 @@ function BugCard({ bug, highlight }: BugCardProps) {
borderRadius: '3px',
}}
>
&#9654; {(bug.meTooBugs ?? []).length} {(bug.meTooBugs ?? []).length === 1 ? 'user' : 'users'} have this
&#9654; {bug.meTooBugs.length} {bug.meTooBugs.length === 1 ? 'user' : 'users'} have this
</span>
</div>
@@ -285,7 +285,7 @@ export default function BugReportPage() {
const fetchBugs = useCallback(() => {
setLoading(true);
bugsApi.getBugs({ status: statusFilter, severity: severityFilter, limit: 100 })
.then((res) => setBugs(Array.isArray(res?.data) ? res.data : []))
.then((res) => setBugs(res.data))
.catch(() => setBugs([]))
.finally(() => setLoading(false));
}, [statusFilter, severityFilter]);
@@ -295,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);
});

View File

@@ -217,7 +217,7 @@ export default function ForumPage() {
{!loading && !error && (
filteredCategories.length === 0 ? (
<div className="crt-box" style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)' }}>
{search.trim() ? `No results found for "${search}"` : 'No forum categories available yet.'}
No results found for "{search}"
</div>
) : (
filteredCategories.map((cat) => (

View File

@@ -1,52 +1,15 @@
import { useEffect, useState } from 'react';
import { teamApi } from '../../utils/api';
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;
const [members, setMembers] = useState<TeamMember[]>([]);
useEffect(() => {
teamApi.getMembers()
.then(setMembers)
.catch(() => { /* show empty state */ });
}, []);
return (
<div style={{ maxWidth: '1000px', margin: '0 auto', padding: '4rem 1.5rem' }}>
@@ -78,9 +41,9 @@ export default function StudioPage() {
marginBottom: '1rem',
}}
>
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.
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.
</p>
<p
style={{
@@ -97,40 +60,6 @@ export default function StudioPage() {
you don't need a $200 million budget to make something that sticks.
</p>
</div>
<div
className="crt-box"
style={{ padding: '1.5rem 2rem', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: '1rem' }}
>
{[
{ label: 'TEAM SIZE', value: '6 PEOPLE' },
{ label: 'FOUNDED', value: '2026' },
{ label: 'CURRENT GAME', value: 'HEADLESS HAZARD' },
].map(({ label, value }) => (
<div key={label}>
<div
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-green)',
fontSize: '0.72rem',
letterSpacing: '0.08em',
marginBottom: '0.45rem',
}}
>
{label}
</div>
<div
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: '1rem',
}}
>
{value}
</div>
</div>
))}
</div>
</div>
{/* History & Vision */}

View File

@@ -114,26 +114,14 @@ export const forumApi = {
getCategories: () =>
apiFetch<ForumCategory[]>('/forum/categories'),
getThreads: async (params?: { categoryId?: string; page?: number; limit?: number }) => {
getThreads: (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));
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,
};
return apiFetch<{ data: ForumThread[]; total: number; page: number; pages: number }>(
`/forum/threads?${q}`
);
},
getThread: (id: string) =>
@@ -202,11 +190,3 @@ export const eventsApi = {
export const teamApi = {
getMembers: () => apiFetch<TeamMember[]>('/team'),
};
// ── Settings API ──────────────────────────────────────────────────────────────
export type SiteSettings = { forumEnabled: boolean; bugsEnabled: boolean };
export const settingsApi = {
get: () => apiFetch<SiteSettings>('/settings'),
};

View File

@@ -1,4 +1,4 @@
FROM node:25-alpine AS build
FROM node:22-alpine AS build
WORKDIR /app

View File

@@ -3,15 +3,14 @@ server {
root /usr/share/nginx/html;
index index.html;
# Docker DNS; resolve API service name at request time.
resolver 127.0.0.11 ipv6=off valid=10s;
set $api_upstream http://api:3000;
# Use Docker's embedded DNS resolver; defer resolution to request time
resolver 127.0.0.11 valid=30s;
location /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;
}
location / {

View File

@@ -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: 'Forum Mod', icon: '[M]', end: false },
{ to: '/intranet/moderation', label: 'Moderation', icon: '[M]', end: false },
{ to: '/intranet/services', label: 'Services', icon: '[S]', end: false },
];

View File

@@ -1,7 +1,6 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useState, useMemo, useCallback } 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,20 +26,6 @@ export default function IntranetBugs() {
const [severityFilter, setSeverityFilter] = useState<BugSeverity | 'all'>('all');
const [assignedFilter, setAssignedFilter] = useState<string | 'all'>('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;
@@ -87,64 +72,13 @@ export default function IntranetBugs() {
setNoteText('');
}, [noteText, user]);
if (!isEnabled) {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '400px', textAlign: 'center' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '1rem' }}>
INTRANET / BUG REPORTS
</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-red)', fontSize: '2rem', marginBottom: '0.5rem' }}>FUNCTIONALITY DISABLED</h1>
<p style={{ color: 'var(--color-text-muted)', marginBottom: '1.5rem' }}>Bug Reports feature is currently disabled</p>
<button
onClick={() => handleToggle(true)}
disabled={toggling}
style={{
background: 'var(--color-green)',
color: 'var(--color-bg)',
border: 'none',
padding: '0.6rem 1.2rem',
fontFamily: 'var(--font-mono)',
fontSize: '0.85rem',
cursor: toggling ? 'not-allowed' : 'pointer',
textTransform: 'uppercase',
letterSpacing: '0.08em',
opacity: toggling ? 0.6 : 1,
}}
>
Re-enable
</button>
</div>
);
}
return (
<div style={{ display: 'grid', gridTemplateColumns: selected ? '1fr 400px' : '1fr', gap: '1.5rem', alignItems: 'start' }}>
{/* Left panel */}
<div>
<div style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em' }}>
INTRANET / BUG REPORTS
</div>
<button
onClick={() => handleToggle(false)}
disabled={toggling}
style={{
background: 'transparent',
border: '1px solid var(--color-red)',
color: 'var(--color-red)',
padding: '0.3rem 0.7rem',
fontFamily: 'var(--font-mono)',
fontSize: '0.65rem',
cursor: toggling ? 'not-allowed' : 'pointer',
textTransform: 'uppercase',
letterSpacing: '0.08em',
opacity: toggling ? 0.6 : 1,
}}
title="Disable this feature"
>
[DISABLE]
</button>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
INTRANET / BUG REPORTS
</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem', marginBottom: '1rem' }}>BUG DASHBOARD</h1>

View File

@@ -125,8 +125,6 @@ export default function IntranetDashboard() {
<NavTile to="/intranet/feed" label="Team Feed" description="Internal staff activity feed. Post updates visible only to the team." icon="[~]" />
<NavTile to="/intranet/users" label="User Management" description="View all registered users. Promote or ban accounts." icon="[U]" />
<NavTile to="/intranet/moderation" label="Forum Moderation" description="Pin, lock, and delete threads and replies from the public forum." icon="[M]" />
<NavTile to="/intranet/events" label="Event Calendar" description="Manage upcoming events, deadlines, and team meetings." icon="[E]" />
<NavTile to="/intranet/services" label="Service Status" description="Redirection to all the services." icon="[S]" />
</div>
</div>

View File

@@ -1,87 +1,13 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useState, useMemo, useCallback } from 'react';
import { formatDateTime } from '../../utils/format';
import { forumApi, settingsApi } from '../../utils/api';
import type { ForumCategory, ForumReply, ForumThread } from '../../types';
import type { ForumThread, ForumReply } from '../../types';
export default function IntranetModeration() {
const [categories, setCategories] = useState<ForumCategory[]>([]);
const [threads, setThreads] = useState<ForumThread[]>([]);
const [replies, setReplies] = useState<ForumReply[]>([]);
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(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<string | null>(null);
const [categoryName, setCategoryName] = useState('');
const [categoryDescription, setCategoryDescription] = useState('');
const [categoryIcon, setCategoryIcon] = useState('📁');
const [search, setSearch] = useState('');
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 })
.then(() => setIsEnabled(enabled))
.catch(() => {})
.finally(() => setToggling(false));
}, []);
const [activeTab, setActiveTab] = useState<'threads' | 'replies'>('threads');
const filteredThreads = useMemo(() => {
if (!search.trim()) return threads;
@@ -95,232 +21,42 @@ export default function IntranetModeration() {
}, [replies, selectedThreadId]);
const deleteThread = useCallback((id: string) => {
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.');
});
setThreads((prev) => prev.filter((t) => t.id !== id));
setReplies((prev) => prev.filter((r) => r.threadId !== id));
if (selectedThreadId === id) setSelectedThreadId(null);
}, [selectedThreadId]);
const togglePin = useCallback((id: string) => {
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]);
setThreads((prev) => prev.map((t) => t.id === id ? { ...t, isPinned: !t.isPinned } : t));
}, []);
const toggleLock = useCallback((id: string) => {
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]);
setThreads((prev) => prev.map((t) => t.id === id ? { ...t, isLocked: !t.isLocked } : t));
}, []);
const deleteReply = useCallback((id: string) => {
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);
setReplies((prev) => prev.filter((r) => r.id !== id));
}, []);
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]);
return (
<div>
{!isEnabled ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '400px', textAlign: 'center' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '1rem' }}>
INTRANET / MODERATION
</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-red)', fontSize: '2rem', marginBottom: '0.5rem' }}>FUNCTIONALITY DISABLED</h1>
<p style={{ color: 'var(--color-text-muted)', marginBottom: '1.5rem' }}>Forum Moderation feature is currently disabled</p>
<button
onClick={() => handleToggle(true)}
disabled={toggling}
style={{
background: 'var(--color-green)',
color: 'var(--color-bg)',
border: 'none',
padding: '0.6rem 1.2rem',
fontFamily: 'var(--font-mono)',
fontSize: '0.85rem',
cursor: toggling ? 'not-allowed' : 'pointer',
textTransform: 'uppercase',
letterSpacing: '0.08em',
opacity: toggling ? 0.6 : 1,
}}
>
Re-enable
</button>
</div>
) : (
<div>
{loading && (
<div style={{ marginBottom: '1rem', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem' }}>
Loading moderation data...
</div>
)}
{error && (
<div style={{ marginBottom: '1rem', color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem' }}>
{error}
</div>
)}
<div style={{ marginBottom: '1.75rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
INTRANET / MODERATION
</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem', marginBottom: '0.5rem' }}>FORUM MODERATION</h1>
<p style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem' }}>
{threads.length} threads &mdash; {replies.length} replies
</p>
</div>
<button
onClick={() => handleToggle(false)}
disabled={toggling}
style={{
background: 'transparent',
border: '1px solid var(--color-red)',
color: 'var(--color-red)',
padding: '0.3rem 0.7rem',
fontFamily: 'var(--font-mono)',
fontSize: '0.65rem',
cursor: toggling ? 'not-allowed' : 'pointer',
textTransform: 'uppercase',
letterSpacing: '0.08em',
height: 'fit-content',
opacity: toggling ? 0.6 : 1,
}}
title="Disable this feature"
>
[DISABLE]
</button>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
INTRANET / MODERATION
</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem', marginBottom: '0.5rem' }}>FORUM MODERATION</h1>
<p style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem' }}>
{threads.length} threads &mdash; {replies.length} replies
</p>
</div>
{/* Tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid var(--color-border)', marginBottom: '1.5rem', gap: 0 }}>
{(['threads', 'replies', 'categories'] as const).map((tab) => (
{(['threads', 'replies'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
@@ -337,11 +73,7 @@ export default function IntranetModeration() {
letterSpacing: '0.08em',
}}
>
{tab === 'threads'
? `Threads (${threads.length})`
: tab === 'replies'
? `Replies (${replies.length})`
: `Categories (${categories.length})`}
{tab === 'threads' ? `Threads (${threads.length})` : `Replies (${replies.length})`}
</button>
))}
</div>
@@ -350,22 +82,6 @@ export default function IntranetModeration() {
<div style={{ display: 'grid', gridTemplateColumns: selectedThreadId ? '1fr 340px' : '1fr', gap: '1.25rem', alignItems: 'start' }}>
{/* Thread list */}
<div>
<div style={{ marginBottom: '1rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<button
className="btn-terminal btn-amber"
onClick={() => setIsCreateModalOpen(true)}
disabled={loading || categories.length === 0}
style={{ opacity: loading || categories.length === 0 ? 0.6 : 1 }}
>
+ Create Thread
</button>
{categories.length === 0 && (
<div style={{ color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.72rem' }}>
No categories available.
</div>
)}
</div>
<input
className="input-terminal"
type="search"
@@ -409,7 +125,6 @@ export default function IntranetModeration() {
className={`btn-terminal ${thread.isPinned ? 'btn-danger' : 'btn-amber'}`}
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
onClick={() => togglePin(thread.id)}
disabled={loading}
>
{thread.isPinned ? 'Unpin' : 'Pin'}
</button>
@@ -417,7 +132,6 @@ 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'}
</button>
@@ -425,7 +139,6 @@ 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
</button>
@@ -512,205 +225,6 @@ export default function IntranetModeration() {
)}
</div>
)}
{activeTab === 'categories' && (
<div>
<div style={{ marginBottom: '1rem', display: 'flex', justifyContent: 'flex-end' }}>
<button className="btn-terminal btn-amber" onClick={openCreateCategoryModal}>
+ Create Category
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '0.75rem' }}>
{categories.map((category) => (
<div
key={category.id}
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
padding: '1rem',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.4rem', gap: '0.5rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.9rem' }}>
{category.icon} {category.name}
</div>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem' }}>
{category.threadCount} threads
</span>
</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.75rem', lineHeight: 1.6 }}>
{category.description}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.4rem', marginTop: '0.7rem' }}>
<button
className="btn-terminal"
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
onClick={() => openEditCategoryModal(category)}
>
Modify
</button>
<button
className="btn-terminal btn-danger"
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
onClick={() => removeCategory(category.id)}
>
Remove
</button>
</div>
</div>
))}
{categories.length === 0 && (
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem', background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}>
No categories found.
</div>
)}
</div>
</div>
)}
{isCreateModalOpen && (
<div
role="dialog"
aria-modal="true"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.55)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 40,
padding: '1rem',
}}
>
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', width: 'min(620px, 100%)', padding: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.8rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.75rem', letterSpacing: '0.1em' }}>
CREATE THREAD
</div>
<button
onClick={() => setIsCreateModalOpen(false)}
style={{ background: 'transparent', border: 'none', color: 'var(--color-text-muted)', cursor: 'pointer' }}
aria-label="Close create thread popup"
>
&#x2715;
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '0.6rem' }}>
<input
className="input-terminal"
type="text"
placeholder="Thread title"
value={createTitle}
onChange={(e) => setCreateTitle(e.target.value)}
/>
<select
className="input-terminal"
value={createCategoryId}
onChange={(e) => setCreateCategoryId(e.target.value)}
>
{categories.map((category) => (
<option key={category.id} value={category.id}>{category.name}</option>
))}
</select>
<textarea
className="input-terminal"
rows={4}
placeholder="Thread content"
value={createContent}
onChange={(e) => setCreateContent(e.target.value)}
style={{ resize: 'vertical' }}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
<button className="btn-terminal" onClick={() => setIsCreateModalOpen(false)}>
Cancel
</button>
<button
className="btn-terminal btn-amber"
onClick={createThread}
disabled={creating || loading || categories.length === 0}
style={{ opacity: creating || loading || categories.length === 0 ? 0.6 : 1 }}
>
{creating ? 'Creating...' : 'Create Thread'}
</button>
</div>
</div>
</div>
</div>
)}
{isCategoryModalOpen && (
<div
role="dialog"
aria-modal="true"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.55)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 50,
padding: '1rem',
}}
>
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', width: 'min(560px, 100%)', padding: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.8rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.75rem', letterSpacing: '0.1em' }}>
{editingCategoryId ? 'MODIFY CATEGORY' : 'CREATE CATEGORY'}
</div>
<button
onClick={() => setIsCategoryModalOpen(false)}
style={{ background: 'transparent', border: 'none', color: 'var(--color-text-muted)', cursor: 'pointer' }}
aria-label="Close category popup"
>
&#x2715;
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '0.6rem' }}>
<input
className="input-terminal"
type="text"
placeholder="Category name"
value={categoryName}
onChange={(e) => setCategoryName(e.target.value)}
/>
<input
className="input-terminal"
type="text"
placeholder="Icon (emoji)"
value={categoryIcon}
onChange={(e) => setCategoryIcon(e.target.value)}
/>
<textarea
className="input-terminal"
rows={3}
placeholder="Category description"
value={categoryDescription}
onChange={(e) => setCategoryDescription(e.target.value)}
style={{ resize: 'vertical' }}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
<button className="btn-terminal" onClick={() => setIsCategoryModalOpen(false)}>
Cancel
</button>
<button
className="btn-terminal btn-amber"
onClick={saveCategory}
disabled={savingCategory}
style={{ opacity: savingCategory ? 0.6 : 1 }}
>
{savingCategory ? 'Saving...' : editingCategoryId ? 'Save Changes' : 'Create Category'}
</button>
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,114 +0,0 @@
import { getToken } from '../contexts/AuthContext';
import type { ForumCategory, ForumReply, ForumThread } from '../types';
export const API_BASE = (import.meta.env.VITE_API_URL as string | undefined) ?? '/api';
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> ?? {}),
};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { error?: unknown };
const message = typeof body.error === 'string' ? body.error : `Request failed (${res.status})`;
throw new Error(message);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
type ThreadsResponse = {
data?: ForumThread[];
threads?: ForumThread[];
total: number;
page: number;
pages: number;
};
export const forumApi = {
getCategories: () => apiFetch<ForumCategory[]>('/forum/categories'),
createCategory: (payload: { name: string; description: string; icon: string }) =>
apiFetch<ForumCategory>('/forum/categories', {
method: 'POST',
body: JSON.stringify(payload),
}),
updateCategory: (id: string, payload: { name?: string; description?: string; icon?: string }) =>
apiFetch<ForumCategory>(`/forum/categories/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
}),
deleteCategory: (id: string) =>
apiFetch<void>(`/forum/categories/${id}`, {
method: 'DELETE',
}),
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));
const result = await apiFetch<ThreadsResponse>(`/forum/threads?${q.toString()}`);
return {
data: result.data ?? result.threads ?? [],
total: result.total,
page: result.page,
pages: result.pages,
};
},
getThread: (id: string) => apiFetch<ForumThread & { replies: ForumReply[] }>(`/forum/threads/${id}`),
createThread: (payload: { title: string; content: string; categoryId: string }) =>
apiFetch<ForumThread>('/forum/threads', {
method: 'POST',
body: JSON.stringify(payload),
}),
updateThread: (id: string, payload: { isPinned?: boolean; isLocked?: boolean; title?: string; content?: string }) =>
apiFetch<ForumThread>(`/forum/threads/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
}),
deleteThread: (id: string) =>
apiFetch<void>(`/forum/threads/${id}`, {
method: 'DELETE',
}),
deleteReply: (id: string) =>
apiFetch<void>(`/forum/replies/${id}`, {
method: 'DELETE',
}),
};
export type SiteSettings = { forumEnabled: boolean; bugsEnabled: boolean };
export const settingsApi = {
get: () => apiFetch<SiteSettings>('/settings'),
update: (data: Partial<SiteSettings>) =>
apiFetch<SiteSettings>('/settings', {
method: 'PATCH',
body: JSON.stringify(data),
}),
};