38 Commits

Author SHA1 Message Date
Thibault Pouch
d66424074c fix: update Dockerfile to compile seed script and run it before starting the application 2026-03-26 13:28:40 +01:00
Thibault Pouch
600026d90c fix: update environment variables and correct front service port in docker-compose.prod.yml 2026-03-26 13:20:31 +01:00
Thibault Pouch
f313c99696 feat: add Postman environment files for local and production setups 2026-03-26 11:34:08 +01:00
Thibault Pouch
3d21e41f88 fix: remove demo login hints from LoginPage form 2026-03-26 11:10:25 +01:00
Thibault Pouch
bc4d3e1dae fix: remove Prisma migrations directory from .gitignore 2026-03-26 11:10:22 +01:00
Thibault Pouch
80e26b85d5 feat: add initial database migration and update package dependencies 2026-03-26 11:10:17 +01:00
Thibault Pouch
0899ba1bc9 fix: update Dockerfile to run database migrations on startup and clean up package.json dependencies 2026-03-26 10:41:02 +01:00
Thibault Pouch
5268e7618b fix: update environment variable handling in Docker Compose files and improve contributing documentation 2026-03-19 21:27:56 +01:00
Thibault Pouch
3714200dc1 fix: update front service port mapping in production Docker Compose 2026-03-19 16:10:28 +01:00
Thibault Pouch
8f5d572632 feat: add production Docker Compose configuration for services 2026-03-19 15:43:43 +01:00
dffb1a6681 git : Merge pull request from feat/connect-front-to-backend into main
Reviewed-on: #1
Reviewed-by: Pierre1901 <pierre.ryssen@crowmate.fr>
2026-03-19 14:56:19 +01:00
Thibault Pouch
e68c2c32ba fix: integrate bug reporting API and enhance dashboard with recent bug statistics 2026-03-18 11:13:35 +01:00
Thibault Pouch
032b08bfb5 feat: implement forum moderation features with category management and thread operations 2026-03-18 10:59:07 +01:00
Thibault Pouch
bc9d93fe90 refactor: improve error handling and response structure for getThreads API function 2026-03-18 10:59:01 +01:00
Thibault Pouch
e7d1cda356 refactor: enhance authentication middleware to validate user existence and status before proceeding 2026-03-18 10:58:55 +01:00
Thibault Pouch
792816c6c8 refactor: connect frontend to backend by integrating SettingsContext and updating route handling for forum and bug reporting features 2026-03-17 16:44:06 +01:00
Thibault Pouch
a46dfde6d2 feat: add SettingsContext for managing forum and bug reporting settings 2026-03-17 16:43:45 +01:00
Thibault Pouch
e8cd7e9562 refactor: enable conditional rendering of forum and bug report links in Navbar and Footer based on settings 2026-03-17 16:43:35 +01:00
Thibault Pouch
2e42d67196 refactor: update response structure for bugs endpoint to use 'data' key 2026-03-17 16:43:27 +01:00
Thibault Pouch
53740dc694 feat: implement settings API for toggling forum and bug reporting features 2026-03-17 11:44:14 +01:00
Thibault Pouch
f9012bd123 feat: integrate settings API to manage forum and bug reporting availability 2026-03-17 11:44:05 +01:00
Thibault Pouch
f481a6fc4e feat: add settings route for managing forum and bug feature toggles 2026-03-17 11:43:50 +01:00
Thibault Pouch
f926951e22 feat: add SiteSettings model to manage forum and bug feature toggles 2026-03-17 11:43:41 +01:00
Thibault Pouch
513bfbda96 feat: update front service port mapping from 5173 to 80 2026-03-12 11:30:55 +01:00
Thibault Pouch
c2135bbb5d feat: implement fallback team members and enhance StudioPage layout with team information 2026-03-12 11:30:51 +01:00
Thibault Pouch
e86eee8744 git : Merge branch 'main' into feat/connect-front-to-backend 2026-03-12 11:13:38 +01:00
Thibault Pouch
8e877c8651 feat: add functionality toggle for Intranet Bugs and Moderation features with enable/disable buttons 2026-03-12 10:59:07 +01:00
Thibault Pouch
77d57fb354 feat: update Dockerfiles to use Node 25-alpine and enhance nginx configuration for API proxying 2026-03-12 10:59:01 +01:00
Thibault Pouch
b6b3d94fac feat: add dependencies for front and intra services to ensure API availability 2026-03-03 10:24:47 +01:00
Thibault Pouch
64fe3d440e feat: update Nginx configuration for API routing; implement API fetch utility in IntranetEvents component 2026-03-03 10:24:41 +01:00
Thibault Pouch
db647fe7ac feat: update Dockerfile and nginx configuration for improved API routing; modify events API response structure 2026-03-03 10:24:31 +01:00
Thibault Pouch
d08cda9d22 feat: enhance logging in PrismaClient for better query, warning, and error visibility 2026-03-03 10:21:29 +01:00
Thibault Pouch
039b9c1ff4 feat: enhance AuthContext to integrate API for authentication and user management 2026-03-03 09:48:54 +01:00
Thibault Pouch
c5a9bd081c refactor: simplify DevRoleSwitcher by removing role selection and unused variables 2026-03-03 09:48:47 +01:00
Thibault Pouch
a45e9a86cd refactor: add embedded poll reference to EventPost interface 2026-03-03 09:48:40 +01:00
Thibault Pouch
9bf759c829 feat: implement comprehensive API structure for authentication, users, forum, bugs, events, and team management 2026-03-03 09:48:28 +01:00
Thibault Pouch
11708032bd refactor: update ports to use 80 for front-end services in Docker and Nginx configuration 2026-03-03 09:48:22 +01:00
Thibault Pouch
f54f237dd9 feat: integrate API calls for forum, bug, and event pages; replace mock data with dynamic data fetching 2026-03-03 09:48:07 +01:00
49 changed files with 3265 additions and 612 deletions

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
# 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 `.env` files based on the examples in each project. See the main [README.md](./README.md) for required environment variables. 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.
### 4. Create a Branch ### 4. Create a Branch

View File

@@ -23,9 +23,11 @@ nest-intra/ # Staff-only internal portal (React + Vite)
### 1. Backend Setup ### 1. Backend Setup
```bash ```bash
cd nest-backend # from repository root
cp .env.example .env cp .env.example .env
# Edit .env with your database credentials # Edit .env with your credentials
cd nest-backend
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
@@ -35,6 +37,7 @@ 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

53
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,53 @@
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
JWT_SECRET: ${JWT_SECRET}
ADMIN_USERNAME: ${ADMIN_USERNAME}
ADMIN_EMAIL: ${ADMIN_EMAIL}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
FRONT_ORIGIN: ${FRONT_ORIGIN:-http://localhost:5173}
INTRA_ORIGIN: ${INTRA_ORIGIN:-http://localhost:5174}
depends_on:
db:
condition: service_healthy
front:
image: git.crowmate.fr/crowmate/nest-front:latest
restart: unless-stopped
ports:
- "5173: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,16 +18,11 @@ services:
build: ./nest-backend build: ./nest-backend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3000:3000" - "${API_HOST_PORT:-3001}: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
@@ -38,13 +33,19 @@ services:
build: ./nest-front build: ./nest-front
restart: unless-stopped restart: unless-stopped
ports: ports:
- "5173:5173" - "80:80"
environment:
API_URL: http://api:3000
depends_on:
- api
intra: intra:
build: ./nest-intra build: ./nest-intra
restart: unless-stopped restart: unless-stopped
ports: ports:
- "5174:5174" - "5174:5174"
depends_on:
- api
volumes: volumes:
db_data: db_data:

View File

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

View File

@@ -10,6 +10,7 @@ RUN npm ci
COPY . . COPY . .
RUN npx prisma generate RUN npx prisma generate
RUN npm run build RUN npm run build
RUN npx tsc --module commonjs --target ES2022 --esModuleInterop --skipLibCheck --outDir dist prisma/seed.ts
FROM node:22-slim FROM node:22-slim
@@ -28,4 +29,4 @@ COPY prisma ./prisma
EXPOSE 3000 EXPOSE 3000
CMD ["node", "dist/index.js"] CMD ["sh", "-c", "npx prisma migrate deploy && node dist/seed.js && node dist/index.js"]

View File

@@ -18,7 +18,9 @@ services:
build: . build: .
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3000:3000" - "${API_HOST_PORT:-3001}: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,7 +864,6 @@
"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

@@ -0,0 +1,254 @@
-- 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

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

View File

@@ -221,6 +221,14 @@ 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,9 +7,101 @@ 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();
// ── Logger ─────────────────────────────────────────────────────────────────────
const R = '\x1b[0m';
const BOLD = '\x1b[1m';
const DIM = '\x1b[2m';
const RED = '\x1b[31m';
const GREEN = '\x1b[32m';
const YELLOW = '\x1b[33m';
const BLUE = '\x1b[34m';
const MAGENTA = '\x1b[35m';
const CYAN = '\x1b[36m';
const WHITE = '\x1b[37m';
const BG_RED = '\x1b[41m';
const BG_GREEN = '\x1b[42m';
const BG_YELLOW = '\x1b[43m';
const BG_BLUE = '\x1b[44m';
const BG_MAGENTA = '\x1b[45m';
const BG_CYAN = '\x1b[46m';
const METHOD_STYLE: Record<string, string> = {
GET: `${BG_BLUE}${WHITE}${BOLD}`,
POST: `${BG_GREEN}${WHITE}${BOLD}`,
PUT: `${BG_YELLOW}${WHITE}${BOLD}`,
PATCH: `${BG_MAGENTA}${WHITE}${BOLD}`,
DELETE: `${BG_RED}${WHITE}${BOLD}`,
};
function methodBadge(method: string): string {
const style = METHOD_STYLE[method] ?? `${BG_CYAN}${WHITE}${BOLD}`;
return `${style} ${method.padEnd(6)} ${R}`;
}
function statusBadge(code: number): string {
if (code < 300) return `${BG_GREEN}${WHITE}${BOLD} ${code} ${R}`;
if (code < 400) return `${BG_YELLOW}${WHITE}${BOLD} ${code} ${R}`;
return `${BG_RED}${WHITE}${BOLD} ${code} ${R}`;
}
function prettyJson(value: unknown): string {
return JSON.stringify(value, (k, v) => k === 'password' ? '***' : v, 2)
.split('\n')
.map((line, i) => i === 0 ? line : ` ${DIM} ${line}${R}`)
.join('\n');
}
const SEP = `${DIM}${'─'.repeat(60)}${R}`;
app.use((req, res, next) => {
const start = Date.now();
const ts = new Date().toISOString().replace('T', ' ').slice(0, 23);
// Skip health check noise
if (req.originalUrl === '/api/health') { next(); return; }
const originalJson = res.json.bind(res);
let resBody: unknown;
res.json = (body) => { resBody = body; return originalJson(body); };
res.on('finish', () => {
const ms = Date.now() - start;
const userId = req.user?.userId ? `${CYAN}${req.user.userId.slice(0, 8)}${R}` : `${DIM}anon${R}`;
const role = req.user?.role ? `${MAGENTA}${req.user.role}${R}` : `${DIM}-${R}`;
const hasBody = ['POST', 'PUT', 'PATCH'].includes(req.method)
&& req.body && Object.keys(req.body).length > 0;
const lines: string[] = [
SEP,
`${DIM}${ts}${R} ${methodBadge(req.method)} ${BOLD}${req.originalUrl}${R}`,
` ${DIM}┌ user ${R} ${userId} ${DIM}role:${R} ${role}`,
` ${DIM}└ status ${R} ${statusBadge(res.statusCode)} ${DIM}${ms}ms${R}`,
];
if (hasBody) {
lines.push(` ${GREEN}↑ REQUEST BODY${R}`);
lines.push(` ${DIM} ${prettyJson(req.body)}${R}`);
}
if (res.statusCode >= 400 && resBody) {
lines.push(` ${RED}↓ ERROR RESPONSE${R}`);
lines.push(` ${DIM} ${prettyJson(resBody)}${R}`);
} else if (res.statusCode < 300 && resBody && req.method !== 'GET') {
lines.push(` ${GREEN}↓ RESPONSE BODY${R}`);
lines.push(` ${DIM} ${prettyJson(resBody)}${R}`);
}
console.log(lines.join('\n'));
});
next();
});
app.use(cors({ app.use(cors({
origin: [ origin: [
'http://localhost:5173', // nest-front dev 'http://localhost:5173', // nest-front dev
@@ -31,13 +123,14 @@ 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' }));
// Global error handler // Global error handler
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err); console.error(`${BG_RED}${WHITE}${BOLD} UNHANDLED ERROR ${R}`, err);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
}); });

View File

@@ -1,5 +1,11 @@
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient(); const prisma = new PrismaClient({
log: [
{ emit: 'stdout', level: 'query' },
{ emit: 'stdout', level: 'warn' },
{ emit: 'stdout', level: 'error' },
],
});
export default prisma; export default prisma;

View File

@@ -1,5 +1,6 @@
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;
@@ -15,7 +16,7 @@ declare global {
} }
} }
export function authenticate(req: Request, res: Response, next: NextFunction): void { export async function authenticate(req: Request, res: Response, next: NextFunction): Promise<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' });
@@ -25,7 +26,21 @@ export function authenticate(req: Request, res: Response, next: NextFunction): v
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;
req.user = payload; const user = await prisma.user.findUnique({
where: { id: payload.userId },
select: { id: true, role: true, isAdmin: true, isBanned: true },
});
if (!user || user.isBanned) {
res.status(401).json({ error: 'Token user no longer exists or is banned. Please login again.' });
return;
}
req.user = {
userId: user.id,
role: user.role,
isAdmin: user.isAdmin,
};
next(); 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({ bugs: bugs.map(formatBug), total, page, pages: Math.ceil(total / limit) }); res.json({ data: bugs.map(formatBug), total, page, pages: Math.ceil(total / limit) });
}); });
// GET /api/bugs/:id // GET /api/bugs/:id

