Init project
This commit is contained in:
24
nest-front/client/.gitignore
vendored
Normal file
24
nest-front/client/.gitignore
vendored
Normal 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?
|
||||
73
nest-front/client/README.md
Normal file
73
nest-front/client/README.md
Normal 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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
nest-front/client/eslint.config.js
Normal file
23
nest-front/client/eslint.config.js
Normal 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,
|
||||
},
|
||||
},
|
||||
])
|
||||
19
nest-front/client/index.html
Normal file
19
nest-front/client/index.html
Normal 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
3817
nest-front/client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
nest-front/client/package.json
Normal file
33
nest-front/client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
21
nest-front/client/public/manifest.json
Normal file
21
nest-front/client/public/manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
1
nest-front/client/public/vite.svg
Normal file
1
nest-front/client/public/vite.svg
Normal 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 |
1
nest-front/client/src/App.css
Normal file
1
nest-front/client/src/App.css
Normal file
@@ -0,0 +1 @@
|
||||
/* App-level overrides — global styles live in index.css */
|
||||
77
nest-front/client/src/App.tsx
Normal file
77
nest-front/client/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
nest-front/client/src/assets/react.svg
Normal file
1
nest-front/client/src/assets/react.svg
Normal 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 |
149
nest-front/client/src/components/layout/IntranetLayout.tsx
Normal file
149
nest-front/client/src/components/layout/IntranetLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
nest-front/client/src/components/layout/PublicLayout.tsx
Normal file
31
nest-front/client/src/components/layout/PublicLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
nest-front/client/src/components/shared/DevRoleSwitcher.tsx
Normal file
116
nest-front/client/src/components/shared/DevRoleSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
nest-front/client/src/components/shared/Footer.tsx
Normal file
103
nest-front/client/src/components/shared/Footer.tsx
Normal 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' }}
|
||||
>
|
||||
> {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"
|
||||
>
|
||||
> {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)' }}>
|
||||
© {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>
|
||||
);
|
||||
}
|
||||
263
nest-front/client/src/components/shared/Navbar.tsx
Normal file
263
nest-front/client/src/components/shared/Navbar.tsx
Normal 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',
|
||||
})}
|
||||
>
|
||||
▮ 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,
|
||||
})}
|
||||
>
|
||||
▮ INTRANET
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
25
nest-front/client/src/components/shared/PageLoader.tsx
Normal file
25
nest-front/client/src/components/shared/PageLoader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
nest-front/client/src/components/shared/ProtectedRoute.tsx
Normal file
29
nest-front/client/src/components/shared/ProtectedRoute.tsx
Normal 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}</>;
|
||||
}
|
||||
156
nest-front/client/src/contexts/AuthContext.tsx
Normal file
156
nest-front/client/src/contexts/AuthContext.tsx
Normal 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;
|
||||
}
|
||||
678
nest-front/client/src/data/mockData.ts
Normal file
678
nest-front/client/src/data/mockData.ts
Normal 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' },
|
||||
},
|
||||
];
|
||||
261
nest-front/client/src/index.css
Normal file
261
nest-front/client/src/index.css
Normal 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;
|
||||
}
|
||||
16
nest-front/client/src/main.tsx
Normal file
16
nest-front/client/src/main.tsx
Normal 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>
|
||||
);
|
||||
251
nest-front/client/src/pages/intranet/IntranetBugs.tsx
Normal file
251
nest-front/client/src/pages/intranet/IntranetBugs.tsx
Normal 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} — 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">✕</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} — {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>
|
||||
);
|
||||
}
|
||||
164
nest-front/client/src/pages/intranet/IntranetDashboard.tsx
Normal file
164
nest-front/client/src/pages/intranet/IntranetDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
nest-front/client/src/pages/intranet/IntranetFeed.tsx
Normal file
145
nest-front/client/src/pages/intranet/IntranetFeed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
231
nest-front/client/src/pages/intranet/IntranetModeration.tsx
Normal file
231
nest-front/client/src/pages/intranet/IntranetModeration.tsx
Normal 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 — {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} — {thread.categoryName} — {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">✕</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></>}
|
||||
{' '}— {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>
|
||||
);
|
||||
}
|
||||
188
nest-front/client/src/pages/intranet/IntranetUsers.tsx
Normal file
188
nest-front/client/src/pages/intranet/IntranetUsers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
248
nest-front/client/src/pages/public/AccountPage.tsx
Normal file
248
nest-front/client/src/pages/public/AccountPage.tsx
Normal 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} — {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>
|
||||
);
|
||||
}
|
||||
343
nest-front/client/src/pages/public/BugDetailPage.tsx
Normal file
343
nest-front/client/src/pages/public/BugDetailPage.tsx
Normal 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>
|
||||
{' '}>{' '}
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
✓ You reported this too
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="btn-terminal"
|
||||
onClick={handleMeToo}
|
||||
style={{ fontSize: '0.78rem', padding: '0.3rem 0.9rem' }}
|
||||
>
|
||||
▶ 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...' : '▶ 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>
|
||||
);
|
||||
}
|
||||
439
nest-front/client/src/pages/public/BugReportPage.tsx
Normal file
439
nest-front/client/src/pages/public/BugReportPage.tsx
Normal 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',
|
||||
}}
|
||||
>
|
||||
▶ {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} — {timeAgo(bug.createdAt)} — v{bug.gameVersion}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-cyan)', fontSize: '0.75rem', flexShrink: 0, paddingTop: '2px' }}>
|
||||
VIEW >
|
||||
</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' }}>
|
||||
▶ 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">
|
||||
▶ 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 —
|
||||
<span style={{ color: 'var(--color-yellow)' }}>{inProgressCount}</span> in progress —
|
||||
<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' : '▶ 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' }}>
|
||||
▶ 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>
|
||||
);
|
||||
}
|
||||
188
nest-front/client/src/pages/public/ForumPage.tsx
Normal file
188
nest-front/client/src/pages/public/ForumPage.tsx
Normal 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>
|
||||
{' '}— {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>
|
||||
);
|
||||
}
|
||||
459
nest-front/client/src/pages/public/HomePage.tsx
Normal file
459
nest-front/client/src/pages/public/HomePage.tsx
Normal 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',
|
||||
}}
|
||||
>
|
||||
>> 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' }}
|
||||
>
|
||||
> Learn More
|
||||
</a>
|
||||
<Link
|
||||
to="/forum"
|
||||
className="btn-terminal btn-amber"
|
||||
style={{ fontSize: '0.9rem', padding: '0.65rem 2rem' }}
|
||||
>
|
||||
> 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)',
|
||||
}}
|
||||
>
|
||||
■■■ CLASSIFIED — LEVEL 9 CLEARANCE REQUIRED ■■■
|
||||
</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)' }}>> </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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
151
nest-front/client/src/pages/public/LoginPage.tsx
Normal file
151
nest-front/client/src/pages/public/LoginPage.tsx
Normal 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%',
|
||||
}}
|
||||
>
|
||||
> {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>
|
||||
);
|
||||
}
|
||||
41
nest-front/client/src/pages/public/NotFoundPage.tsx
Normal file
41
nest-front/client/src/pages/public/NotFoundPage.tsx
Normal 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">> Return to Base</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
nest-front/client/src/pages/public/RegisterPage.tsx
Normal file
153
nest-front/client/src/pages/public/RegisterPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
206
nest-front/client/src/pages/public/StudioPage.tsx
Normal file
206
nest-front/client/src/pages/public/StudioPage.tsx
Normal 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)' }}>>></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>
|
||||
);
|
||||
}
|
||||
225
nest-front/client/src/pages/public/ThreadPage.tsx
Normal file
225
nest-front/client/src/pages/public/ThreadPage.tsx
Normal 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>
|
||||
{' '}>{' '}
|
||||
<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)' }}> — </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)' }}> — {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>
|
||||
);
|
||||
}
|
||||
161
nest-front/client/src/types/index.ts
Normal file
161
nest-front/client/src/types/index.ts
Normal 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';
|
||||
}
|
||||
47
nest-front/client/src/utils/format.ts
Normal file
47
nest-front/client/src/utils/format.ts
Normal 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() + '...';
|
||||
}
|
||||
28
nest-front/client/tsconfig.app.json
Normal file
28
nest-front/client/tsconfig.app.json
Normal 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"]
|
||||
}
|
||||
7
nest-front/client/tsconfig.json
Normal file
7
nest-front/client/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
nest-front/client/tsconfig.node.json
Normal file
26
nest-front/client/tsconfig.node.json
Normal 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"]
|
||||
}
|
||||
10
nest-front/client/vite.config.ts
Normal file
10
nest-front/client/vite.config.ts
Normal 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(),
|
||||
],
|
||||
})
|
||||
Reference in New Issue
Block a user