9 Commits

16 changed files with 378 additions and 39 deletions

View File

@@ -19,15 +19,10 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3000:3000" - "3000:3000"
env_file:
- ./nest-backend/.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
@@ -39,6 +34,8 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "80:80" - "80:80"
environment:
API_URL: http://api:3000
depends_on: depends_on:
- api - api

View File

@@ -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,6 +7,7 @@ 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();
@@ -122,6 +123,7 @@ app.use('/api/bugs', bugsRouter);
app.use('/api/feed', feedRouter); app.use('/api/feed', feedRouter);
app.use('/api/events', eventsRouter); app.use('/api/events', eventsRouter);
app.use('/api/team', teamRouter); app.use('/api/team', teamRouter);
app.use('/api/settings', settingsRouter);
// 404 // 404
app.use((_req, res) => res.status(404).json({ error: 'Not found' })); app.use((_req, res) => res.status(404).json({ error: 'Not found' }));

View File

@@ -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

View File

@@ -1,6 +1,6 @@
import { useState, useMemo, useCallback, useEffect } from 'react'; import { useState, useMemo, useCallback, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { bugsApi } from '../../utils/api'; import { bugsApi, settingsApi } 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';
@@ -278,10 +278,15 @@ export default function BugReportPage() {
const { user, isAuthenticated } = useAuth(); const { user, isAuthenticated } = useAuth();
const [bugs, setBugs] = useState<BugReport[]>([]); const [bugs, setBugs] = useState<BugReport[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [bugsEnabled, setBugsEnabled] = 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);
useEffect(() => {
settingsApi.get().then((s) => setBugsEnabled(s.bugsEnabled)).catch(() => {});
}, []);
const fetchBugs = useCallback(() => { const fetchBugs = useCallback(() => {
setLoading(true); setLoading(true);
bugsApi.getBugs({ status: statusFilter, severity: severityFilter, limit: 100 }) bugsApi.getBugs({ status: statusFilter, severity: severityFilter, limit: 100 })
@@ -312,6 +317,20 @@ export default function BugReportPage() {
const inProgressCount = bugs.filter((b) => b.status === 'in_progress').length; const inProgressCount = bugs.filter((b) => b.status === 'in_progress').length;
const resolvedCount = bugs.filter((b) => b.status === 'resolved').length; const resolvedCount = bugs.filter((b) => b.status === 'resolved').length;
if (!bugsEnabled) {
return (
<div style={{ maxWidth: '960px', margin: '0 auto', padding: '3rem 1.5rem', textAlign: 'center' }}>
<div className="section-label">Issue Tracker</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-red)', fontSize: 'clamp(2rem, 6vw, 3.5rem)', marginTop: '0.25rem' }}>
BUG REPORTS UNAVAILABLE
</h1>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.78rem', marginTop: '1rem', fontFamily: 'var(--font-mono)' }}>
Bug reporting has been temporarily disabled by an administrator.
</p>
</div>
);
}
return ( return (
<div style={{ maxWidth: '960px', margin: '0 auto', padding: '3rem 1.5rem' }}> <div style={{ maxWidth: '960px', margin: '0 auto', padding: '3rem 1.5rem' }}>
{/* Header */} {/* Header */}

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { forumApi } from '../../utils/api'; import { forumApi, settingsApi } 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';
@@ -132,17 +132,20 @@ export default function ForumPage() {
const [threads, setThreads] = useState<ForumThread[]>([]); const [threads, setThreads] = useState<ForumThread[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [forumEnabled, setForumEnabled] = useState(true);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
Promise.all([ Promise.all([
settingsApi.get(),
forumApi.getCategories(), forumApi.getCategories(),
forumApi.getThreads({ limit: 200 }), forumApi.getThreads({ limit: 200 }),
]) ])
.then(([cats, threadRes]) => { .then(([settings, cats, threadRes]) => {
if (cancelled) return; if (cancelled) return;
setForumEnabled(settings.forumEnabled);
setCategories(cats); setCategories(cats);
setThreads(threadRes.data); setThreads(threadRes.data);
}) })
@@ -166,6 +169,20 @@ export default function ForumPage() {
); );
}, [search, categories, threads]); }, [search, categories, threads]);
if (!loading && !forumEnabled) {
return (
<div style={{ maxWidth: '960px', margin: '0 auto', padding: '4rem 1.5rem', textAlign: 'center' }}>
<div className="section-label">Community</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-red)', fontSize: 'clamp(2rem, 5vw, 3rem)', marginTop: '0.5rem' }}>
FORUM UNAVAILABLE
</h1>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8rem', marginTop: '1rem', fontFamily: 'var(--font-mono)' }}>
The forum has been temporarily disabled by an administrator.
</p>
</div>
);
}
return ( return (
<div style={{ maxWidth: '960px', margin: '0 auto', padding: '4rem 1.5rem' }}> <div style={{ maxWidth: '960px', margin: '0 auto', padding: '4rem 1.5rem' }}>
{/* Header */} {/* Header */}

View File

@@ -1,15 +1,52 @@
import { useEffect, useState } from 'react';
import { teamApi } from '../../utils/api';
import type { TeamMember } from '../../types'; import type { TeamMember } from '../../types';
export default function StudioPage() { const FALLBACK_MEMBERS: TeamMember[] = [
const [members, setMembers] = useState<TeamMember[]>([]); {
id: 'studio-1',
name: 'Thibault Pouch',
role: 'Game Dev • Lore / CI-CD',
bio: 'Works on game dev, game lore, CI/CD, assets, and the web platform.',
avatarInitials: 'TP',
},
{
id: 'studio-2',
name: 'Pierre Ryssen',
role: 'Game Dev • Assets / Web',
bio: 'Works on game dev, assets, and the web platform.',
avatarInitials: 'PR',
},
{
id: 'studio-3',
name: 'Antoine Papillon',
role: 'Game Dev • Gameplay',
bio: 'Focused on core game development for the project.',
avatarInitials: 'AP',
},
{
id: 'studio-4',
name: 'Clement Augustinowick',
role: 'Game Dev • Gameplay',
bio: 'Focused on core game development for the project.',
avatarInitials: 'CA',
},
{
id: 'studio-5',
name: 'Dany Lhoir',
role: 'Game Dev • Multiplayer / Security',
bio: 'Works on game dev, multiplayer systems, and cybersecurity.',
avatarInitials: 'DL',
},
{
id: 'studio-6',
name: 'Timote Koenig',
role: 'Game Dev • Assets / Planning',
bio: 'Works on game dev, assets, and project planning.',
avatarInitials: 'TK',
},
];
useEffect(() => { export default function StudioPage() {
teamApi.getMembers() const members = FALLBACK_MEMBERS;
.then(setMembers)
.catch(() => { /* show empty state */ });
}, []);
return ( return (
<div style={{ maxWidth: '1000px', margin: '0 auto', padding: '4rem 1.5rem' }}> <div style={{ maxWidth: '1000px', margin: '0 auto', padding: '4rem 1.5rem' }}>
@@ -41,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={{
@@ -60,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 */}

View File

@@ -190,3 +190,11 @@ export const eventsApi = {
export const teamApi = { export const teamApi = {
getMembers: () => apiFetch<TeamMember[]>('/team'), getMembers: () => apiFetch<TeamMember[]>('/team'),
}; };
// ── Settings API ──────────────────────────────────────────────────────────────
export type SiteSettings = { forumEnabled: boolean; bugsEnabled: boolean };
export const settingsApi = {
get: () => apiFetch<SiteSettings>('/settings'),
};

View File

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

View File

@@ -3,14 +3,15 @@ server {
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 # Docker DNS; resolve API service name at request time.
resolver 127.0.0.11 valid=30s; resolver 127.0.0.11 ipv6=off valid=10s;
set $api_upstream http://api:3000;
location /api/ { location /api/ {
set $api_upstream http://api:3000;
proxy_pass $api_upstream; proxy_pass $api_upstream;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
} }
location / { location / {

View File

@@ -8,7 +8,7 @@ const INTRANET_LINKS = [
{ to: '/intranet/feed', label: 'Team Feed', icon: '[~]', end: false }, { to: '/intranet/feed', label: 'Team Feed', icon: '[~]', end: false },
{ to: '/intranet/events', label: 'Events', icon: '[E]', end: false }, { to: '/intranet/events', label: 'Events', icon: '[E]', end: false },
{ to: '/intranet/users', label: 'Users', icon: '[U]', end: false }, { to: '/intranet/users', label: 'Users', icon: '[U]', end: false },
{ to: '/intranet/moderation', label: '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 { 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,20 @@ 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);
useEffect(() => {
settingsApi.get().then((s) => setIsEnabled(s.bugsEnabled)).catch(() => {});
}, []);
const handleToggle = useCallback((enabled: boolean) => {
setToggling(true);
settingsApi.update({ bugsEnabled: enabled })
.then(() => setIsEnabled(enabled))
.catch(() => {})
.finally(() => setToggling(false));
}, []);
const openCount = bugs.filter((b) => b.status === 'open').length; const 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;
@@ -72,13 +87,64 @@ export default function IntranetBugs() {
setNoteText(''); setNoteText('');
}, [noteText, user]); }, [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>

View File

@@ -125,6 +125,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,6 @@
import { useState, useMemo, useCallback } from 'react'; import { useState, useMemo, useCallback, useEffect } from 'react';
import { formatDateTime } from '../../utils/format'; import { formatDateTime } from '../../utils/format';
import { settingsApi } from '../../utils/api';
import type { ForumThread, ForumReply } from '../../types'; import type { ForumThread, ForumReply } from '../../types';
export default function IntranetModeration() { export default function IntranetModeration() {
@@ -8,6 +9,20 @@ export default function IntranetModeration() {
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null); const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [activeTab, setActiveTab] = useState<'threads' | 'replies'>('threads'); const [activeTab, setActiveTab] = useState<'threads' | 'replies'>('threads');
const [isEnabled, setIsEnabled] = useState(true);
const [toggling, setToggling] = useState(false);
useEffect(() => {
settingsApi.get().then((s) => setIsEnabled(s.forumEnabled)).catch(() => {});
}, []);
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;
@@ -44,14 +59,66 @@ export default function IntranetModeration() {
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>
<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 */}
@@ -226,5 +293,7 @@ export default function IntranetModeration() {
</div> </div>
)} )}
</div> </div>
)}
</div>
); );
} }

View File

@@ -0,0 +1,35 @@
import { getToken } from '../contexts/AuthContext';
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 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),
}),
};