View File

@@ -0,0 +1,44 @@
import { Router } from 'express';
import type { Request, Response } from 'express';
import prisma from '../lib/prisma.js';
import { authenticate, requireAdmin } from '../middleware/auth.js';
const router = Router();
function getOrCreateSettings() {
return prisma.siteSettings.upsert({
where: { id: 1 },
update: {},
create: { id: 1 },
});
}
// GET /api/settings — public
router.get('/', async (_req: Request, res: Response): Promise<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:22-alpine AS build FROM node:25-alpine AS build
WORKDIR /app WORKDIR /app
@@ -17,14 +17,8 @@ RUN npm run build
FROM nginx:alpine FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf.template COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 5173 EXPOSE 80
# API_URL is the backend's public base URL used by nginx proxy_pass. CMD ["nginx", "-g", "daemon off;"]
# Set this at runtime in Coolify, e.g. API_URL=https://api.crowmate.fr
ENV API_URL=http://localhost:3000
# Substitute ${API_URL} in the nginx template at container start, then launch nginx.
# The quoted variable list prevents envsubst from replacing nginx variables like $host.
CMD ["/bin/sh", "-c", "envsubst '${API_URL}' < /etc/nginx/nginx.conf.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'"]

View File

@@ -2,5 +2,5 @@ services:
nest-front: nest-front:
build: . build: .
ports: ports:
- "5173:5173" - "80:80"
restart: unless-stopped restart: unless-stopped

View File

@@ -1,10 +1,14 @@
server { server {
listen 5173; listen 80;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Use Docker's embedded DNS resolver; defer resolution to request time
resolver 127.0.0.11 valid=30s;
location /api/ { location /api/ {
proxy_pass ${API_URL}/api/; set $api_upstream http://api:3000;
proxy_pass $api_upstream;
proxy_set_header Host $host; proxy_set_header 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; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@@ -1,6 +1,7 @@
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';
@@ -19,36 +20,49 @@ 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>
<Suspense fallback={<PageLoader />}> <SettingsProvider>
<Routes> <AppRoutes />
{/* Public Routes */} </SettingsProvider>
<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,8 +1,7 @@
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import type { UserRole } from '../../types';
/** /**
* Developer-only overlay to quickly switch user roles for testing. * Developer-only overlay to quickly log in as test accounts.
* Only visible in development mode. * Only visible in development mode.
*/ */
export function DevRoleSwitcher() { export function DevRoleSwitcher() {
@@ -12,9 +11,8 @@ export function DevRoleSwitcher() {
} }
function DevRoleSwitcherInner() { function DevRoleSwitcherInner() {
const { user, isAuthenticated, devSetRole, login, logout } = useAuth(); const { user, isAuthenticated, login, logout } = useAuth();
const ROLES: UserRole[] = ['user', 'dev', 'com'];
const DEV_ACCOUNTS = [ const DEV_ACCOUNTS = [
{ label: 'Dev/Admin (Kestrel)', email: 'kestrel@crowmate.dev' }, { label: 'Dev/Admin (Kestrel)', email: 'kestrel@crowmate.dev' },
{ label: 'Com Staff (Vesper)', email: 'vesper@crowmate.dev' }, { label: 'Com Staff (Vesper)', email: 'vesper@crowmate.dev' },
@@ -48,30 +46,10 @@ function DevRoleSwitcherInner() {
<div style={{ color: 'var(--color-text-muted)', marginBottom: '0.4rem' }}> <div style={{ color: 'var(--color-text-muted)', marginBottom: '0.4rem' }}>
Logged as: <strong style={{ color: 'var(--color-text)' }}>{user?.username}</strong> Logged as: <strong style={{ color: 'var(--color-text)' }}>{user?.username}</strong>
</div> </div>
<div style={{ color: 'var(--color-text-muted)', marginBottom: '0.5rem' }}> <div style={{ color: 'var(--color-text-muted)', marginBottom: '0.75rem' }}>
Role: <strong style={{ color: 'var(--color-green)' }}>{user?.role}</strong> Role: <strong style={{ color: 'var(--color-green)' }}>{user?.role}</strong>
</div> </div>
<div style={{ display: 'flex', gap: '0.3rem', marginBottom: '0.5rem', flexWrap: 'wrap' }}>
{ROLES.map((r) => (
<button
key={r}
onClick={() => devSetRole(r)}
style={{
background: user?.role === r ? 'var(--color-amber)' : 'transparent',
border: '1px solid var(--color-amber)',
color: user?.role === r ? '#000' : 'var(--color-amber)',
padding: '0.1rem 0.4rem',
cursor: 'pointer',
fontFamily: 'var(--font-mono)',
fontSize: '0.65rem',
}}
>
{r}
</button>
))}
</div>
<button <button
onClick={logout} onClick={logout}
style={{ style={{

View File

@@ -1,7 +1,9 @@
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
@@ -37,11 +39,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' }, { to: '/', label: 'Home', show: true },
{ to: '/studio', label: 'Studio' }, { to: '/studio', label: 'Studio', show: true },
{ to: '/forum', label: 'Forum' }, { to: '/forum', label: 'Forum', show: forumEnabled },
{ to: '/bugs', label: 'Bug Reports' }, { to: '/bugs', label: 'Bug Reports', show: bugsEnabled },
].map(({ to, label }) => ( ].filter((item) => item.show).map(({ to, label }) => (
<li key={to}> <li key={to}>
<Link <Link
to={to} to={to}

View File

@@ -1,20 +1,28 @@
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 NAV_LINKS = [ const BASE_NAV_LINKS = [
{ to: '/', label: 'Home', end: true }, { to: '/', label: 'Home', end: true, feature: null as null | 'forum' | 'bugs' },
{ to: '/studio', label: 'Studio', end: false }, { to: '/studio', label: 'Studio', end: false, feature: null },
{ to: '/events', label: 'Events', end: false }, { to: '/events', label: 'Events', end: false, feature: null },
{ to: '/forum', label: 'Forum', end: false }, { to: '/forum', label: 'Forum', end: false, feature: 'forum' as const },
{ to: '/bugs', label: 'Bugs', end: false }, { to: '/bugs', label: 'Bugs', end: false, feature: 'bugs' as const },
]; ];
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);
@@ -85,7 +93,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">
{NAV_LINKS.map(({ to, label, end }) => ( {navLinks.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>
@@ -148,7 +156,7 @@ export function Navbar() {
}} }}
> >
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.85rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.85rem' }}>
{NAV_LINKS.map(({ to, label, end }) => ( {navLinks.map(({ to, label, end }) => (
<NavLink <NavLink
key={to} key={to}
to={to} to={to}

View File

@@ -2,11 +2,12 @@ import React, {
createContext, createContext,
useCallback, useCallback,
useContext, useContext,
useEffect,
useMemo, useMemo,
useState, useState,
} from 'react'; } from 'react';
import type { User, UserRole } from '../types'; import type { User } from '../types';
import { MOCK_USERS } from '../data/mockData'; import { authApi, usersApi, getToken, setToken, clearToken } from '../utils/api';
// ── Types ────────────────────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────────────────────
@@ -15,12 +16,11 @@ interface AuthContextValue {
isAuthenticated: boolean; isAuthenticated: boolean;
isStaff: boolean; isStaff: boolean;
isAdmin: boolean; isAdmin: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<{ success: boolean; error?: string }>; login: (email: string, password: string) => Promise<{ success: boolean; error?: string }>;
register: (username: string, email: string, password: string) => Promise<{ success: boolean; error?: string }>; register: (username: string, email: string, password: string) => Promise<{ success: boolean; error?: string }>;
logout: () => void; logout: () => void;
updateUsername: (username: string) => void; updateUsername: (username: string) => Promise<{ success: boolean; error?: string }>;
// Dev helper: quickly switch role for testing
devSetRole: (role: UserRole) => void;
} }
// ── Context ──────────────────────────────────────────────────────────────────── // ── Context ────────────────────────────────────────────────────────────────────
@@ -31,16 +31,6 @@ const AuthContext = createContext<AuthContextValue | null>(null);
const STORAGE_KEY = 'crowmate_auth_user'; const STORAGE_KEY = 'crowmate_auth_user';
function loadUserFromStorage(): User | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw) as User;
} catch {
return null;
}
}
function saveUserToStorage(user: User | null): void { function saveUserToStorage(user: User | null): void {
if (user) { if (user) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(user)); localStorage.setItem(STORAGE_KEY, JSON.stringify(user));
@@ -50,96 +40,89 @@ function saveUserToStorage(user: User | null): void {
} }
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(loadUserFromStorage); const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
// On mount: validate stored token and restore session
useEffect(() => {
const token = getToken();
if (!token) {
setIsLoading(false);
return;
}
authApi.me()
.then((freshUser) => {
setUser(freshUser);
saveUserToStorage(freshUser);
})
.catch(() => {
clearToken();
saveUserToStorage(null);
})
.finally(() => setIsLoading(false));
}, []);
const isAuthenticated = user !== null; const isAuthenticated = user !== null;
const isStaff = user?.role === 'dev' || user?.role === 'com'; const isStaff = user?.role === 'dev' || user?.role === 'com';
const isAdmin = user?.isAdmin === true; const isAdmin = user?.isAdmin === true;
const login = useCallback( const login = useCallback(
async (email: string, _password: string): Promise<{ success: boolean; error?: string }> => { async (email: string, password: string): Promise<{ success: boolean; error?: string }> => {
// Simulate network delay try {
await new Promise((r) => setTimeout(r, 400)); const { token, user: loggedInUser } = await authApi.login(email, password);
setToken(token);
const found = MOCK_USERS.find( setUser(loggedInUser);
(u) => u.email.toLowerCase() === email.toLowerCase() saveUserToStorage(loggedInUser);
); return { success: true };
} catch (err) {
if (!found) { const message = err instanceof Error ? err.message : 'Login failed.';
return { success: false, error: 'No account found with that email address.' }; return { success: false, error: message };
} }
if (found.isBanned) {
return { success: false, error: 'This account has been suspended.' };
}
setUser(found);
saveUserToStorage(found);
return { success: true };
}, },
[] []
); );
const register = useCallback( const register = useCallback(
async (username: string, email: string, _password: string): Promise<{ success: boolean; error?: string }> => { async (username: string, email: string, password: string): Promise<{ success: boolean; error?: string }> => {
await new Promise((r) => setTimeout(r, 500)); try {
const { token, user: newUser } = await authApi.register(username, email, password);
const emailTaken = MOCK_USERS.some( setToken(token);
(u) => u.email.toLowerCase() === email.toLowerCase() setUser(newUser);
); saveUserToStorage(newUser);
if (emailTaken) { return { success: true };
return { success: false, error: 'An account with this email already exists.' }; } catch (err) {
const message = err instanceof Error ? err.message : 'Registration failed.';
return { success: false, error: message };
} }
const usernameTaken = MOCK_USERS.some(
(u) => u.username.toLowerCase() === username.toLowerCase()
);
if (usernameTaken) {
return { success: false, error: 'This username is already taken.' };
}
const newUser: User = {
id: `u${Date.now()}`,
username,
email,
role: 'user',
isAdmin: false,
isBanned: false,
createdAt: new Date().toISOString(),
};
setUser(newUser);
saveUserToStorage(newUser);
return { success: true };
}, },
[] []
); );
const logout = useCallback(() => { const logout = useCallback(() => {
clearToken();
setUser(null); setUser(null);
saveUserToStorage(null); saveUserToStorage(null);
}, []); }, []);
const updateUsername = useCallback((username: string) => { const updateUsername = useCallback(
setUser((prev) => { async (username: string): Promise<{ success: boolean; error?: string }> => {
if (!prev) return prev; try {
const updated = { ...prev, username }; const updatedUser = await usersApi.updateUsername(username);
saveUserToStorage(updated); setUser(updatedUser);
return updated; saveUserToStorage(updatedUser);
}); return { success: true };
}, []); } catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update username.';
const devSetRole = useCallback((role: UserRole) => { return { success: false, error: message };
setUser((prev) => { }
if (!prev) return prev; },
const updated = { ...prev, role, isAdmin: role === 'dev' }; []
saveUserToStorage(updated); );
return updated;
});
}, []);
const value = useMemo<AuthContextValue>( const value = useMemo<AuthContextValue>(
() => ({ user, isAuthenticated, isStaff, isAdmin, login, register, logout, updateUsername, devSetRole }), () => ({ user, isAuthenticated, isStaff, isAdmin, isLoading, login, register, logout, updateUsername }),
[user, isAuthenticated, isStaff, isAdmin, login, register, logout, updateUsername, devSetRole] [user, isAuthenticated, isStaff, isAdmin, isLoading, login, register, logout, updateUsername]
); );
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

View File

@@ -0,0 +1,31 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { settingsApi } from '../utils/api';
interface SettingsContextValue {
forumEnabled: boolean;
bugsEnabled: boolean;
loaded: boolean;
}
const SettingsContext = createContext<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

@@ -1,17 +1,29 @@
import { useState, useCallback } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { MOCK_THREADS, MOCK_BUGS } from '../../data/mockData'; import { bugsApi, forumApi, usersApi } from '../../utils/api';
import { formatDate } from '../../utils/format'; import { formatDate } from '../../utils/format';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { BugReport, ForumThread } from '../../types';
type Tab = 'profile' | 'threads' | 'bugs' | 'password'; type Tab = 'profile' | 'threads' | 'bugs' | 'password';
export default function AccountPage() { export default function AccountPage() {
const { user, updateUsername } = useAuth(); const { user, updateUsername } = useAuth();
const [activeTab, setActiveTab] = useState<Tab>('profile'); const [activeTab, setActiveTab] = useState<Tab>('profile');
const [userThreads, setUserThreads] = useState<ForumThread[]>([]);
const [userBugs, setUserBugs] = useState<BugReport[]>([]);
const userThreads = MOCK_THREADS.filter((t) => t.authorId === user?.id); useEffect(() => {
const userBugs = MOCK_BUGS.filter((b) => b.submittedById === user?.id); if (!user) return;
forumApi.getThreads({ limit: 200 })
.then((res) => setUserThreads(res.data.filter((t) => t.authorId === user.id)))
.catch(() => setUserThreads([]));
bugsApi.getBugs({ limit: 200 })
.then((res) => setUserBugs(res.data.filter((b) => b.submittedById === user.id)))
.catch(() => setUserBugs([]));
}, [user]);
const tabs: { id: Tab; label: string }[] = [ const tabs: { id: Tab; label: string }[] = [
{ id: 'profile', label: 'Profile' }, { id: 'profile', label: 'Profile' },
@@ -121,19 +133,23 @@ export default function AccountPage() {
// ── Profile Tab ──────────────────────────────────────────────────────────────── // ── Profile Tab ────────────────────────────────────────────────────────────────
function ProfileTab({ user, updateUsername }: { user: NonNullable<ReturnType<typeof useAuth>['user']>; updateUsername: (u: string) => void }) { function ProfileTab({ user, updateUsername }: { user: NonNullable<ReturnType<typeof useAuth>['user']>; updateUsername: (u: string) => Promise<{ success: boolean; error?: string }> }) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [username, setUsername] = useState(user.username); const [username, setUsername] = useState(user.username);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const handleSave = useCallback(() => { const handleSave = useCallback(async () => {
if (!username.trim()) { setError('Username cannot be empty.'); return; } if (!username.trim()) { setError('Username cannot be empty.'); return; }
if (username.length < 3) { setError('Must be at least 3 characters.'); return; } if (username.length < 3) { setError('Must be at least 3 characters.'); return; }
updateUsername(username.trim()); const result = await updateUsername(username.trim());
setEditing(false); if (result.success) {
setSaved(true); setEditing(false);
setTimeout(() => setSaved(false), 3000); setSaved(true);
setTimeout(() => setSaved(false), 3000);
} else {
setError(result.error ?? 'Failed to update username.');
}
}, [username, updateUsername]); }, [username, updateUsername]);
return ( return (
@@ -210,10 +226,15 @@ function ChangePasswordForm() {
if (Object.keys(next).length > 0) return; if (Object.keys(next).length > 0) return;
setLoading(true); setLoading(true);
await new Promise((r) => setTimeout(r, 400)); try {
setLoading(false); await usersApi.changePassword(form.current, form.next);
setForm({ current: '', next: '', confirm: '' }); setForm({ current: '', next: '', confirm: '' });
setErrors({ success: 'Password changed successfully.' }); setErrors({ success: 'Password changed successfully.' });
} catch (err) {
setErrors({ current: err instanceof Error ? err.message : 'Failed to change password.' });
} finally {
setLoading(false);
}
}, [form]); }, [form]);
return ( return (

View File

@@ -1,6 +1,6 @@
import { useState, useCallback, useMemo } from 'react'; import { useState, useCallback, useEffect, useMemo } from 'react';
import { Link, Navigate, useParams } from 'react-router-dom'; import { Link, Navigate, useParams } from 'react-router-dom';
import { MOCK_BUGS, MOCK_BUG_COMMENTS } from '../../data/mockData'; import { bugsApi, ApiError } from '../../utils/api';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { formatDate, formatDateTime } from '../../utils/format'; import { formatDate, formatDateTime } from '../../utils/format';
import type { BugComment, BugReport, BugSeverity, BugStatus } from '../../types'; import type { BugComment, BugReport, BugSeverity, BugStatus } from '../../types';
@@ -57,23 +57,38 @@ export default function BugDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const { user, isAuthenticated } = useAuth(); const { user, isAuthenticated } = useAuth();
// Local state — mirrors the global bug list in memory const [bug, setBug] = useState<BugReport | null>(null);
const [bugs, setBugs] = useState<BugReport[]>(MOCK_BUGS); const [comments, setComments] = useState<BugComment[]>([]);
const [comments, setComments] = useState<BugComment[]>(MOCK_BUG_COMMENTS); const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
const [newComment, setNewComment] = useState(''); const [newComment, setNewComment] = useState('');
const [commentError, setCommentError] = useState(''); const [commentError, setCommentError] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const bug = useMemo(() => bugs.find((b) => b.id === id), [bugs, id]); useEffect(() => {
if (!id) return;
let cancelled = false;
setLoading(true);
const bugComments = useMemo( bugsApi.getBug(id)
() => comments.filter((c) => c.bugReportId === id).sort((a, b) => a.createdAt.localeCompare(b.createdAt)), .then((data) => {
[comments, id] if (cancelled) return;
); const { comments: bugComments, ...bugData } = data;
setBug(bugData);
setComments(bugComments.sort((a, b) => a.createdAt.localeCompare(b.createdAt)));
})
.catch((err) => {
if (cancelled) return;
if (err instanceof ApiError && err.status === 404) setNotFound(true);
})
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [id]);
// "I have this too" logic
const alreadyVoted = useMemo( 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(
@@ -81,42 +96,48 @@ export default function BugDetailPage() {
[user, bug] [user, bug]
); );
const handleMeToo = useCallback(() => { const handleMeToo = useCallback(async () => {
if (!user || !bug || alreadyVoted || isOwnReport) return; if (!user || !bug || alreadyVoted || isOwnReport) return;
setBugs((prev) => try {
prev.map((b) => await bugsApi.toggleMeToo(bug.id);
b.id === bug.id ? { ...b, meTooBugs: [...b.meTooBugs, user.id] } : b setBug((prev) => prev ? { ...prev, meTooBugs: [...(prev.meTooBugs ?? []), user.id] } : prev);
) } catch {
); // silently ignore
}
}, [user, bug, alreadyVoted, isOwnReport]); }, [user, bug, alreadyVoted, isOwnReport]);
const handleComment = useCallback(async () => { const handleComment = useCallback(async () => {
if (!user) return; if (!user || !bug) return;
if (!newComment.trim()) { setCommentError('Comment cannot be empty.'); return; } if (!newComment.trim()) { setCommentError('Comment cannot be empty.'); return; }
if (newComment.trim().length < 5) { setCommentError('Comment must be at least 5 characters.'); return; } if (newComment.trim().length < 5) { setCommentError('Comment must be at least 5 characters.'); return; }
setCommentError(''); setCommentError('');
setSubmitting(true); setSubmitting(true);
await new Promise((r) => setTimeout(r, 250));
const comment: BugComment = { try {
id: `bc${Date.now()}`, const comment = await bugsApi.addComment(bug.id, newComment.trim());
bugReportId: id!, setComments((prev) => [...prev, comment]);
authorId: user.id, setNewComment('');
authorName: user.username, } catch (err) {
content: newComment.trim(), setCommentError(err instanceof Error ? err.message : 'Failed to post comment.');
createdAt: new Date().toISOString(), } finally {
}; setSubmitting(false);
setComments((prev) => [...prev, comment]); }
setNewComment(''); }, [user, bug, newComment]);
setSubmitting(false);
}, [user, newComment, id]);
if (!bug) { if (loading) {
return (
<div style={{ maxWidth: '860px', margin: '0 auto', padding: '3rem 1.5rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)' }}>
Loading...
</div>
);
}
if (notFound || !bug) {
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' }}>
@@ -129,7 +150,6 @@ export default function BugDetailPage() {
{/* Header */} {/* Header */}
<div className="crt-box" style={{ padding: '1.75rem', marginBottom: '1rem' }}> <div className="crt-box" style={{ padding: '1.75rem', marginBottom: '1rem' }}>
{/* Badges */}
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', alignItems: 'center', marginBottom: '0.75rem' }}> <div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', alignItems: 'center', marginBottom: '0.75rem' }}>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.72rem' }}> <span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.72rem' }}>
{bug.uniqueCode} {bug.uniqueCode}
@@ -138,7 +158,6 @@ export default function BugDetailPage() {
<SeverityBadge severity={bug.severity} /> <SeverityBadge severity={bug.severity} />
</div> </div>
{/* Title */}
<h1 <h1
style={{ style={{
fontFamily: 'var(--font-heading)', fontFamily: 'var(--font-heading)',
@@ -227,13 +246,11 @@ export default function BugDetailPage() {
borderRadius: '6px', borderRadius: '6px',
}} }}
> >
{/* Count */}
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-green)', fontSize: '0.82rem' }}> <div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-green)', fontSize: '0.82rem' }}>
<span style={{ fontSize: '1.1rem' }}>{metooCount}</span>{' '} <span style={{ fontSize: '1.1rem' }}>{metooCount}</span>{' '}
{metooCount === 1 ? 'user has' : 'users have'} this issue {metooCount === 1 ? 'user has' : 'users have'} this issue
</div> </div>
{/* Button logic */}
{!isAuthenticated ? ( {!isAuthenticated ? (
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem' }}> <div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem' }}>
<Link to="/login">Login</Link> to confirm you have this issue <Link to="/login">Login</Link> to confirm you have this issue
@@ -285,22 +302,20 @@ export default function BugDetailPage() {
Discussion Discussion
</span> </span>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.65rem', border: '1px solid var(--color-border)', padding: '0.05rem 0.4rem' }}> <span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.65rem', border: '1px solid var(--color-border)', padding: '0.05rem 0.4rem' }}>
{bugComments.length} {comments.length}
</span> </span>
</div> </div>
{/* Comment list */} {comments.length === 0 ? (
{bugComments.length === 0 ? (
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.8rem', padding: '1rem 0' }}> <div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.8rem', padding: '1rem 0' }}>
No comments yet. Be the first to comment. No comments yet. Be the first to comment.
</div> </div>
) : ( ) : (
bugComments.map((comment) => ( comments.map((comment) => (
<CommentItem key={comment.id} comment={comment} /> <CommentItem key={comment.id} comment={comment} />
)) ))
)} )}
{/* Add comment */}
<div style={{ marginTop: '1.25rem' }}> <div style={{ marginTop: '1.25rem' }}>
{isAuthenticated ? ( {isAuthenticated ? (
<div className="crt-box" style={{ padding: '1.25rem' }}> <div className="crt-box" style={{ padding: '1.25rem' }}>

View File

@@ -1,6 +1,6 @@
import { useState, useMemo, useCallback } from 'react'; import { useState, useMemo, useCallback, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { MOCK_BUGS } from '../../data/mockData'; import { bugsApi } from '../../utils/api';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { timeAgo } from '../../utils/format'; import { timeAgo } from '../../utils/format';
import type { BugReport, BugSeverity, BugStatus, BugReportFormData } from '../../types'; import type { BugReport, BugSeverity, BugStatus, BugReportFormData } from '../../types';
@@ -66,7 +66,6 @@ function BugCard({ bug, highlight }: BugCardProps) {
</span> </span>
<StatusBadge status={bug.status} /> <StatusBadge status={bug.status} />
<SeverityBadge severity={bug.severity} /> <SeverityBadge severity={bug.severity} />
{/* MeToo count */}
<span <span
style={{ style={{
fontFamily: 'var(--font-mono)', fontFamily: 'var(--font-mono)',
@@ -79,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>
@@ -118,7 +117,7 @@ function BugCard({ bug, highlight }: BugCardProps) {
const GAME_VERSIONS = ['0.9.3-alpha', '0.9.2-alpha', '0.9.1-alpha']; const GAME_VERSIONS = ['0.9.3-alpha', '0.9.2-alpha', '0.9.1-alpha'];
const SEVERITIES: BugSeverity[] = ['low', 'medium', 'high', 'critical']; const SEVERITIES: BugSeverity[] = ['low', 'medium', 'high', 'critical'];
function SubmitBugForm({ onSubmit }: { onSubmit: (data: BugReportFormData) => void }) { function SubmitBugForm({ onSubmit }: { onSubmit: (data: BugReportFormData) => Promise<void> }) {
const [form, setForm] = useState<BugReportFormData>({ const [form, setForm] = useState<BugReportFormData>({
title: '', title: '',
description: '', description: '',
@@ -128,6 +127,8 @@ function SubmitBugForm({ onSubmit }: { onSubmit: (data: BugReportFormData) => vo
}); });
const [errors, setErrors] = useState<Partial<Record<keyof BugReportFormData, string>>>({}); const [errors, setErrors] = useState<Partial<Record<keyof BugReportFormData, string>>>({});
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState('');
const set = useCallback(<K extends keyof BugReportFormData>(key: K, value: BugReportFormData[K]) => { const set = useCallback(<K extends keyof BugReportFormData>(key: K, value: BugReportFormData[K]) => {
setForm((prev) => ({ ...prev, [key]: value })); setForm((prev) => ({ ...prev, [key]: value }));
@@ -148,9 +149,16 @@ function SubmitBugForm({ onSubmit }: { onSubmit: (data: BugReportFormData) => vo
const handleSubmit = useCallback(async (e: React.FormEvent) => { const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!validate()) return; if (!validate()) return;
await new Promise((r) => setTimeout(r, 400)); setSubmitting(true);
onSubmit(form); setSubmitError('');
setSubmitted(true); try {
await onSubmit(form);
setSubmitted(true);
} catch (err) {
setSubmitError(err instanceof Error ? err.message : 'Failed to submit report.');
} finally {
setSubmitting(false);
}
}, [form, onSubmit]); }, [form, onSubmit]);
const labelStyle: React.CSSProperties = { const labelStyle: React.CSSProperties = {
@@ -186,6 +194,12 @@ function SubmitBugForm({ onSubmit }: { onSubmit: (data: BugReportFormData) => vo
&#9654; Submit a Bug Report &#9654; Submit a Bug Report
</div> </div>
{submitError && (
<div style={{ color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.75rem', marginBottom: '1rem' }}>
[ERROR] {submitError}
</div>
)}
<div style={{ marginBottom: '0.85rem' }}> <div style={{ marginBottom: '0.85rem' }}>
<label style={labelStyle}>Title *</label> <label style={labelStyle}>Title *</label>
<input <input
@@ -250,8 +264,8 @@ function SubmitBugForm({ onSubmit }: { onSubmit: (data: BugReportFormData) => vo
/> />
</div> </div>
<button type="submit" className="btn-terminal"> <button type="submit" className="btn-terminal" disabled={submitting} style={{ opacity: submitting ? 0.6 : 1 }}>
&#9654; Submit Report {submitting ? 'Submitting...' : '&#9654; Submit Report'}
</button> </button>
</div> </div>
</form> </form>
@@ -262,44 +276,37 @@ function SubmitBugForm({ onSubmit }: { onSubmit: (data: BugReportFormData) => vo
export default function BugReportPage() { export default function BugReportPage() {
const { user, isAuthenticated } = useAuth(); const { user, isAuthenticated } = useAuth();
const [bugs, setBugs] = useState(MOCK_BUGS); const [bugs, setBugs] = useState<BugReport[]>([]);
const [loading, setLoading] = useState(true);
const [statusFilter, setStatusFilter] = useState<BugStatus | 'all'>('all'); const [statusFilter, setStatusFilter] = useState<BugStatus | 'all'>('all');
const [severityFilter, setSeverityFilter] = useState<BugSeverity | 'all'>('all'); const [severityFilter, setSeverityFilter] = useState<BugSeverity | 'all'>('all');
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
// Separate: user's own bugs and all others, both filtered const fetchBugs = useCallback(() => {
setLoading(true);
bugsApi.getBugs({ status: statusFilter, severity: severityFilter, limit: 100 })
.then((res) => setBugs(Array.isArray(res?.data) ? res.data : []))
.catch(() => setBugs([]))
.finally(() => setLoading(false));
}, [statusFilter, severityFilter]);
useEffect(() => { fetchBugs(); }, [fetchBugs]);
const { myBugs, otherBugs } = useMemo(() => { const { myBugs, otherBugs } = useMemo(() => {
const passes = (b: BugReport) => {
if (statusFilter !== 'all' && b.status !== statusFilter) return false;
if (severityFilter !== 'all' && b.severity !== severityFilter) return false;
return true;
};
const my: BugReport[] = []; const my: BugReport[] = [];
const other: BugReport[] = []; const other: BugReport[] = [];
bugs.forEach((b) => { (bugs ?? []).forEach((b) => {
if (!passes(b)) return;
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);
}); });
return { myBugs: my, otherBugs: other }; return { myBugs: my, otherBugs: other };
}, [bugs, statusFilter, severityFilter, user]); }, [bugs, user]);
const handleNewReport = useCallback((data: BugReportFormData) => { const handleNewReport = useCallback(async (data: BugReportFormData) => {
const newBug: BugReport = { const newBug = await bugsApi.createBug(data);
id: `bug${Date.now()}`,
uniqueCode: `HH-${String(bugs.length + 1).padStart(4, '0')}`,
...data,
status: 'open',
submittedById: user?.id ?? 'unknown',
submittedByName: user?.username ?? 'You',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
notes: [],
meTooBugs: [],
};
setBugs((prev) => [newBug, ...prev]); setBugs((prev) => [newBug, ...prev]);
setShowForm(false); setShowForm(false);
}, [bugs.length, user]); }, []);
const openCount = bugs.filter((b) => b.status === 'open').length; const openCount = bugs.filter((b) => b.status === 'open').length;
const inProgressCount = bugs.filter((b) => b.status === 'in_progress').length; const inProgressCount = bugs.filter((b) => b.status === 'in_progress').length;
@@ -373,68 +380,78 @@ export default function BugReportPage() {
</select> </select>
</div> </div>
{/* "Your Reports" section — only for logged-in users with their own bugs */} {loading && (
{isAuthenticated && myBugs.length > 0 && ( <div className="crt-box" style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)' }}>
<section style={{ marginBottom: '2rem' }}> Loading reports...
<div </div>
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
marginBottom: '0.75rem',
paddingBottom: '0.4rem',
borderBottom: '2px solid var(--color-yellow)',
}}
>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-yellow)', fontSize: '0.72rem', letterSpacing: '0.15em', textTransform: 'uppercase' }}>
&#9654; Your Reports
</span>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-yellow)', fontSize: '0.65rem', background: 'rgba(255,255,0,0.1)', border: '1px solid var(--color-yellow)', padding: '0.05rem 0.4rem' }}>
{myBugs.length}
</span>
</div>
{myBugs.map((bug) => (
<BugCard key={bug.id} bug={bug} highlight />
))}
</section>
)} )}
{/* All other reports */} {!loading && (
<section> <>
{isAuthenticated && myBugs.length > 0 && ( {/* "Your Reports" section */}
<div {isAuthenticated && myBugs.length > 0 && (
style={{ <section style={{ marginBottom: '2rem' }}>
display: 'flex', <div
alignItems: 'center', style={{
gap: '0.75rem', display: 'flex',
marginBottom: '0.75rem', alignItems: 'center',
paddingBottom: '0.4rem', gap: '0.75rem',
borderBottom: '2px solid var(--color-border)', marginBottom: '0.75rem',
}} paddingBottom: '0.4rem',
> borderBottom: '2px solid var(--color-yellow)',
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.72rem', letterSpacing: '0.15em', textTransform: 'uppercase' }}> }}
All Reports >
</span> <span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-yellow)', fontSize: '0.72rem', letterSpacing: '0.15em', textTransform: 'uppercase' }}>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.65rem', border: '1px solid var(--color-border)', padding: '0.05rem 0.4rem' }}> &#9654; Your Reports
{otherBugs.length} </span>
</span> <span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-yellow)', fontSize: '0.65rem', background: 'rgba(255,255,0,0.1)', border: '1px solid var(--color-yellow)', padding: '0.05rem 0.4rem' }}>
</div> {myBugs.length}
)} </span>
</div>
{myBugs.map((bug) => (
<BugCard key={bug.id} bug={bug} highlight />
))}
</section>
)}
{otherBugs.length === 0 && myBugs.length === 0 ? ( {/* All other reports */}
<div className="crt-box" style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)' }}> <section>
No bug reports match the selected filters. {isAuthenticated && myBugs.length > 0 && (
</div> <div
) : otherBugs.length === 0 && isAuthenticated ? ( style={{
<div className="crt-box" style={{ padding: '1.5rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}> display: 'flex',
No other reports match the selected filters. alignItems: 'center',
</div> gap: '0.75rem',
) : ( marginBottom: '0.75rem',
otherBugs.map((bug) => ( paddingBottom: '0.4rem',
<BugCard key={bug.id} bug={bug} /> borderBottom: '2px solid var(--color-border)',
)) }}
)} >
</section> <span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.72rem', letterSpacing: '0.15em', textTransform: 'uppercase' }}>
All Reports
</span>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.65rem', border: '1px solid var(--color-border)', padding: '0.05rem 0.4rem' }}>
{otherBugs.length}
</span>
</div>
)}
{otherBugs.length === 0 && myBugs.length === 0 ? (
<div className="crt-box" style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)' }}>
No bug reports match the selected filters.
</div>
) : otherBugs.length === 0 && isAuthenticated ? (
<div className="crt-box" style={{ padding: '1.5rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>
No other reports match the selected filters.
</div>
) : (
otherBugs.map((bug) => (
<BugCard key={bug.id} bug={bug} />
))
)}
</section>
</>
)}
</div> </div>
); );
} }

View File

@@ -1,5 +1,5 @@
import { useState, useCallback } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { MOCK_EVENTS, MOCK_POLLS } from '../../data/mockData'; import { eventsApi } from '../../utils/api';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { formatDateTime } from '../../utils/format'; import { formatDateTime } from '../../utils/format';
import type { EventPost, EventType, Poll, UserRole } from '../../types'; import type { EventPost, EventType, Poll, UserRole } from '../../types';
@@ -54,7 +54,7 @@ function PollCard({ poll, onVote }: { poll: Poll; onVote: (pollId: string, optio
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{poll.options.map((option) => { {poll.options.map((option) => {
const percentage = totalVotes > 0 ? Math.round((option.votes / totalVotes) * 100) : 0; const percentage = totalVotes > 0 ? Math.round((option.votes / totalVotes) * 100) : 0;
const userVoted = option.votedUserIds.includes(user?.id || ''); const userVoted = option.votedUserIds.includes(user?.id ?? '');
return ( return (
<div <div
@@ -158,12 +158,10 @@ function PollCard({ poll, onVote }: { poll: Poll; onVote: (pollId: string, optio
function EventCard({ function EventCard({
event, event,
poll,
onVote, onVote,
}: { }: {
event: EventPost; event: EventPost;
poll?: Poll; onVote: (eventId: string, pollId: string, optionId: string) => void;
onVote: (pollId: string, optionId: string) => void;
}) { }) {
return ( return (
<div <div
@@ -240,7 +238,12 @@ function EventCard({
</div> </div>
{/* Poll if exists */} {/* Poll if exists */}
{poll && <PollCard poll={poll} onVote={onVote} />} {event.poll && (
<PollCard
poll={event.poll}
onVote={(pollId, optionId) => onVote(event.id, pollId, optionId)}
/>
)}
</div> </div>
); );
} }
@@ -249,48 +252,28 @@ function EventCard({
export default function EventsPage() { export default function EventsPage() {
const { user } = useAuth(); const { user } = useAuth();
// Filter to show only public events const [events, setEvents] = useState<EventPost[]>([]);
const publicEvents = MOCK_EVENTS.filter((event) => event.isPublic); const [loading, setLoading] = useState(true);
const [events] = useState<EventPost[]>(publicEvents);
const [polls, setPolls] = useState<Poll[]>(MOCK_POLLS); useEffect(() => {
eventsApi.getEvents(true)
.then((res) => setEvents(res.events))
.catch(() => setEvents([]))
.finally(() => setLoading(false));
}, []);
const handleVote = useCallback( const handleVote = useCallback(
(pollId: string, optionId: string) => { async (eventId: string, _pollId: string, optionId: string) => {
if (!user) return; if (!user) return;
setPolls((prevPolls) => try {
prevPolls.map((poll) => { const updatedEvent = await eventsApi.vote(eventId, [optionId]);
if (poll.id !== pollId) return poll; setEvents((prev) =>
prev.map((e) => (e.id === updatedEvent.id ? updatedEvent : e))
const hasVotedForOption = poll.options.some((opt) => );
opt.votedUserIds.includes(user.id) } catch {
); // silently ignore
}
return {
...poll,
options: poll.options.map((opt) => {
if (opt.id === optionId) {
// Add vote to this option
return {
...opt,
votes: opt.votedUserIds.includes(user.id) ? opt.votes : opt.votes + 1,
votedUserIds: opt.votedUserIds.includes(user.id)
? opt.votedUserIds
: [...opt.votedUserIds, user.id],
};
} else if (!poll.allowMultipleVotes && hasVotedForOption) {
// Remove vote from other options if single vote
return {
...opt,
votes: opt.votedUserIds.includes(user.id) ? opt.votes - 1 : opt.votes,
votedUserIds: opt.votedUserIds.filter((id) => id !== user.id),
};
}
return opt;
}),
};
})
);
}, },
[user] [user]
); );
@@ -334,7 +317,7 @@ export default function EventsPage() {
{/* Events Grid */} {/* Events Grid */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{events.length === 0 ? ( {loading ? (
<div <div
style={{ style={{
background: 'var(--color-surface)', background: 'var(--color-surface)',
@@ -343,21 +326,27 @@ export default function EventsPage() {
textAlign: 'center', textAlign: 'center',
}} }}
> >
<div <div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.85rem' }}>
style={{ Loading events...
fontFamily: 'var(--font-mono)', </div>
color: 'var(--color-text-muted)', </div>
fontSize: '0.85rem', ) : events.length === 0 ? (
}} <div
> style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
padding: '3rem 2rem',
textAlign: 'center',
}}
>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.85rem' }}>
No events available at the moment. Check back soon! No events available at the moment. Check back soon!
</div> </div>
</div> </div>
) : ( ) : (
events.map((event) => { events.map((event) => (
const poll = event.pollId ? polls.find((p) => p.id === event.pollId) : undefined; <EventCard key={event.id} event={event} onVote={handleVote} />
return <EventCard key={event.id} event={event} poll={poll} onVote={handleVote} />; ))
})
)} )}
</div> </div>
</div> </div>

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { MOCK_CATEGORIES, MOCK_THREADS } from '../../data/mockData'; import { forumApi } from '../../utils/api';
import { timeAgo } from '../../utils/format'; import { timeAgo } from '../../utils/format';
import type { ForumCategory, ForumThread } from '../../types'; import type { ForumCategory, ForumThread } from '../../types';
@@ -128,15 +128,43 @@ function CategoryCard({ category, threads }: { category: ForumCategory; threads:
export default function ForumPage() { export default function ForumPage() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [categories, setCategories] = useState<ForumCategory[]>([]);
const [threads, setThreads] = useState<ForumThread[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
setLoading(true);
Promise.all([
forumApi.getCategories(),
forumApi.getThreads({ limit: 200 }),
])
.then(([cats, threadRes]) => {
if (cancelled) return;
setCategories(cats);
setThreads(threadRes.data);
})
.catch(() => {
if (cancelled) return;
setError('Failed to load forum. Please try again.');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
const filteredCategories = useMemo(() => { const filteredCategories = useMemo(() => {
if (!search.trim()) return MOCK_CATEGORIES; if (!search.trim()) return categories;
const q = search.toLowerCase(); const q = search.toLowerCase();
return MOCK_CATEGORIES.filter((cat) => return categories.filter((cat) =>
cat.name.toLowerCase().includes(q) || cat.name.toLowerCase().includes(q) ||
MOCK_THREADS.some((t) => t.categoryId === cat.id && t.title.toLowerCase().includes(q)) threads.some((t) => t.categoryId === cat.id && t.title.toLowerCase().includes(q))
); );
}, [search]); }, [search, categories, threads]);
return ( return (
<div style={{ maxWidth: '960px', margin: '0 auto', padding: '4rem 1.5rem' }}> <div style={{ maxWidth: '960px', margin: '0 auto', padding: '4rem 1.5rem' }}>
@@ -173,15 +201,29 @@ export default function ForumPage() {
</div> </div>
</div> </div>
{/* Categories */} {loading && (
{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)' }}>
No results found for "{search}" Loading forum...
</div> </div>
) : ( )}
filteredCategories.map((cat) => (
<CategoryCard key={cat.id} category={cat} threads={MOCK_THREADS} /> {error && !loading && (
)) <div className="crt-box" style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-red)', fontFamily: 'var(--font-mono)' }}>
{error}
</div>
)}
{/* Categories */}
{!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.'}
</div>
) : (
filteredCategories.map((cat) => (
<CategoryCard key={cat.id} category={cat} threads={threads} />
))
)
)} )}
</div> </div>
); );

View File

@@ -58,39 +58,7 @@ export default function LoginPage() {
</div> </div>
</div> </div>
<form onSubmit={handleSubmit} noValidate className="crt-box" style={{ padding: '2rem' }}> <form onSubmit={handleSubmit}>
{/* 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,6 +1,53 @@
import { TEAM_MEMBERS } from '../../data/mockData'; import type { TeamMember } from '../../types';
const FALLBACK_MEMBERS: TeamMember[] = [
{
id: 'studio-1',
name: 'Thibault Pouch',
role: 'Game Dev • Lore / CI-CD',
bio: 'Works on game dev, game lore, CI/CD, assets, and the web platform.',
avatarInitials: 'TP',
},
{
id: 'studio-2',
name: 'Pierre Ryssen',
role: 'Game Dev • Assets / Web',
bio: 'Works on game dev, assets, and the web platform.',
avatarInitials: 'PR',
},
{
id: 'studio-3',
name: 'Antoine Papillon',
role: 'Game Dev • Gameplay',
bio: 'Focused on core game development for the project.',
avatarInitials: 'AP',
},
{
id: 'studio-4',
name: 'Clement Augustinowick',
role: 'Game Dev • Gameplay',
bio: 'Focused on core game development for the project.',
avatarInitials: 'CA',
},
{
id: 'studio-5',
name: 'Dany Lhoir',
role: 'Game Dev • Multiplayer / Security',
bio: 'Works on game dev, multiplayer systems, and cybersecurity.',
avatarInitials: 'DL',
},
{
id: 'studio-6',
name: 'Timote Koenig',
role: 'Game Dev • Assets / Planning',
bio: 'Works on game dev, assets, and project planning.',
avatarInitials: 'TK',
},
];
export default function StudioPage() { export default function StudioPage() {
const members = FALLBACK_MEMBERS;
return ( return (
<div style={{ maxWidth: '1000px', margin: '0 auto', padding: '4rem 1.5rem' }}> <div style={{ maxWidth: '1000px', margin: '0 auto', padding: '4rem 1.5rem' }}>
{/* Header */} {/* Header */}
@@ -31,9 +78,9 @@ export default function StudioPage() {
marginBottom: '1rem', marginBottom: '1rem',
}} }}
> >
CrowMate Studio is an independent game studio founded in 2023 by a team of six developers CrowMate Studio is an independent game studio founded in 2026 by a team of six developers
united by a shared obsession: games that are strange, atmospheric, and actually interesting. who are all new to game development and learning by building together. We are headquartered
We are headquartered somewhere in Europe and operate fully remote. somewhere in France and operate arround the globe.
</p> </p>
<p <p
style={{ style={{
@@ -50,6 +97,40 @@ 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 */}
@@ -127,7 +208,7 @@ export default function StudioPage() {
gap: '1.25rem', gap: '1.25rem',
}} }}
> >
{TEAM_MEMBERS.map((member) => ( {members.map((member) => (
<div key={member.id} className="crt-box" style={{ padding: '1.5rem' }}> <div key={member.id} className="crt-box" style={{ padding: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
{/* Avatar */} {/* Avatar */}

View File

@@ -1,23 +1,48 @@
import { useState, useCallback } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { Link, useParams, Navigate } from 'react-router-dom'; import { Link, useParams, Navigate } from 'react-router-dom';
import { MOCK_THREADS, MOCK_REPLIES } from '../../data/mockData'; import { forumApi, ApiError } from '../../utils/api';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { formatDateTime, timeAgo } from '../../utils/format'; import { formatDateTime, timeAgo } from '../../utils/format';
import type { ForumThread, ForumReply } from '../../types';
export default function ThreadPage() { export default function ThreadPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const { user, isAuthenticated } = useAuth(); const { user, isAuthenticated } = useAuth();
const thread = MOCK_THREADS.find((t) => t.id === id); const [thread, setThread] = useState<ForumThread | null>(null);
const [replies, setReplies] = useState<ForumReply[]>([]);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
// Local state for new reply (stored in memory, not persisted)
const [replies, setReplies] = useState(
MOCK_REPLIES.filter((r) => r.threadId === id)
);
const [newReply, setNewReply] = useState(''); const [newReply, setNewReply] = useState('');
const [replyError, setReplyError] = useState(''); const [replyError, setReplyError] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!id) return;
let cancelled = false;
setLoading(true);
forumApi.getThread(id)
.then((data) => {
if (cancelled) return;
const { replies: threadReplies, ...threadData } = data;
setThread(threadData);
setReplies(threadReplies);
})
.catch((err) => {
if (cancelled) return;
if (err instanceof ApiError && err.status === 404) {
setNotFound(true);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [id]);
const handleReply = useCallback(async () => { const handleReply = useCallback(async () => {
if (!newReply.trim()) { if (!newReply.trim()) {
setReplyError('Reply cannot be empty.'); setReplyError('Reply cannot be empty.');
@@ -30,23 +55,26 @@ export default function ThreadPage() {
setReplyError(''); setReplyError('');
setSubmitting(true); setSubmitting(true);
await new Promise((r) => setTimeout(r, 300)); try {
const reply = await forumApi.createReply(id!, newReply.trim());
setReplies((prev) => [...prev, reply]);
setNewReply('');
} catch (err) {
setReplyError(err instanceof Error ? err.message : 'Failed to post reply.');
} finally {
setSubmitting(false);
}
}, [newReply, id]);
const reply = { if (loading) {
id: `r${Date.now()}`, return (
content: newReply.trim(), <div style={{ maxWidth: '860px', margin: '0 auto', padding: '4rem 1.5rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)' }}>
authorId: user!.id, Loading thread...
authorName: user!.username, </div>
threadId: id!, );
createdAt: new Date().toISOString(), }
};
setReplies((prev) => [...prev, reply]); if (notFound || !thread) {
setNewReply('');
setSubmitting(false);
}, [newReply, user, id]);
if (!thread) {
return <Navigate to="/forum" replace />; return <Navigate to="/forum" replace />;
} }
@@ -203,7 +231,7 @@ export default function ThreadPage() {
<button <button
className="btn-terminal" className="btn-terminal"
onClick={handleReply} onClick={handleReply}
disabled={submitting} disabled={submitting || !user}
style={{ opacity: submitting ? 0.6 : 1 }} style={{ opacity: submitting ? 0.6 : 1 }}
> >
{submitting ? 'Posting...' : '> Post Reply'} {submitting ? 'Posting...' : '> Post Reply'}

View File

@@ -134,6 +134,7 @@ export interface EventPost {
updatedAt?: string; updatedAt?: string;
isPublic: boolean; // whether visible to community isPublic: boolean; // whether visible to community
pollId?: string; // reference to poll if type is 'poll' pollId?: string; // reference to poll if type is 'poll'
poll?: Poll; // embedded poll from API
} }
export interface PollOption { export interface PollOption {

View File

@@ -1,4 +1,212 @@
import type {
User,
ForumCategory,
ForumThread,
ForumReply,
BugReport,
BugComment,
BugReportFormData,
BugSeverity,
BugStatus,
EventPost,
TeamMember,
} from '../types';
// API base URL. // API base URL.
// - In Docker (Coolify): nginx proxies /api/* to the backend, so we use a relative path. // - In Docker (Coolify): nginx proxies /api/* to the backend, so we use a relative path.
// - Set VITE_API_URL at build time to call the backend directly (e.g. during local dev without nginx). // - Set VITE_API_URL at build time to call the backend directly (e.g. during local dev without nginx).
export const API_BASE = import.meta.env.VITE_API_URL ?? '/api' export const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
// ── Token storage ─────────────────────────────────────────────────────────────
const TOKEN_KEY = 'crowmate_auth_token';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY);
}
// ── Core fetch helper ─────────────────────────────────────────────────────────
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.name = 'ApiError';
}
}
export async function apiFetch<T = unknown>(
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(() => ({}));
const message =
typeof body.error === 'string'
? body.error
: body.error?.message ?? `Request failed (${res.status})`;
throw new ApiError(res.status, message);
}
// 204 No Content
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
// ── Auth API ──────────────────────────────────────────────────────────────────
export const authApi = {
login: (email: string, password: string) =>
apiFetch<{ token: string; user: User }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
}),
register: (username: string, email: string, password: string) =>
apiFetch<{ token: string; user: User }>('/auth/register', {
method: 'POST',
body: JSON.stringify({ username, email, password }),
}),
me: () => apiFetch<User>('/auth/me'),
};
// ── Users API ─────────────────────────────────────────────────────────────────
export const usersApi = {
updateUsername: (username: string) =>
apiFetch<User>('/users/me/username', {
method: 'PATCH',
body: JSON.stringify({ username }),
}),
changePassword: (currentPassword: string, newPassword: string) =>
apiFetch<{ message: string }>('/users/me/password', {
method: 'PATCH',
body: JSON.stringify({ currentPassword, newPassword }),
}),
};
// ── Forum API ─────────────────────────────────────────────────────────────────
export const forumApi = {
getCategories: () =>
apiFetch<ForumCategory[]>('/forum/categories'),
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<{
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) =>
apiFetch<ForumThread & { replies: ForumReply[] }>(`/forum/threads/${id}`),
createReply: (threadId: string, content: string) =>
apiFetch<ForumReply>(`/forum/threads/${threadId}/replies`, {
method: 'POST',
body: JSON.stringify({ content }),
}),
};
// ── Bugs API ──────────────────────────────────────────────────────────────────
export const bugsApi = {
getBugs: (params?: { status?: BugStatus | 'all'; severity?: BugSeverity | '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);
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}`
);
},
getBug: (id: string) =>
apiFetch<BugReport & { comments: BugComment[] }>(`/bugs/${id}`),
createBug: (data: BugReportFormData) =>
apiFetch<BugReport>('/bugs', {
method: 'POST',
body: JSON.stringify(data),
}),
toggleMeToo: (bugId: string) =>
apiFetch<{ message: string }>(`/bugs/${bugId}/me-too`, { method: 'POST' }),
addComment: (bugId: string, content: string) =>
apiFetch<BugComment>(`/bugs/${bugId}/comments`, {
method: 'POST',
body: JSON.stringify({ content }),
}),
};
// ── Events API ────────────────────────────────────────────────────────────────
export const eventsApi = {
getEvents: (publicOnly = true) => {
const q = new URLSearchParams({ limit: '50' });
if (publicOnly) q.set('public', 'true');
return apiFetch<{ events: EventPost[]; total: number; page: number; pages: number }>(
`/events?${q}`
);
},
vote: (eventId: string, optionIds: string[]) =>
apiFetch<EventPost>(`/events/${eventId}/vote`, {
method: 'POST',
body: JSON.stringify({ optionIds }),
}),
};
// ── Team API ──────────────────────────────────────────────────────────────────
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

@@ -7,4 +7,7 @@ export default defineConfig({
react(), react(),
tailwindcss(), tailwindcss(),
], ],
server: {
port: 80,
},
}) })

View File

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

View File

@@ -3,10 +3,15 @@ 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.
resolver 127.0.0.11 ipv6=off valid=10s;
set $api_upstream http://api:3000;
location /api/ { location /api/ {
proxy_pass http://api:3000/api/; 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: 'Moderation', icon: '[M]', end: false }, { to: '/intranet/moderation', label: 'Forum Mod', 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,6 +1,7 @@
import { useState, useMemo, useCallback } from 'react'; import { useState, useMemo, useCallback, useEffect } 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 }) {
@@ -26,6 +27,48 @@ 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;
@@ -44,41 +87,102 @@ 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 });
}, [updateBug]); bugsApi.updateBug(bugId, { assignedToId: staffId || null }).catch(() => {
fetchBugs();
});
}, [fetchBugs, updateBug]);
const handleStatusChange = useCallback((bugId: string, status: BugStatus) => { const handleStatusChange = useCallback((bugId: string, status: BugStatus) => {
updateBug(bugId, { status }); updateBug(bugId, { status });
}, [updateBug]); bugsApi.updateBug(bugId, { status }).catch(() => {
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: noteText.trim(), content,
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('');
}, [noteText, user]); bugsApi.addNote(bugId, content).catch(() => {
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={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
INTRANET / BUG REPORTS <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> </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>
@@ -116,7 +220,16 @@ 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' }}>
{filtered.length === 0 ? ( {loadError && (
<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,5 +1,8 @@
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;
@@ -79,10 +82,30 @@ 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 (
@@ -105,16 +128,70 @@ 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={openBugs} accent="green" /> <StatCard label="Open Bugs" value={loadingBugs ? '...' : openBugs} accent="green" />
<StatCard label="Critical" value={criticalBugs} accent="red" /> <StatCard label="Critical" value={loadingBugs ? '...' : criticalBugs} accent="red" />
<StatCard label="Assigned to Me" value={assignedToMe} accent="amber" /> <StatCard label="Assigned to Me" value={loadingBugs ? '...' : 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' }}>
@@ -125,6 +202,8 @@ 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,5 +1,5 @@
import { useState, useCallback } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth, getToken } from '../../contexts/AuthContext';
import { formatDateTime } from '../../utils/format'; import { formatDateTime } from '../../utils/format';
import type { EventPost, EventType, Poll, UserRole } from '../../types'; import type { EventPost, EventType, Poll, UserRole } from '../../types';
@@ -244,10 +244,27 @@ function EventCard({
// ── Main Component ───────────────────────────────────────────────────────────── // ── Main Component ─────────────────────────────────────────────────────────────
function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
return fetch(`/api${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers as Record<string, string> ?? {}),
},
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `Request failed (${res.status})`);
}
return res.json() as Promise<T>;
});
}
export default function IntranetEvents() { export default function IntranetEvents() {
const { user } = useAuth(); const { user } = useAuth();
const [events, setEvents] = useState<EventPost[]>([]); const [events, setEvents] = useState<EventPost[]>([]);
const [polls, setPolls] = useState<Poll[]>([]);
const [showCreateForm, setShowCreateForm] = useState(false); const [showCreateForm, setShowCreateForm] = useState(false);
// Form state // Form state
@@ -261,124 +278,78 @@ export default function IntranetEvents() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [posting, setPosting] = useState(false); const [posting, setPosting] = useState(false);
useEffect(() => {
apiFetch<{ events: EventPost[] }>('/events?limit=50')
.then((res) => setEvents(res.events))
.catch(() => setEvents([]));
}, []);
const handleVote = useCallback( const handleVote = useCallback(
(pollId: string, optionId: string) => { async (pollId: string, optionId: string) => {
if (!user) return; const event = events.find((e) => e.pollId === pollId || e.poll?.id === pollId);
if (!event || !user) return;
setPolls((prevPolls) => try {
prevPolls.map((poll) => { const updated = await apiFetch<EventPost>(`/events/${event.id}/vote`, {
if (poll.id !== pollId) return poll; method: 'POST',
body: JSON.stringify({ optionIds: [optionId] }),
const hasVotedForOption = poll.options.some((opt) => });
opt.votedUserIds.includes(user.id) setEvents((prev) => prev.map((e) => (e.id === updated.id ? updated : e)));
); } catch {
// silently ignore
return { }
...poll,
options: poll.options.map((opt) => {
if (opt.id === optionId) {
// Add vote to this option
return {
...opt,
votes: opt.votedUserIds.includes(user.id) ? opt.votes : opt.votes + 1,
votedUserIds: opt.votedUserIds.includes(user.id)
? opt.votedUserIds
: [...opt.votedUserIds, user.id],
};
} else if (!poll.allowMultipleVotes && hasVotedForOption) {
// Remove vote from other options if single vote
return {
...opt,
votes: opt.votedUserIds.includes(user.id) ? opt.votes - 1 : opt.votes,
votedUserIds: opt.votedUserIds.filter((id) => id !== user.id),
};
}
return opt;
}),
};
})
);
}, },
[user] [events, user]
); );
const handleSubmit = useCallback(async () => { const handleSubmit = useCallback(async () => {
// Validation if (!title.trim()) { setError('Title is required.'); return; }
if (!title.trim()) { if (!content.trim()) { setError('Content is required.'); return; }
setError('Title is required.');
return;
}
if (!content.trim()) {
setError('Content is required.');
return;
}
if (createPoll) { if (createPoll) {
if (!pollQuestion.trim()) { if (!pollQuestion.trim()) { setError('Poll question is required.'); return; }
setError('Poll question is required.');
return;
}
const validOptions = pollOptions.filter((opt) => opt.trim()); const validOptions = pollOptions.filter((opt) => opt.trim());
if (validOptions.length < 2) { if (validOptions.length < 2) { setError('Poll must have at least 2 options.'); return; }
setError('Poll must have at least 2 options.');
return;
}
} }
if (!user) return; if (!user) return;
setError(''); setError('');
setPosting(true); setPosting(true);
await new Promise((r) => setTimeout(r, 300));
const newEventId = `evt${Date.now()}`; try {
let newPollId: string | undefined; const body: Record<string, unknown> = {
type: createPoll ? 'poll' : eventType,
// Create poll if needed title: title.trim(),
if (createPoll) { content: content.trim(),
newPollId = `poll${Date.now()}`; isPublic,
const validOptions = pollOptions.filter((opt) => opt.trim());
const newPoll: Poll = {
id: newPollId,
eventId: newEventId,
question: pollQuestion.trim(),
options: validOptions.map((opt, idx) => ({
id: `opt${Date.now()}_${idx}`,
text: opt.trim(),
votes: 0,
votedUserIds: [],
})),
isActive: true,
allowMultipleVotes: false,
createdAt: new Date().toISOString(),
}; };
setPolls((prev) => [newPoll, ...prev]);
if (createPoll) {
body.poll = {
question: pollQuestion.trim(),
options: pollOptions.filter((o) => o.trim()).map((o) => ({ text: o.trim() })),
};
}
const created = await apiFetch<EventPost>('/events', {
method: 'POST',
body: JSON.stringify(body),
});
setEvents((prev) => [created, ...prev]);
// Reset form
setTitle('');
setContent('');
setEventType('announcement');
setIsPublic(true);
setCreatePoll(false);
setPollQuestion('');
setPollOptions(['', '']);
setShowCreateForm(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create event.');
} finally {
setPosting(false);
} }
// Create event
const newEvent: EventPost = {
id: newEventId,
type: createPoll ? 'poll' : eventType,
title: title.trim(),
content: content.trim(),
authorId: user.id,
authorName: user.username,
authorRole: user.role,
createdAt: new Date().toISOString(),
isPublic,
pollId: newPollId,
};
setEvents((prev) => [newEvent, ...prev]);
// Reset form
setTitle('');
setContent('');
setEventType('announcement');
setIsPublic(true);
setCreatePoll(false);
setPollQuestion('');
setPollOptions(['', '']);
setPosting(false);
setShowCreateForm(false);
}, [title, content, eventType, isPublic, createPoll, pollQuestion, pollOptions, user]); }, [title, content, eventType, isPublic, createPoll, pollQuestion, pollOptions, user]);
return ( return (
@@ -711,10 +682,9 @@ export default function IntranetEvents() {
{/* Events List */} {/* Events List */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{events.map((event) => { {events.map((event) => (
const poll = event.pollId ? polls.find((p) => p.id === event.pollId) : undefined; <EventCard key={event.id} event={event} poll={event.poll ?? undefined} onVote={handleVote} />
return <EventCard key={event.id} event={event} poll={poll} onVote={handleVote} />; ))}
})}
</div> </div>
</div> </div>
); );

View File

@@ -1,13 +1,87 @@
import { useState, useMemo, useCallback } from 'react'; import { useState, useMemo, useCallback, useEffect } from 'react';
import { formatDateTime } from '../../utils/format'; import { formatDateTime } from '../../utils/format';
import type { ForumThread, ForumReply } from '../../types'; import { forumApi, settingsApi } from '../../utils/api';
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'>('threads'); const [activeTab, setActiveTab] = useState<'threads' | 'replies' | 'categories'>('threads');
const [isEnabled, setIsEnabled] = useState(true);
const [toggling, setToggling] = useState(false);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [savingCategory, setSavingCategory] = useState(false);
const [error, setError] = useState('');
const loadModerationData = useCallback(async () => {
setLoading(true);
setError('');
try {
const [cats, threadRes] = await Promise.all([
forumApi.getCategories(),
forumApi.getThreads({ limit: 200 }),
]);
const loadedThreads = threadRes.data;
setCategories(cats);
setThreads(loadedThreads);
const detailed = await Promise.all(
loadedThreads.map((thread) => forumApi.getThread(thread.id).catch(() => null))
);
const allReplies = detailed
.filter((thread): thread is ForumThread & { replies: ForumReply[] } => Boolean(thread))
.flatMap((thread) => thread.replies);
setReplies(allReplies);
} catch {
setError('Failed to load moderation data.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
settingsApi.get().then((s) => setIsEnabled(s.forumEnabled)).catch(() => {});
}, []);
useEffect(() => {
void loadModerationData();
}, [loadModerationData]);
useEffect(() => {
if (categories.length === 0) {
setCreateCategoryId('');
return;
}
const exists = categories.some((category) => category.id === createCategoryId);
if (!exists) {
setCreateCategoryId(categories[0].id);
}
}, [categories, createCategoryId]);
const handleToggle = useCallback((enabled: boolean) => {
setToggling(true);
settingsApi.update({ forumEnabled: enabled })
.then(() => setIsEnabled(enabled))
.catch(() => {})
.finally(() => setToggling(false));
}, []);
const filteredThreads = useMemo(() => { const filteredThreads = useMemo(() => {
if (!search.trim()) return threads; if (!search.trim()) return threads;
@@ -21,42 +95,232 @@ export default function IntranetModeration() {
}, [replies, selectedThreadId]); }, [replies, selectedThreadId]);
const deleteThread = useCallback((id: string) => { const deleteThread = useCallback((id: string) => {
setThreads((prev) => prev.filter((t) => t.id !== id)); forumApi.deleteThread(id)
setReplies((prev) => prev.filter((r) => r.threadId !== id)); .then(() => {
if (selectedThreadId === id) setSelectedThreadId(null); setThreads((prev) => prev.filter((t) => t.id !== id));
setReplies((prev) => prev.filter((r) => r.threadId !== id));
if (selectedThreadId === id) setSelectedThreadId(null);
})
.catch(() => {
setError('Failed to delete thread.');
});
}, [selectedThreadId]); }, [selectedThreadId]);
const togglePin = useCallback((id: string) => { const togglePin = useCallback((id: string) => {
setThreads((prev) => prev.map((t) => t.id === id ? { ...t, isPinned: !t.isPinned } : t)); const thread = threads.find((t) => t.id === id);
}, []); if (!thread) return;
forumApi.updateThread(id, { isPinned: !thread.isPinned })
.then((updated) => {
setThreads((prev) => prev.map((t) => (t.id === id ? updated : t)));
})
.catch(() => {
setError('Failed to update pin state.');
});
}, [threads]);
const toggleLock = useCallback((id: string) => { const toggleLock = useCallback((id: string) => {
setThreads((prev) => prev.map((t) => t.id === id ? { ...t, isLocked: !t.isLocked } : t)); const thread = threads.find((t) => t.id === id);
}, []); if (!thread) return;
forumApi.updateThread(id, { isLocked: !thread.isLocked })
.then((updated) => {
setThreads((prev) => prev.map((t) => (t.id === id ? updated : t)));
})
.catch(() => {
setError('Failed to update lock state.');
});
}, [threads]);
const deleteReply = useCallback((id: string) => { const deleteReply = useCallback((id: string) => {
setReplies((prev) => prev.filter((r) => r.id !== id)); const removedReply = replies.find((r) => r.id === id);
forumApi.deleteReply(id)
.then(() => {
setReplies((prev) => prev.filter((r) => r.id !== id));
setThreads((prev) => prev.map((t) => {
if (!removedReply || removedReply.threadId !== t.id) return t;
return { ...t, replyCount: Math.max(0, t.replyCount - 1) };
}));
})
.catch(() => {
setError('Failed to delete reply.');
});
}, [replies]);
const createThread = useCallback(() => {
const title = createTitle.trim();
const content = createContent.trim();
if (!title || !content || !createCategoryId) {
setError('Title, category and content are required.');
return;
}
setCreating(true);
setError('');
forumApi.createThread({
title,
content,
categoryId: createCategoryId,
})
.then((thread) => {
setThreads((prev) => [thread, ...prev]);
setCategories((prev) => prev.map((cat) => (
cat.id === createCategoryId
? { ...cat, threadCount: cat.threadCount + 1 }
: cat
)));
setCreateTitle('');
setCreateContent('');
setIsCreateModalOpen(false);
})
.catch(() => {
setError('Failed to create thread.');
})
.finally(() => {
setCreating(false);
});
}, [createCategoryId, createContent, createTitle]);
const openCreateCategoryModal = useCallback(() => {
setEditingCategoryId(null);
setCategoryName('');
setCategoryDescription('');
setCategoryIcon('📁');
setIsCategoryModalOpen(true);
}, []); }, []);
const openEditCategoryModal = useCallback((category: ForumCategory) => {
setEditingCategoryId(category.id);
setCategoryName(category.name);
setCategoryDescription(category.description);
setCategoryIcon(category.icon || '📁');
setIsCategoryModalOpen(true);
}, []);
const saveCategory = useCallback(() => {
const name = categoryName.trim();
const description = categoryDescription.trim();
const icon = categoryIcon.trim() || '📁';
if (!name || !description) {
setError('Category name and description are required.');
return;
}
setSavingCategory(true);
setError('');
const action = editingCategoryId
? forumApi.updateCategory(editingCategoryId, { name, description, icon })
: forumApi.createCategory({ name, description, icon });
action
.then(() => loadModerationData())
.then(() => setIsCategoryModalOpen(false))
.catch(() => {
setError(editingCategoryId ? 'Failed to update category.' : 'Failed to create category.');
})
.finally(() => {
setSavingCategory(false);
});
}, [categoryDescription, categoryIcon, categoryName, editingCategoryId, loadModerationData]);
const removeCategory = useCallback((id: string) => {
const confirmed = window.confirm('Delete this category? This can fail if it still has threads.');
if (!confirmed) return;
setError('');
forumApi.deleteCategory(id)
.then(() => loadModerationData())
.catch(() => {
setError('Failed to delete category. Remove or move threads first.');
});
}, [loadModerationData]);
const recentReplies = useMemo(() => { 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>
<div style={{ marginBottom: '1.75rem' }}> {!isEnabled ? (
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '400px', textAlign: 'center' }}>
INTRANET / MODERATION <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> </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'] as const).map((tab) => ( {(['threads', 'replies', 'categories'] as const).map((tab) => (
<button <button
key={tab} key={tab}
onClick={() => setActiveTab(tab)} onClick={() => setActiveTab(tab)}
@@ -73,7 +337,11 @@ export default function IntranetModeration() {
letterSpacing: '0.08em', letterSpacing: '0.08em',
}} }}
> >
{tab === 'threads' ? `Threads (${threads.length})` : `Replies (${replies.length})`} {tab === 'threads'
? `Threads (${threads.length})`
: tab === 'replies'
? `Replies (${replies.length})`
: `Categories (${categories.length})`}
</button> </button>
))} ))}
</div> </div>
@@ -82,6 +350,22 @@ 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"
@@ -125,6 +409,7 @@ 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>
@@ -132,6 +417,7 @@ 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>
@@ -139,6 +425,7 @@ 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>
@@ -225,6 +512,205 @@ 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

@@ -133,7 +133,8 @@ export interface EventPost {
createdAt: string; createdAt: string;
updatedAt?: string; updatedAt?: string;
isPublic: boolean; // whether visible to community isPublic: boolean; // whether visible to community
pollId?: string; // reference to poll if type is 'poll' pollId?: string | null; // reference to poll if type is 'poll'
poll?: Poll | null; // embedded poll data from API
} }
export interface PollOption { export interface PollOption {

153
nest-intra/src/utils/api.ts Normal file
View File

@@ -0,0 +1,153 @@
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 }),
}),
};

View File

@@ -0,0 +1,897 @@
{
"info": {
"name": "CrowMate API",
"_postman_id": "crowmate-nest-api",
"description": "CrowMate Nest backend API — all endpoints organized by resource. After logging in, the token is automatically saved to the `token` collection variable.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"variable": [
{
"key": "token",
"value": "",
"type": "string"
}
],
"item": [
{
"name": "Health",
"item": [
{
"name": "Health Check",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/health",
"host": ["{{baseUrl}}"],
"path": ["api", "health"]
}
}
}
]
},
{
"name": "Auth",
"item": [
{
"name": "Login",
"event": [
{
"listen": "test",
"script": {
"exec": [
"const res = pm.response.json();",
"if (res.token) {",
" pm.collectionVariables.set('token', res.token);",
" pm.test('Token saved', () => pm.expect(res.token).to.be.a('string'));",
"}"
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"email\": \"{{adminEmail}}\",\n \"password\": \"{{adminPassword}}\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/auth/login",
"host": ["{{baseUrl}}"],
"path": ["api", "auth", "login"]
}
}
},
{
"name": "Register",
"request": {
"method": "POST",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"username\": \"testuser\",\n \"email\": \"testuser@example.com\",\n \"password\": \"password123\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/auth/register",
"host": ["{{baseUrl}}"],
"path": ["api", "auth", "register"]
}
}
},
{
"name": "Me",
"request": {
"method": "GET",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/auth/me",
"host": ["{{baseUrl}}"],
"path": ["api", "auth", "me"]
}
}
}
]
},
{
"name": "Users",
"item": [
{
"name": "List Users (Admin)",
"request": {
"method": "GET",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/users",
"host": ["{{baseUrl}}"],
"path": ["api", "users"]
}
}
},
{
"name": "Create Staff Account (Admin)",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"username\": \"staffmember\",\n \"email\": \"staff@example.com\",\n \"password\": \"password123\",\n \"role\": \"dev\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/users",
"host": ["{{baseUrl}}"],
"path": ["api", "users"]
}
}
},
{
"name": "My Profile",
"request": {
"method": "GET",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/users/me/profile",
"host": ["{{baseUrl}}"],
"path": ["api", "users", "me", "profile"]
}
}
},
{
"name": "Update My Username",
"request": {
"method": "PATCH",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"username\": \"newusername\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/users/me/username",
"host": ["{{baseUrl}}"],
"path": ["api", "users", "me", "username"]
}
}
},
{
"name": "Update My Password",
"request": {
"method": "PATCH",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"currentPassword\": \"oldpassword\",\n \"newPassword\": \"newpassword123\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/users/me/password",
"host": ["{{baseUrl}}"],
"path": ["api", "users", "me", "password"]
}
}
},
{
"name": "Get User by ID (Admin)",
"request": {
"method": "GET",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/users/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "users", ":id"],
"variable": [{ "key": "id", "value": "USER_ID_HERE" }]
}
}
},
{
"name": "Update User Role (Admin)",
"request": {
"method": "PATCH",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"role\": \"dev\",\n \"isAdmin\": false\n}"
},
"url": {
"raw": "{{baseUrl}}/api/users/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "users", ":id"],
"variable": [{ "key": "id", "value": "USER_ID_HERE" }]
}
}
},
{
"name": "Ban User (Admin)",
"request": {
"method": "POST",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/users/:id/ban",
"host": ["{{baseUrl}}"],
"path": ["api", "users", ":id", "ban"],
"variable": [{ "key": "id", "value": "USER_ID_HERE" }]
}
}
},
{
"name": "Unban User (Admin)",
"request": {
"method": "POST",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/users/:id/unban",
"host": ["{{baseUrl}}"],
"path": ["api", "users", ":id", "unban"],
"variable": [{ "key": "id", "value": "USER_ID_HERE" }]
}
}
}
]
},
{
"name": "Forum",
"item": [
{
"name": "Categories",
"item": [
{
"name": "List Categories",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/forum/categories",
"host": ["{{baseUrl}}"],
"path": ["api", "forum", "categories"]
}
}
},
{
"name": "Create Category (Admin)",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"General\",\n \"description\": \"General discussion\",\n \"icon\": \"💬\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/forum/categories",
"host": ["{{baseUrl}}"],
"path": ["api", "forum", "categories"]
}
}
},
{
"name": "Update Category (Admin)",
"request": {
"method": "PATCH",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"General Updated\",\n \"description\": \"Updated description\",\n \"icon\": \"🗨️\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/forum/categories/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "forum", "categories", ":id"],
"variable": [{ "key": "id", "value": "CATEGORY_ID_HERE" }]
}
}
},
{
"name": "Delete Category (Admin)",
"request": {
"method": "DELETE",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/forum/categories/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "forum", "categories", ":id"],
"variable": [{ "key": "id", "value": "CATEGORY_ID_HERE" }]
}
}
}
]
},
{
"name": "Threads",
"item": [
{
"name": "List Threads",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/forum/threads?categoryId=&page=1&limit=20",
"host": ["{{baseUrl}}"],
"path": ["api", "forum", "threads"],
"query": [
{ "key": "categoryId", "value": "", "description": "Filter by category ID" },
{ "key": "page", "value": "1" },
{ "key": "limit", "value": "20", "description": "Max 50" }
]
}
}
},
{
"name": "Get Thread",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/forum/threads/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "forum", "threads", ":id"],
"variable": [{ "key": "id", "value": "THREAD_ID_HERE" }]
}
}
},
{
"name": "Create Thread",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"My thread title\",\n \"content\": \"Thread content here\",\n \"categoryId\": \"CATEGORY_ID_HERE\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/forum/threads",
"host": ["{{baseUrl}}"],
"path": ["api", "forum", "threads"]
}
}
},
{
"name": "Update Thread",
"request": {
"method": "PATCH",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"Updated title\",\n \"content\": \"Updated content\",\n \"isPinned\": false,\n \"isLocked\": false\n}"
},
"url": {
"raw": "{{baseUrl}}/api/forum/threads/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "forum", "threads", ":id"],
"variable": [{ "key": "id", "value": "THREAD_ID_HERE" }]
}
}
},
{
"name": "Delete Thread (Admin)",
"request": {
"method": "DELETE",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/forum/threads/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "forum", "threads", ":id"],
"variable": [{ "key": "id", "value": "THREAD_ID_HERE" }]
}
}
}
]
},
{
"name": "Replies",
"item": [
{
"name": "Post Reply",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"content\": \"My reply content\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/forum/threads/:id/replies",
"host": ["{{baseUrl}}"],
"path": ["api", "forum", "threads", ":id", "replies"],
"variable": [{ "key": "id", "value": "THREAD_ID_HERE" }]
}
}
},
{
"name": "Delete Reply (Admin)",
"request": {
"method": "DELETE",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/forum/replies/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "forum", "replies", ":id"],
"variable": [{ "key": "id", "value": "REPLY_ID_HERE" }]
}
}
}
]
}
]
},
{
"name": "Bugs",
"item": [
{
"name": "List Bugs",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/bugs?status=all&severity=all&page=1&limit=20",
"host": ["{{baseUrl}}"],
"path": ["api", "bugs"],
"query": [
{ "key": "status", "value": "all", "description": "open | in_progress | resolved | closed | all" },
{ "key": "severity", "value": "all", "description": "low | medium | high | critical | all" },
{ "key": "assignedTo", "value": "", "description": "userId | unassigned | all", "disabled": true },
{ "key": "page", "value": "1" },
{ "key": "limit", "value": "20", "description": "Max 50" }
]
}
}
},
{
"name": "Get Bug",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/bugs/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "bugs", ":id"],
"variable": [{ "key": "id", "value": "BUG_ID_HERE" }]
}
}
},
{
"name": "Submit Bug Report",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"Bug title\",\n \"description\": \"Detailed description of the bug\",\n \"stepsToReproduce\": \"1. Do this\\n2. Do that\\n3. See error\",\n \"severity\": \"medium\",\n \"gameVersion\": \"1.0.0\",\n \"screenshotUrl\": \"\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/bugs",
"host": ["{{baseUrl}}"],
"path": ["api", "bugs"]
}
}
},
{
"name": "Update Bug (Staff)",
"request": {
"method": "PATCH",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"status\": \"in_progress\",\n \"assignedToId\": null,\n \"severity\": \"high\",\n \"title\": \"Updated bug title\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/bugs/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "bugs", ":id"],
"variable": [{ "key": "id", "value": "BUG_ID_HERE" }]
}
}
},
{
"name": "Toggle Me Too",
"request": {
"method": "POST",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/bugs/:id/me-too",
"host": ["{{baseUrl}}"],
"path": ["api", "bugs", ":id", "me-too"],
"variable": [{ "key": "id", "value": "BUG_ID_HERE" }]
}
}
},
{
"name": "Add Comment",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"content\": \"This is a public comment on the bug\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/bugs/:id/comments",
"host": ["{{baseUrl}}"],
"path": ["api", "bugs", ":id", "comments"],
"variable": [{ "key": "id", "value": "BUG_ID_HERE" }]
}
}
},
{
"name": "Delete Comment (Admin)",
"request": {
"method": "DELETE",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/bugs/comments/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "bugs", "comments", ":id"],
"variable": [{ "key": "id", "value": "COMMENT_ID_HERE" }]
}
}
},
{
"name": "Add Staff Note (Staff)",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"content\": \"Internal staff note — not visible to users\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/bugs/:id/notes",
"host": ["{{baseUrl}}"],
"path": ["api", "bugs", ":id", "notes"],
"variable": [{ "key": "id", "value": "BUG_ID_HERE" }]
}
}
},
{
"name": "Delete Staff Note (Admin)",
"request": {
"method": "DELETE",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/bugs/notes/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "bugs", "notes", ":id"],
"variable": [{ "key": "id", "value": "NOTE_ID_HERE" }]
}
}
}
]
},
{
"name": "Events",
"item": [
{
"name": "List Events",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/events?public=true&page=1&limit=20",
"host": ["{{baseUrl}}"],
"path": ["api", "events"],
"query": [
{ "key": "public", "value": "true", "description": "Filter to public events only" },
{ "key": "page", "value": "1" },
{ "key": "limit", "value": "20" }
]
}
}
},
{
"name": "Get Event",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/events/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "events", ":id"],
"variable": [{ "key": "id", "value": "EVENT_ID_HERE" }]
}
}
},
{
"name": "Create Announcement (Staff)",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"type\": \"announcement\",\n \"title\": \"Big announcement\",\n \"content\": \"Something important happened!\",\n \"isPublic\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/events",
"host": ["{{baseUrl}}"],
"path": ["api", "events"]
}
}
},
{
"name": "Create Poll (Staff)",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"type\": \"poll\",\n \"title\": \"Community Poll\",\n \"content\": \"Vote on your favourite feature!\",\n \"isPublic\": true,\n \"poll\": {\n \"question\": \"What should we add next?\",\n \"options\": [\n { \"text\": \"New maps\" },\n { \"text\": \"More characters\" },\n { \"text\": \"Better UI\" }\n ],\n \"isActive\": true,\n \"allowMultipleVotes\": false\n }\n}"
},
"url": {
"raw": "{{baseUrl}}/api/events",
"host": ["{{baseUrl}}"],
"path": ["api", "events"]
}
}
},
{
"name": "Update Event (Staff)",
"request": {
"method": "PATCH",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"title\": \"Updated title\",\n \"content\": \"Updated content\",\n \"isPublic\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/events/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "events", ":id"],
"variable": [{ "key": "id", "value": "EVENT_ID_HERE" }]
}
}
},
{
"name": "Delete Event (Admin)",
"request": {
"method": "DELETE",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/events/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "events", ":id"],
"variable": [{ "key": "id", "value": "EVENT_ID_HERE" }]
}
}
},
{
"name": "Vote on Poll",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"optionIds\": [\"OPTION_ID_HERE\"]\n}"
},
"url": {
"raw": "{{baseUrl}}/api/events/:id/vote",
"host": ["{{baseUrl}}"],
"path": ["api", "events", ":id", "vote"],
"variable": [{ "key": "id", "value": "EVENT_ID_HERE" }]
}
}
},
{
"name": "Control Poll (Staff)",
"request": {
"method": "PATCH",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"isActive\": false,\n \"endsAt\": null\n}"
},
"url": {
"raw": "{{baseUrl}}/api/events/:id/poll",
"host": ["{{baseUrl}}"],
"path": ["api", "events", ":id", "poll"],
"variable": [{ "key": "id", "value": "EVENT_ID_HERE" }]
}
}
}
]
},
{
"name": "Feed",
"item": [
{
"name": "List Feed (Staff)",
"request": {
"method": "GET",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/feed",
"host": ["{{baseUrl}}"],
"path": ["api", "feed"]
}
}
},
{
"name": "Post to Feed (Staff)",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"content\": \"Internal staff feed post content\"\n}"
},
"url": {
"raw": "{{baseUrl}}/api/feed",
"host": ["{{baseUrl}}"],
"path": ["api", "feed"]
}
}
},
{
"name": "Delete Feed Post",
"request": {
"method": "DELETE",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/feed/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "feed", ":id"],
"variable": [{ "key": "id", "value": "POST_ID_HERE" }]
}
}
}
]
},
{
"name": "Team",
"item": [
{
"name": "List Team Members",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/team",
"host": ["{{baseUrl}}"],
"path": ["api", "team"]
}
}
},
{
"name": "Create Team Member (Admin)",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"John Doe\",\n \"role\": \"Lead Developer\",\n \"bio\": \"A short bio about this team member.\",\n \"avatarInitials\": \"JD\",\n \"social\": {\n \"twitter\": \"johndoe\",\n \"github\": \"johndoe\"\n }\n}"
},
"url": {
"raw": "{{baseUrl}}/api/team",
"host": ["{{baseUrl}}"],
"path": ["api", "team"]
}
}
},
{
"name": "Update Team Member (Admin)",
"request": {
"method": "PATCH",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"name\": \"John Doe\",\n \"role\": \"Senior Developer\",\n \"bio\": \"Updated bio.\",\n \"avatarInitials\": \"JD\",\n \"social\": {\n \"twitter\": \"johndoe\",\n \"github\": \"johndoe\"\n }\n}"
},
"url": {
"raw": "{{baseUrl}}/api/team/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "team", ":id"],
"variable": [{ "key": "id", "value": "MEMBER_ID_HERE" }]
}
}
},
{
"name": "Delete Team Member (Admin)",
"request": {
"method": "DELETE",
"header": [{ "key": "Authorization", "value": "Bearer {{token}}" }],
"url": {
"raw": "{{baseUrl}}/api/team/:id",
"host": ["{{baseUrl}}"],
"path": ["api", "team", ":id"],
"variable": [{ "key": "id", "value": "MEMBER_ID_HERE" }]
}
}
}
]
},
{
"name": "Settings",
"item": [
{
"name": "Get Settings",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/settings",
"host": ["{{baseUrl}}"],
"path": ["api", "settings"]
}
}
},
{
"name": "Update Settings (Admin)",
"request": {
"method": "PATCH",
"header": [
{ "key": "Authorization", "value": "Bearer {{token}}" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"forumEnabled\": true,\n \"bugsEnabled\": true\n}"
},
"url": {
"raw": "{{baseUrl}}/api/settings",
"host": ["{{baseUrl}}"],
"path": ["api", "settings"]
}
}
}
]
}
]
}

