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
34 changed files with 155 additions and 1484 deletions

View File

@@ -1,12 +0,0 @@
# Host-side port for the API container (container port remains 3000)
API_HOST_PORT=3001
DATABASE_URL="postgresql://user:password@localhost:5432/nest_db"
JWT_SECRET="change_me_to_a_long_random_string"
PORT=3000
ADMIN_USERNAME="admin"
ADMIN_EMAIL="admin@example.com"
ADMIN_PASSWORD="change_me"
FRONT_ORIGIN="http://localhost:5173"
INTRA_ORIGIN="http://localhost:5174"

View File

@@ -49,7 +49,7 @@ npm install
### 3. Set Up Environment ### 3. Set Up Environment
Create a root `.env` file from `.env.example` (`cp .env.example .env`) and adjust values as needed. See the main [README.md](./README.md) for required environment variables. Create `.env` files based on the examples in each project. See the main [README.md](./README.md) for required environment variables.
### 4. Create a Branch ### 4. Create a Branch

View File

@@ -23,11 +23,9 @@ nest-intra/ # Staff-only internal portal (React + Vite)
### 1. Backend Setup ### 1. Backend Setup
```bash ```bash
# from repository root
cp .env.example .env
# Edit .env with your credentials
cd nest-backend cd nest-backend
cp .env.example .env
# Edit .env with your database credentials
npm install npm install
npm run db:push # Initialize database schema npm run db:push # Initialize database schema
npm run db:seed # Populate with sample data npm run db:seed # Populate with sample data
@@ -37,7 +35,6 @@ npm run dev # Start dev server (http://localhost:3000)
#### Backend Environment Variables #### Backend Environment Variables
```env ```env
API_HOST_PORT=3001
DATABASE_URL="postgresql://user:password@localhost:5432/nest_db" DATABASE_URL="postgresql://user:password@localhost:5432/nest_db"
JWT_SECRET="your-secret-key" JWT_SECRET="your-secret-key"
PORT=3000 PORT=3000

View File

@@ -1,47 +0,0 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: nest_db
POSTGRES_USER: nest_user
POSTGRES_PASSWORD: nest_password
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nest_user -d nest_db"]
interval: 5s
timeout: 5s
retries: 5
api:
image: git.crowmate.fr/crowmate/nest-api:latest
restart: unless-stopped
ports:
- "${API_HOST_PORT:-3001}:3000"
environment:
DATABASE_URL: postgresql://nest_user:nest_password@db:5432/nest_db
depends_on:
db:
condition: service_healthy
front:
image: git.crowmate.fr/crowmate/nest-front:latest
restart: unless-stopped
ports:
- "5143:80"
environment:
API_URL: http://api:3000
depends_on:
- api
intra:
image: git.crowmate.fr/crowmate/nest-intra:latest
restart: unless-stopped
ports:
- "5174:5174"
depends_on:
- api
volumes:
db_data:

View File

@@ -18,11 +18,16 @@ services:
build: ./nest-backend build: ./nest-backend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${API_HOST_PORT:-3001}:3000" - "3000:3000"
env_file:
- ./.env
environment: environment:
DATABASE_URL: postgresql://nest_user:nest_password@db:5432/nest_db 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: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -34,8 +39,6 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "80:80" - "80:80"
environment:
API_URL: http://api:3000
depends_on: depends_on:
- api - api

View File

@@ -9,6 +9,9 @@ dist/
.env.local .env.local
.env.*.local .env.*.local
# Prisma
prisma/migrations/
# Logs # Logs
*.log *.log
npm-debug.log* npm-debug.log*

View File

@@ -28,4 +28,4 @@ COPY prisma ./prisma
EXPOSE 3000 EXPOSE 3000
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"] CMD ["node", "dist/index.js"]

View File

@@ -18,9 +18,7 @@ services:
build: . build: .
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${API_HOST_PORT:-3001}:3000" - "3000:3000"
env_file:
- ../.env
environment: environment:
DATABASE_URL: postgresql://nest_user:nest_password@db:5432/nest_db DATABASE_URL: postgresql://nest_user:nest_password@db:5432/nest_db
JWT_SECRET: ${JWT_SECRET:-change_me_in_production} JWT_SECRET: ${JWT_SECRET:-change_me_in_production}

View File

@@ -864,6 +864,7 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,

View File

