Init project

This commit is contained in:
Thibault Pouch
2026-02-18 09:15:38 +01:00
commit 0fb3fc77cd
43 changed files with 9628 additions and 0 deletions

24
nest-front/client/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="manifest" href="/manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0a0d0a" />
<meta name="description" content="Headless Hazard — A retro-futuristic puzzle game by CrowMate Studio. Control a detached robotic head through an underground corporate megacomplex." />
<meta property="og:title" content="Headless Hazard | CrowMate Studio" />
<meta property="og:description" content="Lose your head. Keep your body. Navigate a bureaucratic nightmare underground." />
<meta property="og:type" content="website" />
<title>Headless Hazard | CrowMate Studio</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3817
nest-front/client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^6.30.3",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}

View File

@@ -0,0 +1,21 @@
{
"name": "Headless Hazard — CrowMate Studio",
"short_name": "Headless Hazard",
"description": "Community hub for Headless Hazard, a retro-futuristic puzzle game by CrowMate Studio.",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0d0a",
"theme_color": "#0a0d0a",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1 @@
/* App-level overrides — global styles live in index.css */

View File

@@ -0,0 +1,77 @@
import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
import { ProtectedRoute } from './components/shared/ProtectedRoute';
import { PublicLayout } from './components/layout/PublicLayout';
import { IntranetLayout } from './components/layout/IntranetLayout';
import { PageLoader } from './components/shared/PageLoader';
// ── Public Pages (lazy-loaded) ────────────────────────────────────────────────
const HomePage = lazy(() => import('./pages/public/HomePage'));
const StudioPage = lazy(() => import('./pages/public/StudioPage'));
const ForumPage = lazy(() => import('./pages/public/ForumPage'));
const ThreadPage = lazy(() => import('./pages/public/ThreadPage'));
const BugReportPage = lazy(() => import('./pages/public/BugReportPage'));
const BugDetailPage = lazy(() => import('./pages/public/BugDetailPage'));
const AccountPage = lazy(() => import('./pages/public/AccountPage'));
const LoginPage = lazy(() => import('./pages/public/LoginPage'));
const RegisterPage = lazy(() => import('./pages/public/RegisterPage'));
const NotFoundPage = lazy(() => import('./pages/public/NotFoundPage'));
// ── Intranet Pages (lazy-loaded) ──────────────────────────────────────────────
const IntranetDashboard = lazy(() => import('./pages/intranet/IntranetDashboard'));
const IntranetBugs = lazy(() => import('./pages/intranet/IntranetBugs'));
const IntranetFeed = lazy(() => import('./pages/intranet/IntranetFeed'));
const IntranetUsers = lazy(() => import('./pages/intranet/IntranetUsers'));
const IntranetModeration = lazy(() => import('./pages/intranet/IntranetModeration'));
// ── App ────────────────────────────────────────────────────────────────────────
export default function App() {
return (
<AuthProvider>
<Suspense fallback={<PageLoader />}>
<Routes>
{/* Public Routes */}
<Route element={<PublicLayout />}>
<Route index element={<HomePage />} />
<Route path="studio" element={<StudioPage />} />
<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>
{/* Intranet Routes — staff only */}
<Route
path="intranet"
element={
<ProtectedRoute staffOnly redirectTo="/">
<IntranetLayout />
</ProtectedRoute>
}
>
<Route index element={<IntranetDashboard />} />
<Route path="bugs" element={<IntranetBugs />} />
<Route path="feed" element={<IntranetFeed />} />
<Route path="users" element={<IntranetUsers />} />
<Route path="moderation" element={<IntranetModeration />} />
</Route>
</Routes>
</Suspense>
</AuthProvider>
);
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,149 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { useCallback } from 'react';
const INTRANET_LINKS = [
{ to: '/intranet', label: 'Dashboard', icon: '[>]', end: true },
{ to: '/intranet/bugs', label: 'Bug Reports', icon: '[!]', end: false },
{ to: '/intranet/feed', label: 'Team Feed', icon: '[~]', end: false },
{ to: '/intranet/users', label: 'Users', icon: '[U]', end: false },
{ to: '/intranet/moderation', label: 'Moderation', icon: '[M]', end: false },
];
export function IntranetLayout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = useCallback(() => {
logout();
navigate('/');
}, [logout, navigate]);
return (
<div style={{ display: 'flex', minHeight: '100vh', background: 'var(--color-bg)' }}>
{/* Sidebar */}
<aside
style={{
width: '220px',
flexShrink: 0,
background: 'var(--color-bg-alt)',
borderRight: '2px solid var(--color-border)',
display: 'flex',
flexDirection: 'column',
padding: '1.5rem 0',
}}
>
{/* Logo */}
<div style={{ padding: '0 1.25rem 1.25rem', borderBottom: '1px solid var(--color-border)' }}>
<div
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-yellow)',
fontSize: '1.3rem',
letterSpacing: '0.08em',
}}
>
INTRANET
</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.62rem', marginTop: '2px' }}>
CROWMATE STUDIO
</div>
</div>
{/* Nav links */}
<nav style={{ flex: 1, padding: '0.75rem 0' }}>
{INTRANET_LINKS.map(({ to, label, icon, end }) => (
<NavLink
key={to}
to={to}
end={end}
style={({ isActive }) => ({
display: 'flex',
alignItems: 'center',
gap: '0.6rem',
padding: '0.55rem 1.25rem',
fontFamily: 'var(--font-mono)',
fontSize: '0.78rem',
color: isActive ? 'var(--color-yellow)' : 'var(--color-text-muted)',
background: isActive ? 'rgba(255,255,0,0.06)' : 'transparent',
borderLeft: isActive ? '3px solid var(--color-yellow)' : '3px solid transparent',
textDecoration: 'none',
transition: 'color 0.1s, background 0.1s',
letterSpacing: '0.05em',
})}
>
<span style={{ opacity: 0.6, fontSize: '0.68rem' }}>{icon}</span>
{label}
</NavLink>
))}
</nav>
{/* User info */}
<div
style={{
padding: '1rem 1.25rem',
borderTop: '1px solid var(--color-border)',
fontFamily: 'var(--font-mono)',
}}
>
<div style={{ color: 'var(--color-text-dim)', fontSize: '0.75rem', marginBottom: '0.4rem' }}>
{user?.username}
</div>
<div
style={{
display: 'inline-block',
background: 'rgba(255,255,0,0.08)',
border: '1px solid var(--color-yellow)',
color: 'var(--color-yellow)',
fontSize: '0.6rem',
padding: '0.1rem 0.4rem',
letterSpacing: '0.1em',
textTransform: 'uppercase',
marginBottom: '0.75rem',
}}
>
{user?.role}
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
onClick={handleLogout}
style={{
background: 'transparent',
border: '1px solid var(--color-red)',
color: 'var(--color-red)',
fontFamily: 'var(--font-mono)',
fontSize: '0.63rem',
padding: '0.2rem 0.5rem',
cursor: 'pointer',
letterSpacing: '0.05em',
}}
>
Logout
</button>
<NavLink
to="/"
style={{
background: 'transparent',
border: '1px solid var(--color-border)',
color: 'var(--color-text-muted)',
fontFamily: 'var(--font-mono)',
fontSize: '0.63rem',
padding: '0.2rem 0.5rem',
letterSpacing: '0.05em',
textDecoration: 'none',
display: 'inline-block',
}}
>
Public
</NavLink>
</div>
</div>
</aside>
{/* Main content */}
<main style={{ flex: 1, overflowY: 'auto', padding: '2rem', background: 'var(--color-bg)' }}>
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { Outlet, useLocation } from 'react-router-dom';
import { useEffect, useRef } from 'react';
import { Navbar } from '../shared/Navbar';
import { Footer } from '../shared/Footer';
import { DevRoleSwitcher } from '../shared/DevRoleSwitcher';
export function PublicLayout() {
const location = useLocation();
const mainRef = useRef<HTMLDivElement>(null);
// Scroll to top and add page-enter animation on route change
useEffect(() => {
window.scrollTo({ top: 0, behavior: 'instant' });
const el = mainRef.current;
if (!el) return;
el.classList.remove('page-enter');
void el.offsetWidth; // reflow to restart animation
el.classList.add('page-enter');
}, [location.pathname]);
return (
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<Navbar />
<main ref={mainRef} style={{ flex: 1 }}>
<Outlet />
</main>
<Footer />
<DevRoleSwitcher />
</div>
);
}

View File

@@ -0,0 +1,116 @@
import { useAuth } from '../../contexts/AuthContext';
import type { UserRole } from '../../types';
/**
* Developer-only overlay to quickly switch user roles for testing.
* Only visible in development mode.
*/
export function DevRoleSwitcher() {
if (import.meta.env.PROD) return null;
return <DevRoleSwitcherInner />;
}
function DevRoleSwitcherInner() {
const { user, isAuthenticated, devSetRole, login, logout } = useAuth();
const ROLES: UserRole[] = ['user', 'dev', 'com'];
const DEV_ACCOUNTS = [
{ label: 'Dev/Admin (Kestrel)', email: 'kestrel@crowmate.dev' },
{ label: 'Com Staff (Vesper)', email: 'vesper@crowmate.dev' },
{ label: 'User (GlitchHunter)', email: 'glitch@mail.com' },
];
return (
<div
style={{
position: 'fixed',
bottom: '1rem',
right: '1rem',
background: '#0a1a0a',
border: '1px solid var(--color-amber)',
padding: '0.75rem',
zIndex: 9999,
fontSize: '0.7rem',
fontFamily: 'var(--font-mono)',
color: 'var(--color-amber)',
maxWidth: '220px',
}}
>
<div style={{ marginBottom: '0.5rem', fontWeight: 'bold', letterSpacing: '0.1em' }}>
[DEV] Auth Switcher
</div>
{isAuthenticated ? (
<>
<div style={{ color: 'var(--color-text-muted)', marginBottom: '0.4rem' }}>
Logged as: <strong style={{ color: 'var(--color-text)' }}>{user?.username}</strong>
</div>
<div style={{ color: 'var(--color-text-muted)', marginBottom: '0.5rem' }}>
Role: <strong style={{ color: 'var(--color-green)' }}>{user?.role}</strong>
</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
onClick={logout}
style={{
background: 'transparent',
border: '1px solid var(--color-red)',
color: 'var(--color-red)',
padding: '0.2rem 0.5rem',
cursor: 'pointer',
fontFamily: 'var(--font-mono)',
fontSize: '0.65rem',
width: '100%',
}}
>
Logout
</button>
</>
) : (
<>
<div style={{ color: 'var(--color-text-muted)', marginBottom: '0.5rem' }}>Quick login:</div>
{DEV_ACCOUNTS.map(({ label, email }) => (
<button
key={email}
onClick={() => login(email, 'password')}
style={{
background: 'transparent',
border: '1px solid var(--color-border)',
color: 'var(--color-text-dim)',
padding: '0.2rem 0.4rem',
cursor: 'pointer',
fontFamily: 'var(--font-mono)',
fontSize: '0.63rem',
width: '100%',
marginBottom: '0.2rem',
textAlign: 'left',
}}
>
{label}
</button>
))}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { Link } from 'react-router-dom';
export function Footer() {
const year = new Date().getFullYear();
return (
<footer
style={{
background: 'rgba(8,11,8,0.95)',
borderTop: '1px solid var(--color-border)',
padding: '2.5rem 1.5rem 2rem',
marginTop: 'auto',
}}
>
<div className="max-w-7xl mx-auto">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-8 mb-8">
{/* Brand */}
<div>
<div
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-green)',
fontSize: '1.1rem',
letterSpacing: '0.1em',
marginBottom: '0.5rem',
}}
>
CROWMATE STUDIO
</div>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem', lineHeight: 1.7 }}>
Building strange worlds for strange people.
</p>
</div>
{/* Navigation */}
<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' }}>
{[
{ to: '/', label: 'Home' },
{ to: '/studio', label: 'Studio' },
{ to: '/forum', label: 'Forum' },
{ to: '/bugs', label: 'Bug Reports' },
].map(({ to, label }) => (
<li key={to}>
<Link
to={to}
style={{ color: 'var(--color-text-muted)', fontSize: '0.8rem' }}
>
&gt; {label}
</Link>
</li>
))}
</ul>
</div>
{/* Social */}
<div>
<div className="section-label" style={{ marginBottom: '0.75rem' }}>Connect</div>
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
{[
{ href: '#', label: 'Twitter / X' },
{ href: '#', label: 'Discord' },
{ href: '#', label: 'YouTube' },
{ href: '#', label: 'Steam Page' },
].map(({ href, label }) => (
<li key={label}>
<a
href={href}
style={{ color: 'var(--color-text-muted)', fontSize: '0.8rem' }}
rel="noopener noreferrer"
>
&gt; {label}
</a>
</li>
))}
</ul>
</div>
</div>
{/* Bottom bar */}
<div
style={{
borderTop: '1px solid var(--color-border)',
paddingTop: '1.25rem',
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignItems: 'center',
gap: '0.5rem',
}}
>
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.7rem', fontFamily: 'var(--font-mono)' }}>
&copy; {year} CrowMate Studio. All rights reserved.
</span>
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.7rem', fontFamily: 'var(--font-mono)', opacity: 0.5 }}>
HEADLESS HAZARD v0.9.3-alpha
</span>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,263 @@
import { useState, useCallback } from 'react';
import { Link, NavLink, useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
const NAV_LINKS = [
{ to: '/', label: 'Home', end: true },
{ to: '/studio', label: 'Studio', end: false },
{ to: '/forum', label: 'Forum', end: false },
{ to: '/bugs', label: 'Bugs', end: false },
];
export function Navbar() {
const { user, isAuthenticated, isStaff, logout } = useAuth();
const navigate = useNavigate();
const [menuOpen, setMenuOpen] = useState(false);
const handleLogout = useCallback(() => {
logout();
setMenuOpen(false);
navigate('/');
}, [logout, navigate]);
const closeMenu = useCallback(() => setMenuOpen(false), []);
const navLinkStyle = ({ isActive }: { isActive: boolean }): React.CSSProperties => ({
fontFamily: 'var(--font-mono)',
fontSize: '0.82rem',
textTransform: 'uppercase',
letterSpacing: '0.12em',
textDecoration: 'none',
color: isActive ? 'var(--color-yellow)' : 'var(--color-text-dim)',
borderBottom: isActive ? '2px solid var(--color-yellow)' : '2px solid transparent',
paddingBottom: '2px',
transition: 'color 0.1s, border-color 0.1s',
});
return (
<header
style={{
background: 'var(--color-bg)',
borderBottom: '2px solid var(--color-border)',
position: 'sticky',
top: 0,
zIndex: 50,
}}
>
<nav
className="max-w-7xl mx-auto px-4 sm:px-6 h-14 flex items-center justify-between"
role="navigation"
aria-label="Main navigation"
>
{/* Logo */}
<Link
to="/"
onClick={closeMenu}
style={{
display: 'flex',
alignItems: 'baseline',
gap: '0.5rem',
textDecoration: 'none',
}}
>
<span
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-yellow)',
fontSize: '1.5rem',
letterSpacing: '0.08em',
}}
>
CROWMATE
</span>
<span
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-muted)',
fontSize: '0.62rem',
letterSpacing: '0.15em',
}}
>
STUDIO
</span>
</Link>
{/* Desktop Nav */}
<div className="hidden md:flex items-center gap-6">
{NAV_LINKS.map(({ to, label, end }) => (
<NavLink key={to} to={to} end={end} style={navLinkStyle}>
{label}
</NavLink>
))}
{/* Intranet — visually separated, highlighted button */}
{isStaff && (
<>
{/* Vertical divider */}
<span
aria-hidden="true"
style={{
display: 'inline-block',
width: '1px',
height: '18px',
background: 'var(--color-border)',
margin: '0 0.25rem',
}}
/>
<NavLink
to="/intranet"
style={({ isActive }) => ({
fontFamily: 'var(--font-mono)',
fontSize: '0.75rem',
textTransform: 'uppercase',
letterSpacing: '0.1em',
textDecoration: 'none',
background: isActive ? 'var(--color-yellow)' : 'var(--color-yellow)',
color: 'var(--color-bg)',
border: '2px solid var(--color-yellow)',
padding: '0.2rem 0.65rem',
fontWeight: 'bold',
opacity: isActive ? 1 : 0.85,
transition: 'opacity 0.1s',
})}
>
&#9646; INTRANET
</NavLink>
</>
)}
</div>
{/* Desktop Auth */}
<div className="hidden md:flex items-center gap-3">
{isAuthenticated ? (
<>
<Link
to="/account"
style={{
color: 'var(--color-text-dim)',
fontSize: '0.78rem',
fontFamily: 'var(--font-mono)',
textDecoration: 'none',
}}
>
[{user?.username}]
</Link>
<button className="btn-terminal btn-danger" onClick={handleLogout} style={{ padding: '0.3rem 0.85rem', fontSize: '0.75rem' }}>
Logout
</button>
</>
) : (
<>
<Link to="/login" className="btn-terminal" style={{ padding: '0.3rem 0.85rem', fontSize: '0.75rem' }}>
Login
</Link>
<Link to="/register" className="btn-terminal btn-amber" style={{ padding: '0.3rem 0.85rem', fontSize: '0.75rem' }}>
Register
</Link>
</>
)}
</div>
{/* Mobile hamburger */}
<button
className="md:hidden"
style={{ background: 'transparent', border: 'none', cursor: 'pointer', padding: '4px' }}
onClick={() => setMenuOpen((v) => !v)}
aria-label={menuOpen ? 'Close menu' : 'Open menu'}
aria-expanded={menuOpen}
>
{/* Three-bar icon using block chars */}
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-cyan)', fontSize: '1.2rem', lineHeight: 1 }}>
{menuOpen ? '✕' : '≡'}
</div>
</button>
</nav>
{/* Mobile menu */}
{menuOpen && (
<div
style={{
background: 'var(--color-bg)',
borderTop: '2px solid var(--color-border)',
padding: '1rem 1.25rem',
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.85rem' }}>
{NAV_LINKS.map(({ to, label, end }) => (
<NavLink
key={to}
to={to}
end={end}
onClick={closeMenu}
style={navLinkStyle}
>
{label}
</NavLink>
))}
{/* Auth section */}
<div
style={{
borderTop: '1px solid var(--color-border)',
paddingTop: '0.85rem',
display: 'flex',
gap: '0.6rem',
flexWrap: 'wrap',
}}
>
{isAuthenticated ? (
<>
<Link
to="/account"
style={{ color: 'var(--color-text-dim)', fontSize: '0.78rem', textDecoration: 'none', fontFamily: 'var(--font-mono)' }}
onClick={closeMenu}
>
[{user?.username}]
</Link>
<button className="btn-terminal btn-danger" onClick={handleLogout} style={{ padding: '0.25rem 0.7rem', fontSize: '0.73rem' }}>
Logout
</button>
</>
) : (
<>
<Link to="/login" className="btn-terminal" onClick={closeMenu} style={{ padding: '0.25rem 0.7rem', fontSize: '0.73rem' }}>
Login
</Link>
<Link to="/register" className="btn-terminal btn-amber" onClick={closeMenu} style={{ padding: '0.25rem 0.7rem', fontSize: '0.73rem' }}>
Register
</Link>
</>
)}
</div>
{/* Intranet button — mobile: separated at the bottom */}
{isStaff && (
<div style={{ borderTop: '2px solid var(--color-yellow)', paddingTop: '0.85rem' }}>
<NavLink
to="/intranet"
onClick={closeMenu}
style={({ isActive }) => ({
display: 'inline-block',
fontFamily: 'var(--font-mono)',
fontSize: '0.8rem',
textTransform: 'uppercase' as const,
letterSpacing: '0.1em',
textDecoration: 'none',
background: 'var(--color-yellow)',
color: 'var(--color-bg)',
border: '2px solid var(--color-yellow)',
padding: '0.3rem 0.85rem',
fontWeight: 'bold',
opacity: isActive ? 0.85 : 1,
})}
>
&#9646; INTRANET
</NavLink>
</div>
)}
</div>
</div>
)}
</header>
);
}

