Compare commits

..

4 Commits

12 changed files with 186 additions and 18 deletions

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

@@ -78,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
who are all new to game development and learning by building together. We are headquartered who are all new to game development and learning by building together. We are headquartered
somewhere in Europe and operate fully remote. somewhere in France and operate arround the globe.
</p> </p>
<p <p
style={{ style={{
@@ -105,7 +105,6 @@ export default function StudioPage() {
{[ {[
{ label: 'TEAM SIZE', value: '6 PEOPLE' }, { label: 'TEAM SIZE', value: '6 PEOPLE' },
{ label: 'FOUNDED', value: '2026' }, { label: 'FOUNDED', value: '2026' },
{ label: 'WORK MODE', value: 'REMOTE' },
{ label: 'CURRENT GAME', value: 'HEADLESS HAZARD' }, { label: 'CURRENT GAME', value: 'HEADLESS HAZARD' },
].map(({ label, value }) => ( ].map(({ label, value }) => (
<div key={label}> <div key={label}>

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

@@ -8,7 +8,7 @@ server {
set $api_upstream http://api:3000; set $api_upstream http://api:3000;
location /api/ { location /api/ {
proxy_pass $api_upstream/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; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

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 }) {
@@ -27,6 +28,19 @@ export default function IntranetBugs() {
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 [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;
@@ -82,7 +96,8 @@ export default function IntranetBugs() {
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-red)', fontSize: '2rem', marginBottom: '0.5rem' }}>FUNCTIONALITY DISABLED</h1> <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> <p style={{ color: 'var(--color-text-muted)', marginBottom: '1.5rem' }}>Bug Reports feature is currently disabled</p>
<button <button
onClick={() => setIsEnabled(true)} onClick={() => handleToggle(true)}
disabled={toggling}
style={{ style={{
background: 'var(--color-green)', background: 'var(--color-green)',
color: 'var(--color-bg)', color: 'var(--color-bg)',
@@ -90,9 +105,10 @@ export default function IntranetBugs() {
padding: '0.6rem 1.2rem', padding: '0.6rem 1.2rem',
fontFamily: 'var(--font-mono)', fontFamily: 'var(--font-mono)',
fontSize: '0.85rem', fontSize: '0.85rem',
cursor: 'pointer', cursor: toggling ? 'not-allowed' : 'pointer',
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: '0.08em', letterSpacing: '0.08em',
opacity: toggling ? 0.6 : 1,
}} }}
> >
Re-enable Re-enable
@@ -111,7 +127,8 @@ export default function IntranetBugs() {
INTRANET / BUG REPORTS INTRANET / BUG REPORTS
</div> </div>
<button <button
onClick={() => setIsEnabled(false)} onClick={() => handleToggle(false)}
disabled={toggling}
style={{ style={{
background: 'transparent', background: 'transparent',
border: '1px solid var(--color-red)', border: '1px solid var(--color-red)',
@@ -119,9 +136,10 @@ export default function IntranetBugs() {
padding: '0.3rem 0.7rem', padding: '0.3rem 0.7rem',
fontFamily: 'var(--font-mono)', fontFamily: 'var(--font-mono)',
fontSize: '0.65rem', fontSize: '0.65rem',
cursor: 'pointer', cursor: toggling ? 'not-allowed' : 'pointer',
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: '0.08em', letterSpacing: '0.08em',
opacity: toggling ? 0.6 : 1,
}} }}
title="Disable this feature" title="Disable this feature"
> >

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() {
@@ -9,6 +10,19 @@ export default function IntranetModeration() {
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 [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;
@@ -53,7 +67,8 @@ export default function IntranetModeration() {
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-red)', fontSize: '2rem', marginBottom: '0.5rem' }}>FUNCTIONALITY DISABLED</h1> <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> <p style={{ color: 'var(--color-text-muted)', marginBottom: '1.5rem' }}>Forum Moderation feature is currently disabled</p>
<button <button
onClick={() => setIsEnabled(true)} onClick={() => handleToggle(true)}
disabled={toggling}
style={{ style={{
background: 'var(--color-green)', background: 'var(--color-green)',
color: 'var(--color-bg)', color: 'var(--color-bg)',
@@ -61,9 +76,10 @@ export default function IntranetModeration() {
padding: '0.6rem 1.2rem', padding: '0.6rem 1.2rem',
fontFamily: 'var(--font-mono)', fontFamily: 'var(--font-mono)',
fontSize: '0.85rem', fontSize: '0.85rem',
cursor: 'pointer', cursor: toggling ? 'not-allowed' : 'pointer',
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: '0.08em', letterSpacing: '0.08em',
opacity: toggling ? 0.6 : 1,
}} }}
> >
Re-enable Re-enable
@@ -83,7 +99,8 @@ export default function IntranetModeration() {
</p> </p>
</div> </div>
<button <button
onClick={() => setIsEnabled(false)} onClick={() => handleToggle(false)}
disabled={toggling}
style={{ style={{
background: 'transparent', background: 'transparent',
border: '1px solid var(--color-red)', border: '1px solid var(--color-red)',
@@ -91,10 +108,11 @@ export default function IntranetModeration() {
padding: '0.3rem 0.7rem', padding: '0.3rem 0.7rem',
fontFamily: 'var(--font-mono)', fontFamily: 'var(--font-mono)',
fontSize: '0.65rem', fontSize: '0.65rem',
cursor: 'pointer', cursor: toggling ? 'not-allowed' : 'pointer',
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: '0.08em', letterSpacing: '0.08em',
height: 'fit-content', height: 'fit-content',
opacity: toggling ? 0.6 : 1,
}} }}
title="Disable this feature" title="Disable this feature"
> >

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),
}),
};