@@ -1,254 +0,0 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('user', 'dev', 'com');
-- CreateEnum
CREATE TYPE "BugSeverity" AS ENUM ('low', 'medium', 'high', 'critical');
-- CreateEnum
CREATE TYPE "BugStatus" AS ENUM ('open', 'in_progress', 'resolved', 'closed');
-- CreateEnum
CREATE TYPE "EventType" AS ENUM ('announcement', 'update', 'milestone', 'poll');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"role" "UserRole" NOT NULL DEFAULT 'user',
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
"isBanned" BOOLEAN NOT NULL DEFAULT false,
"avatarUrl" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ForumCategory" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"icon" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ForumCategory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ForumThread" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"isPinned" BOOLEAN NOT NULL DEFAULT false,
"isLocked" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"authorId" TEXT NOT NULL,
"categoryId" TEXT NOT NULL,
CONSTRAINT "ForumThread_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ForumReply" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"authorId" TEXT NOT NULL,
"threadId" TEXT NOT NULL,
CONSTRAINT "ForumReply_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BugReport" (
"id" TEXT NOT NULL,
"uniqueCode" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"stepsToReproduce" TEXT NOT NULL,
"severity" "BugSeverity" NOT NULL,
"gameVersion" TEXT NOT NULL,
"screenshotUrl" TEXT,
"status" "BugStatus" NOT NULL DEFAULT 'open',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"submittedById" TEXT NOT NULL,
"assignedToId" TEXT,
CONSTRAINT "BugReport_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BugComment" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"bugReportId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
CONSTRAINT "BugComment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BugReportNote" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"bugReportId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
CONSTRAINT "BugReportNote_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MeTooBug" (
"userId" TEXT NOT NULL,
"bugReportId" TEXT NOT NULL,
CONSTRAINT "MeTooBug_pkey" PRIMARY KEY ("userId","bugReportId")
);
-- CreateTable
CREATE TABLE "StaffPost" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"authorId" TEXT NOT NULL,
CONSTRAINT "StaffPost_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EventPost" (
"id" TEXT NOT NULL,
"type" "EventType" NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"isPublic" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"authorId" TEXT NOT NULL,
CONSTRAINT "EventPost_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Poll" (
"id" TEXT NOT NULL,
"question" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"endsAt" TIMESTAMP(3),
"allowMultipleVotes" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"eventId" TEXT NOT NULL,
CONSTRAINT "Poll_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PollOption" (
"id" TEXT NOT NULL,
"text" TEXT NOT NULL,
"pollId" TEXT NOT NULL,
CONSTRAINT "PollOption_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PollVote" (
"userId" TEXT NOT NULL,
"pollOptionId" TEXT NOT NULL,
CONSTRAINT "PollVote_pkey" PRIMARY KEY ("userId","pollOptionId")
);
-- CreateTable
CREATE TABLE "SiteSettings" (
"id" INTEGER NOT NULL DEFAULT 1,
"forumEnabled" BOOLEAN NOT NULL DEFAULT true,
"bugsEnabled" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "SiteSettings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TeamMember" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" TEXT NOT NULL,
"bio" TEXT,
"avatarInitials" TEXT NOT NULL,
"twitterHandle" TEXT,
"githubHandle" TEXT,
CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "BugReport_uniqueCode_key" ON "BugReport"("uniqueCode");
-- CreateIndex
CREATE UNIQUE INDEX "Poll_eventId_key" ON "Poll"("eventId");
-- AddForeignKey
ALTER TABLE "ForumThread" ADD CONSTRAINT "ForumThread_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ForumThread" ADD CONSTRAINT "ForumThread_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "ForumCategory"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ForumReply" ADD CONSTRAINT "ForumReply_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ForumReply" ADD CONSTRAINT "ForumReply_threadId_fkey" FOREIGN KEY ("threadId") REFERENCES "ForumThread"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BugReport" ADD CONSTRAINT "BugReport_submittedById_fkey" FOREIGN KEY ("submittedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BugReport" ADD CONSTRAINT "BugReport_assignedToId_fkey" FOREIGN KEY ("assignedToId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BugComment" ADD CONSTRAINT "BugComment_bugReportId_fkey" FOREIGN KEY ("bugReportId") REFERENCES "BugReport"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BugComment" ADD CONSTRAINT "BugComment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BugReportNote" ADD CONSTRAINT "BugReportNote_bugReportId_fkey" FOREIGN KEY ("bugReportId") REFERENCES "BugReport"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BugReportNote" ADD CONSTRAINT "BugReportNote_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MeTooBug" ADD CONSTRAINT "MeTooBug_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MeTooBug" ADD CONSTRAINT "MeTooBug_bugReportId_fkey" FOREIGN KEY ("bugReportId") REFERENCES "BugReport"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StaffPost" ADD CONSTRAINT "StaffPost_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventPost" ADD CONSTRAINT "EventPost_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Poll" ADD CONSTRAINT "Poll_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "EventPost"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PollOption" ADD CONSTRAINT "PollOption_pollId_fkey" FOREIGN KEY ("pollId") REFERENCES "Poll"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PollVote" ADD CONSTRAINT "PollVote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PollVote" ADD CONSTRAINT "PollVote_pollOptionId_fkey" FOREIGN KEY ("pollOptionId") REFERENCES "PollOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@@ -221,14 +221,6 @@ model PollVote {
@@id([userId, pollOptionId]) @@id([userId, pollOptionId])
} }
// ── Site Settings ──────────────────────────────────────────────────────────────
model SiteSettings {
id Int @id @default(1)
forumEnabled Boolean @default(true)
bugsEnabled Boolean @default(true)
}
// ── Team Members ─────────────────────────────────────────────────────────────── // ── Team Members ───────────────────────────────────────────────────────────────
model TeamMember { model TeamMember {

View File

@@ -7,7 +7,6 @@ import bugsRouter from './routes/bugs.js';
import feedRouter from './routes/feed.js'; import feedRouter from './routes/feed.js';
import eventsRouter from './routes/events.js'; import eventsRouter from './routes/events.js';
import teamRouter from './routes/team.js'; import teamRouter from './routes/team.js';
import settingsRouter from './routes/settings.js';
const app = express(); const app = express();
@@ -123,7 +122,6 @@ app.use('/api/bugs', bugsRouter);
app.use('/api/feed', feedRouter); app.use('/api/feed', feedRouter);
app.use('/api/events', eventsRouter); app.use('/api/events', eventsRouter);
app.use('/api/team', teamRouter); app.use('/api/team', teamRouter);
app.use('/api/settings', settingsRouter);
// 404 // 404
app.use((_req, res) => res.status(404).json({ error: 'Not found' })); app.use((_req, res) => res.status(404).json({ error: 'Not found' }));

View File

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

View File

@@ -1,7 +1,6 @@
import { lazy, Suspense } from 'react'; import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom'; import { Routes, Route } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext'; import { AuthProvider } from './contexts/AuthContext';
import { SettingsProvider, useSettings } from './contexts/SettingsContext';
import { ProtectedRoute } from './components/shared/ProtectedRoute'; import { ProtectedRoute } from './components/shared/ProtectedRoute';
import { PublicLayout } from './components/layout/PublicLayout'; import { PublicLayout } from './components/layout/PublicLayout';
import { PageLoader } from './components/shared/PageLoader'; 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 RegisterPage = lazy(() => import('./pages/public/RegisterPage'));
const NotFoundPage = lazy(() => import('./pages/public/NotFoundPage')); 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 ──────────────────────────────────────────────────────────────────────── // ── App ────────────────────────────────────────────────────────────────────────
export default function App() { export default function App() {
return ( return (
<AuthProvider> <AuthProvider>
<SettingsProvider> <Suspense fallback={<PageLoader />}>
<AppRoutes /> <Routes>
</SettingsProvider> {/* 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> </AuthProvider>
); );
} }

View File

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

View File

@@ -1,28 +1,20 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { Link, NavLink, useNavigate } from 'react-router-dom'; import { Link, NavLink, useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { useSettings } from '../../contexts/SettingsContext';
const BASE_NAV_LINKS = [ const NAV_LINKS = [
{ to: '/', label: 'Home', end: true, feature: null as null | 'forum' | 'bugs' }, { to: '/', label: 'Home', end: true },
{ to: '/studio', label: 'Studio', end: false, feature: null }, { to: '/studio', label: 'Studio', end: false },
{ to: '/events', label: 'Events', end: false, feature: null }, { to: '/events', label: 'Events', end: false },
{ to: '/forum', label: 'Forum', end: false, feature: 'forum' as const }, { to: '/forum', label: 'Forum', end: false },
{ to: '/bugs', label: 'Bugs', end: false, feature: 'bugs' as const }, { to: '/bugs', label: 'Bugs', end: false },
]; ];
export function Navbar() { export function Navbar() {
const { user, isAuthenticated, logout } = useAuth(); const { user, isAuthenticated, logout } = useAuth();
const { forumEnabled, bugsEnabled } = useSettings();
const navigate = useNavigate(); const navigate = useNavigate();
const [menuOpen, setMenuOpen] = useState(false); 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(() => { const handleLogout = useCallback(() => {
logout(); logout();
setMenuOpen(false); setMenuOpen(false);
@@ -93,7 +85,7 @@ export function Navbar() {
{/* Desktop Nav */} {/* Desktop Nav */}
<div className="hidden md:flex items-center gap-6"> <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}> <NavLink key={to} to={to} end={end} style={navLinkStyle}>
{label} {label}
</NavLink> </NavLink>
@@ -156,7 +148,7 @@ export function Navbar() {
}} }}
> >
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.85rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.85rem' }}>
{navLinks.map(({ to, label, end }) => ( {NAV_LINKS.map(({ to, label, end }) => (
<NavLink <NavLink
key={to} key={to}
to={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]); }, [id]);
const alreadyVoted = useMemo( const alreadyVoted = useMemo(
() => !!user && !!bug && (bug.meTooBugs ?? []).includes(user.id), () => !!user && !!bug && bug.meTooBugs.includes(user.id),
[user, bug] [user, bug]
); );
const isOwnReport = useMemo( const isOwnReport = useMemo(
@@ -100,7 +100,7 @@ export default function BugDetailPage() {
if (!user || !bug || alreadyVoted || isOwnReport) return; if (!user || !bug || alreadyVoted || isOwnReport) return;
try { try {
await bugsApi.toggleMeToo(bug.id); 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 { } catch {
// silently ignore // silently ignore
} }
@@ -137,7 +137,7 @@ export default function BugDetailPage() {
return <Navigate to="/bugs" replace />; return <Navigate to="/bugs" replace />;
} }
const metooCount = (bug.meTooBugs ?? []).length; const metooCount = bug.meTooBugs.length;
return ( return (
<div style={{ maxWidth: '860px', margin: '0 auto', padding: '3rem 1.5rem' }}> <div style={{ maxWidth: '860px', margin: '0 auto', padding: '3rem 1.5rem' }}>

View File

@@ -78,7 +78,7 @@ function BugCard({ bug, highlight }: BugCardProps) {
borderRadius: '3px', 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> </span>
</div> </div>
@@ -285,7 +285,7 @@ export default function BugReportPage() {
const fetchBugs = useCallback(() => { const fetchBugs = useCallback(() => {
setLoading(true); setLoading(true);
bugsApi.getBugs({ status: statusFilter, severity: severityFilter, limit: 100 }) bugsApi.getBugs({ status: statusFilter, severity: severityFilter, limit: 100 })
.then((res) => setBugs(Array.isArray(res?.data) ? res.data : [])) .then((res) => setBugs(res.data))
.catch(() => setBugs([])) .catch(() => setBugs([]))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [statusFilter, severityFilter]); }, [statusFilter, severityFilter]);
@@ -295,7 +295,7 @@ export default function BugReportPage() {
const { myBugs, otherBugs } = useMemo(() => { const { myBugs, otherBugs } = useMemo(() => {
const my: BugReport[] = []; const my: BugReport[] = [];
const other: BugReport[] = []; const other: BugReport[] = [];
(bugs ?? []).forEach((b) => { bugs.forEach((b) => {
if (user && b.submittedById === user.id) my.push(b); if (user && b.submittedById === user.id) my.push(b);
else other.push(b); else other.push(b);
}); });

View File

@@ -217,7 +217,7 @@ export default function ForumPage() {
{!loading && !error && ( {!loading && !error && (
filteredCategories.length === 0 ? ( filteredCategories.length === 0 ? (
<div className="crt-box" style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)' }}> <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> </div>
) : ( ) : (
filteredCategories.map((cat) => ( filteredCategories.map((cat) => (

View File

@@ -58,7 +58,39 @@ export default function LoginPage() {
</div> </div>
</div> </div>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit} noValidate className="crt-box" style={{ padding: '2rem' }}>
{/* Demo hint */}
<div style={{ background: 'rgba(217,119,6,0.08)', border: '1px solid rgba(217,119,6,0.2)', padding: '0.75rem', marginBottom: '1.5rem', borderRadius: '6px' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.7rem', letterSpacing: '0.05em', marginBottom: '0.4rem' }}>
[DEMO] Quick login emails:
</div>
{[
{ label: 'Dev/Admin', email: 'kestrel@crowmate.dev' },
{ label: 'Com Staff', email: 'vesper@crowmate.dev' },
{ label: 'User', email: 'glitch@mail.com' },
].map(({ label, email: e }) => (
<button
key={e}
type="button"
onClick={() => { setEmail(e); setPassword('password'); }}
style={{
background: 'transparent',
border: 'none',
color: 'var(--color-text-muted)',
cursor: 'pointer',
fontFamily: 'var(--font-mono)',
fontSize: '0.65rem',
display: 'block',
textAlign: 'left',
padding: '0.1rem 0',
width: '100%',
}}
>
&gt; {label}: {e}
</button>
))}
</div>
{errors.form && ( {errors.form && (
<div style={{ background: 'rgba(220,38,38,0.1)', border: '1px solid rgba(220,38,38,0.3)', color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem', padding: '0.75rem', marginBottom: '1.25rem', borderRadius: '6px' }}> <div style={{ background: 'rgba(220,38,38,0.1)', border: '1px solid rgba(220,38,38,0.3)', color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem', padding: '0.75rem', marginBottom: '1.25rem', borderRadius: '6px' }}>
[ERROR] {errors.form} [ERROR] {errors.form}

View File

@@ -1,52 +1,15 @@
import { useEffect, useState } from 'react';
import { teamApi } from '../../utils/api';
import type { TeamMember } from '../../types'; 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() { export default function StudioPage() {
const members = FALLBACK_MEMBERS; const [members, setMembers] = useState<TeamMember[]>([]);
useEffect(() => {
teamApi.getMembers()
.then(setMembers)
.catch(() => { /* show empty state */ });
}, []);
return ( return (
<div style={{ maxWidth: '1000px', margin: '0 auto', padding: '4rem 1.5rem' }}> <div style={{ maxWidth: '1000px', margin: '0 auto', padding: '4rem 1.5rem' }}>
@@ -78,9 +41,9 @@ export default function StudioPage() {
marginBottom: '1rem', marginBottom: '1rem',
}} }}
> >
CrowMate Studio is an independent game studio founded in 2026 by a team of six developers CrowMate Studio is an independent game studio founded in 2023 by a team of six developers
who are all new to game development and learning by building together. We are headquartered united by a shared obsession: games that are strange, atmospheric, and actually interesting.
somewhere in France and operate arround the globe. We are headquartered somewhere in Europe and operate fully remote.
</p> </p>
<p <p
style={{ style={{
@@ -97,40 +60,6 @@ export default function StudioPage() {
you don't need a $200 million budget to make something that sticks. you don't need a $200 million budget to make something that sticks.
</p> </p>
</div> </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> </div>
{/* History & Vision */} {/* History & Vision */}

View File

@@ -114,26 +114,14 @@ export const forumApi = {
getCategories: () => getCategories: () =>
apiFetch<ForumCategory[]>('/forum/categories'), 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(); const q = new URLSearchParams();
if (params?.categoryId) q.set('categoryId', params.categoryId); if (params?.categoryId) q.set('categoryId', params.categoryId);
q.set('page', String(params?.page ?? 1)); q.set('page', String(params?.page ?? 1));
q.set('limit', String(params?.limit ?? 100)); q.set('limit', String(params?.limit ?? 100));
return apiFetch<{ data: ForumThread[]; total: number; page: number; pages: number }>(
const result = await apiFetch<{ `/forum/threads?${q}`
data?: ForumThread[]; );
threads?: ForumThread[];
total: number;
page: number;
pages: number;
}>(`/forum/threads?${q}`);
return {
data: result.data ?? result.threads ?? [],
total: result.total,
page: result.page,
pages: result.pages,
};
}, },
getThread: (id: string) => getThread: (id: string) =>
@@ -202,11 +190,3 @@ export const eventsApi = {
export const teamApi = { export const teamApi = {
getMembers: () => apiFetch<TeamMember[]>('/team'), 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 WORKDIR /app

View File

@@ -3,15 +3,14 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Docker DNS; resolve API service name at request time. # Use Docker's embedded DNS resolver; defer resolution to request time
resolver 127.0.0.11 ipv6=off valid=10s; resolver 127.0.0.11 valid=30s;
set $api_upstream http://api:3000;
location /api/ { location /api/ {
set $api_upstream http://api:3000;
proxy_pass $api_upstream; proxy_pass $api_upstream;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
} }
location / { location / {

View File

@@ -8,7 +8,7 @@ const INTRANET_LINKS = [
{ to: '/intranet/feed', label: 'Team Feed', icon: '[~]', end: false }, { to: '/intranet/feed', label: 'Team Feed', icon: '[~]', end: false },
{ to: '/intranet/events', label: 'Events', icon: '[E]', end: false }, { to: '/intranet/events', label: 'Events', icon: '[E]', end: false },
{ to: '/intranet/users', label: 'Users', icon: '[U]', 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 }, { 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 { useAuth } from '../../contexts/AuthContext';
import { formatDate, formatDateTime } from '../../utils/format'; import { formatDate, formatDateTime } from '../../utils/format';
import { bugsApi, settingsApi } from '../../utils/api';
import type { BugReport, BugSeverity, BugStatus, BugReportNote } from '../../types'; import type { BugReport, BugSeverity, BugStatus, BugReportNote } from '../../types';
function StatusBadge({ status }: { status: BugStatus }) { function StatusBadge({ status }: { status: BugStatus }) {
@@ -27,48 +26,6 @@ export default function IntranetBugs() {
const [severityFilter, setSeverityFilter] = useState<BugSeverity | 'all'>('all'); const [severityFilter, setSeverityFilter] = useState<BugSeverity | 'all'>('all');
const [assignedFilter, setAssignedFilter] = useState<string | 'all'>('all'); const [assignedFilter, setAssignedFilter] = useState<string | 'all'>('all');
const [noteText, setNoteText] = useState(''); const [noteText, setNoteText] = useState('');
const [isEnabled, setIsEnabled] = useState(true);
const [toggling, setToggling] = useState(false);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
useEffect(() => {
settingsApi.get().then((s) => setIsEnabled(s.bugsEnabled)).catch(() => {});
}, []);
const fetchBugs = useCallback(() => {
setLoading(true);
setLoadError('');
bugsApi
.getBugs({
status: statusFilter,
severity: severityFilter,
assignedTo: assignedFilter,
limit: 100,
})
.then((res) => {
const next = Array.isArray(res?.data) ? res.data : [];
setBugs(next);
setSelected((prev) => (prev ? next.find((b) => b.id === prev.id) ?? null : null));
})
.catch((err) => {
setBugs([]);
setLoadError(err instanceof Error ? err.message : 'Failed to load bug reports.');
})
.finally(() => setLoading(false));
}, [statusFilter, severityFilter, assignedFilter]);
useEffect(() => {
fetchBugs();
}, [fetchBugs]);
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 openCount = bugs.filter((b) => b.status === 'open').length;
const criticalCount = bugs.filter((b) => b.severity === 'critical').length; const criticalCount = bugs.filter((b) => b.severity === 'critical').length;
@@ -87,102 +44,41 @@ export default function IntranetBugs() {
}, [bugs, statusFilter, severityFilter, assignedFilter]); }, [bugs, statusFilter, severityFilter, assignedFilter]);
const updateBug = useCallback((id: string, changes: Partial<BugReport>) => { const updateBug = useCallback((id: string, changes: Partial<BugReport>) => {
setBugs((prev) => prev.map((b) => (b.id === id ? { ...b, ...changes, updatedAt: new Date().toISOString() } : b))); setBugs((prev) => prev.map((b) => b.id === id ? { ...b, ...changes, updatedAt: new Date().toISOString() } : b));
setSelected((prev) => (prev?.id === id ? { ...prev, ...changes, updatedAt: new Date().toISOString() } : prev)); setSelected((prev) => prev?.id === id ? { ...prev, ...changes, updatedAt: new Date().toISOString() } : prev);
}, []); }, []);
const handleAssign = useCallback((bugId: string, staffId: string) => { const handleAssign = useCallback((bugId: string, staffId: string) => {
const staff = STAFF_MEMBERS.find((s) => s.id === staffId); const staff = STAFF_MEMBERS.find((s) => s.id === staffId);
updateBug(bugId, { assignedToId: staffId || undefined, assignedToName: staff?.username }); updateBug(bugId, { assignedToId: staffId || undefined, assignedToName: staff?.username });
bugsApi.updateBug(bugId, { assignedToId: staffId || null }).catch(() => { }, [updateBug]);
fetchBugs();
});
}, [fetchBugs, updateBug]);
const handleStatusChange = useCallback((bugId: string, status: BugStatus) => { const handleStatusChange = useCallback((bugId: string, status: BugStatus) => {
updateBug(bugId, { status }); updateBug(bugId, { status });
bugsApi.updateBug(bugId, { status }).catch(() => { }, [updateBug]);
fetchBugs();
});
}, [fetchBugs, updateBug]);
const handleAddNote = useCallback((bugId: string) => { const handleAddNote = useCallback((bugId: string) => {
if (!noteText.trim() || !user) return; if (!noteText.trim() || !user) return;
const content = noteText.trim();
const note: BugReportNote = { const note: BugReportNote = {
id: `n${Date.now()}`, id: `n${Date.now()}`,
bugReportId: bugId, bugReportId: bugId,
authorId: user.id, authorId: user.id,
authorName: user.username, authorName: user.username,
content, content: noteText.trim(),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
setBugs((prev) => prev.map((b) => b.id === bugId ? { ...b, notes: [...(b.notes ?? []), note] } : b)); setBugs((prev) => prev.map((b) => b.id === bugId ? { ...b, notes: [...(b.notes ?? []), note] } : b));
setSelected((prev) => prev?.id === bugId ? { ...prev, notes: [...(prev.notes ?? []), note] } : prev); setSelected((prev) => prev?.id === bugId ? { ...prev, notes: [...(prev.notes ?? []), note] } : prev);
setNoteText(''); setNoteText('');
bugsApi.addNote(bugId, content).catch(() => { }, [noteText, user]);
fetchBugs();
});
}, [fetchBugs, 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 ( return (
<div style={{ display: 'grid', gridTemplateColumns: selected ? '1fr 400px' : '1fr', gap: '1.5rem', alignItems: 'start' }}> <div style={{ display: 'grid', gridTemplateColumns: selected ? '1fr 400px' : '1fr', gap: '1.5rem', alignItems: 'start' }}>
{/* Left panel */} {/* Left panel */}
<div> <div>
<div style={{ marginBottom: '1.5rem' }}> <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', marginBottom: '0.5rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em' }}> INTRANET / BUG REPORTS
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> </div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem', marginBottom: '1rem' }}>BUG DASHBOARD</h1> <h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem', marginBottom: '1rem' }}>BUG DASHBOARD</h1>
@@ -220,16 +116,7 @@ export default function IntranetBugs() {
{/* Bug list */} {/* Bug list */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
{loadError && ( {filtered.length === 0 ? (
<div style={{ background: 'rgba(220,38,38,0.08)', border: '1px solid rgba(220,38,38,0.35)', padding: '0.75rem 0.9rem', color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.72rem' }}>
{loadError}
</div>
)}
{loading ? (
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>
Loading reports...
</div>
) : filtered.length === 0 ? (
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}> <div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>
No reports match filters. No reports match filters.
</div> </div>

View File

@@ -1,8 +1,5 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useEffect, useMemo, useState } from 'react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { bugsApi } from '../../utils/api';
import type { BugReport } from '../../types';
interface StatCardProps { interface StatCardProps {
label: string; label: string;
@@ -82,30 +79,10 @@ function NavTile({ to, label, description, icon }: NavTileProps) {
export default function IntranetDashboard() { export default function IntranetDashboard() {
const { user } = useAuth(); const { user } = useAuth();
const [bugs, setBugs] = useState<BugReport[]>([]);
const [loadingBugs, setLoadingBugs] = useState(true);
const [bugError, setBugError] = useState('');
useEffect(() => {
setLoadingBugs(true);
setBugError('');
bugsApi
.getBugs({ limit: 100 })
.then((res) => setBugs(Array.isArray(res?.data) ? res.data : []))
.catch((err) => {
setBugs([]);
setBugError(err instanceof Error ? err.message : 'Failed to load bug reports.');
})
.finally(() => setLoadingBugs(false));
}, []);
const { openBugs, criticalBugs, assignedToMe, recentBugs } = useMemo(() => {
const open = bugs.filter((b) => b.status === 'open').length;
const critical = bugs.filter((b) => b.severity === 'critical').length;
const mine = bugs.filter((b) => b.assignedToId === user?.id).length;
return { openBugs: open, criticalBugs: critical, assignedToMe: mine, recentBugs: bugs.slice(0, 5) };
}, [bugs, user?.id]);
const openBugs = 0;
const criticalBugs = 0;
const assignedToMe = 0;
const totalUsers = 0; const totalUsers = 0;
return ( return (
@@ -128,70 +105,16 @@ export default function IntranetDashboard() {
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '1rem' }}> <div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '1rem' }}>
QUICK STATS QUICK STATS
</div> </div>
{bugError && (
<div style={{ marginBottom: '0.75rem', background: 'rgba(220,38,38,0.08)', border: '1px solid rgba(220,38,38,0.35)', padding: '0.6rem 0.8rem', color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.72rem' }}>
{bugError}
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '0.75rem' }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '0.75rem' }}>
<StatCard label="Open Bugs" value={loadingBugs ? '...' : openBugs} accent="green" /> <StatCard label="Open Bugs" value={openBugs} accent="green" />
<StatCard label="Critical" value={loadingBugs ? '...' : criticalBugs} accent="red" /> <StatCard label="Critical" value={criticalBugs} accent="red" />
<StatCard label="Assigned to Me" value={loadingBugs ? '...' : assignedToMe} accent="amber" /> <StatCard label="Assigned to Me" value={assignedToMe} accent="amber" />
<StatCard label="Total Users" value={totalUsers} accent="green" /> <StatCard label="Total Users" value={totalUsers} accent="green" />
<StatCard label="Forum Threads" value={0} accent="green" /> <StatCard label="Forum Threads" value={0} accent="green" />
<StatCard label="Staff Posts Today" value={0} accent="amber" /> <StatCard label="Staff Posts Today" value={0} accent="amber" />
</div> </div>
</div> </div>
{/* Recent bug reports */}
<div style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em' }}>
RECENT BUG REPORTS
</div>
<Link to="/intranet/bugs" style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-yellow)', fontSize: '0.72rem', textDecoration: 'none' }}>
View all
</Link>
</div>
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}>
{loadingBugs ? (
<div style={{ padding: '1rem', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem' }}>
Loading bug reports...
</div>
) : recentBugs.length === 0 ? (
<div style={{ padding: '1rem', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem' }}>
No bug reports yet.
</div>
) : (
recentBugs.map((bug) => (
<Link
key={bug.id}
to="/intranet/bugs"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
padding: '0.75rem 1rem',
textDecoration: 'none',
borderTop: '1px solid var(--color-border)',
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.66rem' }}>{bug.uniqueCode}</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text)', fontSize: '0.78rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{bug.title}
</div>
</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', flexShrink: 0 }}>
{bug.status}
</div>
</Link>
))
)}
</div>
</div>
{/* Navigation tiles */} {/* Navigation tiles */}
<div style={{ marginBottom: '2rem' }}> <div style={{ marginBottom: '2rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '1rem' }}> <div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '1rem' }}>
@@ -202,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/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/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/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>
</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 { formatDateTime } from '../../utils/format';
import { forumApi, settingsApi } from '../../utils/api'; import type { ForumThread, ForumReply } from '../../types';
import type { ForumCategory, ForumReply, ForumThread } from '../../types';
export default function IntranetModeration() { export default function IntranetModeration() {
const [categories, setCategories] = useState<ForumCategory[]>([]);
const [threads, setThreads] = useState<ForumThread[]>([]); const [threads, setThreads] = useState<ForumThread[]>([]);
const [replies, setReplies] = useState<ForumReply[]>([]); const [replies, setReplies] = useState<ForumReply[]>([]);
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null); 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 [search, setSearch] = useState('');
const [activeTab, setActiveTab] = useState<'threads' | 'replies' | 'categories'>('threads'); const [activeTab, setActiveTab] = useState<'threads' | 'replies'>('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 filteredThreads = useMemo(() => { const filteredThreads = useMemo(() => {
if (!search.trim()) return threads; if (!search.trim()) return threads;
@@ -95,232 +21,42 @@ export default function IntranetModeration() {
}, [replies, selectedThreadId]); }, [replies, selectedThreadId]);
const deleteThread = useCallback((id: string) => { const deleteThread = useCallback((id: string) => {
forumApi.deleteThread(id) setThreads((prev) => prev.filter((t) => t.id !== id));
.then(() => { setReplies((prev) => prev.filter((r) => r.threadId !== id));
setThreads((prev) => prev.filter((t) => t.id !== id)); if (selectedThreadId === id) setSelectedThreadId(null);
setReplies((prev) => prev.filter((r) => r.threadId !== id));
if (selectedThreadId === id) setSelectedThreadId(null);
})
.catch(() => {
setError('Failed to delete thread.');
});
}, [selectedThreadId]); }, [selectedThreadId]);
const togglePin = useCallback((id: string) => { const togglePin = useCallback((id: string) => {
const thread = threads.find((t) => t.id === id); setThreads((prev) => prev.map((t) => t.id === id ? { ...t, isPinned: !t.isPinned } : t));
if (!thread) return; }, []);
forumApi.updateThread(id, { isPinned: !thread.isPinned })
.then((updated) => {
setThreads((prev) => prev.map((t) => (t.id === id ? updated : t)));
})
.catch(() => {
setError('Failed to update pin state.');
});
}, [threads]);
const toggleLock = useCallback((id: string) => { const toggleLock = useCallback((id: string) => {
const thread = threads.find((t) => t.id === id); setThreads((prev) => prev.map((t) => t.id === id ? { ...t, isLocked: !t.isLocked } : t));
if (!thread) return; }, []);
forumApi.updateThread(id, { isLocked: !thread.isLocked })
.then((updated) => {
setThreads((prev) => prev.map((t) => (t.id === id ? updated : t)));
})
.catch(() => {
setError('Failed to update lock state.');
});
}, [threads]);
const deleteReply = useCallback((id: string) => { const deleteReply = useCallback((id: string) => {
const removedReply = replies.find((r) => r.id === id); setReplies((prev) => prev.filter((r) => r.id !== id));
forumApi.deleteReply(id)
.then(() => {
setReplies((prev) => prev.filter((r) => r.id !== id));
setThreads((prev) => prev.map((t) => {
if (!removedReply || removedReply.threadId !== t.id) return t;
return { ...t, replyCount: Math.max(0, t.replyCount - 1) };
}));
})
.catch(() => {
setError('Failed to delete reply.');
});
}, [replies]);
const createThread = useCallback(() => {
const title = createTitle.trim();
const content = createContent.trim();
if (!title || !content || !createCategoryId) {
setError('Title, category and content are required.');
return;
}
setCreating(true);
setError('');
forumApi.createThread({
title,
content,
categoryId: createCategoryId,
})
.then((thread) => {
setThreads((prev) => [thread, ...prev]);
setCategories((prev) => prev.map((cat) => (
cat.id === createCategoryId
? { ...cat, threadCount: cat.threadCount + 1 }
: cat
)));
setCreateTitle('');
setCreateContent('');
setIsCreateModalOpen(false);
})
.catch(() => {
setError('Failed to create thread.');
})
.finally(() => {
setCreating(false);
});
}, [createCategoryId, createContent, createTitle]);
const openCreateCategoryModal = useCallback(() => {
setEditingCategoryId(null);
setCategoryName('');
setCategoryDescription('');
setCategoryIcon('📁');
setIsCategoryModalOpen(true);
}, []); }, []);
const openEditCategoryModal = useCallback((category: ForumCategory) => {
setEditingCategoryId(category.id);
setCategoryName(category.name);
setCategoryDescription(category.description);
setCategoryIcon(category.icon || '📁');
setIsCategoryModalOpen(true);
}, []);
const saveCategory = useCallback(() => {
const name = categoryName.trim();
const description = categoryDescription.trim();
const icon = categoryIcon.trim() || '📁';
if (!name || !description) {
setError('Category name and description are required.');
return;
}
setSavingCategory(true);
setError('');
const action = editingCategoryId
? forumApi.updateCategory(editingCategoryId, { name, description, icon })
: forumApi.createCategory({ name, description, icon });
action
.then(() => loadModerationData())
.then(() => setIsCategoryModalOpen(false))
.catch(() => {
setError(editingCategoryId ? 'Failed to update category.' : 'Failed to create category.');
})
.finally(() => {
setSavingCategory(false);
});
}, [categoryDescription, categoryIcon, categoryName, editingCategoryId, loadModerationData]);
const removeCategory = useCallback((id: string) => {
const confirmed = window.confirm('Delete this category? This can fail if it still has threads.');
if (!confirmed) return;
setError('');
forumApi.deleteCategory(id)
.then(() => loadModerationData())
.catch(() => {
setError('Failed to delete category. Remove or move threads first.');
});
}, [loadModerationData]);
const recentReplies = useMemo(() => { const recentReplies = useMemo(() => {
return [...replies].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()).slice(0, 20); return [...replies].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()).slice(0, 20);
}, [replies]); }, [replies]);
return ( return (
<div> <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={{ marginBottom: '1.75rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
<div> INTRANET / MODERATION
<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> </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> </div>
{/* Tabs */} {/* Tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid var(--color-border)', marginBottom: '1.5rem', gap: 0 }}> <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 <button
key={tab} key={tab}
onClick={() => setActiveTab(tab)} onClick={() => setActiveTab(tab)}
@@ -337,11 +73,7 @@ export default function IntranetModeration() {
letterSpacing: '0.08em', letterSpacing: '0.08em',
}} }}
> >
{tab === 'threads' {tab === 'threads' ? `Threads (${threads.length})` : `Replies (${replies.length})`}
? `Threads (${threads.length})`
: tab === 'replies'
? `Replies (${replies.length})`
: `Categories (${categories.length})`}
</button> </button>
))} ))}
</div> </div>
@@ -350,22 +82,6 @@ export default function IntranetModeration() {
<div style={{ display: 'grid', gridTemplateColumns: selectedThreadId ? '1fr 340px' : '1fr', gap: '1.25rem', alignItems: 'start' }}> <div style={{ display: 'grid', gridTemplateColumns: selectedThreadId ? '1fr 340px' : '1fr', gap: '1.25rem', alignItems: 'start' }}>
{/* Thread list */} {/* Thread list */}
<div> <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 <input
className="input-terminal" className="input-terminal"
type="search" type="search"
@@ -409,7 +125,6 @@ export default function IntranetModeration() {
className={`btn-terminal ${thread.isPinned ? 'btn-danger' : 'btn-amber'}`} className={`btn-terminal ${thread.isPinned ? 'btn-danger' : 'btn-amber'}`}
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }} style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
onClick={() => togglePin(thread.id)} onClick={() => togglePin(thread.id)}
disabled={loading}
> >
{thread.isPinned ? 'Unpin' : 'Pin'} {thread.isPinned ? 'Unpin' : 'Pin'}
</button> </button>
@@ -417,7 +132,6 @@ export default function IntranetModeration() {
className="btn-terminal btn-amber" className="btn-terminal btn-amber"
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }} style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
onClick={() => toggleLock(thread.id)} onClick={() => toggleLock(thread.id)}
disabled={loading}
> >
{thread.isLocked ? 'Unlock' : 'Lock'} {thread.isLocked ? 'Unlock' : 'Lock'}
</button> </button>
@@ -425,7 +139,6 @@ export default function IntranetModeration() {
className="btn-terminal btn-danger" className="btn-terminal btn-danger"
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }} style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
onClick={() => deleteThread(thread.id)} onClick={() => deleteThread(thread.id)}
disabled={loading}
> >
Delete Delete
</button> </button>
@@ -512,205 +225,6 @@ export default function IntranetModeration() {
)} )}
</div> </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> </div>
); );
} }

View File

@@ -1,153 +0,0 @@
import { getToken } from '../contexts/AuthContext';
import type {
BugReport,
BugReportNote,
BugSeverity,
BugStatus,
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),
}),
};
export const bugsApi = {
getBugs: (params?: {
status?: BugStatus | 'all';
severity?: BugSeverity | 'all';
assignedTo?: string | 'all';
page?: number;
limit?: number;
}) => {
const q = new URLSearchParams();
if (params?.status && params.status !== 'all') q.set('status', params.status);
if (params?.severity && params.severity !== 'all') q.set('severity', params.severity);
if (params?.assignedTo && params.assignedTo !== 'all') q.set('assignedTo', params.assignedTo);
q.set('page', String(params?.page ?? 1));
q.set('limit', String(params?.limit ?? 100));
return apiFetch<{ data: BugReport[]; total: number; page: number; pages: number }>(`/bugs?${q.toString()}`);
},
updateBug: (id: string, data: { status?: BugStatus; assignedToId?: string | null }) =>
apiFetch<BugReport>(`/bugs/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
addNote: (id: string, content: string) =>
apiFetch<BugReportNote>(`/bugs/${id}/notes`, {
method: 'POST',
body: JSON.stringify({ content }),
}),
};