View File

@@ -0,0 +1,34 @@
{
"id": "crowmate-local-env",
"name": "CrowMate — Local",
"values": [
{
"key": "baseUrl",
"value": "http://localhost:3000",
"type": "default",
"enabled": true
},
{
"key": "adminEmail",
"value": "admin@example.com",
"type": "default",
"enabled": true
},
{
"key": "adminPassword",
"value": "change_me",
"type": "secret",
"enabled": true
},
{
"key": "token",
"value": "",
"type": "secret",
"enabled": true,
"description": "Auto-populated by the Login request"
}
],
"_postman_variable_scope": "environment",
"_postman_exported_at": "2026-03-26T00:00:00.000Z",
"_postman_exported_using": "Claude Code"
}

View File

@@ -0,0 +1,37 @@
{
"id": "crowmate-prod-env",
"name": "CrowMate — Production",
"values": [
{
"key": "baseUrl",
"value": "https://api.your-domain.com",
"type": "default",
"enabled": true,
"description": "Replace with your actual production API URL"
},
{
"key": "adminEmail",
"value": "admin@your-domain.com",
"type": "default",
"enabled": true,
"description": "Replace with your production admin email"
},
{
"key": "adminPassword",
"value": "",
"type": "secret",
"enabled": true,
"description": "Fill in before use — never commit this value"
},
{
"key": "token",
"value": "",
"type": "secret",
"enabled": true,
"description": "Auto-populated by the Login request"
}
],
"_postman_variable_scope": "environment",
"_postman_exported_at": "2026-03-26T00:00:00.000Z",
"_postman_exported_using": "Claude Code"
}