View File

@@ -0,0 +1,25 @@
export function PageLoader() {
return (
<div
className="fixed inset-0 flex items-center justify-center"
style={{ background: 'var(--color-bg)' }}
role="status"
aria-label="Loading"
>
<div className="text-center">
<div
className="text-4xl font-bold mb-4 cursor-blink"
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-green)',
}}
>
LOADING
</div>
<div style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem', letterSpacing: '0.2em' }}>
CROWMATE STUDIO / HEADLESS HAZARD
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
interface ProtectedRouteProps {
children: React.ReactNode;
/** If true, requires staff role (dev or com). */
staffOnly?: boolean;
/** Redirect destination when access is denied. Defaults to /login. */
redirectTo?: string;
}
export function ProtectedRoute({
children,
staffOnly = false,
redirectTo = '/login',
}: ProtectedRouteProps) {
const { isAuthenticated, isStaff } = useAuth();
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to={redirectTo} state={{ from: location }} replace />;
}
if (staffOnly && !isStaff) {
return <Navigate to="/" replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,156 @@
import React, {
createContext,
useCallback,
useContext,
useMemo,
useState,
} from 'react';
import type { User, UserRole } from '../types';
import { MOCK_USERS } from '../data/mockData';
// ── Types ──────────────────────────────────────────────────────────────────────
interface AuthContextValue {
user: User | null;
isAuthenticated: boolean;
isStaff: boolean;
isAdmin: boolean;
login: (email: string, password: string) => Promise<{ success: boolean; error?: string }>;
register: (username: string, email: string, password: string) => Promise<{ success: boolean; error?: string }>;
logout: () => void;
updateUsername: (username: string) => void;
// Dev helper: quickly switch role for testing
devSetRole: (role: UserRole) => void;
}
// ── Context ────────────────────────────────────────────────────────────────────
const AuthContext = createContext<AuthContextValue | null>(null);
// ── Provider ───────────────────────────────────────────────────────────────────
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 {
if (user) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(user));
} else {
localStorage.removeItem(STORAGE_KEY);
}
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(loadUserFromStorage);
const isAuthenticated = user !== null;
const isStaff = user?.role === 'dev' || user?.role === 'com';
const isAdmin = user?.isAdmin === true;
const login = useCallback(
async (email: string, _password: string): Promise<{ success: boolean; error?: string }> => {
// Simulate network delay
await new Promise((r) => setTimeout(r, 400));
const found = MOCK_USERS.find(
(u) => u.email.toLowerCase() === email.toLowerCase()
);
if (!found) {
return { success: false, error: 'No account found with that email address.' };
}
if (found.isBanned) {
return { success: false, error: 'This account has been suspended.' };
}
setUser(found);
saveUserToStorage(found);
return { success: true };
},
[]
);
const register = useCallback(
async (username: string, email: string, _password: string): Promise<{ success: boolean; error?: string }> => {
await new Promise((r) => setTimeout(r, 500));
const emailTaken = MOCK_USERS.some(
(u) => u.email.toLowerCase() === email.toLowerCase()
);
if (emailTaken) {
return { success: false, error: 'An account with this email already exists.' };
}
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(() => {
setUser(null);
saveUserToStorage(null);
}, []);
const updateUsername = useCallback((username: string) => {
setUser((prev) => {
if (!prev) return prev;
const updated = { ...prev, username };
saveUserToStorage(updated);
return updated;
});
}, []);
const devSetRole = useCallback((role: UserRole) => {
setUser((prev) => {
if (!prev) return prev;
const updated = { ...prev, role, isAdmin: role === 'dev' };
saveUserToStorage(updated);
return updated;
});
}, []);
const value = useMemo<AuthContextValue>(
() => ({ user, isAuthenticated, isStaff, isAdmin, login, register, logout, updateUsername, devSetRole }),
[user, isAuthenticated, isStaff, isAdmin, login, register, logout, updateUsername, devSetRole]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
// ── Hook ───────────────────────────────────────────────────────────────────────
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error('useAuth must be used inside <AuthProvider>');
}
return ctx;
}

View File

@@ -0,0 +1,678 @@
import type {
User,
ForumCategory,
ForumThread,
ForumReply,
BugReport,
BugComment,
BugReportNote,
StaffPost,
TeamMember,
} from '../types';
// ── Mock Users ─────────────────────────────────────────────────────────────────
export const MOCK_USERS: User[] = [
{
id: 'u1',
username: 'Kestrel',
email: 'kestrel@crowmate.dev',
role: 'dev',
isAdmin: true,
isBanned: false,
createdAt: '2023-09-01T08:00:00Z',
},
{
id: 'u2',
username: 'Vesper',
email: 'vesper@crowmate.dev',
role: 'com',
isAdmin: false,
isBanned: false,
createdAt: '2023-09-03T10:00:00Z',
},
{
id: 'u3',
username: 'GlitchHunter',
email: 'glitch@mail.com',
role: 'user',
isAdmin: false,
isBanned: false,
createdAt: '2024-01-15T14:22:00Z',
},
{
id: 'u4',
username: 'NullPointer',
email: 'null@mail.com',
role: 'user',
isAdmin: false,
isBanned: false,
createdAt: '2024-02-20T09:11:00Z',
},
{
id: 'u5',
username: 'XenoArch',
email: 'xeno@mail.com',
role: 'user',
isAdmin: false,
isBanned: false,
createdAt: '2024-03-05T16:44:00Z',
},
{
id: 'u6',
username: 'Phantom404',
email: 'phantom@mail.com',
role: 'user',
isAdmin: false,
isBanned: true,
createdAt: '2024-04-12T12:00:00Z',
},
{
id: 'u7',
username: 'NeonCrawler',
email: 'neon@mail.com',
role: 'user',
isAdmin: false,
isBanned: false,
createdAt: '2024-05-01T08:30:00Z',
},
{
id: 'u8',
username: 'ByteWitch',
email: 'byte@mail.com',
role: 'dev',
isAdmin: false,
isBanned: false,
createdAt: '2023-09-10T11:00:00Z',
},
];
// Quick-access login credentials for demo
// Any email + password combo works. Role determined by MOCK_USERS list.
export const DEMO_CREDENTIALS: Record<string, string> = {
admin: 'admin@crowmate.dev', // kestrel — dev + admin
staff: 'vesper@crowmate.dev', // vesper — com
user: 'glitch@mail.com', // glitchhunter — user
};
// ── Mock Forum Categories ──────────────────────────────────────────────────────
export const MOCK_CATEGORIES: ForumCategory[] = [
{
id: 'cat1',
name: 'General Discussion',
description: 'Everything and anything about Headless Hazard.',
icon: '///',
threadCount: 42,
lastActivity: '2026-02-17T18:30:00Z',
},
{
id: 'cat2',
name: 'Game Suggestions',
description: 'Share your ideas to improve the game.',
icon: '[!]',
threadCount: 27,
lastActivity: '2026-02-16T09:15:00Z',
},
{
id: 'cat3',
name: 'Multiplayer',
description: 'Find teammates, share strategies, report cheaters.',
icon: '>>',
threadCount: 19,
lastActivity: '2026-02-18T07:00:00Z',
},
{
id: 'cat4',
name: 'Lore & Theories',
description: 'Dig into the deep lore of the corporate complex.',
icon: '[?]',
threadCount: 33,
lastActivity: '2026-02-15T21:00:00Z',
},
{
id: 'cat5',
name: 'Off Topic',
description: 'Chat about anything not related to the game.',
icon: '~',
threadCount: 14,
lastActivity: '2026-02-14T13:45:00Z',
},
{
id: 'cat6',
name: 'Technical Support',
description: 'Having trouble running the game? Ask here.',
icon: '[X]',
threadCount: 8,
lastActivity: '2026-02-17T10:20:00Z',
},
];
// ── Mock Forum Threads ─────────────────────────────────────────────────────────
export const MOCK_THREADS: ForumThread[] = [
{
id: 'th1',
title: 'Official Welcome Thread — Read Before Posting!',
authorId: 'u2',
authorName: 'Vesper',
categoryId: 'cat1',
categoryName: 'General Discussion',
content: `Welcome to the official Headless Hazard community forum. Before you post, please read our community guidelines.\n\n1. Be respectful\n2. No spoilers without tags\n3. Use the bug report page for bugs, not the forum\n\nHappy gaming — and watch out for rogue security protocols.`,
isPinned: true,
isLocked: false,
replyCount: 12,
createdAt: '2025-11-01T10:00:00Z',
updatedAt: '2026-01-10T08:00:00Z',
lastReplyAuthor: 'NeonCrawler',
},
{
id: 'th2',
title: 'The head physics feel clunky — how do you control it?',
authorId: 'u3',
authorName: 'GlitchHunter',
categoryId: 'cat1',
categoryName: 'General Discussion',
content: `I've been playing for 3 hours and I still can't wrap my head (lol) around the head movement controls. The inertia system is wild. Anyone have tips?\n\nI keep slamming the head into walls when I try to look around corners.`,
isPinned: false,
isLocked: false,
replyCount: 8,
createdAt: '2026-01-20T15:30:00Z',
updatedAt: '2026-01-22T11:00:00Z',
lastReplyAuthor: 'XenoArch',
},
{
id: 'th3',
title: 'SUGGESTION: Let us name the girl!',
authorId: 'u4',
authorName: 'NullPointer',
categoryId: 'cat2',
categoryName: 'Game Suggestions',
content: `She's referred to as "the girl" throughout the whole game. I think we should be able to name her. It would add so much to the emotional connection.\n\nSome candidates I thought of: Zara, Pip, Elodie, Mira...`,
isPinned: false,
isLocked: false,
replyCount: 31,
createdAt: '2026-01-25T09:00:00Z',
updatedAt: '2026-02-10T17:30:00Z',
lastReplyAuthor: 'ByteWitch',
},
{
id: 'th4',
title: 'Looking for co-op partner — Floor 3 boss is brutal',
authorId: 'u5',
authorName: 'XenoArch',
categoryId: 'cat3',
categoryName: 'Multiplayer',
content: `Floor 3 boss: The Sentinel Prime. It tracks the body AND the head simultaneously. Solo is nearly impossible on hard mode.\n\nAnyone want to team up? I'm online weekday evenings UTC+1.`,
isPinned: false,
isLocked: false,
replyCount: 5,
createdAt: '2026-02-01T20:00:00Z',
updatedAt: '2026-02-03T09:15:00Z',
lastReplyAuthor: 'NeonCrawler',
},
{
id: 'th5',
title: 'THEORY: The girl is the daughter of the [BLEEP] CEO',
authorId: 'u7',
authorName: 'NeonCrawler',
categoryId: 'cat4',
categoryName: 'Lore & Theories',
content: `Hear me out. There's a family portrait in the executive suite on floor 5. The girl in the painting has the same red hair as our protagonist. AND the security clearance codes found in the vault match a name that's always been redacted...\n\nI think her father IS the corporation. This changes everything about why the protocols went haywire.`,
isPinned: false,
isLocked: false,
replyCount: 44,
createdAt: '2026-02-05T11:00:00Z',
updatedAt: '2026-02-17T22:10:00Z',
lastReplyAuthor: 'GlitchHunter',
},
{
id: 'th6',
title: 'Game crashes on startup — Windows 11 RTX 4080',
authorId: 'u3',
authorName: 'GlitchHunter',
categoryId: 'cat6',
categoryName: 'Technical Support',
content: `Getting a DirectX 12 error on startup. Already tried reinstalling, verifying files, and updating drivers.\n\nError: DXGI_ERROR_DEVICE_REMOVED\nOS: Windows 11 22H2\nGPU: RTX 4080`,
isPinned: false,
isLocked: false,
replyCount: 3,
createdAt: '2026-02-10T14:20:00Z',
updatedAt: '2026-02-11T10:00:00Z',
lastReplyAuthor: 'Vesper',
},
{
id: 'th7',
title: 'The VHS aesthetic is a MASTERPIECE — appreciation post',
authorId: 'u4',
authorName: 'NullPointer',
categoryId: 'cat1',
categoryName: 'General Discussion',
content: `Just want to say: whoever did the visual design deserves an award. The scan lines, the color grading, the way the CRT flickers when the head rolls across a monitor screen — *chef's kiss*.\n\nThis is what indie games are about.`,
isPinned: false,
isLocked: false,
replyCount: 17,
createdAt: '2026-02-12T08:45:00Z',
updatedAt: '2026-02-16T19:30:00Z',
lastReplyAuthor: 'Kestrel',
},
{
id: 'th8',
title: 'Speedrun strats — sub 45min run possible?',
authorId: 'u5',
authorName: 'XenoArch',
categoryId: 'cat3',
categoryName: 'Multiplayer',
content: `Current WR is 48:32 by user HexBlade (not on this forum). I think sub-45 is possible with the elevator skip on floor 2 and the head-throw glitch to open the airlock.\n\nLet's document all known skips here.`,
isPinned: false,
isLocked: false,
replyCount: 9,
createdAt: '2026-02-14T17:00:00Z',
updatedAt: '2026-02-18T06:00:00Z',
lastReplyAuthor: 'NullPointer',
},
];
// ── Mock Replies ───────────────────────────────────────────────────────────────
export const MOCK_REPLIES: ForumReply[] = [
// Thread 1 replies
{
id: 'r1',
content: 'Thanks for the welcome! Excited to dive into this game. The concept sounds wild.',
authorId: 'u3',
authorName: 'GlitchHunter',
threadId: 'th1',
createdAt: '2025-11-02T09:00:00Z',
},
{
id: 'r2',
content: 'Read the guidelines. Solid rules. Looking forward to theorizing about the lore!',
authorId: 'u7',
authorName: 'NeonCrawler',
threadId: 'th1',
createdAt: '2025-11-05T14:00:00Z',
},
// Thread 2 replies
{
id: 'r3',
content: 'The trick is to use short bursts. Don\'t hold the direction key — tap it. The head has massive momentum.',
authorId: 'u5',
authorName: 'XenoArch',
threadId: 'th2',
createdAt: '2026-01-20T16:00:00Z',
},
{
id: 'r4',
content: 'Also check your sensitivity settings. Mine was at 100% by default which is insane. I dropped it to 35%.',
authorId: 'u4',
authorName: 'NullPointer',
threadId: 'th2',
createdAt: '2026-01-21T09:30:00Z',
},
// Thread 3 replies
{
id: 'r5',
content: 'I\'ve been calling her Mira since day one. Feels right.',
authorId: 'u7',
authorName: 'NeonCrawler',
threadId: 'th3',
createdAt: '2026-01-25T10:00:00Z',
},
{
id: 'r6',
content: 'Hard disagree — the ambiguity is part of the point. She\'s every lost child. Naming her loses that.',
authorId: 'u8',
authorName: 'ByteWitch',
threadId: 'th3',
createdAt: '2026-01-25T11:30:00Z',
},
{
id: 'r7',
content: 'Voting for Pip. Short, punchy, and weirdly adorable for someone causing this much chaos.',
authorId: 'u5',
authorName: 'XenoArch',
threadId: 'th3',
createdAt: '2026-01-26T08:15:00Z',
},
// Thread 5 replies
{
id: 'r8',
content: 'I noticed that too! The hair is definitely the same shade. And there\'s a memo on floor 3 signed with a heavily redacted name with only the initials visible...',
authorId: 'u3',
authorName: 'GlitchHunter',
threadId: 'th5',
createdAt: '2026-02-05T12:00:00Z',
},
{
id: 'r9',
content: 'The developers confirmed on stream that the girl\'s backstory will be explored in a DLC. This theory might be onto something.',
authorId: 'u8',
authorName: 'ByteWitch',
threadId: 'th5',
createdAt: '2026-02-06T15:00:00Z',
},
// Thread 6 replies
{
id: 'r10',
content: 'We\'re aware of this crash on certain Nvidia configurations. A patch is being prepared. ETA: next week. Sorry for the inconvenience.',
authorId: 'u2',
authorName: 'Vesper',
threadId: 'th6',
createdAt: '2026-02-11T10:00:00Z',
},
// Thread 7 replies
{
id: 'r11',
content: 'Agreed 100%. The moment I saw the first CRT flicker I knew this was something special.',
authorId: 'u3',
authorName: 'GlitchHunter',
threadId: 'th7',
createdAt: '2026-02-12T10:00:00Z',
},
{
id: 'r12',
content: 'Thanks for the kind words! The visual team worked incredibly hard on those effects. More surprises coming in future updates.',
authorId: 'u1',
authorName: 'Kestrel',
threadId: 'th7',
createdAt: '2026-02-13T09:00:00Z',
},
];
// ── Mock Bug Reports ───────────────────────────────────────────────────────────
export const MOCK_BUG_NOTES: BugReportNote[] = [
{
id: 'n1',
bugReportId: 'bug1',
authorId: 'u1',
authorName: 'Kestrel',
content: 'Reproduced internally. Relates to the head collision hitbox on slopes. Assigning to ByteWitch for physics review.',
createdAt: '2026-01-16T09:00:00Z',
},
{
id: 'n2',
bugReportId: 'bug3',
authorId: 'u8',
authorName: 'ByteWitch',
content: 'This is the DirectX 12 feature level issue. Nvidia driver 566.x introduced a regression. Workaround: force DX11 via launch options.',
createdAt: '2026-02-11T11:00:00Z',
},
];
export const MOCK_BUGS: BugReport[] = [
{
id: 'bug1',
uniqueCode: 'HH-0001',
title: 'Head clips through floor geometry on ramp sections',
description:
'When rolling the head down the maintenance ramp on floor 2, the head occasionally clips through the floor and falls into the void. This causes a soft reset but loses all checkpoint progress.',
stepsToReproduce:
'1. Reach Floor 2, section C\n2. Roll head down the maintenance ramp at full speed\n3. Head clips through at approximately 80% of the way down\n4. Game enters a permanent loading state',
severity: 'high',
gameVersion: '0.9.3-alpha',
status: 'in_progress',
submittedById: 'u3',
submittedByName: 'GlitchHunter',
assignedToId: 'u8',
assignedToName: 'ByteWitch',
createdAt: '2026-01-15T18:00:00Z',
updatedAt: '2026-01-16T09:00:00Z',
notes: [MOCK_BUG_NOTES[0]],
meTooBugs: ['u4', 'u5', 'u7'],
},
{
id: 'bug2',
uniqueCode: 'HH-0002',
title: 'Audio desync in co-op mode after host migration',
description:
'When the host player disconnects and host migration occurs, all audio becomes desynced. Sound effects play 2-3 seconds after the triggering event.',
stepsToReproduce:
'1. Start a co-op session with 3+ players\n2. Have the host disconnect mid-game\n3. Continue playing after host migration completes\n4. Observe audio desync within 30 seconds',
severity: 'medium',
gameVersion: '0.9.3-alpha',
status: 'open',
submittedById: 'u5',
submittedByName: 'XenoArch',
createdAt: '2026-01-28T14:00:00Z',
updatedAt: '2026-01-28T14:00:00Z',
notes: [],
meTooBugs: ['u3'],
},
{
id: 'bug3',
uniqueCode: 'HH-0003',
title: 'Game crashes on startup — DirectX 12 error',
description:
'Game fails to launch with DXGI_ERROR_DEVICE_REMOVED on RTX 4080 with specific Nvidia driver versions (566.x series).',
stepsToReproduce:
'1. Install Nvidia driver 566.03 or higher\n2. Attempt to launch Headless Hazard\n3. Game shows splash screen then crashes\n4. Windows Event Viewer shows DXGI_ERROR_DEVICE_REMOVED',
severity: 'critical',
gameVersion: '0.9.3-alpha',
status: 'in_progress',
submittedById: 'u3',
submittedByName: 'GlitchHunter',
assignedToId: 'u1',
assignedToName: 'Kestrel',
createdAt: '2026-02-10T14:20:00Z',
updatedAt: '2026-02-11T11:00:00Z',
notes: [MOCK_BUG_NOTES[1]],
meTooBugs: ['u4', 'u5', 'u7', 'u2'],
},
{
id: 'bug4',
uniqueCode: 'HH-0004',
title: 'Girl NPC gets stuck in T-pose near elevator door',
description:
'The girl character enters a T-pose animation state when the elevator door closes while she is within 1 meter of the door. She remains stuck until the player rolls the head near her.',
stepsToReproduce:
'1. Floor 1, elevator B\n2. Position the girl next to the closed elevator door\n3. Call the elevator\n4. As doors close, T-pose triggers',
severity: 'low',
gameVersion: '0.9.2-alpha',
status: 'resolved',
submittedById: 'u4',
submittedByName: 'NullPointer',
assignedToId: 'u8',
assignedToName: 'ByteWitch',
createdAt: '2025-12-20T10:00:00Z',
updatedAt: '2026-01-05T16:00:00Z',
notes: [],
meTooBugs: [],
},
{
id: 'bug5',
uniqueCode: 'HH-0005',
title: 'Save corruption when quitting during cutscene',
description:
'Alt+F4 or force-quitting during any cutscene corrupts the save file. The save file becomes unreadable and the game starts fresh on next launch.',
stepsToReproduce:
'1. Trigger any in-game cutscene\n2. While the cutscene is playing, alt+F4 the game\n3. Relaunch the game\n4. Game shows "Save file corrupted" and resets progress',
severity: 'critical',
gameVersion: '0.9.3-alpha',
status: 'open',
submittedById: 'u7',
submittedByName: 'NeonCrawler',
createdAt: '2026-02-14T19:30:00Z',
updatedAt: '2026-02-14T19:30:00Z',
notes: [],
meTooBugs: ['u3', 'u4', 'u8'],
},
{
id: 'bug6',
uniqueCode: 'HH-0006',
title: 'Head-camera view flickers when standing near florescent lights',
description:
'Minor visual bug: when viewing through the head camera near certain florescent light fixtures, the camera feed flickers at approximately 60hz in an uncomfortable strobing pattern.',
stepsToReproduce:
'1. Floor 0 server room\n2. Position head under the overhead fluorescent tubes\n3. Open the head camera view\n4. Observe strobing flicker',
severity: 'low',
gameVersion: '0.9.3-alpha',
status: 'closed',
submittedById: 'u5',
submittedByName: 'XenoArch',
createdAt: '2026-01-10T11:00:00Z',
updatedAt: '2026-01-12T14:00:00Z',
notes: [],
meTooBugs: ['u4'],
},
];
// ── Mock Bug Comments ──────────────────────────────────────────────────────────
export const MOCK_BUG_COMMENTS: BugComment[] = [
{
id: 'bc1',
bugReportId: 'bug1',
authorId: 'u5',
authorName: 'XenoArch',
content: 'Can confirm this happens to me too. Specifically when the head reaches full speed before the curve at the bottom.',
createdAt: '2026-01-16T10:00:00Z',
},
{
id: 'bc2',
bugReportId: 'bug1',
authorId: 'u4',
authorName: 'NullPointer',
content: 'Workaround: slow down before the bend. Tap the brake key twice. Annoying but it prevents the clip.',
createdAt: '2026-01-17T08:30:00Z',
},
{
id: 'bc3',
bugReportId: 'bug3',
authorId: 'u4',
authorName: 'NullPointer',
content: 'Downgrading to driver 565.90 fixed it for me. Not ideal but at least I can play.',
createdAt: '2026-02-11T14:00:00Z',
},
{
id: 'bc4',
bugReportId: 'bug3',
authorId: 'u7',
authorName: 'NeonCrawler',
content: 'The DX11 workaround mentioned in the internal note also works. Add -dx11 to launch options in Steam.',
createdAt: '2026-02-12T09:00:00Z',
},
{
id: 'bc5',
bugReportId: 'bug5',
authorId: 'u3',
authorName: 'GlitchHunter',
content: 'This wiped my 6-hour playthrough. Please prioritize this fix.',
createdAt: '2026-02-14T20:00:00Z',
},
{
id: 'bc6',
bugReportId: 'bug5',
authorId: 'u4',
authorName: 'NullPointer',
content: 'Same here. Lost floor 4 and 5 progress. The auto-save system really needs to not write mid-cutscene.',
createdAt: '2026-02-15T11:00:00Z',
},
];
// ── Mock Staff Feed ────────────────────────────────────────────────────────────
export const MOCK_STAFF_POSTS: StaffPost[] = [
{
id: 'sp1',
authorId: 'u1',
authorName: 'Kestrel',
authorRole: 'dev',
content: 'Physics patch is ready for internal testing. ByteWitch, can you check the head-ramp collision fix before EOD?',
createdAt: '2026-02-18T08:30:00Z',
},
{
id: 'sp2',
authorId: 'u2',
authorName: 'Vesper',
authorRole: 'com',
content: 'Social media post about the co-op update went live. Already 400 likes in 2 hours. The community is really excited.',
createdAt: '2026-02-18T09:15:00Z',
},
{
id: 'sp3',
authorId: 'u8',
authorName: 'ByteWitch',
authorRole: 'dev',
content: 'Checked the ramp fix — collision normals are now correct. Still seeing minor jitter at the bottom but nothing game-breaking. Marking as good to merge.',
createdAt: '2026-02-18T11:00:00Z',
},
{
id: 'sp4',
authorId: 'u1',
authorName: 'Kestrel',
authorRole: 'dev',
content: 'Merging physics fix. Will bundle with the DirectX hotfix into patch 0.9.4. Target: Monday release.',
createdAt: '2026-02-18T11:45:00Z',
},
{
id: 'sp5',
authorId: 'u2',
authorName: 'Vesper',
authorRole: 'com',
content: 'Reminder: community AMA is scheduled for Wednesday 7pm UTC. I\'ll be handling questions, feel free to DM me answers for anything technical.',
createdAt: '2026-02-17T16:00:00Z',
},
{
id: 'sp6',
authorId: 'u8',
authorName: 'ByteWitch',
authorRole: 'dev',
content: 'Save corruption bug (HH-0005) root cause identified: file handle wasn\'t being closed before force-quit. Simple fix, will be in next patch.',
createdAt: '2026-02-15T14:30:00Z',
},
];
// ── Team Members ───────────────────────────────────────────────────────────────
export const TEAM_MEMBERS: TeamMember[] = [
{
id: 'tm1',
name: 'Alexei Voronov',
role: 'Studio Director & Lead Developer',
bio: 'Former AAA engine programmer turned indie. Obsessed with physics simulations and 80s science fiction. The brains behind the detached head mechanic.',
avatarInitials: 'AV',
social: { twitter: '@alexei_dev', github: 'alexei-v' },
},
{
id: 'tm2',
name: 'Sadie Mercier',
role: 'Lead Artist & Art Director',
bio: 'Pixel art veteran and VHS enthusiast. Responsible for the retro-futuristic visual identity of Headless Hazard. Also makes incredible cheese.',
avatarInitials: 'SM',
social: { twitter: '@sadie_pixels' },
},
{
id: 'tm3',
name: 'Rio Tanaka',
role: 'Game Designer & Narrative Lead',
bio: 'Wrote the full lore bible for the Headless Hazard universe, including 300 pages that will never see daylight. Loves bureaucratic dystopias.',
avatarInitials: 'RT',
social: { twitter: '@rio_writes', github: 'rio-tanaka' },
},
{
id: 'tm4',
name: 'Misha Devereux',
role: 'Sound Designer & Composer',
bio: 'Creates audio using a mix of synthesizers, field recordings in abandoned factories, and heavily processed VHS tapes. The soundscape of HH is entirely his.',
avatarInitials: 'MD',
social: { twitter: '@misha_sounds' },
},
{
id: 'tm5',
name: 'Priya Anand',
role: 'Backend & Infrastructure Engineer',
bio: 'Keeps the multiplayer servers alive and the databases sane. Dark mode absolutist. Currently obsessed with Rust.',
avatarInitials: 'PA',
social: { github: 'priya-anand-dev' },
},
{
id: 'tm6',
name: 'Camille Dupont',
role: 'Community Manager & QA Lead',
bio: 'The bridge between the studio and the players. Has played through the full game 47 times for QA purposes and still finds it fun somehow.',
avatarInitials: 'CD',
social: { twitter: '@camille_crow' },
},
];

View File

@@ -0,0 +1,261 @@
@import url('https://fonts.googleapis.com/css2?family=VT323&family=Press+Start+2P&family=Courier+Prime:wght@400;700&family=Share+Tech+Mono&display=swap');
@import "tailwindcss";
/* ── Design Tokens — Minitel Aesthetic ─────────────────── */
:root {
--color-bg: #00001a;
--color-bg-alt: #000033;
--color-surface: #00003d;
--color-surface-alt: #000055;
--color-border: #0000cc;
--color-border-dim: #000088;
/* Primary accent: yellow */
--color-yellow: #ffff00;
--color-yellow-dim: #cccc00;
/* Secondary accent: cyan */
--color-cyan: #00ffff;
--color-cyan-dim: #00cccc;
/* Tertiary: magenta */
--color-magenta: #ff00ff;
--color-magenta-dim: #cc00cc;
/* Red for errors/danger */
--color-red: #ff4444;
/* Text */
--color-text: #e0e0ff;
--color-text-dim: #a0a0cc;
--color-text-muted: #6060aa;
/* Legacy aliases kept so existing pages compile without changes */
--color-green: #00ffff;
--color-green-dim: #00cccc;
--color-amber: #ffff00;
--color-amber-dim: #cccc00;
--font-mono: 'Share Tech Mono', 'Courier Prime', monospace;
--font-heading: 'VT323', 'Press Start 2P', monospace;
}
/* ── Base ──────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
background-color: var(--color-bg);
color: var(--color-text);
font-family: var(--font-mono);
min-height: 100vh;
overflow-x: hidden;
line-height: 1.6;
display: block;
}
#root {
min-height: 100vh;
}
/* ── Scrollbar ─────────────────────────────────────────── */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: var(--color-bg); }
::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 0; }
::-webkit-scrollbar-thumb:hover { background: var(--color-cyan); }
/* ── Typography ────────────────────────────────────────── */
h1, h2, h3, h4, h5 {
font-family: var(--font-heading);
letter-spacing: 0.04em;
text-transform: uppercase;
margin: 0;
}
a {
color: var(--color-cyan);
text-decoration: underline;
transition: color 0.1s;
}
a:hover { color: var(--color-yellow); }
/* ── Minitel box (replaces crt-box) ────────────────────── */
.crt-box {
border: 2px solid var(--color-border);
background: var(--color-surface);
position: relative;
overflow: hidden;
}
/* ── Scanlines — kept as no-op class (Minitel: no effect) */
.scanlines { position: relative; }
.scanlines::after { display: none; }
/* ── VHS grain — removed (no-op) ──────────────────────── */
.vhs-grain { position: relative; }
.vhs-grain::before { display: none; }
/* ── Glitch text — replaced by simple color shift ──────── */
.glitch-text { position: relative; }
.glitch-text::before,
.glitch-text::after { display: none; }
/* ── Redacted — Minitel style: yellow block characters ─── */
.redacted {
display: inline-block;
color: var(--color-yellow);
background: var(--color-yellow);
font-family: var(--font-mono);
padding: 0 2px;
user-select: none;
cursor: default;
letter-spacing: 0;
border: 1px solid var(--color-yellow);
transition: background 0.1s, color 0.1s;
}
.redacted:hover {
background: var(--color-red);
border-color: var(--color-red);
color: var(--color-red);
}
/* ── No glow classes — kept as no-ops for compatibility ── */
.glow-green { color: var(--color-cyan); }
.glow-amber { color: var(--color-yellow); }
.flicker {}
/* ── Buttons — blocky flat Minitel style ───────────────── */
.btn-terminal {
font-family: var(--font-mono);
background: var(--color-bg);
border: 2px solid var(--color-cyan);
color: var(--color-cyan);
padding: 0.4rem 1.1rem;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
transition: background 0.1s, color 0.1s;
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
border-radius: 0;
text-decoration: none;
}
.btn-terminal::before { display: none; }
.btn-terminal:hover {
background: var(--color-cyan);
color: var(--color-bg);
}
.btn-amber {
border-color: var(--color-yellow);
color: var(--color-yellow);
}
.btn-amber:hover {
background: var(--color-yellow);
color: var(--color-bg);
}
.btn-danger {
border-color: var(--color-red);
color: var(--color-red);
}
.btn-danger:hover {
background: var(--color-red);
color: #fff;
}
/* ── Form inputs — blocky flat style ──────────────────── */
.input-terminal {
background: var(--color-bg);
border: 2px solid var(--color-border);
border-radius: 0;
color: var(--color-text);
font-family: var(--font-mono);
font-size: 0.9rem;
padding: 0.4rem 0.7rem;
width: 100%;
transition: border-color 0.1s;
outline: none;
-webkit-appearance: none;
appearance: none;
}
.input-terminal::placeholder {
color: var(--color-text-muted);
}
.input-terminal:focus {
border-color: var(--color-cyan);
}
.input-terminal.error {
border-color: var(--color-red);
}
/* ── Section label ─────────────────────────────────────── */
.section-label {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--color-cyan);
text-transform: uppercase;
letter-spacing: 0.2em;
margin-bottom: 0.5rem;
}
/* ── Page transition ───────────────────────────────────── */
@keyframes page-enter {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.page-enter {
animation: page-enter 0.2s ease forwards;
}
/* ── Status badges — flat, blocky ──────────────────────── */
.badge {
font-family: var(--font-mono);
font-size: 0.65rem;
padding: 0.1rem 0.45rem;
text-transform: uppercase;
letter-spacing: 0.08em;
border-radius: 0;
display: inline-block;
white-space: nowrap;
border: 1px solid;
}
.badge-open { background: #002200; color: #00ff88; border-color: #00ff88; }
.badge-progress { background: #221100; color: var(--color-yellow); border-color: var(--color-yellow); }
.badge-resolved { background: #002211; color: #00ffaa; border-color: #00ffaa; }
.badge-closed { background: #111133; color: var(--color-text-muted); border-color: var(--color-text-muted); }
.badge-critical { background: #330000; color: var(--color-red); border-color: var(--color-red); }
.badge-high { background: #221100; color: #ff8800; border-color: #ff8800; }
.badge-medium { background: #221100; color: var(--color-yellow); border-color: var(--color-yellow); }
.badge-low { background: #001122; color: var(--color-cyan-dim); border-color: var(--color-cyan-dim); }
/* ── Blink cursor ──────────────────────────────────────── */
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
.cursor-blink::after {
content: '_';
animation: blink 1s step-end infinite;
color: var(--color-cyan);
}
/* ── Minitel horizontal rule ───────────────────────────── */
.minitel-rule {
border: none;
border-top: 2px solid var(--color-border);
margin: 2rem 0;
}
/* ── Minitel block decoration ──────────────────────────── */
.block-deco {
color: var(--color-border);
font-family: var(--font-mono);
letter-spacing: -2px;
user-select: none;
}

View File

@@ -0,0 +1,16 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import './index.css';
import App from './App.tsx';
const rootElement = document.getElementById('root');
if (!rootElement) throw new Error('Root element not found');
createRoot(rootElement).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);

View File

@@ -0,0 +1,251 @@
import { useState, useMemo, useCallback } from 'react';
import { MOCK_BUGS, MOCK_USERS } from '../../data/mockData';
import { useAuth } from '../../contexts/AuthContext';
import { formatDate, formatDateTime } from '../../utils/format';
import type { BugReport, BugSeverity, BugStatus, BugReportNote } from '../../types';
function StatusBadge({ status }: { status: BugStatus }) {
const map: Record<BugStatus, string> = { open: 'badge-open', in_progress: 'badge-progress', resolved: 'badge-resolved', closed: 'badge-closed' };
const labels: Record<BugStatus, string> = { open: 'Open', in_progress: 'In Progress', resolved: 'Resolved', closed: 'Closed' };
return <span className={`badge ${map[status]}`}>{labels[status]}</span>;
}
function SeverityBadge({ severity }: { severity: BugSeverity }) {
const map: Record<BugSeverity, string> = { low: 'badge-low', medium: 'badge-medium', high: 'badge-high', critical: 'badge-critical' };
return <span className={`badge ${map[severity]}`}>{severity}</span>;
}
const STATUSES: BugStatus[] = ['open', 'in_progress', 'resolved', 'closed'];
const STAFF_MEMBERS = MOCK_USERS.filter((u) => u.role === 'dev' || u.role === 'com');
export default function IntranetBugs() {
const { user } = useAuth();
const [bugs, setBugs] = useState<BugReport[]>(MOCK_BUGS);
const [selected, setSelected] = useState<BugReport | null>(null);
const [statusFilter, setStatusFilter] = useState<BugStatus | 'all'>('all');
const [severityFilter, setSeverityFilter] = useState<BugSeverity | 'all'>('all');
const [assignedFilter, setAssignedFilter] = useState<string | 'all'>('all');
const [noteText, setNoteText] = useState('');
const openCount = bugs.filter((b) => b.status === 'open').length;
const criticalCount = bugs.filter((b) => b.severity === 'critical').length;
const myCount = bugs.filter((b) => b.assignedToId === user?.id).length;
const filtered = useMemo(() => {
return bugs.filter((b) => {
if (statusFilter !== 'all' && b.status !== statusFilter) return false;
if (severityFilter !== 'all' && b.severity !== severityFilter) return false;
if (assignedFilter !== 'all') {
if (assignedFilter === 'unassigned' && b.assignedToId) return false;
if (assignedFilter !== 'unassigned' && b.assignedToId !== assignedFilter) return false;
}
return true;
});
}, [bugs, statusFilter, severityFilter, assignedFilter]);
const updateBug = useCallback((id: string, changes: Partial<BugReport>) => {
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);
}, []);
const handleAssign = useCallback((bugId: string, staffId: string) => {
const staff = STAFF_MEMBERS.find((s) => s.id === staffId);
updateBug(bugId, { assignedToId: staffId || undefined, assignedToName: staff?.username });
}, [updateBug]);
const handleStatusChange = useCallback((bugId: string, status: BugStatus) => {
updateBug(bugId, { status });
}, [updateBug]);
const handleAddNote = useCallback((bugId: string) => {
if (!noteText.trim() || !user) return;
const note: BugReportNote = {
id: `n${Date.now()}`,
bugReportId: bugId,
authorId: user.id,
authorName: user.username,
content: noteText.trim(),
createdAt: new Date().toISOString(),
};
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);
setNoteText('');
}, [noteText, user]);
return (
<div style={{ display: 'grid', gridTemplateColumns: selected ? '1fr 400px' : '1fr', gap: '1.5rem', alignItems: 'start' }}>
{/* Left panel */}
<div>
<div style={{ marginBottom: '1.5rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
INTRANET / BUG REPORTS
</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem', marginBottom: '1rem' }}>BUG DASHBOARD</h1>
{/* Stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.5rem', marginBottom: '1.25rem' }}>
{[
{ label: 'Open', value: openCount, color: 'var(--color-green)' },
{ label: 'Critical', value: criticalCount, color: 'var(--color-red)' },
{ label: 'Mine', value: myCount, color: 'var(--color-amber)' },
].map(({ label, value, color }) => (
<div key={label} style={{ background: '#0a0d0a', border: '1px solid #1a2a1a', padding: '0.75rem', textAlign: 'center' }}>
<div style={{ fontFamily: 'var(--font-heading)', color, fontSize: '1.8rem' }}>{value}</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.65rem', letterSpacing: '0.1em' }}>{label}</div>
</div>
))}
</div>
{/* Filters */}
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<select className="input-terminal" style={{ width: 'auto', fontSize: '0.75rem' }} value={statusFilter} onChange={(e) => setStatusFilter(e.target.value as BugStatus | 'all')}>
<option value="all">All Statuses</option>
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<select className="input-terminal" style={{ width: 'auto', fontSize: '0.75rem' }} value={severityFilter} onChange={(e) => setSeverityFilter(e.target.value as BugSeverity | 'all')}>
<option value="all">All Severities</option>
{(['critical','high','medium','low'] as BugSeverity[]).map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<select className="input-terminal" style={{ width: 'auto', fontSize: '0.75rem' }} value={assignedFilter} onChange={(e) => setAssignedFilter(e.target.value)}>
<option value="all">All Assigned</option>
<option value="unassigned">Unassigned</option>
{STAFF_MEMBERS.map((s) => <option key={s.id} value={s.id}>{s.username}</option>)}
</select>
</div>
</div>
{/* Bug list */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
{filtered.length === 0 ? (
<div style={{ background: '#0a0d0a', border: '1px solid #1a2a1a', padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>
No reports match filters.
</div>
) : (
filtered.map((bug) => (
<div
key={bug.id}
onClick={() => setSelected(bug === selected ? null : bug)}
style={{
background: selected?.id === bug.id ? 'rgba(255,176,0,0.05)' : '#0a0d0a',
border: `1px solid ${selected?.id === bug.id ? 'rgba(255,176,0,0.3)' : '#1a2a1a'}`,
padding: '0.85rem 1.1rem',
cursor: 'pointer',
transition: 'all 0.15s',
}}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && setSelected(bug === selected ? null : bug)}
aria-label={`Select bug report ${bug.uniqueCode}`}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '0.3rem' }}>
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem' }}>{bug.uniqueCode}</span>
<StatusBadge status={bug.status} />
<SeverityBadge severity={bug.severity} />
</div>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', flexShrink: 0 }}>
{formatDate(bug.createdAt)}
</span>
</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text)', fontSize: '0.83rem', marginBottom: '0.2rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{bug.title}
</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem' }}>
{bug.submittedByName} &mdash; Assigned: {bug.assignedToName ?? 'None'}
</div>
</div>
))
)}
</div>
</div>
{/* Right panel — detail */}
{selected && (
<div style={{ background: '#0a0d0a', border: '1px solid #1a2a1a', padding: '1.5rem', position: 'sticky', top: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
<div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', marginBottom: '0.25rem' }}>{selected.uniqueCode}</div>
<h2 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.1rem' }}>{selected.title}</h2>
</div>
<button onClick={() => setSelected(null)} style={{ background: 'transparent', border: 'none', color: 'var(--color-text-muted)', cursor: 'pointer', fontSize: '1.1rem' }} aria-label="Close">&#x2715;</button>
</div>
{/* Controls */}
<div style={{ display: 'grid', gap: '0.75rem', marginBottom: '1.25rem' }}>
<div>
<label style={{ display: 'block', fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', letterSpacing: '0.1em', marginBottom: '0.3rem' }}>STATUS</label>
<select
className="input-terminal"
style={{ fontSize: '0.75rem' }}
value={selected.status}
onChange={(e) => handleStatusChange(selected.id, e.target.value as BugStatus)}
>
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<div>
<label style={{ display: 'block', fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', letterSpacing: '0.1em', marginBottom: '0.3rem' }}>ASSIGN TO</label>
<select
className="input-terminal"
style={{ fontSize: '0.75rem' }}
value={selected.assignedToId ?? ''}
onChange={(e) => handleAssign(selected.id, e.target.value)}
>
<option value="">Unassigned</option>
{STAFF_MEMBERS.map((s) => <option key={s.id} value={s.id}>{s.username} ({s.role})</option>)}
</select>
</div>
</div>
{/* Description */}
<div style={{ marginBottom: '1rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.65rem', letterSpacing: '0.1em', marginBottom: '0.4rem' }}>DESCRIPTION</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.78rem', lineHeight: 1.7, whiteSpace: 'pre-wrap', background: 'rgba(0,0,0,0.3)', padding: '0.75rem' }}>
{selected.description}
</div>
</div>
<div style={{ marginBottom: '1rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.65rem', letterSpacing: '0.1em', marginBottom: '0.4rem' }}>STEPS TO REPRODUCE</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.78rem', lineHeight: 1.7, whiteSpace: 'pre-wrap', background: 'rgba(0,0,0,0.3)', padding: '0.75rem' }}>
{selected.stepsToReproduce}
</div>
</div>
{/* Internal notes */}
<div style={{ marginBottom: '1rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.65rem', letterSpacing: '0.1em', marginBottom: '0.5rem' }}>INTERNAL NOTES (staff only)</div>
{(selected.notes ?? []).length === 0 ? (
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.75rem', padding: '0.5rem 0' }}>No notes yet.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '0.75rem' }}>
{(selected.notes ?? []).map((note) => (
<div key={note.id} style={{ background: 'rgba(255,176,0,0.05)', border: '1px solid rgba(255,176,0,0.15)', padding: '0.6rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.65rem', marginBottom: '0.2rem' }}>
{note.authorName} &mdash; {formatDateTime(note.createdAt)}
</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.77rem', lineHeight: 1.6 }}>
{note.content}
</div>
</div>
))}
</div>
)}
<textarea
className="input-terminal"
rows={3}
placeholder="Add an internal note..."
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
style={{ resize: 'vertical', fontSize: '0.8rem', marginBottom: '0.5rem' }}
/>
<button className="btn-terminal btn-amber" onClick={() => handleAddNote(selected.id)} style={{ padding: '0.35rem 0.9rem', fontSize: '0.75rem' }}>
Add Note
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,164 @@
import { Link } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { MOCK_BUGS, MOCK_STAFF_POSTS, MOCK_USERS, MOCK_THREADS } from '../../data/mockData';
interface StatCardProps {
label: string;
value: number | string;
accent?: 'green' | 'amber' | 'red';
}
function StatCard({ label, value, accent = 'green' }: StatCardProps) {
const colors = {
green: 'var(--color-green)',
amber: 'var(--color-amber)',
red: 'var(--color-red)',
};
return (
<div
style={{
background: '#0a0d0a',
border: '1px solid #1a2a1a',
padding: '1.25rem',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
}}
>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', textTransform: 'uppercase' }}>
{label}
</div>
<div style={{ fontFamily: 'var(--font-heading)', color: colors[accent], fontSize: '2.5rem', lineHeight: 1 }}>
{value}
</div>
</div>
);
}
interface NavTileProps {
to: string;
label: string;
description: string;
icon: string;
}
function NavTile({ to, label, description, icon }: NavTileProps) {
return (
<Link
to={to}
style={{
background: '#0a0d0a',
border: '1px solid #1a2a1a',
padding: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.6rem',
textDecoration: 'none',
transition: 'border-color 0.2s, background 0.2s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLAnchorElement).style.borderColor = 'rgba(255,176,0,0.4)';
(e.currentTarget as HTMLAnchorElement).style.background = 'rgba(255,176,0,0.03)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLAnchorElement).style.borderColor = '#1a2a1a';
(e.currentTarget as HTMLAnchorElement).style.background = '#0a0d0a';
}}
>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.75rem', letterSpacing: '0.1em' }}>
{icon}
</div>
<div style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.1rem' }}>
{label}
</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem', lineHeight: 1.6 }}>
{description}
</div>
</Link>
);
}
export default function IntranetDashboard() {
const { user } = useAuth();
const openBugs = MOCK_BUGS.filter((b) => b.status === 'open').length;
const criticalBugs = MOCK_BUGS.filter((b) => b.severity === 'critical').length;
const assignedToMe = MOCK_BUGS.filter((b) => b.assignedToId === user?.id).length;
const totalUsers = MOCK_USERS.filter((u) => !u.isAdmin).length;
return (
<div>
{/* Header */}
<div style={{ marginBottom: '2rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
INTRANET / DASHBOARD
</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem', marginBottom: '0.25rem' }}>
Welcome, {user?.username}
</h1>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.8rem' }}>
{new Date().toLocaleString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</div>
</div>
{/* Stats */}
<div style={{ marginBottom: '2rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '1rem' }}>
QUICK STATS
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '0.75rem' }}>
<StatCard label="Open Bugs" value={openBugs} accent="green" />
<StatCard label="Critical" value={criticalBugs} accent="red" />
<StatCard label="Assigned to Me" value={assignedToMe} accent="amber" />
<StatCard label="Total Users" value={totalUsers} accent="green" />
<StatCard label="Forum Threads" value={MOCK_THREADS.length} accent="green" />
<StatCard label="Staff Posts Today" value={MOCK_STAFF_POSTS.filter((p) => p.createdAt.startsWith('2026-02-18')).length} accent="amber" />
</div>
</div>
{/* Navigation tiles */}
<div style={{ marginBottom: '2rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '1rem' }}>
SECTIONS
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '0.75rem' }}>
<NavTile to="/intranet/bugs" label="Bug Reports" description="Review, assign, and update reported issues. Filter by severity and status." 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/moderation" label="Forum Moderation" description="Pin, lock, and delete threads and replies from the public forum." icon="[M]" />
</div>
</div>
{/* Recent staff posts */}
<div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '1rem' }}>
RECENT TEAM ACTIVITY
</div>
<div style={{ background: '#0a0d0a', border: '1px solid #1a2a1a' }}>
{MOCK_STAFF_POSTS.slice(0, 4).map((post, idx) => (
<div
key={post.id}
style={{
padding: '0.85rem 1.25rem',
borderBottom: idx < 3 ? '1px solid #1a2a1a' : 'none',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', marginBottom: '0.3rem', flexWrap: 'wrap' }}>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.78rem' }}>
{post.authorName}
<span style={{ color: 'var(--color-text-muted)', marginLeft: '0.4rem', fontSize: '0.65rem' }}>[{post.authorRole}]</span>
</span>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem' }}>
{new Date(post.createdAt).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.82rem', lineHeight: 1.7 }}>
{post.content}
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,145 @@
import { useState, useCallback } from 'react';
import { MOCK_STAFF_POSTS } from '../../data/mockData';
import { useAuth } from '../../contexts/AuthContext';
import { formatDateTime } from '../../utils/format';
import type { StaffPost, UserRole } from '../../types';
const ROLE_COLORS: Record<UserRole, string> = {
dev: 'var(--color-green)',
com: 'var(--color-amber)',
user: 'var(--color-text-muted)',
};
function FeedPost({ post }: { post: StaffPost }) {
return (
<div style={{ background: '#0a0d0a', border: '1px solid #1a2a1a', padding: '1.1rem 1.25rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '1rem', marginBottom: '0.5rem', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<div
style={{
width: '30px',
height: '30px',
background: 'rgba(255,176,0,0.08)',
border: '1px solid rgba(255,176,0,0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: 'var(--font-heading)',
color: 'var(--color-amber)',
fontSize: '0.85rem',
flexShrink: 0,
}}
>
{post.authorName[0].toUpperCase()}
</div>
<div>
<span style={{ fontFamily: 'var(--font-mono)', color: ROLE_COLORS[post.authorRole], fontSize: '0.82rem' }}>
{post.authorName}
</span>
<span
style={{
fontFamily: 'var(--font-mono)',
background: 'rgba(255,176,0,0.08)',
border: '1px solid rgba(255,176,0,0.2)',
color: 'var(--color-amber)',
fontSize: '0.6rem',
padding: '0.05rem 0.35rem',
marginLeft: '0.5rem',
letterSpacing: '0.08em',
textTransform: 'uppercase',
}}
>
{post.authorRole}
</span>
</div>
</div>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', flexShrink: 0 }}>
{formatDateTime(post.createdAt)}
</span>
</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.85rem', lineHeight: 1.75, marginLeft: '36px' }}>
{post.content}
</div>
</div>
);
}
export default function IntranetFeed() {
const { user } = useAuth();
const [posts, setPosts] = useState<StaffPost[]>(MOCK_STAFF_POSTS);
const [content, setContent] = useState('');
const [error, setError] = useState('');
const [posting, setPosting] = useState(false);
const handlePost = useCallback(async () => {
if (!content.trim()) { setError('Post cannot be empty.'); return; }
if (content.trim().length < 5) { setError('Post must be at least 5 characters.'); return; }
if (!user) return;
setError('');
setPosting(true);
await new Promise((r) => setTimeout(r, 250));
const newPost: StaffPost = {
id: `sp${Date.now()}`,
authorId: user.id,
authorName: user.username,
authorRole: user.role as 'dev' | 'com',
content: content.trim(),
createdAt: new Date().toISOString(),
};
setPosts((prev) => [newPost, ...prev]);
setContent('');
setPosting(false);
}, [content, user]);
return (
<div style={{ maxWidth: '720px' }}>
<div style={{ marginBottom: '2rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
INTRANET / TEAM FEED
</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem' }}>TEAM ACTIVITY</h1>
<p style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem', marginTop: '0.4rem' }}>
Staff-only internal feed. Posts are not visible to the public.
</p>
</div>
{/* Compose */}
<div style={{ background: '#0a0d0a', border: '1px solid #1a2a1a', padding: '1.25rem', marginBottom: '1.5rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.7rem', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
[{user?.username} {user?.role}] Post an update
</div>
<textarea
className={`input-terminal${error ? ' error' : ''}`}
rows={3}
placeholder="What's happening? Share an update with the team..."
value={content}
onChange={(e) => { setContent(e.target.value); setError(''); }}
style={{ resize: 'vertical', fontSize: '0.85rem', marginBottom: '0.75rem' }}
disabled={posting}
/>
{error && (
<div style={{ color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', marginBottom: '0.6rem' }}>
{error}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem' }}>
{content.length} chars
</span>
<button className="btn-terminal btn-amber" onClick={handlePost} disabled={posting} style={{ opacity: posting ? 0.6 : 1 }}>
{posting ? 'Posting...' : '> Post'}
</button>
</div>
</div>
{/* Feed */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{posts.map((post) => (
<FeedPost key={post.id} post={post} />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,231 @@
import { useState, useMemo, useCallback } from 'react';
import { MOCK_THREADS, MOCK_REPLIES } from '../../data/mockData';
import { formatDateTime } from '../../utils/format';
import type { ForumThread, ForumReply } from '../../types';
export default function IntranetModeration() {
const [threads, setThreads] = useState<ForumThread[]>(MOCK_THREADS);
const [replies, setReplies] = useState<ForumReply[]>(MOCK_REPLIES);
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [activeTab, setActiveTab] = useState<'threads' | 'replies'>('threads');
const filteredThreads = useMemo(() => {
if (!search.trim()) return threads;
const q = search.toLowerCase();
return threads.filter((t) => t.title.toLowerCase().includes(q) || t.authorName.toLowerCase().includes(q));
}, [threads, search]);
const selectedThreadReplies = useMemo(() => {
if (!selectedThreadId) return [];
return replies.filter((r) => r.threadId === selectedThreadId);
}, [replies, selectedThreadId]);
const deleteThread = useCallback((id: string) => {
setThreads((prev) => prev.filter((t) => t.id !== id));
setReplies((prev) => prev.filter((r) => r.threadId !== id));
if (selectedThreadId === id) setSelectedThreadId(null);
}, [selectedThreadId]);
const togglePin = useCallback((id: string) => {
setThreads((prev) => prev.map((t) => t.id === id ? { ...t, isPinned: !t.isPinned } : t));
}, []);
const toggleLock = useCallback((id: string) => {
setThreads((prev) => prev.map((t) => t.id === id ? { ...t, isLocked: !t.isLocked } : t));
}, []);
const deleteReply = useCallback((id: string) => {
setReplies((prev) => prev.filter((r) => r.id !== id));
}, []);
const recentReplies = useMemo(() => {
return [...replies].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()).slice(0, 20);
}, [replies]);
return (
<div>
<div style={{ marginBottom: '1.75rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
INTRANET / MODERATION
</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem', marginBottom: '0.5rem' }}>FORUM MODERATION</h1>
<p style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem' }}>
{threads.length} threads &mdash; {replies.length} replies
</p>
</div>
{/* Tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid #1a2a1a', marginBottom: '1.5rem', gap: 0 }}>
{(['threads', 'replies'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
style={{
background: 'transparent',
border: 'none',
borderBottom: activeTab === tab ? '2px solid var(--color-amber)' : '2px solid transparent',
color: activeTab === tab ? 'var(--color-amber)' : 'var(--color-text-muted)',
fontFamily: 'var(--font-mono)',
fontSize: '0.75rem',
padding: '0.55rem 1rem',
cursor: 'pointer',
textTransform: 'uppercase',
letterSpacing: '0.08em',
}}
>
{tab === 'threads' ? `Threads (${threads.length})` : `Replies (${replies.length})`}
</button>
))}
</div>
{activeTab === 'threads' && (
<div style={{ display: 'grid', gridTemplateColumns: selectedThreadId ? '1fr 340px' : '1fr', gap: '1.25rem', alignItems: 'start' }}>
{/* Thread list */}
<div>
<input
className="input-terminal"
type="search"
placeholder="Search threads..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ marginBottom: '1rem', maxWidth: '300px' }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
{filteredThreads.map((thread) => (
<div
key={thread.id}
style={{
background: selectedThreadId === thread.id ? 'rgba(255,176,0,0.04)' : '#0a0d0a',
border: `1px solid ${selectedThreadId === thread.id ? 'rgba(255,176,0,0.25)' : '#1a2a1a'}`,
padding: '0.85rem 1.1rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '0.3rem' }}>
<div>
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', marginBottom: '0.2rem' }}>
{thread.isPinned && <span className="badge badge-progress">Pinned</span>}
{thread.isLocked && <span className="badge badge-closed">Locked</span>}
</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text)', fontSize: '0.83rem' }}>{thread.title}</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', marginTop: '0.2rem' }}>
by {thread.authorName} &mdash; {thread.categoryName} &mdash; {thread.replyCount} replies
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '0.4rem', marginTop: '0.6rem', flexWrap: 'wrap' }}>
<button
className="btn-terminal"
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
onClick={() => setSelectedThreadId(selectedThreadId === thread.id ? null : thread.id)}
>
Replies
</button>
<button
className={`btn-terminal ${thread.isPinned ? 'btn-danger' : 'btn-amber'}`}
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
onClick={() => togglePin(thread.id)}
>
{thread.isPinned ? 'Unpin' : 'Pin'}
</button>
<button
className="btn-terminal btn-amber"
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
onClick={() => toggleLock(thread.id)}
>
{thread.isLocked ? 'Unlock' : 'Lock'}
</button>
<button
className="btn-terminal btn-danger"
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
onClick={() => deleteThread(thread.id)}
>
Delete
</button>
</div>
</div>
))}
{filteredThreads.length === 0 && (
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem', background: '#0a0d0a', border: '1px solid #1a2a1a' }}>
No threads found.
</div>
)}
</div>
</div>
{/* Thread replies panel */}
{selectedThreadId && (
<div style={{ background: '#0a0d0a', border: '1px solid #1a2a1a', padding: '1.25rem', position: 'sticky', top: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.7rem', letterSpacing: '0.1em' }}>
REPLIES ({selectedThreadReplies.length})
</div>
<button onClick={() => setSelectedThreadId(null)} style={{ background: 'transparent', border: 'none', color: 'var(--color-text-muted)', cursor: 'pointer' }} aria-label="Close">&#x2715;</button>
</div>
{selectedThreadReplies.length === 0 ? (
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem' }}>No replies.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{selectedThreadReplies.map((reply) => (
<div key={reply.id} style={{ background: '#0d110d', border: '1px solid #1a2a1a', padding: '0.75rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.35rem' }}>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.72rem' }}>{reply.authorName}</span>
<button
className="btn-terminal btn-danger"
style={{ padding: '0.1rem 0.45rem', fontSize: '0.6rem' }}
onClick={() => deleteReply(reply.id)}
>
Delete
</button>
</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.77rem', lineHeight: 1.65 }}>
{reply.content.slice(0, 150)}{reply.content.length > 150 ? '...' : ''}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
{activeTab === 'replies' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
{recentReplies.map((reply) => {
const thread = threads.find((t) => t.id === reply.threadId);
return (
<div key={reply.id} style={{ background: '#0a0d0a', border: '1px solid #1a2a1a', padding: '0.85rem 1.1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.75rem', flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', marginBottom: '0.2rem' }}>
by <span style={{ color: 'var(--color-text-dim)' }}>{reply.authorName}</span>
{thread && <> in <span style={{ color: 'var(--color-text-dim)' }}>{thread.title}</span></>}
{' '}&mdash; {formatDateTime(reply.createdAt)}
</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text)', fontSize: '0.82rem', lineHeight: 1.6, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{reply.content}
</div>
</div>
<button
className="btn-terminal btn-danger"
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem', flexShrink: 0 }}
onClick={() => deleteReply(reply.id)}
>
Delete
</button>
</div>
</div>
);
})}
{recentReplies.length === 0 && (
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem', background: '#0a0d0a', border: '1px solid #1a2a1a' }}>
No replies found.
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,188 @@
import { useState, useMemo, useCallback } from 'react';
import { MOCK_USERS, MOCK_THREADS, MOCK_BUGS } from '../../data/mockData';
import { useAuth } from '../../contexts/AuthContext';
import { formatDate } from '../../utils/format';
import type { User, UserRole } from '../../types';
export default function IntranetUsers() {
const { user: currentUser } = useAuth();
const [users, setUsers] = useState<User[]>(MOCK_USERS);
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState<UserRole | 'all'>('all');
const [confirmAction, setConfirmAction] = useState<{ userId: string; action: 'promote' | 'ban' | 'unban' } | null>(null);
const threadCounts = useMemo(() => {
const map: Record<string, number> = {};
MOCK_THREADS.forEach((t) => { map[t.authorId] = (map[t.authorId] ?? 0) + 1; });
return map;
}, []);
const bugCounts = useMemo(() => {
const map: Record<string, number> = {};
MOCK_BUGS.forEach((b) => { map[b.submittedById] = (map[b.submittedById] ?? 0) + 1; });
return map;
}, []);
const filtered = useMemo(() => {
return users.filter((u) => {
const matchSearch = !search.trim() || u.username.toLowerCase().includes(search.toLowerCase()) || u.email.toLowerCase().includes(search.toLowerCase());
const matchRole = roleFilter === 'all' || u.role === roleFilter;
return matchSearch && matchRole;
});
}, [users, search, roleFilter]);
const handlePromote = useCallback((userId: string, targetRole: UserRole) => {
setUsers((prev) => prev.map((u) => u.id === userId ? { ...u, role: targetRole } : u));
setConfirmAction(null);
}, []);
const handleToggleBan = useCallback((userId: string, ban: boolean) => {
setUsers((prev) => prev.map((u) => u.id === userId ? { ...u, isBanned: ban } : u));
setConfirmAction(null);
}, []);
return (
<div>
<div style={{ marginBottom: '1.75rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
INTRANET / USER MANAGEMENT
</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem', marginBottom: '1rem' }}>USERS</h1>
{/* Filters */}
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<input
className="input-terminal"
type="search"
placeholder="Search username or email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ maxWidth: '260px' }}
/>
<select className="input-terminal" style={{ width: 'auto', minWidth: '130px' }} value={roleFilter} onChange={(e) => setRoleFilter(e.target.value as UserRole | 'all')}>
<option value="all">All Roles</option>
<option value="user">Users</option>
<option value="dev">Dev</option>
<option value="com">Com</option>
</select>
</div>
</div>
{/* Confirm dialog */}
{confirmAction && (
<div style={{ background: 'rgba(0,0,0,0.8)', position: 'fixed', inset: 0, zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={() => setConfirmAction(null)}>
<div style={{ background: '#0a0d0a', border: '1px solid var(--color-amber)', padding: '2rem', maxWidth: '380px', width: '90%' }}
onClick={(e) => e.stopPropagation()}>
<h3 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', marginBottom: '1rem' }}>CONFIRM ACTION</h3>
<p style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.82rem', marginBottom: '1.5rem', lineHeight: 1.7 }}>
{confirmAction.action === 'promote'
? `Promote this user to staff? They will gain access to the intranet.`
: confirmAction.action === 'ban'
? `Ban this user? They will be unable to login.`
: `Unban this user? They will regain access to their account.`}
</p>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<button
className={`btn-terminal ${confirmAction.action === 'ban' ? 'btn-danger' : 'btn-amber'}`}
onClick={() => {
if (confirmAction.action === 'promote') handlePromote(confirmAction.userId, 'dev');
else if (confirmAction.action === 'ban') handleToggleBan(confirmAction.userId, true);
else handleToggleBan(confirmAction.userId, false);
}}
>
Confirm
</button>
<button className="btn-terminal" onClick={() => setConfirmAction(null)}>Cancel</button>
</div>
</div>
</div>
)}
{/* Table */}
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-mono)', fontSize: '0.78rem' }}>
<thead>
<tr style={{ borderBottom: '1px solid #1a2a1a' }}>
{['Username', 'Email', 'Role', 'Joined', 'Threads', 'Bugs', 'Status', 'Actions'].map((h) => (
<th key={h} style={{ padding: '0.6rem 0.75rem', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 'normal', letterSpacing: '0.1em', fontSize: '0.68rem', textTransform: 'uppercase', whiteSpace: 'nowrap' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{filtered.map((u) => {
const isSelf = u.id === currentUser?.id;
return (
<tr
key={u.id}
style={{ borderBottom: '1px solid #111811', background: u.isBanned ? 'rgba(255,34,68,0.03)' : 'transparent' }}
>
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-text)', fontWeight: isSelf ? 'bold' : 'normal' }}>
{u.username} {isSelf && <span style={{ color: 'var(--color-amber)', fontSize: '0.65rem' }}>(you)</span>}
{u.isAdmin && <span style={{ color: 'var(--color-green)', fontSize: '0.62rem', marginLeft: '0.3rem' }}>[admin]</span>}
</td>
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-text-muted)' }}>{u.email}</td>
<td style={{ padding: '0.7rem 0.75rem' }}>
<span className={`badge ${u.role === 'dev' ? 'badge-open' : u.role === 'com' ? 'badge-medium' : 'badge-closed'}`}>
{u.role}
</span>
</td>
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>{formatDate(u.createdAt)}</td>
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-green)', textAlign: 'center' }}>{threadCounts[u.id] ?? 0}</td>
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-green)', textAlign: 'center' }}>{bugCounts[u.id] ?? 0}</td>
<td style={{ padding: '0.7rem 0.75rem' }}>
{u.isBanned ? (
<span className="badge badge-critical">Banned</span>
) : (
<span className="badge badge-open">Active</span>
)}
</td>
<td style={{ padding: '0.7rem 0.75rem' }}>
{!isSelf && !u.isAdmin && (
<div style={{ display: 'flex', gap: '0.35rem', flexWrap: 'nowrap' }}>
{u.role === 'user' && currentUser?.isAdmin && (
<button
className="btn-terminal btn-amber"
style={{ padding: '0.15rem 0.5rem', fontSize: '0.62rem' }}
onClick={() => setConfirmAction({ userId: u.id, action: 'promote' })}
>
Promote
</button>
)}
{u.isBanned ? (
<button
className="btn-terminal"
style={{ padding: '0.15rem 0.5rem', fontSize: '0.62rem' }}
onClick={() => setConfirmAction({ userId: u.id, action: 'unban' })}
>
Unban
</button>
) : (
<button
className="btn-terminal btn-danger"
style={{ padding: '0.15rem 0.5rem', fontSize: '0.62rem' }}
onClick={() => setConfirmAction({ userId: u.id, action: 'ban' })}
>
Ban
</button>
)}
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
{filtered.length === 0 && (
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>
No users match the current filters.
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,248 @@
import { useState, useCallback } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { MOCK_THREADS, MOCK_BUGS } from '../../data/mockData';
import { formatDate } from '../../utils/format';
import { Link } from 'react-router-dom';
type Tab = 'profile' | 'threads' | 'bugs' | 'password';
export default function AccountPage() {
const { user, updateUsername } = useAuth();
const [activeTab, setActiveTab] = useState<Tab>('profile');
const userThreads = MOCK_THREADS.filter((t) => t.authorId === user?.id);
const userBugs = MOCK_BUGS.filter((b) => b.submittedById === user?.id);
const tabs: { id: Tab; label: string }[] = [
{ id: 'profile', label: 'Profile' },
{ id: 'threads', label: `Threads (${userThreads.length})` },
{ id: 'bugs', label: `Bug Reports (${userBugs.length})` },
{ id: 'password', label: 'Change Password' },
];
if (!user) return null;
return (
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '4rem 1.5rem' }}>
<div className="section-label">My Account</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: 'clamp(2rem, 5vw, 3rem)', marginTop: '0.5rem', marginBottom: '2rem' }}>
{user.username}
</h1>
{/* Tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid var(--color-border)', marginBottom: '2rem', gap: '0', flexWrap: 'wrap' }}>
{tabs.map(({ id, label }) => (
<button
key={id}
onClick={() => setActiveTab(id)}
style={{
background: 'transparent',
border: 'none',
borderBottom: activeTab === id ? '2px solid var(--color-green)' : '2px solid transparent',
color: activeTab === id ? 'var(--color-green)' : 'var(--color-text-muted)',
fontFamily: 'var(--font-mono)',
fontSize: '0.78rem',
padding: '0.6rem 1rem',
cursor: 'pointer',
letterSpacing: '0.05em',
textTransform: 'uppercase',
transition: 'all 0.2s',
}}
>
{label}
</button>
))}
</div>
{/* Profile Tab */}
{activeTab === 'profile' && (
<ProfileTab user={user} updateUsername={updateUsername} />
)}
{/* Threads Tab */}
{activeTab === 'threads' && (
<div>
{userThreads.length === 0 ? (
<div className="crt-box" style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)' }}>
You haven't posted any threads yet.{' '}
<Link to="/forum" style={{ color: 'var(--color-green)' }}>Go to Forum</Link>
</div>
) : (
userThreads.map((t) => (
<div key={t.id} className="crt-box" style={{ padding: '1rem 1.25rem', marginBottom: '0.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
<Link to={`/forum/thread/${t.id}`} style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text)', fontSize: '0.87rem' }}>
{t.title}
</Link>
<span style={{ color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', flexShrink: 0 }}>
{formatDate(t.createdAt)}
</span>
</div>
<div style={{ color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', marginTop: '0.25rem' }}>
{t.categoryName} &mdash; {t.replyCount} replies
</div>
</div>
))
)}
</div>
)}
{/* Bug Reports Tab */}
{activeTab === 'bugs' && (
<div>
{userBugs.length === 0 ? (
<div className="crt-box" style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)' }}>
You haven't submitted any bug reports.{' '}
<Link to="/bugs" style={{ color: 'var(--color-green)' }}>Report a Bug</Link>
</div>
) : (
userBugs.map((b) => (
<div key={b.id} className="crt-box" style={{ padding: '1rem 1.25rem', marginBottom: '0.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap', marginBottom: '0.25rem' }}>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text)', fontSize: '0.87rem' }}>{b.title}</span>
<span style={{ color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', flexShrink: 0 }}>{formatDate(b.createdAt)}</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem' }}>{b.uniqueCode}</span>
<span className={`badge badge-${b.status === 'in_progress' ? 'progress' : b.status}`}>{b.status}</span>
<span className={`badge badge-${b.severity}`}>{b.severity}</span>
</div>
</div>
))
)}
</div>
)}
{/* Password Tab */}
{activeTab === 'password' && <ChangePasswordForm />}
</div>
);
}
// ── Profile Tab ────────────────────────────────────────────────────────────────
function ProfileTab({ user, updateUsername }: { user: NonNullable<ReturnType<typeof useAuth>['user']>; updateUsername: (u: string) => void }) {
const [editing, setEditing] = useState(false);
const [username, setUsername] = useState(user.username);
const [error, setError] = useState('');
const [saved, setSaved] = useState(false);
const handleSave = useCallback(() => {
if (!username.trim()) { setError('Username cannot be empty.'); return; }
if (username.length < 3) { setError('Must be at least 3 characters.'); return; }
updateUsername(username.trim());
setEditing(false);
setSaved(true);
setTimeout(() => setSaved(false), 3000);
}, [username, updateUsername]);
return (
<div className="crt-box" style={{ padding: '2rem' }}>
{saved && (
<div style={{ color: 'var(--color-green)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem', marginBottom: '1rem', background: 'rgba(0,255,65,0.07)', border: '1px solid rgba(0,255,65,0.2)', padding: '0.6rem 0.75rem' }}>
[OK] Username updated successfully.
</div>
)}
<div style={{ display: 'grid', gap: '1rem' }}>
{/* Username */}
<div style={{ display: 'grid', gridTemplateColumns: '140px 1fr', gap: '0.5rem', alignItems: 'center' }}>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.75rem', letterSpacing: '0.1em' }}>USERNAME</span>
{editing ? (
<div style={{ display: 'flex', gap: '0.5rem' }}>
<input
className={`input-terminal${error ? ' error' : ''}`}
value={username}
onChange={(e) => { setUsername(e.target.value); setError(''); }}
style={{ flex: 1 }}
autoFocus
/>
<button className="btn-terminal" onClick={handleSave} style={{ padding: '0.35rem 0.75rem', fontSize: '0.75rem' }}>Save</button>
<button className="btn-terminal btn-danger" onClick={() => { setEditing(false); setUsername(user.username); setError(''); }} style={{ padding: '0.35rem 0.75rem', fontSize: '0.75rem' }}>Cancel</button>
</div>
) : (
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text)', fontSize: '0.87rem' }}>{user.username}</span>
<button
className="btn-terminal"
onClick={() => setEditing(true)}
style={{ padding: '0.2rem 0.6rem', fontSize: '0.65rem' }}
>
Edit
</button>
</div>
)}
</div>
{error && <div style={{ color: 'var(--color-red)', fontSize: '0.72rem', gridColumn: '2' }}>{error}</div>}
{/* Static fields */}
{[
{ label: 'EMAIL', value: user.email },
{ label: 'ROLE', value: user.role.toUpperCase() },
{ label: 'MEMBER SINCE', value: formatDate(user.createdAt) },
{ label: 'ADMIN', value: user.isAdmin ? 'Yes' : 'No' },
].map(({ label, value }) => (
<div key={label} style={{ display: 'grid', gridTemplateColumns: '140px 1fr', gap: '0.5rem', alignItems: 'center' }}>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.75rem', letterSpacing: '0.1em' }}>{label}</span>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.87rem' }}>{value}</span>
</div>
))}
</div>
</div>
);
}
// ── Change Password Form ───────────────────────────────────────────────────────
function ChangePasswordForm() {
const [form, setForm] = useState({ current: '', next: '', confirm: '' });
const [errors, setErrors] = useState<Partial<typeof form & { success: string }>>({});
const [loading, setLoading] = useState(false);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
const next: typeof errors = {};
if (!form.current) next.current = 'Current password required.';
if (!form.next) next.next = 'New password required.';
else if (form.next.length < 8) next.next = 'Must be at least 8 characters.';
if (form.next !== form.confirm) next.confirm = 'Passwords do not match.';
setErrors(next);
if (Object.keys(next).length > 0) return;
setLoading(true);
await new Promise((r) => setTimeout(r, 400));
setLoading(false);
setForm({ current: '', next: '', confirm: '' });
setErrors({ success: 'Password changed successfully.' });
}, [form]);
return (
<form onSubmit={handleSubmit} noValidate className="crt-box" style={{ padding: '2rem' }}>
{errors.success && (
<div style={{ color: 'var(--color-green)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem', marginBottom: '1.25rem', background: 'rgba(0,255,65,0.07)', border: '1px solid rgba(0,255,65,0.2)', padding: '0.6rem 0.75rem' }}>
[OK] {errors.success}
</div>
)}
{[
{ key: 'current' as const, label: 'Current Password', auto: 'current-password' },
{ key: 'next' as const, label: 'New Password', auto: 'new-password' },
{ key: 'confirm' as const, label: 'Confirm New Password', auto: 'new-password' },
].map(({ key, label, auto }) => (
<div key={key} style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.75rem', marginBottom: '0.4rem' }}>{label}</label>
<input
className={`input-terminal${errors[key] ? ' error' : ''}`}
type="password"
autoComplete={auto}
value={form[key]}
onChange={(e) => { setForm((p) => ({ ...p, [key]: e.target.value })); setErrors((p) => ({ ...p, [key]: undefined })); }}
/>
{errors[key] && <div style={{ color: 'var(--color-red)', fontSize: '0.72rem', marginTop: '0.25rem' }}>{errors[key]}</div>}
</div>
))}
<button type="submit" className="btn-terminal" disabled={loading} style={{ marginTop: '0.5rem', opacity: loading ? 0.7 : 1 }}>
{loading ? 'Saving...' : '> Update Password'}
</button>
</form>
);
}

View File

@@ -0,0 +1,343 @@
import { useState, useCallback, useMemo } from 'react';
import { Link, Navigate, useParams } from 'react-router-dom';
import { MOCK_BUGS, MOCK_BUG_COMMENTS } from '../../data/mockData';
import { useAuth } from '../../contexts/AuthContext';
import { formatDate, formatDateTime } from '../../utils/format';
import type { BugComment, BugReport, BugSeverity, BugStatus } from '../../types';
// ── Helpers ────────────────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: BugStatus }) {
const map: Record<BugStatus, string> = {
open: 'badge-open', in_progress: 'badge-progress', resolved: 'badge-resolved', closed: 'badge-closed',
};
const labels: Record<BugStatus, string> = {
open: 'Open', in_progress: 'In Progress', resolved: 'Resolved', closed: 'Closed',
};
return <span className={`badge ${map[status]}`}>{labels[status]}</span>;
}
function SeverityBadge({ severity }: { severity: BugSeverity }) {
const map: Record<BugSeverity, string> = {
low: 'badge-low', medium: 'badge-medium', high: 'badge-high', critical: 'badge-critical',
};
return <span className={`badge ${map[severity]}`}>{severity}</span>;
}
// ── Comment component ──────────────────────────────────────────────────────────
function CommentItem({ comment }: { comment: BugComment }) {
return (
<div
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
padding: '0.9rem 1.1rem',
marginBottom: '0.5rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem', gap: '1rem', flexWrap: 'wrap' }}>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-cyan)', fontSize: '0.78rem' }}>
{comment.authorName}
</span>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', flexShrink: 0 }}>
{formatDateTime(comment.createdAt)}
</span>
</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.83rem', lineHeight: 1.75 }}>
{comment.content}
</div>
</div>
);
}
// ── Bug Detail Page ────────────────────────────────────────────────────────────
export default function BugDetailPage() {
const { id } = useParams<{ id: string }>();
const { user, isAuthenticated } = useAuth();
// Local state — mirrors the global bug list in memory
const [bugs, setBugs] = useState<BugReport[]>(MOCK_BUGS);
const [comments, setComments] = useState<BugComment[]>(MOCK_BUG_COMMENTS);
const [newComment, setNewComment] = useState('');
const [commentError, setCommentError] = useState('');
const [submitting, setSubmitting] = useState(false);
const bug = useMemo(() => bugs.find((b) => b.id === id), [bugs, id]);
const bugComments = useMemo(
() => comments.filter((c) => c.bugReportId === id).sort((a, b) => a.createdAt.localeCompare(b.createdAt)),
[comments, id]
);
// "I have this too" logic
const alreadyVoted = useMemo(
() => !!user && !!bug && bug.meTooBugs.includes(user.id),
[user, bug]
);
const isOwnReport = useMemo(
() => !!user && !!bug && bug.submittedById === user.id,
[user, bug]
);
const handleMeToo = useCallback(() => {
if (!user || !bug || alreadyVoted || isOwnReport) return;
setBugs((prev) =>
prev.map((b) =>
b.id === bug.id ? { ...b, meTooBugs: [...b.meTooBugs, user.id] } : b
)
);
}, [user, bug, alreadyVoted, isOwnReport]);
const handleComment = useCallback(async () => {
if (!user) return;
if (!newComment.trim()) { setCommentError('Comment cannot be empty.'); return; }
if (newComment.trim().length < 5) { setCommentError('Comment must be at least 5 characters.'); return; }
setCommentError('');
setSubmitting(true);
await new Promise((r) => setTimeout(r, 250));
const comment: BugComment = {
id: `bc${Date.now()}`,
bugReportId: id!,
authorId: user.id,
authorName: user.username,
content: newComment.trim(),
createdAt: new Date().toISOString(),
};
setComments((prev) => [...prev, comment]);
setNewComment('');
setSubmitting(false);
}, [user, newComment, id]);
if (!bug) {
return <Navigate to="/bugs" replace />;
}
const metooCount = bug.meTooBugs.length;
return (
<div style={{ maxWidth: '860px', margin: '0 auto', padding: '3rem 1.5rem' }}>
{/* Breadcrumb */}
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--color-text-muted)', marginBottom: '1.5rem' }}>
<Link to="/bugs" style={{ color: 'var(--color-cyan)' }}>Bug Reports</Link>
{' '}&gt;{' '}
<span style={{ color: 'var(--color-text-dim)' }}>{bug.uniqueCode}</span>
</div>
{/* Header */}
<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' }}>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.72rem' }}>
{bug.uniqueCode}
</span>
<StatusBadge status={bug.status} />
<SeverityBadge severity={bug.severity} />
</div>
{/* Title */}
<h1
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: 'clamp(1.4rem, 4vw, 2rem)',
marginBottom: '1.25rem',
}}
>
{bug.title}
</h1>
{/* Meta grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: '0.6rem', marginBottom: '1.5rem' }}>
{[
{ label: 'Submitted by', value: bug.submittedByName },
{ label: 'Date', value: formatDate(bug.createdAt) },
{ label: 'Game Version', value: `v${bug.gameVersion}` },
{ label: 'Assigned to', value: bug.assignedToName ?? 'Unassigned' },
].map(({ label, value }) => (
<div
key={label}
style={{
background: 'rgba(0,0,0,0.4)',
border: '1px solid var(--color-border-dim)',
padding: '0.55rem 0.7rem',
}}
>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.62rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: '0.2rem' }}>
{label}
</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.8rem' }}>{value}</div>
</div>
))}
</div>
{/* Description */}
<div style={{ marginBottom: '1.25rem' }}>
<div className="section-label" style={{ marginBottom: '0.4rem' }}>Description</div>
<div
style={{
background: 'rgba(0,0,0,0.4)',
border: '1px solid var(--color-border-dim)',
padding: '0.9rem 1rem',
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-dim)',
fontSize: '0.83rem',
lineHeight: 1.8,
whiteSpace: 'pre-wrap',
}}
>
{bug.description}
</div>
</div>
{/* Steps to reproduce */}
<div style={{ marginBottom: '1.5rem' }}>
<div className="section-label" style={{ marginBottom: '0.4rem' }}>Steps to Reproduce</div>
<div
style={{
background: 'rgba(0,0,0,0.4)',
border: '1px solid var(--color-border-dim)',
padding: '0.9rem 1rem',
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-dim)',
fontSize: '0.83rem',
lineHeight: 1.8,
whiteSpace: 'pre-wrap',
}}
>
{bug.stepsToReproduce}
</div>
</div>
{/* "I have this too" section */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '1rem',
flexWrap: 'wrap',
padding: '0.9rem 1rem',
background: 'rgba(0,255,255,0.04)',
border: '1px solid var(--color-border)',
}}
>
{/* Count */}
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-cyan)', fontSize: '0.82rem' }}>
<span style={{ fontSize: '1.1rem' }}>{metooCount}</span>{' '}
{metooCount === 1 ? 'user has' : 'users have'} this issue
</div>
{/* Button logic */}
{!isAuthenticated ? (
<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
</div>
) : isOwnReport ? (
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem' }}>
(this is your report)
</span>
) : alreadyVoted ? (
<div
style={{
fontFamily: 'var(--font-mono)',
fontSize: '0.78rem',
color: '#00ffaa',
border: '1px solid #00ffaa',
padding: '0.25rem 0.75rem',
background: 'rgba(0,255,170,0.07)',
cursor: 'default',
}}
>
&#10003; You reported this too
</div>
) : (
<button
className="btn-terminal"
onClick={handleMeToo}
style={{ fontSize: '0.78rem', padding: '0.3rem 0.9rem' }}
>
&#9654; I have this too
</button>
)}
</div>
</div>
{/* Comments section */}
<div style={{ marginTop: '2rem' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
borderBottom: '2px solid var(--color-border)',
paddingBottom: '0.5rem',
marginBottom: '1rem',
}}
>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-cyan)', fontSize: '0.72rem', letterSpacing: '0.15em', textTransform: 'uppercase' }}>
Discussion
</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' }}>
{bugComments.length}
</span>
</div>
{/* Comment list */}
{bugComments.length === 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.
</div>
) : (
bugComments.map((comment) => (
<CommentItem key={comment.id} comment={comment} />
))
)}
{/* Add comment */}
<div style={{ marginTop: '1.25rem' }}>
{isAuthenticated ? (
<div className="crt-box" style={{ padding: '1.25rem' }}>
<div className="section-label" style={{ marginBottom: '0.6rem' }}>Add a Comment</div>
<textarea
className={`input-terminal${commentError ? ' error' : ''}`}
rows={4}
placeholder="Write your comment..."
value={newComment}
onChange={(e) => { setNewComment(e.target.value); setCommentError(''); }}
style={{ resize: 'vertical', marginBottom: '0.6rem' }}
disabled={submitting}
aria-label="Comment text"
/>
{commentError && (
<div style={{ color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', marginBottom: '0.6rem' }}>
{commentError}
</div>
)}
<button
className="btn-terminal"
onClick={handleComment}
disabled={submitting}
style={{ opacity: submitting ? 0.6 : 1 }}
>
{submitting ? 'Posting...' : '&#9654; Post Comment'}
</button>
</div>
) : (
<div className="crt-box" style={{ padding: '1.25rem', textAlign: 'center' }}>
<p style={{ color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.82rem', marginBottom: '0.75rem' }}>
You must be logged in to comment.
</p>
<div style={{ display: 'flex', gap: '0.6rem', justifyContent: 'center' }}>
<Link to="/login" className="btn-terminal">Login</Link>
<Link to="/register" className="btn-terminal btn-amber">Register</Link>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,439 @@
import { useState, useMemo, useCallback } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { MOCK_BUGS } from '../../data/mockData';
import { useAuth } from '../../contexts/AuthContext';
import { timeAgo } from '../../utils/format';
import type { BugReport, BugSeverity, BugStatus, BugReportFormData } from '../../types';
// ── Helpers ────────────────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: BugStatus }) {
const map: Record<BugStatus, string> = {
open: 'badge-open',
in_progress: 'badge-progress',
resolved: 'badge-resolved',
closed: 'badge-closed',
};
const labels: Record<BugStatus, string> = {
open: 'Open', in_progress: 'In Progress', resolved: 'Resolved', closed: 'Closed',
};
return <span className={`badge ${map[status]}`}>{labels[status]}</span>;
}
function SeverityBadge({ severity }: { severity: BugSeverity }) {
const map: Record<BugSeverity, string> = {
low: 'badge-low', medium: 'badge-medium', high: 'badge-high', critical: 'badge-critical',
};
return <span className={`badge ${map[severity]}`}>{severity}</span>;
}
// ── Bug List Card ──────────────────────────────────────────────────────────────
interface BugCardProps {
bug: BugReport;
highlight?: boolean;
}
function BugCard({ bug, highlight }: BugCardProps) {
const navigate = useNavigate();
const handleClick = useCallback(() => {
navigate(`/bugs/${bug.id}`);
}, [bug.id, navigate]);
return (
<div
onClick={handleClick}
onKeyDown={(e) => e.key === 'Enter' && handleClick()}
role="button"
tabIndex={0}
aria-label={`View bug report ${bug.uniqueCode}: ${bug.title}`}
style={{
background: highlight ? 'rgba(255,255,0,0.04)' : 'var(--color-surface)',
border: `2px solid ${highlight ? 'var(--color-yellow)' : 'var(--color-border)'}`,
padding: '0.9rem 1.1rem',
cursor: 'pointer',
marginBottom: '0.5rem',
transition: 'border-color 0.1s, background 0.1s',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '1rem', flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: 0 }}>
{/* Badges row */}
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', flexWrap: 'wrap', marginBottom: '0.3rem' }}>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem' }}>
{bug.uniqueCode}
</span>
<StatusBadge status={bug.status} />
<SeverityBadge severity={bug.severity} />
{/* MeToo count */}
<span
style={{
fontFamily: 'var(--font-mono)',
fontSize: '0.65rem',
color: 'var(--color-cyan)',
border: '1px solid var(--color-cyan)',
padding: '0.05rem 0.4rem',
background: 'rgba(0,255,255,0.06)',
whiteSpace: 'nowrap',
}}
>
&#9654; {bug.meTooBugs.length} {bug.meTooBugs.length === 1 ? 'user' : 'users'} have this
</span>
</div>
{/* Title */}
<div
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text)',
fontSize: '0.87rem',
marginBottom: '0.2rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{bug.title}
</div>
{/* Meta */}
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem' }}>
by {bug.submittedByName} &mdash; {timeAgo(bug.createdAt)} &mdash; v{bug.gameVersion}
</div>
</div>
{/* Arrow */}
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-cyan)', fontSize: '0.75rem', flexShrink: 0, paddingTop: '2px' }}>
VIEW &gt;
</div>
</div>
</div>
);
}
// ── Submit Form ────────────────────────────────────────────────────────────────
const GAME_VERSIONS = ['0.9.3-alpha', '0.9.2-alpha', '0.9.1-alpha'];
const SEVERITIES: BugSeverity[] = ['low', 'medium', 'high', 'critical'];
function SubmitBugForm({ onSubmit }: { onSubmit: (data: BugReportFormData) => void }) {
const [form, setForm] = useState<BugReportFormData>({
title: '',
description: '',
stepsToReproduce: '',
severity: 'medium',
gameVersion: '0.9.3-alpha',
});
const [errors, setErrors] = useState<Partial<Record<keyof BugReportFormData, string>>>({});
const [submitted, setSubmitted] = useState(false);
const set = useCallback(<K extends keyof BugReportFormData>(key: K, value: BugReportFormData[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
setErrors((prev) => ({ ...prev, [key]: undefined }));
}, []);
const validate = (): boolean => {
const next: typeof errors = {};
if (!form.title.trim()) next.title = 'Title is required.';
else if (form.title.length < 10) next.title = 'Title must be at least 10 characters.';
if (!form.description.trim()) next.description = 'Description is required.';
else if (form.description.length < 20) next.description = 'Description must be at least 20 characters.';
if (!form.stepsToReproduce.trim()) next.stepsToReproduce = 'Steps to reproduce are required.';
setErrors(next);
return Object.keys(next).length === 0;
};
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
await new Promise((r) => setTimeout(r, 400));
onSubmit(form);
setSubmitted(true);
}, [form, onSubmit]);
const labelStyle: React.CSSProperties = {
display: 'block',
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-muted)',
fontSize: '0.72rem',
letterSpacing: '0.1em',
marginBottom: '0.35rem',
textTransform: 'uppercase',
};
if (submitted) {
return (
<div className="crt-box" style={{ padding: '2rem', textAlign: 'center' }}>
<div style={{ color: 'var(--color-cyan)', fontFamily: 'var(--font-mono)', fontSize: '0.9rem', marginBottom: '0.5rem' }}>
[OK] Bug report submitted.
</div>
<div style={{ color: 'var(--color-text-muted)', fontSize: '0.8rem', marginBottom: '1rem' }}>
A unique code has been assigned. The team will review it shortly.
</div>
<button className="btn-terminal" onClick={() => setSubmitted(false)}>
Submit Another
</button>
</div>
);
}
return (
<form onSubmit={handleSubmit} noValidate>
<div className="crt-box" style={{ padding: '1.5rem' }}>
<div className="section-label" style={{ marginBottom: '1.25rem' }}>
&#9654; Submit a Bug Report
</div>
<div style={{ marginBottom: '0.85rem' }}>
<label style={labelStyle}>Title *</label>
<input
className={`input-terminal${errors.title ? ' error' : ''}`}
type="text"
placeholder="Short, descriptive title..."
value={form.title}
onChange={(e) => set('title', e.target.value)}
/>
{errors.title && <div style={{ color: 'var(--color-red)', fontSize: '0.7rem', marginTop: '0.2rem' }}>{errors.title}</div>}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.85rem', marginBottom: '0.85rem' }}>
<div>
<label style={labelStyle}>Severity *</label>
<select className="input-terminal" value={form.severity} onChange={(e) => set('severity', e.target.value as BugSeverity)}>
{SEVERITIES.map((s) => <option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>)}
</select>
</div>
<div>
<label style={labelStyle}>Game Version *</label>
<select className="input-terminal" value={form.gameVersion} onChange={(e) => set('gameVersion', e.target.value)}>
{GAME_VERSIONS.map((v) => <option key={v} value={v}>{v}</option>)}
</select>
</div>
</div>
<div style={{ marginBottom: '0.85rem' }}>
<label style={labelStyle}>Description *</label>
<textarea
className={`input-terminal${errors.description ? ' error' : ''}`}
rows={4}
placeholder="Describe what happened..."
value={form.description}
onChange={(e) => set('description', e.target.value)}
style={{ resize: 'vertical' }}
/>
{errors.description && <div style={{ color: 'var(--color-red)', fontSize: '0.7rem', marginTop: '0.2rem' }}>{errors.description}</div>}
</div>
<div style={{ marginBottom: '0.85rem' }}>
<label style={labelStyle}>Steps to Reproduce *</label>
<textarea
className={`input-terminal${errors.stepsToReproduce ? ' error' : ''}`}
rows={4}
placeholder={'1. Go to...\n2. Click on...\n3. Observe...'}
value={form.stepsToReproduce}
onChange={(e) => set('stepsToReproduce', e.target.value)}
style={{ resize: 'vertical' }}
/>
{errors.stepsToReproduce && <div style={{ color: 'var(--color-red)', fontSize: '0.7rem', marginTop: '0.2rem' }}>{errors.stepsToReproduce}</div>}
</div>
<div style={{ marginBottom: '1.25rem' }}>
<label style={labelStyle}>Screenshot URL (optional)</label>
<input
className="input-terminal"
type="url"
placeholder="https://..."
value={form.screenshotUrl ?? ''}
onChange={(e) => set('screenshotUrl', e.target.value || undefined)}
/>
</div>
<button type="submit" className="btn-terminal">
&#9654; Submit Report
</button>
</div>
</form>
);
}
// ── Bug Report Page ────────────────────────────────────────────────────────────
export default function BugReportPage() {
const { user, isAuthenticated } = useAuth();
const [bugs, setBugs] = useState(MOCK_BUGS);
const [statusFilter, setStatusFilter] = useState<BugStatus | 'all'>('all');
const [severityFilter, setSeverityFilter] = useState<BugSeverity | 'all'>('all');
const [showForm, setShowForm] = useState(false);
// Separate: user's own bugs and all others, both filtered
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 other: BugReport[] = [];
bugs.forEach((b) => {
if (!passes(b)) return;
if (user && b.submittedById === user.id) my.push(b);
else other.push(b);
});
return { myBugs: my, otherBugs: other };
}, [bugs, statusFilter, severityFilter, user]);
const handleNewReport = useCallback((data: BugReportFormData) => {
const newBug: BugReport = {
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]);
setShowForm(false);
}, [bugs.length, user]);
const openCount = bugs.filter((b) => b.status === 'open').length;
const inProgressCount = bugs.filter((b) => b.status === 'in_progress').length;
const resolvedCount = bugs.filter((b) => b.status === 'resolved').length;
return (
<div style={{ maxWidth: '960px', margin: '0 auto', padding: '3rem 1.5rem' }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', flexWrap: 'wrap', gap: '1.5rem', marginBottom: '2rem' }}>
<div>
<div className="section-label">Issue Tracker</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: 'clamp(2rem, 6vw, 3.5rem)', marginTop: '0.25rem' }}>
BUG REPORTS
</h1>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.78rem', marginTop: '0.5rem', fontFamily: 'var(--font-mono)' }}>
<span style={{ color: 'var(--color-cyan)' }}>{openCount}</span> open &nbsp;&mdash;&nbsp;
<span style={{ color: 'var(--color-yellow)' }}>{inProgressCount}</span> in progress &nbsp;&mdash;&nbsp;
<span style={{ color: '#00ffaa' }}>{resolvedCount}</span> resolved
</p>
</div>
{isAuthenticated ? (
<button
className={`btn-terminal ${showForm ? 'btn-danger' : 'btn-amber'}`}
onClick={() => setShowForm((v) => !v)}
>
{showForm ? 'Cancel' : '&#9654; Submit Bug'}
</button>
) : (
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.78rem', color: 'var(--color-text-muted)' }}>
<Link to="/login">Login</Link> to submit a report
</div>
)}
</div>
{/* Submit form */}
{showForm && isAuthenticated && (
<div style={{ marginBottom: '1.75rem' }}>
<SubmitBugForm onSubmit={handleNewReport} />
</div>
)}
{/* Filters */}
<div style={{ display: 'flex', gap: '0.6rem', flexWrap: 'wrap', marginBottom: '1.5rem' }}>
<select
className="input-terminal"
style={{ width: 'auto', minWidth: '130px' }}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as BugStatus | 'all')}
aria-label="Filter by status"
>
<option value="all">All Statuses</option>
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
</select>
<select
className="input-terminal"
style={{ width: 'auto', minWidth: '130px' }}
value={severityFilter}
onChange={(e) => setSeverityFilter(e.target.value as BugSeverity | 'all')}
aria-label="Filter by severity"
>
<option value="all">All Severities</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
{/* "Your Reports" section — only for logged-in users with their own bugs */}
{isAuthenticated && myBugs.length > 0 && (
<section style={{ marginBottom: '2rem' }}>
<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 */}
<section>
{isAuthenticated && myBugs.length > 0 && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
marginBottom: '0.75rem',
paddingBottom: '0.4rem',
borderBottom: '2px solid var(--color-border)',
}}
>
<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>
);
}

View File

@@ -0,0 +1,188 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { MOCK_CATEGORIES, MOCK_THREADS } from '../../data/mockData';
import { timeAgo } from '../../utils/format';
import type { ForumCategory, ForumThread } from '../../types';
// ── Sub-components ─────────────────────────────────────────────────────────────
function CategoryCard({ category, threads }: { category: ForumCategory; threads: ForumThread[] }) {
const pinned = threads.filter((t) => t.isPinned && t.categoryId === category.id);
const regular = threads.filter((t) => !t.isPinned && t.categoryId === category.id);
const categoryThreads = [...pinned, ...regular];
return (
<section className="crt-box" style={{ marginBottom: '1.5rem' }}>
{/* Category header */}
<div
style={{
padding: '1rem 1.5rem',
borderBottom: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
flexWrap: 'wrap',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<span
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-green)',
fontSize: '0.75rem',
opacity: 0.7,
}}
>
{category.icon}
</span>
<div>
<h2
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: '1.1rem',
margin: 0,
}}
>
{category.name}
</h2>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem', margin: 0 }}>
{category.description}
</p>
</div>
</div>
<div
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-muted)',
fontSize: '0.72rem',
textAlign: 'right',
}}
>
<span style={{ color: 'var(--color-green)' }}>{category.threadCount}</span> threads
</div>
</div>
{/* Threads */}
<div>
{categoryThreads.length === 0 ? (
<div style={{ padding: '1.5rem', color: 'var(--color-text-muted)', fontSize: '0.8rem', textAlign: 'center' }}>
No threads yet. Be the first to post.
</div>
) : (
categoryThreads.map((thread, idx) => (
<div
key={thread.id}
style={{
padding: '0.85rem 1.5rem',
borderBottom: idx < categoryThreads.length - 1 ? '1px solid var(--color-border)' : 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
flexWrap: 'wrap',
background: thread.isPinned ? 'rgba(255,176,0,0.03)' : 'transparent',
}}
>
<div style={{ flex: 1, minWidth: '0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '0.25rem' }}>
{thread.isPinned && (
<span className="badge badge-progress">Pinned</span>
)}
{thread.isLocked && (
<span className="badge badge-closed">Locked</span>
)}
<Link
to={`/forum/thread/${thread.id}`}
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text)',
fontSize: '0.87rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{thread.title}
</Link>
</div>
<div style={{ color: 'var(--color-text-muted)', fontSize: '0.72rem', fontFamily: 'var(--font-mono)' }}>
by <span style={{ color: 'var(--color-text-dim)' }}>{thread.authorName}</span>
{' '}&mdash; {timeAgo(thread.createdAt)}
</div>
</div>
<div style={{ textAlign: 'right', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--color-text-muted)', flexShrink: 0 }}>
<div style={{ color: 'var(--color-green)' }}>{thread.replyCount}</div>
<div>replies</div>
</div>
</div>
))
)}
</div>
</section>
);
}
// ── Forum Page ─────────────────────────────────────────────────────────────────
export default function ForumPage() {
const [search, setSearch] = useState('');
const filteredCategories = useMemo(() => {
if (!search.trim()) return MOCK_CATEGORIES;
const q = search.toLowerCase();
return MOCK_CATEGORIES.filter((cat) =>
cat.name.toLowerCase().includes(q) ||
MOCK_THREADS.some((t) => t.categoryId === cat.id && t.title.toLowerCase().includes(q))
);
}, [search]);
return (
<div style={{ maxWidth: '960px', margin: '0 auto', padding: '4rem 1.5rem' }}>
{/* Header */}
<div style={{ marginBottom: '2.5rem', display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', flexWrap: 'wrap', gap: '1.5rem' }}>
<div>
<div className="section-label">Community</div>
<h1
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: 'clamp(2rem, 5vw, 3rem)',
marginTop: '0.5rem',
}}
>
FORUM
</h1>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8rem', marginTop: '0.5rem', fontFamily: 'var(--font-mono)' }}>
Read freely. Login to post.
</p>
</div>
{/* Search */}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<input
className="input-terminal"
type="search"
placeholder="Search threads..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ width: '220px' }}
aria-label="Search forum threads"
/>
</div>
</div>
{/* Categories */}
{filteredCategories.length === 0 ? (
<div className="crt-box" style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)' }}>
No results found for "{search}"
</div>
) : (
filteredCategories.map((cat) => (
<CategoryCard key={cat.id} category={cat} threads={MOCK_THREADS} />
))
)}
</div>
);
}

View File

@@ -0,0 +1,459 @@
import { Link } from 'react-router-dom';
// ── Sub-components ─────────────────────────────────────────────────────────────
function Redacted({ children }: { children: string }) {
return (
<span className="redacted" aria-label="censored" title="[BLEEP]">
{children}
</span>
);
}
function SectionDivider() {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '1rem',
margin: '0 auto 3rem',
maxWidth: '200px',
}}
>
<div style={{ flex: 1, height: '1px', background: 'var(--color-border)' }} />
<span style={{ color: 'var(--color-green)', fontFamily: 'var(--font-mono)', fontSize: '0.7rem' }}>///</span>
<div style={{ flex: 1, height: '1px', background: 'var(--color-border)' }} />
</div>
);
}
// ── Hero Section ───────────────────────────────────────────────────────────────
function HeroSection() {
return (
<section
className="scanlines vhs-grain"
style={{
position: 'relative',
minHeight: '92vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
overflow: 'hidden',
padding: '4rem 1.5rem',
}}
>
{/* Minitel background — deep navy with subtle cyan tint */}
<div
style={{
position: 'absolute',
inset: 0,
background: `
radial-gradient(ellipse 70% 50% at 50% 40%, rgba(0,0,80,0.7) 0%, transparent 70%),
var(--color-bg)
`,
zIndex: 0,
}}
/>
{/* Grid lines — Minitel character grid feel */}
<div
style={{
position: 'absolute',
inset: 0,
backgroundImage: `
linear-gradient(rgba(0,0,204,0.15) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,0,204,0.15) 1px, transparent 1px)
`,
backgroundSize: '40px 40px',
zIndex: 0,
}}
/>
<div style={{ position: 'relative', zIndex: 3, maxWidth: '900px', width: '100%' }}>
{/* Pre-title */}
<div className="section-label" style={{ marginBottom: '1.5rem' }}>
CrowMate Studio presents
</div>
{/* Game Title */}
<h1
className="glitch-text glow-green flicker"
data-text="HEADLESS HAZARD"
style={{
fontSize: 'clamp(3rem, 10vw, 8rem)',
fontFamily: 'var(--font-heading)',
color: 'var(--color-green)',
lineHeight: 1,
marginBottom: '0.5rem',
letterSpacing: '0.08em',
}}
>
HEADLESS HAZARD
</h1>
{/* Subtitle */}
<div
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-amber)',
fontSize: 'clamp(0.85rem, 2.5vw, 1.1rem)',
letterSpacing: '0.3em',
marginBottom: '2.5rem',
textTransform: 'uppercase',
}}
>
&gt;&gt; LOSE YOUR HEAD. KEEP YOUR BODY.
</div>
{/* Tagline */}
<p
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-dim)',
fontSize: 'clamp(0.85rem, 2vw, 1rem)',
maxWidth: '600px',
margin: '0 auto 3rem',
lineHeight: 1.8,
}}
>
Navigate a sprawling underground complex. Control a detached robotic head.
Survive bureaucratic hell. Save the girl. <br />
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.85em' }}>
(or don't — the corporation doesn't care either way)
</span>
</p>
{/* CTA buttons */}
<div style={{ display: 'flex', gap: '1rem', justifyContent: 'center', flexWrap: 'wrap' }}>
<a
href="#gameplay"
className="btn-terminal"
style={{ fontSize: '0.9rem', padding: '0.65rem 2rem' }}
>
&gt; Learn More
</a>
<Link
to="/forum"
className="btn-terminal btn-amber"
style={{ fontSize: '0.9rem', padding: '0.65rem 2rem' }}
>
&gt; Join Community
</Link>
</div>
{/* Version tag */}
<div
style={{
marginTop: '3rem',
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-muted)',
fontSize: '0.7rem',
letterSpacing: '0.15em',
}}
>
ALPHA v0.9.3 EARLY ACCESS COMING SOON
</div>
</div>
</section>
);
}
// ── Lore Section ───────────────────────────────────────────────────────────────
function LoreSection() {
return (
<section
id="lore"
style={{ padding: '6rem 1.5rem', maxWidth: '900px', margin: '0 auto' }}
>
<div style={{ textAlign: 'center', marginBottom: '3.5rem' }}>
<div className="section-label">The World</div>
<h2
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: 'clamp(2rem, 5vw, 3rem)',
marginTop: '0.5rem',
}}
>
SOMEWHERE UNDERGROUND
</h2>
</div>
<div className="crt-box" style={{ padding: '2.5rem' }}>
{/* Classification header */}
<div
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-red)',
fontSize: '0.7rem',
letterSpacing: '0.2em',
marginBottom: '1.5rem',
paddingBottom: '0.75rem',
borderBottom: '2px solid var(--color-red)',
}}
>
&#9632;&#9632;&#9632; CLASSIFIED LEVEL 9 CLEARANCE REQUIRED &#9632;&#9632;&#9632;
</div>
<div
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-dim)',
fontSize: '0.9rem',
lineHeight: 2,
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
}}
>
<p>
Deep beneath the surface, an underground megacomplex spans 47 floors of corridors, server rooms,
cafeterias, and security checkpoints all operated by{' '}
<Redacted>AMALGAM INDUSTRIES CORP</Redacted>
{', '}a corporation so powerful it has legally removed its own name from public record.
</p>
<p>
UNIT-7 is a security enforcement robot. Standard model. Bipedal, armored,
designed to neutralize threats with efficiency and no questions asked.
There is one small problem: its head is no longer attached to its body.
This is, officially, a{' '}
<span style={{ color: 'var(--color-amber)' }}>NON-CRITICAL OPERATIONAL DEVIATION</span>.
The head still works. The body still works. They just work... separately.
</p>
<p>
Then there is the girl. Eight years old. Lost. She wandered into the complex
through an unsecured maintenance hatch and when she found the central computer,
she did what any eight-year-old would do:{' '}
<span style={{ color: 'var(--color-green)' }}>she started pressing buttons</span>.
All of them. At once. The terminal, she explained later, looked exactly like
an arcade cabinet. This triggered{' '}
<span style={{ color: 'var(--color-red)' }}>PROTOCOL OMEGA</span> activating
every automated defense system, locking every door, and sealing every exit
in the facility.
</p>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.82rem', borderLeft: '2px solid var(--color-border)', paddingLeft: '1rem' }}>
The girl is currently located in Sector 12-C. She is eating from the emergency ration
storage and appears to be having the time of her life. UNIT-7 has been tasked with
extraction. Corporate does not know she exists. This is everyone's problem now.
</p>
</div>
</div>
</section>
);
}
// ── Gameplay Section ───────────────────────────────────────────────────────────
function GameplaySection() {
const mechanics = [
{
icon: '[CAM]',
title: 'Head as Camera',
desc: 'Roll, bounce, and launch your detached head through vents and around corners. The head sees everything your body cannot.',
},
{
icon: '[BOT]',
title: 'Body Controls',
desc: 'Direct your headless body remotely. Lift, punch, carry, operate terminals but it\'s blind. The head is its only eyes.',
},
{
icon: '[CO-OP]',
title: 'Multiplayer Chaos',
desc: 'One player controls the head, another the body. Communication is everything. Miscommunication is hilarious.',
},
{
icon: '[SLO]',
title: 'Solo Campaign',
desc: 'Switch between head and body control at will. 12 floors of escalating complexity, optional challenge rooms, and a full narrative.',
},
];
return (
<section
id="gameplay"
style={{
padding: '6rem 1.5rem',
background: 'rgba(0,0,0,0.2)',
borderTop: '1px solid var(--color-border)',
borderBottom: '1px solid var(--color-border)',
}}
>
<div style={{ maxWidth: '1100px', margin: '0 auto' }}>
<div style={{ textAlign: 'center', marginBottom: '3.5rem' }}>
<div className="section-label">How It Works</div>
<h2
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: 'clamp(2rem, 5vw, 3rem)',
marginTop: '0.5rem',
}}
>
GAMEPLAY MECHANICS
</h2>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.85rem', marginTop: '1rem', maxWidth: '500px', margin: '1rem auto 0' }}>
Second-person perspective puzzle-platformer with asymmetric co-op support.
Control the detached head as a camera drone. Direct the headless body remotely.
</p>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
gap: '1.5rem',
}}
>
{mechanics.map(({ icon, title, desc }) => (
<div key={title} className="crt-box" style={{ padding: '1.75rem' }}>
<div
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-green)',
fontSize: '0.75rem',
letterSpacing: '0.15em',
marginBottom: '0.75rem',
}}
>
{icon}
</div>
<h3
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: '1.1rem',
marginBottom: '0.6rem',
}}
>
{title}
</h3>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.82rem', lineHeight: 1.75, margin: 0 }}>
{desc}
</p>
</div>
))}
</div>
</div>
</section>
);
}
// ── Visual Style Section ───────────────────────────────────────────────────────
function VisualStyleSection() {
const attributes = [
{ label: 'Aesthetic', value: 'Retro-Futuristic / 1980s Megacorp' },
{ label: 'Visual Effect', value: 'VHS Tape Artifacts + CRT Scanlines' },
{ label: 'Color Palette', value: 'Terminal Green, Amber Warning, Void Black' },
{ label: 'Typography', value: 'Monospace + Condensed Industrial' },
{ label: 'Architecture', value: 'Brutalist Bunker + Corporate Bureaucracy' },
{ label: 'Tone', value: 'Dark Comedy / Kafkaesque Horror' },
];
return (
<section style={{ padding: '6rem 1.5rem', maxWidth: '900px', margin: '0 auto' }}>
<div style={{ textAlign: 'center', marginBottom: '3.5rem' }}>
<div className="section-label">Aesthetic</div>
<h2
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: 'clamp(2rem, 5vw, 3rem)',
marginTop: '0.5rem',
}}
>
THE VISUAL IDENTITY
</h2>
</div>
<div className="crt-box" style={{ padding: '2rem' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr',
gap: 0,
}}
>
{attributes.map(({ label, value }, i) => (
<div
key={label}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.85rem 0',
borderBottom: i < attributes.length - 1 ? '1px solid var(--color-border)' : 'none',
gap: '1rem',
flexWrap: 'wrap',
}}
>
<span
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-muted)',
fontSize: '0.75rem',
textTransform: 'uppercase',
letterSpacing: '0.15em',
flexShrink: 0,
}}
>
{label}
</span>
<span
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-green)',
fontSize: '0.85rem',
textAlign: 'right',
}}
>
{value}
</span>
</div>
))}
</div>
<div
style={{
marginTop: '2rem',
padding: '1.25rem',
background: 'rgba(0,255,65,0.03)',
border: '1px solid rgba(0,255,65,0.1)',
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-muted)',
fontSize: '0.8rem',
lineHeight: 1.8,
}}
>
<span style={{ color: 'var(--color-green)' }}>&gt; </span>
The world of Headless Hazard is a love letter to the aesthetic of late-80s science fiction.
Think corporate cafeterias lit by flickering fluorescent tubes. Think instruction manuals
written in Comic Sans translated from Japanese. Think a DANGER warning label on a door
that has been there so long nobody remembers what the danger was.
</div>
</div>
</section>
);
}
// ── Home Page ──────────────────────────────────────────────────────────────────
export default function HomePage() {
return (
<>
<HeroSection />
<LoreSection />
<SectionDivider />
<GameplaySection />
<SectionDivider />
<VisualStyleSection />
</>
);
}

