chore: merge nest-front history into monorepo
This commit is contained in:
29
nest-front/.gitignore
vendored
Normal file
29
nest-front/.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# 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?
|
||||
|
||||
# MISC
|
||||
.claude
|
||||
claude.md
|
||||
subject.md
|
||||
18
nest-front/Dockerfile
Normal file
18
nest-front/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
73
nest-front/README.md
Normal file
73
nest-front/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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
6
nest-front/docker-compose.yml
Normal file
6
nest-front/docker-compose.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
services:
|
||||
nest-front:
|
||||
build: .
|
||||
ports:
|
||||
- "5173:5173"
|
||||
restart: unless-stopped
|
||||
23
nest-front/eslint.config.js
Normal file
23
nest-front/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/index.html
Normal file
19
nest-front/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>
|
||||
9
nest-front/nginx.conf
Normal file
9
nest-front/nginx.conf
Normal file
@@ -0,0 +1,9 @@
|
||||
server {
|
||||
listen 5173;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
3817
nest-front/package-lock.json
generated
Normal file
3817
nest-front/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
nest-front/package.json
Normal file
33
nest-front/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/public/manifest.json
Normal file
21
nest-front/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": "#ffffff",
|
||||
"theme_color": "#ffffff",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
1
nest-front/public/vite.svg
Normal file
1
nest-front/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/src/App.css
Normal file
1
nest-front/src/App.css
Normal file
@@ -0,0 +1 @@
|
||||
/* App-level overrides — global styles live in index.css */
|
||||
54
nest-front/src/App.tsx
Normal file
54
nest-front/src/App.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
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 { PageLoader } from './components/shared/PageLoader';
|
||||
|
||||
// ── Public Pages (lazy-loaded) ────────────────────────────────────────────────
|
||||
|
||||
const HomePage = lazy(() => import('./pages/public/HomePage'));
|
||||
const StudioPage = lazy(() => import('./pages/public/StudioPage'));
|
||||
const EventsPage = lazy(() => import('./pages/public/EventsPage'));
|
||||
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'));
|
||||
|
||||
// ── 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="events" element={<EventsPage />} />
|
||||
<Route path="forum" element={<ForumPage />} />
|
||||
<Route path="forum/thread/:id" element={<ThreadPage />} />
|
||||
<Route path="bugs" element={<BugReportPage />} />
|
||||
<Route path="bugs/:id" element={<BugDetailPage />} />
|
||||
<Route
|
||||
path="account"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AccountPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="login" element={<LoginPage />} />
|
||||
<Route path="register" element={<RegisterPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
1
nest-front/src/assets/react.svg
Normal file
1
nest-front/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 |
31
nest-front/src/components/layout/PublicLayout.tsx
Normal file
31
nest-front/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>
|
||||
);
|
||||
}
|
||||
118
nest-front/src/components/shared/DevRoleSwitcher.tsx
Normal file
118
nest-front/src/components/shared/DevRoleSwitcher.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
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: 'var(--color-surface)',
|
||||
border: '2px solid var(--color-yellow)',
|
||||
padding: '0.75rem',
|
||||
zIndex: 9999,
|
||||
fontSize: '0.7rem',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-text)',
|
||||
maxWidth: '220px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
<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/src/components/shared/Footer.tsx
Normal file
103
nest-front/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: 'var(--color-surface-alt)',
|
||||
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>
|
||||
);
|
||||
}
|
||||
202
nest-front/src/components/shared/Navbar.tsx
Normal file
202
nest-front/src/components/shared/Navbar.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
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: '/events', label: 'Events', end: false },
|
||||
{ to: '/forum', label: 'Forum', end: false },
|
||||
{ to: '/bugs', label: 'Bugs', end: false },
|
||||
];
|
||||
|
||||
export function Navbar() {
|
||||
const { user, isAuthenticated, 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>
|
||||
))}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
25
nest-front/src/components/shared/PageLoader.tsx
Normal file
25
nest-front/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/src/components/shared/ProtectedRoute.tsx
Normal file
29
nest-front/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/src/contexts/AuthContext.tsx
Normal file
156
nest-front/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;
|
||||
}
|
||||
797
nest-front/src/data/mockData.ts
Normal file
797
nest-front/src/data/mockData.ts
Normal file
@@ -0,0 +1,797 @@
|
||||
import type {
|
||||
User,
|
||||
ForumCategory,
|
||||
ForumThread,
|
||||
ForumReply,
|
||||
BugReport,
|
||||
BugComment,
|
||||
BugReportNote,
|
||||
StaffPost,
|
||||
TeamMember,
|
||||
EventPost,
|
||||
Poll,
|
||||
} 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',
|
||||
},
|
||||
];
|
||||
|
||||
// ── Mock Events & Polls ────────────────────────────────────────────────────────
|
||||
|
||||
export const MOCK_POLLS: Poll[] = [
|
||||
{
|
||||
id: 'poll1',
|
||||
eventId: 'evt2',
|
||||
question: 'Which feature should we prioritize for the next major update?',
|
||||
options: [
|
||||
{ id: 'opt1', text: 'New multiplayer maps', votes: 42, votedUserIds: ['u3', 'u4', 'u5', 'u7'] },
|
||||
{ id: 'opt2', text: 'Co-op campaign mode', votes: 78, votedUserIds: ['u1', 'u2', 'u8'] },
|
||||
{ id: 'opt3', text: 'Advanced physics system', votes: 35, votedUserIds: [] },
|
||||
{ id: 'opt4', text: 'Character customization', votes: 51, votedUserIds: [] },
|
||||
],
|
||||
isActive: true,
|
||||
endsAt: '2026-02-28T23:59:59Z',
|
||||
allowMultipleVotes: false,
|
||||
createdAt: '2026-02-16T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'poll2',
|
||||
eventId: 'evt5',
|
||||
question: 'What type of content would you like to see more of in our devlogs?',
|
||||
options: [
|
||||
{ id: 'opt5', text: 'Behind-the-scenes coding', votes: 23, votedUserIds: [] },
|
||||
{ id: 'opt6', text: 'Art process & concept art', votes: 45, votedUserIds: [] },
|
||||
{ id: 'opt7', text: 'Level design breakdown', votes: 18, votedUserIds: [] },
|
||||
{ id: 'opt8', text: 'Bug fix explanations', votes: 12, votedUserIds: [] },
|
||||
],
|
||||
isActive: false,
|
||||
endsAt: '2026-02-10T23:59:59Z',
|
||||
allowMultipleVotes: true,
|
||||
createdAt: '2026-02-01T09:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_EVENTS: EventPost[] = [
|
||||
{
|
||||
id: 'evt1',
|
||||
type: 'milestone',
|
||||
title: 'Version 0.9.5 Released!',
|
||||
content: 'We are excited to announce version 0.9.5 is now live! This update includes major performance improvements, the new "Factory District" map, and over 30 bug fixes. Check the changelog for full details. Thank you to everyone who participated in testing!',
|
||||
authorId: 'u1',
|
||||
authorName: 'Kestrel',
|
||||
authorRole: 'dev',
|
||||
createdAt: '2026-02-17T14:00:00Z',
|
||||
isPublic: true,
|
||||
},
|
||||
{
|
||||
id: 'evt2',
|
||||
type: 'poll',
|
||||
title: 'Community Poll: Next Feature Priority',
|
||||
content: 'Help us decide what to work on next! We want to hear from you about which feature would enhance your experience the most. Vote below and feel free to discuss in the forum.',
|
||||
authorId: 'u2',
|
||||
authorName: 'Vesper',
|
||||
authorRole: 'com',
|
||||
createdAt: '2026-02-16T10:00:00Z',
|
||||
isPublic: true,
|
||||
pollId: 'poll1',
|
||||
},
|
||||
{
|
||||
id: 'evt3',
|
||||
type: 'announcement',
|
||||
title: 'Server Maintenance Scheduled',
|
||||
content: 'We will be performing server maintenance on February 20th from 2:00 AM to 6:00 AM UTC. Multiplayer services will be unavailable during this time. Single-player mode will remain accessible. We apologize for any inconvenience!',
|
||||
authorId: 'u8',
|
||||
authorName: 'ByteWitch',
|
||||
authorRole: 'dev',
|
||||
createdAt: '2026-02-15T16:30:00Z',
|
||||
isPublic: true,
|
||||
},
|
||||
{
|
||||
id: 'evt4',
|
||||
type: 'update',
|
||||
title: 'Co-op Mode Development Progress',
|
||||
content: 'Quick update on co-op mode development: networking code is 80% complete, and we\'ve successfully tested 4-player sessions internally. Still working on some sync issues with physics objects, but overall progress is excellent. Aiming for beta testing in March!',
|
||||
authorId: 'u1',
|
||||
authorName: 'Kestrel',
|
||||
authorRole: 'dev',
|
||||
createdAt: '2026-02-14T11:20:00Z',
|
||||
isPublic: true,
|
||||
},
|
||||
{
|
||||
id: 'evt5',
|
||||
type: 'poll',
|
||||
title: 'Devlog Content Poll',
|
||||
content: 'We want to make our devlogs more interesting for you! Let us know what kind of behind-the-scenes content you\'d like to see more of. You can vote for multiple options.',
|
||||
authorId: 'u2',
|
||||
authorName: 'Vesper',
|
||||
authorRole: 'com',
|
||||
createdAt: '2026-02-01T09:00:00Z',
|
||||
isPublic: true,
|
||||
pollId: 'poll2',
|
||||
},
|
||||
{
|
||||
id: 'evt6',
|
||||
type: 'announcement',
|
||||
title: 'Community AMA This Wednesday',
|
||||
content: 'Join us for a live AMA (Ask Me Anything) session this Wednesday at 7:00 PM UTC! The dev team will be answering questions about the game, upcoming features, and the development process. Post your questions in the forum thread beforehand or ask live during the session.',
|
||||
authorId: 'u2',
|
||||
authorName: 'Vesper',
|
||||
authorRole: 'com',
|
||||
createdAt: '2026-02-12T14:00:00Z',
|
||||
isPublic: true,
|
||||
},
|
||||
{
|
||||
id: 'evt7',
|
||||
type: 'update',
|
||||
title: 'New Character Model Work in Progress',
|
||||
content: 'Our art team has been working on updated character models with more detailed textures while maintaining the retro aesthetic. Early tests look fantastic! We\'ll share some screenshots next week. This won\'t affect performance - we\'re being very careful about optimization.',
|
||||
authorId: 'u1',
|
||||
authorName: 'Kestrel',
|
||||
authorRole: 'dev',
|
||||
createdAt: '2026-02-08T10:15:00Z',
|
||||
isPublic: true,
|
||||
},
|
||||
];
|
||||
|
||||
// ── 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' },
|
||||
},
|
||||
];
|
||||
269
nest-front/src/index.css
Normal file
269
nest-front/src/index.css
Normal file
@@ -0,0 +1,269 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ── Design Tokens — White Theme ─────────────────── */
|
||||
:root {
|
||||
--color-bg: #ffffff;
|
||||
--color-bg-alt: #f8f9fa;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-alt: #f1f3f5;
|
||||
--color-border: #dee2e6;
|
||||
--color-border-dim: #e9ecef;
|
||||
|
||||
/* Primary accent: blue */
|
||||
--color-yellow: #2563eb;
|
||||
--color-yellow-dim: #1d4ed8;
|
||||
|
||||
/* Secondary accent: dark gray */
|
||||
--color-cyan: #374151;
|
||||
--color-cyan-dim: #4b5563;
|
||||
|
||||
/* Tertiary: purple */
|
||||
--color-magenta: #7c3aed;
|
||||
--color-magenta-dim: #6d28d9;
|
||||
|
||||
/* Red for errors/danger */
|
||||
--color-red: #dc2626;
|
||||
|
||||
/* Text */
|
||||
--color-text: #1f2937;
|
||||
--color-text-dim: #4b5563;
|
||||
--color-text-muted: #9ca3af;
|
||||
|
||||
/* Legacy aliases kept so existing pages compile without changes */
|
||||
--color-green: #059669;
|
||||
--color-green-dim: #047857;
|
||||
--color-amber: #d97706;
|
||||
--color-amber-dim: #b45309;
|
||||
|
||||
--font-mono: 'JetBrains Mono', 'Courier New', monospace;
|
||||
--font-heading: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
/* ── 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);
|
||||
font-size: 17px;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
line-height: 1.7;
|
||||
display: block;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Scrollbar ─────────────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 8px; }
|
||||
::-webkit-scrollbar-track { background: var(--color-bg-alt); }
|
||||
::-webkit-scrollbar-thumb { background: var(--color-border); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--color-cyan); }
|
||||
|
||||
/* ── Typography ────────────────────────────────────────── */
|
||||
h1, h2, h3, h4, h5 {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-yellow);
|
||||
text-decoration: underline;
|
||||
transition: color 0.1s;
|
||||
}
|
||||
a:hover { color: var(--color-yellow-dim); }
|
||||
|
||||
/* ── Content box ─────────────────────────────────────── */
|
||||
.crt-box {
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* ── 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 — blocked text ─── */
|
||||
.redacted {
|
||||
display: inline-block;
|
||||
color: #6b7280;
|
||||
background: #6b7280;
|
||||
font-family: var(--font-mono);
|
||||
padding: 0 2px;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
letter-spacing: 0;
|
||||
border: 1px solid #6b7280;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
.redacted:hover {
|
||||
background: var(--color-red);
|
||||
border-color: var(--color-red);
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
/* ── Color classes ── */
|
||||
.glow-green { color: var(--color-green); }
|
||||
.glow-amber { color: var(--color-amber); }
|
||||
.flicker {}
|
||||
|
||||
/* ── Buttons — clean white theme style ───────────────── */
|
||||
.btn-terminal {
|
||||
font-family: var(--font-mono);
|
||||
background: var(--color-bg);
|
||||
border: 2px solid var(--color-yellow);
|
||||
color: var(--color-yellow);
|
||||
padding: 0.6rem 1.3rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1rem;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-terminal::before { display: none; }
|
||||
.btn-terminal:hover {
|
||||
background: var(--color-yellow);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-amber {
|
||||
border-color: var(--color-amber);
|
||||
color: var(--color-amber);
|
||||
}
|
||||
.btn-amber:hover {
|
||||
background: var(--color-amber);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
border-color: var(--color-red);
|
||||
color: var(--color-red);
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background: var(--color-red);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Form inputs — clean style ──────────────────── */
|
||||
.input-terminal {
|
||||
background: var(--color-bg);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.05rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
width: 100%;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
.input-terminal::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.input-terminal:focus {
|
||||
border-color: var(--color-yellow);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
.input-terminal.error {
|
||||
border-color: var(--color-red);
|
||||
}
|
||||
|
||||
/* ── Section label ─────────────────────────────────────── */
|
||||
.section-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
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 — clean style ──────────────────────── */
|
||||
.badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
border: 1px solid;
|
||||
}
|
||||
.badge-open { background: #d1fae5; color: #065f46; border-color: #059669; }
|
||||
.badge-progress { background: #fef3c7; color: #92400e; border-color: #d97706; }
|
||||
.badge-resolved { background: #dbeafe; color: #1e40af; border-color: #2563eb; }
|
||||
.badge-closed { background: #f3f4f6; color: #6b7280; border-color: #9ca3af; }
|
||||
.badge-critical { background: #fee2e2; color: #991b1b; border-color: #dc2626; }
|
||||
.badge-high { background: #fed7aa; color: #9a3412; border-color: #ea580c; }
|
||||
.badge-medium { background: #fef3c7; color: #92400e; border-color: #d97706; }
|
||||
.badge-low { background: #e0e7ff; color: #3730a3; border-color: #6366f1; }
|
||||
|
||||
/* ── Blink cursor ──────────────────────────────────────── */
|
||||
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
|
||||
.cursor-blink::after {
|
||||
content: '_';
|
||||
animation: blink 1s step-end infinite;
|
||||
color: var(--color-text-dim);
|
||||
}
|
||||
|
||||
/* ── Horizontal rule ───────────────────────────── */
|
||||
.minitel-rule {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
/* ── Block decoration ──────────────────────────── */
|
||||
.block-deco {
|
||||
color: var(--color-border);
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: -2px;
|
||||
user-select: none;
|
||||
}
|
||||
16
nest-front/src/main.tsx
Normal file
16
nest-front/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>
|
||||
);
|
||||
248
nest-front/src/pages/public/AccountPage.tsx
Normal file
248
nest-front/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>
|
||||
);
|
||||
}
|
||||
347
nest-front/src/pages/public/BugDetailPage.tsx
Normal file
347
nest-front/src/pages/public/BugDetailPage.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
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: 'var(--color-surface-alt)',
|
||||
border: '1px solid var(--color-border)',
|
||||
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: 'var(--color-surface-alt)',
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '0.9rem 1rem',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-text-dim)',
|
||||
fontSize: '0.83rem',
|
||||
lineHeight: 1.8,
|
||||
whiteSpace: 'pre-wrap',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
>
|
||||
{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: 'var(--color-surface-alt)',
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '0.9rem 1rem',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-text-dim)',
|
||||
fontSize: '0.83rem',
|
||||
lineHeight: 1.8,
|
||||
whiteSpace: 'pre-wrap',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
>
|
||||
{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(5,150,105,0.05)',
|
||||
border: '1px solid rgba(5,150,105,0.2)',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
>
|
||||
{/* Count */}
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-green)', 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: 'var(--color-green)',
|
||||
border: '1px solid var(--color-green)',
|
||||
padding: '0.25rem 0.75rem',
|
||||
background: 'rgba(5,150,105,0.08)',
|
||||
cursor: 'default',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
✓ 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>
|
||||
);
|
||||
}
|
||||
440
nest-front/src/pages/public/BugReportPage.tsx
Normal file
440
nest-front/src/pages/public/BugReportPage.tsx
Normal file
@@ -0,0 +1,440 @@
|
||||
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-green)',
|
||||
border: '1px solid var(--color-green)',
|
||||
padding: '0.05rem 0.4rem',
|
||||
background: 'rgba(5,150,105,0.08)',
|
||||
whiteSpace: 'nowrap',
|
||||
borderRadius: '3px',
|
||||
}}
|
||||
>
|
||||
▶ {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: 'var(--color-green)' }}>{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>
|
||||
);
|
||||
}
|
||||
366
nest-front/src/pages/public/EventsPage.tsx
Normal file
366
nest-front/src/pages/public/EventsPage.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { MOCK_EVENTS, MOCK_POLLS } from '../../data/mockData';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { formatDateTime } from '../../utils/format';
|
||||
import type { EventPost, EventType, Poll, UserRole } from '../../types';
|
||||
|
||||
const EVENT_TYPE_COLORS: Record<EventType, string> = {
|
||||
announcement: 'var(--color-yellow)',
|
||||
update: 'var(--color-blue)',
|
||||
milestone: 'var(--color-green)',
|
||||
poll: 'var(--color-amber)',
|
||||
};
|
||||
|
||||
const EVENT_TYPE_LABELS: Record<EventType, string> = {
|
||||
announcement: 'ANNOUNCEMENT',
|
||||
update: 'DEV UPDATE',
|
||||
milestone: 'MILESTONE',
|
||||
poll: 'COMMUNITY POLL',
|
||||
};
|
||||
|
||||
const ROLE_COLORS: Record<UserRole, string> = {
|
||||
dev: 'var(--color-green)',
|
||||
com: 'var(--color-amber)',
|
||||
user: 'var(--color-text-muted)',
|
||||
};
|
||||
|
||||
// ── Poll Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
function PollCard({ poll, onVote }: { poll: Poll; onVote: (pollId: string, optionId: string) => void }) {
|
||||
const { user } = useAuth();
|
||||
const totalVotes = poll.options.reduce((sum, opt) => sum + opt.votes, 0);
|
||||
const isEnded = poll.endsAt ? new Date(poll.endsAt) < new Date() : false;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-bg-alt)',
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '1rem',
|
||||
marginTop: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--color-text)',
|
||||
marginBottom: '0.85rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{poll.question}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{poll.options.map((option) => {
|
||||
const percentage = totalVotes > 0 ? Math.round((option.votes / totalVotes) * 100) : 0;
|
||||
const userVoted = option.votedUserIds.includes(user?.id || '');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
style={{
|
||||
position: 'relative',
|
||||
background: 'var(--color-surface)',
|
||||
border: `1px solid ${userVoted ? 'var(--color-amber)' : 'var(--color-border)'}`,
|
||||
padding: '0.6rem 0.75rem',
|
||||
cursor: !isEnded && poll.isActive && user ? 'pointer' : 'default',
|
||||
opacity: isEnded || !poll.isActive ? 0.7 : 1,
|
||||
transition: 'border-color 0.2s',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!isEnded && poll.isActive && user) {
|
||||
onVote(poll.id, option.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Progress bar */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
width: `${percentage}%`,
|
||||
background: userVoted
|
||||
? 'rgba(217,119,6,0.15)'
|
||||
: 'rgba(59,130,246,0.1)',
|
||||
transition: 'width 0.3s ease',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--color-text-dim)' }}>
|
||||
{userVoted && '✓ '}
|
||||
{option.text}
|
||||
</span>
|
||||
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.7rem' }}>
|
||||
{option.votes} ({percentage}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.75rem',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.65rem',
|
||||
color: 'var(--color-text-muted)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{totalVotes} total vote{totalVotes !== 1 ? 's' : ''}
|
||||
{poll.allowMultipleVotes && ' • Multiple votes allowed'}
|
||||
</span>
|
||||
{poll.endsAt && (
|
||||
<span style={{ color: isEnded ? 'var(--color-red)' : 'var(--color-amber)' }}>
|
||||
{isEnded ? 'Poll Ended' : `Ends ${formatDateTime(poll.endsAt)}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!user && !isEnded && poll.isActive && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.75rem',
|
||||
padding: '0.5rem',
|
||||
background: 'rgba(217,119,6,0.1)',
|
||||
border: '1px solid rgba(217,119,6,0.25)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.7rem',
|
||||
color: 'var(--color-amber)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Please <a href="/login" style={{ color: 'var(--color-amber)', textDecoration: 'underline' }}>log in</a> to vote
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Event Card Component ───────────────────────────────────────────────────────
|
||||
|
||||
function EventCard({
|
||||
event,
|
||||
poll,
|
||||
onVote,
|
||||
}: {
|
||||
event: EventPost;
|
||||
poll?: Poll;
|
||||
onVote: (pollId: string, optionId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '1.5rem',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
background: `${EVENT_TYPE_COLORS[event.type]}15`,
|
||||
border: `1px solid ${EVENT_TYPE_COLORS[event.type]}40`,
|
||||
color: EVENT_TYPE_COLORS[event.type],
|
||||
fontSize: '0.6rem',
|
||||
padding: '0.2rem 0.5rem',
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
{EVENT_TYPE_LABELS[event.type]}
|
||||
</span>
|
||||
</div>
|
||||
<h2
|
||||
style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
color: 'var(--color-text)',
|
||||
fontSize: 'clamp(1.25rem, 4vw, 1.5rem)',
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{event.title}
|
||||
</h2>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: '0.7rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: ROLE_COLORS[event.authorRole] }}>
|
||||
{event.authorName}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{formatDateTime(event.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-text-dim)',
|
||||
fontSize: '0.88rem',
|
||||
lineHeight: 1.75,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{event.content}
|
||||
</div>
|
||||
|
||||
{/* Poll if exists */}
|
||||
{poll && <PollCard poll={poll} onVote={onVote} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function EventsPage() {
|
||||
const { user } = useAuth();
|
||||
// Filter to show only public events
|
||||
const publicEvents = MOCK_EVENTS.filter((event) => event.isPublic);
|
||||
const [events] = useState<EventPost[]>(publicEvents);
|
||||
const [polls, setPolls] = useState<Poll[]>(MOCK_POLLS);
|
||||
|
||||
const handleVote = useCallback(
|
||||
(pollId: string, optionId: string) => {
|
||||
if (!user) return;
|
||||
|
||||
setPolls((prevPolls) =>
|
||||
prevPolls.map((poll) => {
|
||||
if (poll.id !== pollId) return poll;
|
||||
|
||||
const hasVotedForOption = poll.options.some((opt) =>
|
||||
opt.votedUserIds.includes(user.id)
|
||||
);
|
||||
|
||||
return {
|
||||
...poll,
|
||||
options: poll.options.map((opt) => {
|
||||
if (opt.id === optionId) {
|
||||
// Add vote to this option
|
||||
return {
|
||||
...opt,
|
||||
votes: opt.votedUserIds.includes(user.id) ? opt.votes : opt.votes + 1,
|
||||
votedUserIds: opt.votedUserIds.includes(user.id)
|
||||
? opt.votedUserIds
|
||||
: [...opt.votedUserIds, user.id],
|
||||
};
|
||||
} else if (!poll.allowMultipleVotes && hasVotedForOption) {
|
||||
// Remove vote from other options if single vote
|
||||
return {
|
||||
...opt,
|
||||
votes: opt.votedUserIds.includes(user.id) ? opt.votes - 1 : opt.votes,
|
||||
votedUserIds: opt.votedUserIds.filter((id) => id !== user.id),
|
||||
};
|
||||
}
|
||||
return opt;
|
||||
}),
|
||||
};
|
||||
})
|
||||
);
|
||||
},
|
||||
[user]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: '3rem', textAlign: 'center' }}>
|
||||
<div
|
||||
className="section-label"
|
||||
style={{ marginBottom: '0.75rem' }}
|
||||
>
|
||||
DEVELOPMENT UPDATES
|
||||
</div>
|
||||
<h1
|
||||
style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
color: 'var(--color-text)',
|
||||
fontSize: 'clamp(2rem, 6vw, 3rem)',
|
||||
marginBottom: '1rem',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
COMMUNITY EVENTS
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: '0.85rem',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
Stay up to date with the latest game development news, announcements, and participate
|
||||
in community polls to help shape the future of Headless Hazard.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Events Grid */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||
{events.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '3rem 2rem',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
>
|
||||
No events available at the moment. Check back soon!
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
events.map((event) => {
|
||||
const poll = event.pollId ? polls.find((p) => p.id === event.pollId) : undefined;
|
||||
return <EventCard key={event.id} event={event} poll={poll} onVote={handleVote} />;
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
nest-front/src/pages/public/ForumPage.tsx
Normal file
188
nest-front/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(217,119,6,0.05)' : '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>
|
||||
);
|
||||
}
|
||||
460
nest-front/src/pages/public/HomePage.tsx
Normal file
460
nest-front/src/pages/public/HomePage.tsx
Normal file
@@ -0,0 +1,460 @@
|
||||
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',
|
||||
}}
|
||||
>
|
||||
{/* Subtle gradient background */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: `
|
||||
radial-gradient(ellipse 70% 50% at 50% 40%, rgba(37,99,235,0.05) 0%, transparent 70%),
|
||||
var(--color-bg)
|
||||
`,
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Grid lines — subtle pattern */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(200,200,200,0.3) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(200,200,200,0.3) 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: 'var(--color-bg-alt)',
|
||||
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(5,150,105,0.05)',
|
||||
border: '1px solid rgba(5,150,105,0.15)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: '0.8rem',
|
||||
lineHeight: 1.8,
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
>
|
||||
<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/src/pages/public/LoginPage.tsx
Normal file
151
nest-front/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(217,119,6,0.08)', border: '1px solid rgba(217,119,6,0.2)', padding: '0.75rem', marginBottom: '1.5rem', borderRadius: '6px' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.7rem', letterSpacing: '0.05em', marginBottom: '0.4rem' }}>
|
||||
[DEMO] Quick login emails:
|
||||
</div>
|
||||
{[
|
||||
{ label: 'Dev/Admin', email: 'kestrel@crowmate.dev' },
|
||||
{ label: 'Com Staff', email: 'vesper@crowmate.dev' },
|
||||
{ label: 'User', email: 'glitch@mail.com' },
|
||||
].map(({ label, email: e }) => (
|
||||
<button
|
||||
key={e}
|
||||
type="button"
|
||||
onClick={() => { setEmail(e); setPassword('password'); }}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--color-text-muted)',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.65rem',
|
||||
display: 'block',
|
||||
textAlign: 'left',
|
||||
padding: '0.1rem 0',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
> {label}: {e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{errors.form && (
|
||||
<div style={{ background: 'rgba(220,38,38,0.1)', border: '1px solid rgba(220,38,38,0.3)', color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem', padding: '0.75rem', marginBottom: '1.25rem', borderRadius: '6px' }}>
|
||||
[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/src/pages/public/NotFoundPage.tsx
Normal file
41
nest-front/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: '#6b7280', padding: '0 4px', color: 'transparent', border: '1px solid var(--color-red)' }}>
|
||||
AMALGAM CORP
|
||||
</span>.
|
||||
</p>
|
||||
<Link to="/" className="btn-terminal">> Return to Base</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
nest-front/src/pages/public/RegisterPage.tsx
Normal file
153
nest-front/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(220,38,38,0.1)', border: '1px solid rgba(220,38,38,0.3)', color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem', padding: '0.75rem', marginBottom: '1.25rem', borderRadius: '6px' }}>
|
||||
[ERROR] {errors.form}
|
||||
</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/src/pages/public/StudioPage.tsx
Normal file
206
nest-front/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/src/pages/public/ThreadPage.tsx
Normal file
225
nest-front/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>
|
||||
);
|
||||
}
|
||||
197
nest-front/src/types/index.ts
Normal file
197
nest-front/src/types/index.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
// ── 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;
|
||||
}
|
||||
|
||||
// ── Events & Polls ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type EventType = 'announcement' | 'update' | 'milestone' | 'poll';
|
||||
|
||||
export interface EventPost {
|
||||
id: string;
|
||||
type: EventType;
|
||||
title: string;
|
||||
content: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
authorRole: UserRole;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
isPublic: boolean; // whether visible to community
|
||||
pollId?: string; // reference to poll if type is 'poll'
|
||||
}
|
||||
|
||||
export interface PollOption {
|
||||
id: string;
|
||||
text: string;
|
||||
votes: number;
|
||||
votedUserIds: string[]; // track who voted for this option
|
||||
}
|
||||
|
||||
export interface Poll {
|
||||
id: string;
|
||||
eventId: string;
|
||||
question: string;
|
||||
options: PollOption[];
|
||||
isActive: boolean;
|
||||
endsAt?: string; // ISO 8601
|
||||
allowMultipleVotes: boolean;
|
||||
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/src/utils/format.ts
Normal file
47
nest-front/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/tsconfig.app.json
Normal file
28
nest-front/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/tsconfig.json
Normal file
7
nest-front/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
nest-front/tsconfig.node.json
Normal file
26
nest-front/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/vite.config.ts
Normal file
10
nest-front/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