View File

@@ -0,0 +1,151 @@
import { useState, useCallback, useEffect } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
export default function LoginPage() {
const { login, isAuthenticated } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const from = (location.state as { from?: Location })?.from?.pathname ?? '/';
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<{ email?: string; password?: string; form?: string }>({});
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isAuthenticated) navigate(from, { replace: true });
}, [isAuthenticated, from, navigate]);
const validate = (): boolean => {
const next: typeof errors = {};
if (!email.trim()) next.email = 'Email is required.';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) next.email = 'Enter a valid email address.';
if (!password) next.password = 'Password is required.';
setErrors(next);
return Object.keys(next).length === 0;
};
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
setLoading(true);
const result = await login(email, password);
setLoading(false);
if (!result.success) {
setErrors({ form: result.error });
}
}, [email, password, login]);
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem 1rem',
}}
>
<div style={{ width: '100%', maxWidth: '420px' }}>
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
<div className="section-label">Authentication</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '2rem', marginTop: '0.5rem' }}>
LOGIN
</h1>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.72rem', marginTop: '0.5rem' }}>
CROWMATE STUDIO / HEADLESS HAZARD COMMUNITY
</div>
</div>
<form onSubmit={handleSubmit} noValidate className="crt-box" style={{ padding: '2rem' }}>
{/* Demo hint */}
<div style={{ background: 'rgba(255,176,0,0.06)', border: '1px solid rgba(255,176,0,0.2)', padding: '0.75rem', marginBottom: '1.5rem' }}>
<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 && (
<div style={{ background: 'rgba(255,34,68,0.08)', border: '1px solid rgba(255,34,68,0.3)', color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem', padding: '0.75rem', marginBottom: '1.25rem' }}>
[ERROR] {errors.form}
</div>
)}
{/* Email */}
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.75rem', marginBottom: '0.4rem' }}>
Email Address
</label>
<input
className={`input-terminal${errors.email ? ' error' : ''}`}
type="email"
autoComplete="email"
placeholder="your@email.com"
value={email}
onChange={(e) => { setEmail(e.target.value); setErrors((p) => ({ ...p, email: undefined })); }}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && <div id="email-error" style={{ color: 'var(--color-red)', fontSize: '0.72rem', marginTop: '0.25rem' }}>{errors.email}</div>}
</div>
{/* Password */}
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.75rem', marginBottom: '0.4rem' }}>
Password
</label>
<input
className={`input-terminal${errors.password ? ' error' : ''}`}
type="password"
autoComplete="current-password"
placeholder="••••••••"
value={password}
onChange={(e) => { setPassword(e.target.value); setErrors((p) => ({ ...p, password: undefined })); }}
aria-describedby={errors.password ? 'pass-error' : undefined}
/>
{errors.password && <div id="pass-error" style={{ color: 'var(--color-red)', fontSize: '0.72rem', marginTop: '0.25rem' }}>{errors.password}</div>}
</div>
<button
type="submit"
className="btn-terminal"
disabled={loading}
style={{ width: '100%', justifyContent: 'center', opacity: loading ? 0.7 : 1, marginBottom: '1.25rem' }}
>
{loading ? 'Authenticating...' : '> Login'}
</button>
<div style={{ textAlign: 'center', fontFamily: 'var(--font-mono)', fontSize: '0.78rem', color: 'var(--color-text-muted)' }}>
No account?{' '}
<Link to="/register" style={{ color: 'var(--color-green)' }}>Register here</Link>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { Link } from 'react-router-dom';
export default function NotFoundPage() {
return (
<div
style={{
minHeight: 'calc(100vh - 56px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '4rem 1.5rem',
textAlign: 'center',
}}
>
<div>
<div
className="glow-green flicker"
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-green)',
fontSize: 'clamp(5rem, 20vw, 12rem)',
lineHeight: 1,
marginBottom: '1rem',
}}
>
404
</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.9rem', letterSpacing: '0.2em', marginBottom: '1.5rem' }}>
SECTOR NOT FOUND
</div>
<p style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.85rem', maxWidth: '400px', margin: '0 auto 2rem', lineHeight: 1.8 }}>
The page you're looking for doesn't exist, has been moved, or was redacted by{' '}
<span style={{ background: '#111', padding: '0 4px', color: 'transparent', border: '1px solid rgba(255,34,68,0.3)' }}>
AMALGAM CORP
</span>.
</p>
<Link to="/" className="btn-terminal">&gt; Return to Base</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,153 @@
import { useState, useCallback, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
export default function RegisterPage() {
const { register, isAuthenticated } = useAuth();
const navigate = useNavigate();
const [form, setForm] = useState({ username: '', email: '', password: '', confirmPassword: '' });
const [errors, setErrors] = useState<Partial<typeof form & { form: string }>>({});
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isAuthenticated) navigate('/', { replace: true });
}, [isAuthenticated, navigate]);
const set = (key: keyof typeof form, value: string) => {
setForm((prev) => ({ ...prev, [key]: value }));
setErrors((prev) => ({ ...prev, [key]: undefined }));
};
const validate = (): boolean => {
const next: typeof errors = {};
if (!form.username.trim()) next.username = 'Username is required.';
else if (form.username.length < 3) next.username = 'Username must be at least 3 characters.';
else if (!/^[a-zA-Z0-9_-]+$/.test(form.username)) next.username = 'Only letters, numbers, _ and - allowed.';
if (!form.email.trim()) next.email = 'Email is required.';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) next.email = 'Enter a valid email.';
if (!form.password) next.password = 'Password is required.';
else if (form.password.length < 8) next.password = 'Password must be at least 8 characters.';
if (form.password !== form.confirmPassword) next.confirmPassword = 'Passwords do not match.';
setErrors(next);
return Object.keys(next).length === 0;
};
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
setLoading(true);
const result = await register(form.username, form.email, form.password);
setLoading(false);
if (!result.success) {
setErrors({ form: result.error });
}
}, [form, register]);
const inputStyle = (_field?: keyof typeof form) => ({
display: 'block' as const,
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-muted)',
fontSize: '0.75rem',
marginBottom: '0.4rem',
});
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem 1rem',
}}
>
<div style={{ width: '100%', maxWidth: '440px' }}>
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
<div className="section-label">Create Account</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '2rem', marginTop: '0.5rem' }}>
REGISTER
</h1>
</div>
<form onSubmit={handleSubmit} noValidate className="crt-box" style={{ padding: '2rem' }}>
{errors.form && (
<div style={{ background: 'rgba(255,34,68,0.08)', border: '1px solid rgba(255,34,68,0.3)', color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem', padding: '0.75rem', marginBottom: '1.25rem' }}>
[ERROR] {errors.form}
</div>
)}
{/* Username */}
<div style={{ marginBottom: '1rem' }}>
<label style={inputStyle('username')}>Username</label>
<input
className={`input-terminal${errors.username ? ' error' : ''}`}
type="text"
autoComplete="username"
placeholder="YourCallsign"
value={form.username}
onChange={(e) => set('username', e.target.value)}
/>
{errors.username && <div style={{ color: 'var(--color-red)', fontSize: '0.72rem', marginTop: '0.25rem' }}>{errors.username}</div>}
</div>
{/* Email */}
<div style={{ marginBottom: '1rem' }}>
<label style={inputStyle('email')}>Email Address</label>
<input
className={`input-terminal${errors.email ? ' error' : ''}`}
type="email"
autoComplete="email"
placeholder="your@email.com"
value={form.email}
onChange={(e) => set('email', e.target.value)}
/>
{errors.email && <div style={{ color: 'var(--color-red)', fontSize: '0.72rem', marginTop: '0.25rem' }}>{errors.email}</div>}
</div>
{/* Password */}
<div style={{ marginBottom: '1rem' }}>
<label style={inputStyle('password')}>Password</label>
<input
className={`input-terminal${errors.password ? ' error' : ''}`}
type="password"
autoComplete="new-password"
placeholder="At least 8 characters"
value={form.password}
onChange={(e) => set('password', e.target.value)}
/>
{errors.password && <div style={{ color: 'var(--color-red)', fontSize: '0.72rem', marginTop: '0.25rem' }}>{errors.password}</div>}
</div>
{/* Confirm Password */}
<div style={{ marginBottom: '1.5rem' }}>
<label style={inputStyle('confirmPassword')}>Confirm Password</label>
<input
className={`input-terminal${errors.confirmPassword ? ' error' : ''}`}
type="password"
autoComplete="new-password"
placeholder="Repeat password"
value={form.confirmPassword}
onChange={(e) => set('confirmPassword', e.target.value)}
/>
{errors.confirmPassword && <div style={{ color: 'var(--color-red)', fontSize: '0.72rem', marginTop: '0.25rem' }}>{errors.confirmPassword}</div>}
</div>
<button
type="submit"
className="btn-terminal btn-amber"
disabled={loading}
style={{ width: '100%', justifyContent: 'center', opacity: loading ? 0.7 : 1, marginBottom: '1.25rem' }}
>
{loading ? 'Creating account...' : '> Create Account'}
</button>
<div style={{ textAlign: 'center', fontFamily: 'var(--font-mono)', fontSize: '0.78rem', color: 'var(--color-text-muted)' }}>
Already registered?{' '}
<Link to="/login" style={{ color: 'var(--color-green)' }}>Login here</Link>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,206 @@
import { TEAM_MEMBERS } from '../../data/mockData';
export default function StudioPage() {
return (
<div style={{ maxWidth: '1000px', margin: '0 auto', padding: '4rem 1.5rem' }}>
{/* Header */}
<div style={{ marginBottom: '4rem' }}>
<div className="section-label">About</div>
<h1
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: 'clamp(2.5rem, 6vw, 4rem)',
marginTop: '0.5rem',
marginBottom: '1.5rem',
}}
>
CROWMATE STUDIO
</h1>
<div
className="crt-box"
style={{ padding: '2rem', marginBottom: '3rem' }}
>
<p
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-dim)',
fontSize: '0.95rem',
lineHeight: 2,
margin: 0,
marginBottom: '1rem',
}}
>
CrowMate Studio is an independent game studio founded in 2023 by a team of six developers
united by a shared obsession: games that are strange, atmospheric, and actually interesting.
We are headquartered somewhere in Europe and operate fully remote.
</p>
<p
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-dim)',
fontSize: '0.95rem',
lineHeight: 2,
margin: 0,
}}
>
<span style={{ color: 'var(--color-green)' }}>&gt;&gt;</span>{' '}
Our debut title, <strong style={{ color: 'var(--color-text)' }}>Headless Hazard</strong>,
is currently in development. We believe that constraints breed creativity and that
you don't need a $200 million budget to make something that sticks.
</p>
</div>
</div>
{/* History & Vision */}
<div style={{ marginBottom: '4rem' }}>
<div className="section-label" style={{ marginBottom: '1rem' }}>Our Vision</div>
<h2
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: '1.8rem',
marginBottom: '1.5rem',
}}
>
WHY WE BUILD
</h2>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
gap: '1.25rem',
}}
>
{[
{
title: 'Strange Mechanics',
content: 'We look for the game ideas that make people say "wait, how does that even work?" then we find out.',
},
{
title: 'Atmospheric Worlds',
content: 'Every pixel, every sound, every line of UI text should reinforce the world. Atmosphere is not decoration, it is the game.',
},
{
title: 'Community First',
content: 'We build in public. We listen to our players. Bug reports are not annoyances they are conversations.',
},
].map(({ title, content }) => (
<div key={title} className="crt-box" style={{ padding: '1.5rem' }}>
<h3
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-amber)',
fontSize: '1rem',
marginBottom: '0.75rem',
}}
>
{title}
</h3>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.83rem', lineHeight: 1.75, margin: 0 }}>
{content}
</p>
</div>
))}
</div>
</div>
{/* Team */}
<div>
<div className="section-label" style={{ marginBottom: '1rem' }}>The Team</div>
<h2
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: '1.8rem',
marginBottom: '2rem',
}}
>
MEET THE CREW
</h2>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
gap: '1.25rem',
}}
>
{TEAM_MEMBERS.map((member) => (
<div key={member.id} className="crt-box" style={{ padding: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
{/* Avatar */}
<div
style={{
width: '48px',
height: '48px',
background: 'rgba(0,255,65,0.08)',
border: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: 'var(--font-heading)',
color: 'var(--color-green)',
fontSize: '1rem',
flexShrink: 0,
}}
>
{member.avatarInitials}
</div>
<div>
<div
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: '1rem',
}}
>
{member.name}
</div>
<div
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-green)',
fontSize: '0.7rem',
letterSpacing: '0.05em',
}}
>
{member.role}
</div>
</div>
</div>
{member.bio && (
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8rem', lineHeight: 1.7, margin: '0 0 1rem' }}>
{member.bio}
</p>
)}
{member.social && (
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
{member.social.twitter && (
<a
href="#"
style={{ color: 'var(--color-text-muted)', fontSize: '0.72rem', fontFamily: 'var(--font-mono)' }}
>
{member.social.twitter}
</a>
)}
{member.social.github && (
<a
href="#"
style={{ color: 'var(--color-text-muted)', fontSize: '0.72rem', fontFamily: 'var(--font-mono)' }}
>
gh/{member.social.github}
</a>
)}
</div>
)}
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,225 @@
import { useState, useCallback } from 'react';
import { Link, useParams, Navigate } from 'react-router-dom';
import { MOCK_THREADS, MOCK_REPLIES } from '../../data/mockData';
import { useAuth } from '../../contexts/AuthContext';
import { formatDateTime, timeAgo } from '../../utils/format';
export default function ThreadPage() {
const { id } = useParams<{ id: string }>();
const { user, isAuthenticated } = useAuth();
const thread = MOCK_THREADS.find((t) => t.id === id);
// 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 [replyError, setReplyError] = useState('');
const [submitting, setSubmitting] = useState(false);
const handleReply = useCallback(async () => {
if (!newReply.trim()) {
setReplyError('Reply cannot be empty.');
return;
}
if (newReply.trim().length < 10) {
setReplyError('Reply must be at least 10 characters.');
return;
}
setReplyError('');
setSubmitting(true);
await new Promise((r) => setTimeout(r, 300));
const reply = {
id: `r${Date.now()}`,
content: newReply.trim(),
authorId: user!.id,
authorName: user!.username,
threadId: id!,
createdAt: new Date().toISOString(),
};
setReplies((prev) => [...prev, reply]);
setNewReply('');
setSubmitting(false);
}, [newReply, user, id]);
if (!thread) {
return <Navigate to="/forum" replace />;
}
const category = thread.categoryName;
return (
<div style={{ maxWidth: '860px', margin: '0 auto', padding: '4rem 1.5rem' }}>
{/* Breadcrumb */}
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.75rem', color: 'var(--color-text-muted)', marginBottom: '1.5rem' }}>
<Link to="/forum" style={{ color: 'var(--color-text-muted)' }}>Forum</Link>
{' '}&gt;{' '}
<span style={{ color: 'var(--color-text-dim)' }}>{category}</span>
</div>
{/* Thread Header */}
<div className="crt-box" style={{ padding: '2rem', marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '0.75rem' }}>
{thread.isPinned && <span className="badge badge-progress">Pinned</span>}
{thread.isLocked && <span className="badge badge-closed">Locked</span>}
<span className="badge badge-open">{category}</span>
</div>
<h1
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: 'clamp(1.4rem, 4vw, 2rem)',
marginBottom: '1rem',
}}
>
{thread.title}
</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '1.5rem' }}>
<div
style={{
width: '32px',
height: '32px',
background: 'rgba(0,255,65,0.08)',
border: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: 'var(--font-heading)',
color: 'var(--color-green)',
fontSize: '0.85rem',
flexShrink: 0,
}}
>
{thread.authorName[0].toUpperCase()}
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>
<span style={{ color: 'var(--color-text-dim)' }}>{thread.authorName}</span>
<span style={{ color: 'var(--color-text-muted)' }}> &mdash; </span>
<span style={{ color: 'var(--color-text-muted)' }}>{formatDateTime(thread.createdAt)}</span>
</div>
</div>
<div
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-dim)',
fontSize: '0.88rem',
lineHeight: 1.85,
whiteSpace: 'pre-wrap',
}}
>
{thread.content}
</div>
</div>
{/* Replies */}
<div style={{ marginBottom: '2rem' }}>
<div className="section-label" style={{ marginBottom: '1rem' }}>
{replies.length} {replies.length === 1 ? 'Reply' : 'Replies'}
</div>
{replies.length === 0 ? (
<div className="crt-box" style={{ padding: '1.5rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>
No replies yet. Be the first to respond.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{replies.map((reply) => (
<div key={reply.id} className="crt-box" style={{ padding: '1.25rem 1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.75rem' }}>
<div
style={{
width: '28px',
height: '28px',
background: 'rgba(0,255,65,0.06)',
border: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: 'var(--font-heading)',
color: 'var(--color-green)',
fontSize: '0.75rem',
flexShrink: 0,
}}
>
{reply.authorName[0].toUpperCase()}
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.77rem' }}>
<span style={{ color: 'var(--color-text-dim)' }}>{reply.authorName}</span>
<span style={{ color: 'var(--color-text-muted)' }}> &mdash; {timeAgo(reply.createdAt)}</span>
</div>
</div>
<div
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-dim)',
fontSize: '0.85rem',
lineHeight: 1.8,
whiteSpace: 'pre-wrap',
}}
>
{reply.content}
</div>
</div>
))}
</div>
)}
</div>
{/* Reply form */}
{thread.isLocked ? (
<div className="crt-box" style={{ padding: '1.25rem', textAlign: 'center' }}>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.8rem' }}>
This thread is locked. No new replies can be posted.
</span>
</div>
) : isAuthenticated ? (
<div className="crt-box" style={{ padding: '1.5rem' }}>
<div className="section-label" style={{ marginBottom: '0.75rem' }}>Post a Reply</div>
<textarea
className={`input-terminal${replyError ? ' error' : ''}`}
rows={5}
placeholder="Write your reply..."
value={newReply}
onChange={(e) => {
setNewReply(e.target.value);
if (replyError) setReplyError('');
}}
style={{ resize: 'vertical', marginBottom: '0.75rem' }}
aria-label="Reply content"
disabled={submitting}
/>
{replyError && (
<div style={{ color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.75rem', marginBottom: '0.75rem' }}>
[ERROR] {replyError}
</div>
)}
<button
className="btn-terminal"
onClick={handleReply}
disabled={submitting}
style={{ opacity: submitting ? 0.6 : 1 }}
>
{submitting ? 'Posting...' : '> Post Reply'}
</button>
</div>
) : (
<div className="crt-box" style={{ padding: '1.5rem', textAlign: 'center' }}>
<p style={{ color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.85rem', marginBottom: '1rem' }}>
You must be logged in to reply.
</p>
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}>
<Link to="/login" className="btn-terminal">Login</Link>
<Link to="/register" className="btn-terminal btn-amber">Register</Link>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,161 @@
// ── User & Auth ────────────────────────────────────────────────────────────────
export type UserRole = 'user' | 'dev' | 'com';
export interface User {
id: string;
username: string;
email: string;
role: UserRole;
isAdmin: boolean;
isBanned: boolean;
createdAt: string; // ISO 8601
avatarUrl?: string;
}
export interface AuthState {
user: User | null;
isAuthenticated: boolean;
}
// ── Forum ──────────────────────────────────────────────────────────────────────
export interface ForumCategory {
id: string;
name: string;
description: string;
icon: string;
threadCount: number;
lastActivity?: string;
}
export interface ForumThread {
id: string;
title: string;
authorId: string;
authorName: string;
categoryId: string;
categoryName: string;
content: string;
isPinned: boolean;
isLocked: boolean;
replyCount: number;
createdAt: string;
updatedAt: string;
lastReplyAuthor?: string;
}
export interface ForumReply {
id: string;
content: string;
authorId: string;
authorName: string;
threadId: string;
createdAt: string;
}
// ── Bug Reports ────────────────────────────────────────────────────────────────
export type BugSeverity = 'low' | 'medium' | 'high' | 'critical';
export type BugStatus = 'open' | 'in_progress' | 'resolved' | 'closed';
export interface BugReport {
id: string;
uniqueCode: string;
title: string;
description: string;
stepsToReproduce: string;
severity: BugSeverity;
gameVersion: string;
screenshotUrl?: string;
status: BugStatus;
submittedById: string;
submittedByName: string;
assignedToId?: string;
assignedToName?: string;
createdAt: string;
updatedAt: string;
notes?: BugReportNote[];
/** IDs of users who clicked "I have this too" */
meTooBugs: string[];
}
export interface BugComment {
id: string;
bugReportId: string;
authorId: string;
authorName: string;
content: string;
createdAt: string;
}
export interface BugReportNote {
id: string;
bugReportId: string;
authorId: string;
authorName: string;
content: string;
createdAt: string;
}
export interface BugReportFormData {
title: string;
description: string;
stepsToReproduce: string;
severity: BugSeverity;
gameVersion: string;
screenshotUrl?: string;
}
// ── Staff Feed ─────────────────────────────────────────────────────────────────
export interface StaffPost {
id: string;
authorId: string;
authorName: string;
authorRole: UserRole;
content: string;
createdAt: string;
}
// ── Team / Studio ──────────────────────────────────────────────────────────────
export interface TeamMember {
id: string;
name: string;
role: string;
bio?: string;
avatarInitials: string;
social?: {
twitter?: string;
github?: string;
};
}
// ── Forms ──────────────────────────────────────────────────────────────────────
export interface LoginFormData {
email: string;
password: string;
}
export interface RegisterFormData {
username: string;
email: string;
password: string;
confirmPassword: string;
}
export interface ChangePasswordFormData {
currentPassword: string;
newPassword: string;
confirmPassword: string;
}
// ── Filters ────────────────────────────────────────────────────────────────────
export interface BugFilters {
status: BugStatus | 'all';
severity: BugSeverity | 'all';
assignedTo: string | 'all';
}

View File

@@ -0,0 +1,47 @@
/**
* Format an ISO 8601 date string to a human-readable date.
*/
export function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
/**
* Format an ISO 8601 date string to a human-readable datetime.
*/
export function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
/**
* Return a relative time string (e.g. "3 days ago").
*/
export function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return formatDate(iso);
}
/**
* Truncate a string to maxLength and append ellipsis.
*/
export function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength).trimEnd() + '...';
}

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
})