chore: merge nest-intra history into monorepo
This commit is contained in:
24
nest-intra/.gitignore
vendored
Normal file
24
nest-intra/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
18
nest-intra/Dockerfile
Normal file
18
nest-intra/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 5174
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
60
nest-intra/README.md
Normal file
60
nest-intra/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# CrowMate Intranet
|
||||
|
||||
Internal staff portal for CrowMate Studio — Headless Hazard.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The intranet runs on **http://localhost:5174** (port 5174 to avoid conflicts with the public site on 5173).
|
||||
|
||||
## Demo Accounts
|
||||
|
||||
| Account | Email | Role |
|
||||
|---------|-------|------|
|
||||
| Kestrel (Admin) | `kestrel@crowmate.dev` | dev + admin |
|
||||
| Vesper (Staff) | `vesper@crowmate.dev` | com |
|
||||
|
||||
Regular user accounts are blocked from intranet access.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
App.tsx # Router (login + intranet routes)
|
||||
main.tsx # Entry point
|
||||
index.css # Design tokens & global styles
|
||||
contexts/
|
||||
AuthContext.tsx # Auth state (staff-only login)
|
||||
components/
|
||||
layout/
|
||||
IntranetLayout.tsx # Sidebar + main content layout
|
||||
shared/
|
||||
ProtectedRoute.tsx # Route guard (auth + staff check)
|
||||
PageLoader.tsx # Loading spinner
|
||||
pages/
|
||||
LoginPage.tsx # Staff login page
|
||||
intranet/
|
||||
IntranetDashboard.tsx # Overview stats & nav tiles
|
||||
IntranetBugs.tsx # Bug triage (filter, assign, notes)
|
||||
IntranetFeed.tsx # Staff-only activity feed
|
||||
IntranetEvents.tsx # Create/manage events & polls
|
||||
IntranetUsers.tsx # User management (promote/ban)
|
||||
IntranetModeration.tsx # Forum moderation (pin/lock/delete)
|
||||
data/
|
||||
mockData.ts # All mock data
|
||||
types/
|
||||
index.ts # TypeScript interfaces
|
||||
utils/
|
||||
format.ts # Date formatting helpers
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
- `npm run dev` — Start dev server
|
||||
- `npm run build` — Type-check + production build
|
||||
- `npm run lint` — Run ESLint
|
||||
- `npm run preview` — Preview production build
|
||||
6
nest-intra/docker-compose.yml
Normal file
6
nest-intra/docker-compose.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
services:
|
||||
nest-intra:
|
||||
build: .
|
||||
ports:
|
||||
- "5174:5174"
|
||||
restart: unless-stopped
|
||||
23
nest-intra/eslint.config.js
Normal file
23
nest-intra/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,
|
||||
},
|
||||
},
|
||||
])
|
||||
16
nest-intra/index.html
Normal file
16
nest-intra/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0a0d0a" />
|
||||
<meta name="description" content="CrowMate Studio — Internal Intranet" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title>CrowMate Intranet</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
15
nest-intra/nginx.conf
Normal file
15
nest-intra/nginx.conf
Normal file
@@ -0,0 +1,15 @@
|
||||
server {
|
||||
listen 5174;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:3000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
3833
nest-intra/package-lock.json
generated
Normal file
3833
nest-intra/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
nest-intra/package.json
Normal file
33
nest-intra/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "nest-intra",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
27
nest-intra/public/services.json
Normal file
27
nest-intra/public/services.json
Normal file
@@ -0,0 +1,27 @@
|
||||
[
|
||||
{
|
||||
"category": "Development",
|
||||
"services": [
|
||||
{ "name": "GitHub", "url": "https://github.com/CrowMate" },
|
||||
{ "name": "Plane", "url": "https://plane.crowmate.fr/" },
|
||||
{ "name": "Jenkins", "url": "https://jenkins.crowmate.fr/" },
|
||||
{ "name": "Excalidraw", "url": "https://excalidraw.com/#room=dd1651be168b191eee57,LYP7GsVY6EKoyyp6vxWUGQ" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Infrastructure",
|
||||
"services": [
|
||||
{ "name": "Proxmox", "url": "https://proxmox.devgoblin.me/" },
|
||||
{ "name": "Cloudflare", "url": "https://cloudflare.com/" },
|
||||
{ "name": "Coolify", "url": "https://deploy.crowmate.fr/" },
|
||||
{ "name": "Grafana", "url": "https://grafana.crowmate.fr/" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Communication",
|
||||
"services": [
|
||||
{ "name": "Google Drive", "url": "https://drive.google.com/drive/folders/1hegR6sCQ5a5BGfUgZKZu8MwBY7o7SeQb" },
|
||||
{ "name": "Mail", "url": "https://zimbra1.mail.ovh.net/" }
|
||||
]
|
||||
}
|
||||
]
|
||||
61
nest-intra/src/App.tsx
Normal file
61
nest-intra/src/App.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { ProtectedRoute } from './components/shared/ProtectedRoute';
|
||||
import { IntranetLayout } from './components/layout/IntranetLayout';
|
||||
import { PageLoader } from './components/shared/PageLoader';
|
||||
|
||||
// ── Pages (lazy-loaded) ──────────────────────────────────────────────────────
|
||||
|
||||
const LoginPage = lazy(() => import('./pages/LoginPage'));
|
||||
const IntranetDashboard = lazy(() => import('./pages/intranet/IntranetDashboard'));
|
||||
const IntranetBugs = lazy(() => import('./pages/intranet/IntranetBugs'));
|
||||
const IntranetFeed = lazy(() => import('./pages/intranet/IntranetFeed'));
|
||||
const IntranetEvents = lazy(() => import('./pages/intranet/IntranetEvents'));
|
||||
const IntranetUsers = lazy(() => import('./pages/intranet/IntranetUsers'));
|
||||
const IntranetModeration = lazy(() => import('./pages/intranet/IntranetModeration'));
|
||||
const IntranetServices = lazy(() => import('./pages/intranet/IntranetServices'));
|
||||
|
||||
// ── App ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
{/* Login — own Suspense so the full-page loader only shows here */}
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<LoginPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Intranet (staff only) — Suspense is inside IntranetLayout */}
|
||||
<Route
|
||||
path="/intranet"
|
||||
element={
|
||||
<ProtectedRoute staffOnly redirectTo="/login">
|
||||
<IntranetLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<IntranetDashboard />} />
|
||||
<Route path="bugs" element={<IntranetBugs />} />
|
||||
<Route path="feed" element={<IntranetFeed />} />
|
||||
<Route path="events" element={<IntranetEvents />} />
|
||||
<Route path="users" element={<IntranetUsers />} />
|
||||
<Route path="moderation" element={<IntranetModeration />} />
|
||||
<Route path="services" element={<IntranetServices />} />
|
||||
</Route>
|
||||
|
||||
{/* Redirect root to intranet */}
|
||||
<Route path="/" element={<Navigate to="/intranet" replace />} />
|
||||
|
||||
{/* Catch-all: redirect to intranet */}
|
||||
<Route path="*" element={<Navigate to="/intranet" replace />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
161
nest-intra/src/components/layout/IntranetLayout.tsx
Normal file
161
nest-intra/src/components/layout/IntranetLayout.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useCallback, Suspense } from 'react';
|
||||
|
||||
const INTRANET_LINKS = [
|
||||
{ to: '/intranet', label: 'Dashboard', icon: '[>]', end: true },
|
||||
{ to: '/intranet/bugs', label: 'Bug Reports', icon: '[!]', end: false },
|
||||
{ to: '/intranet/feed', label: 'Team Feed', icon: '[~]', end: false },
|
||||
{ to: '/intranet/events', label: 'Events', icon: '[E]', end: false },
|
||||
{ to: '/intranet/users', label: 'Users', icon: '[U]', end: false },
|
||||
{ to: '/intranet/moderation', label: 'Moderation', icon: '[M]', end: false },
|
||||
{ to: '/intranet/services', label: 'Services', icon: '[S]', end: false },
|
||||
];
|
||||
|
||||
export function IntranetLayout() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
logout();
|
||||
navigate('/');
|
||||
}, [logout, navigate]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100vh', overflow: 'hidden', background: 'var(--color-bg)' }}>
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
style={{
|
||||
width: '220px',
|
||||
flexShrink: 0,
|
||||
background: 'var(--color-bg-alt)',
|
||||
borderRight: '2px solid var(--color-border)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '1.5rem 0',
|
||||
}}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div style={{ padding: '0 1.25rem 1.25rem', borderBottom: '1px solid var(--color-border)' }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
color: 'var(--color-yellow)',
|
||||
fontSize: '1.3rem',
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
INTRANET
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.62rem', marginTop: '2px' }}>
|
||||
CROWMATE STUDIO
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav links */}
|
||||
<nav style={{ flex: 1, padding: '0.75rem 0' }}>
|
||||
{INTRANET_LINKS.map(({ to, label, icon, end }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
style={({ isActive }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.6rem',
|
||||
padding: '0.55rem 1.25rem',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.78rem',
|
||||
color: isActive ? 'var(--color-yellow)' : 'var(--color-text-muted)',
|
||||
background: isActive ? 'rgba(37,99,235,0.08)' : 'transparent',
|
||||
borderLeft: isActive ? '3px solid var(--color-yellow)' : '3px solid transparent',
|
||||
textDecoration: 'none',
|
||||
transition: 'color 0.1s, background 0.1s',
|
||||
letterSpacing: '0.05em',
|
||||
})}
|
||||
>
|
||||
<span style={{ opacity: 0.6, fontSize: '0.68rem' }}>{icon}</span>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* User info */}
|
||||
<div
|
||||
style={{
|
||||
padding: '1rem 1.25rem',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}}
|
||||
>
|
||||
<div style={{ color: 'var(--color-text-dim)', fontSize: '0.75rem', marginBottom: '0.4rem' }}>
|
||||
{user?.username}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
background: 'rgba(255,255,0,0.08)',
|
||||
border: '1px solid var(--color-yellow)',
|
||||
color: 'var(--color-yellow)',
|
||||
fontSize: '0.6rem',
|
||||
padding: '0.1rem 0.4rem',
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{user?.role}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--color-red)',
|
||||
color: 'var(--color-red)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.63rem',
|
||||
padding: '0.2rem 0.5rem',
|
||||
cursor: 'pointer',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
<NavLink
|
||||
to="/"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.63rem',
|
||||
padding: '0.2rem 0.5rem',
|
||||
letterSpacing: '0.05em',
|
||||
textDecoration: 'none',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
Public
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content — only this area scrolls */}
|
||||
<main style={{ flex: 1, overflowY: 'auto', padding: '2rem', background: 'var(--color-bg)' }}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.8rem', letterSpacing: '0.1em' }}>
|
||||
LOADING...
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
nest-intra/src/components/shared/PageLoader.tsx
Normal file
25
nest-intra/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-amber)',
|
||||
}}
|
||||
>
|
||||
LOADING
|
||||
</div>
|
||||
<div style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem', letterSpacing: '0.2em' }}>
|
||||
CROWMATE STUDIO / INTRANET
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
nest-intra/src/components/shared/ProtectedRoute.tsx
Normal file
29
nest-intra/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}</>;
|
||||
}
|
||||
129
nest-intra/src/contexts/AuthContext.tsx
Normal file
129
nest-intra/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { User } from '../types';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface AuthContextValue {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isStaff: boolean;
|
||||
isAdmin: boolean;
|
||||
login: (email: string, password: string) => Promise<{ success: boolean; error?: string }>;
|
||||
logout: () => void;
|
||||
updateUsername: (username: string) => void;
|
||||
}
|
||||
|
||||
// ── Context ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
// ── Storage helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
const USER_KEY = 'crowmate_intra_user';
|
||||
const TOKEN_KEY = 'crowmate_intra_token';
|
||||
|
||||
function loadUserFromStorage(): User | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(USER_KEY);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as User;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveUserToStorage(user: User | null): void {
|
||||
if (user) {
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
} else {
|
||||
localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the stored JWT for use in authenticated fetch calls. */
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
// ── Provider ───────────────────────────────────────────────────────────────────
|
||||
|
||||
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 }> => {
|
||||
let data: { token?: string; user?: User; error?: string };
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
data = await res.json();
|
||||
if (!res.ok) {
|
||||
return { success: false, error: data.error ?? 'Login failed.' };
|
||||
}
|
||||
} catch {
|
||||
return { success: false, error: 'Cannot reach the server. Please try again.' };
|
||||
}
|
||||
|
||||
const loggedInUser = data.user!;
|
||||
|
||||
if (loggedInUser.role === 'user') {
|
||||
return { success: false, error: 'Access denied. Staff accounts only.' };
|
||||
}
|
||||
if (loggedInUser.isBanned) {
|
||||
return { success: false, error: 'This account has been suspended.' };
|
||||
}
|
||||
|
||||
localStorage.setItem(TOKEN_KEY, data.token!);
|
||||
saveUserToStorage(loggedInUser);
|
||||
setUser(loggedInUser);
|
||||
return { success: true };
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setUser(null);
|
||||
saveUserToStorage(null);
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}, []);
|
||||
|
||||
const updateUsername = useCallback((username: string) => {
|
||||
setUser((prev) => {
|
||||
if (!prev) return prev;
|
||||
const updated = { ...prev, username };
|
||||
saveUserToStorage(updated);
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({ user, isAuthenticated, isStaff, isAdmin, login, logout, updateUsername }),
|
||||
[user, isAuthenticated, isStaff, isAdmin, login, logout, updateUsername]
|
||||
);
|
||||
|
||||
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-intra/src/data/mockData.ts
Normal file
797
nest-intra/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' },
|
||||
},
|
||||
];
|
||||
227
nest-intra/src/index.css
Normal file
227
nest-intra/src/index.css
Normal file
@@ -0,0 +1,227 @@
|
||||
@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;
|
||||
|
||||
/* Blue alias */
|
||||
--color-blue: #2563eb;
|
||||
|
||||
--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);
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
}
|
||||
16
nest-intra/src/main.tsx
Normal file
16
nest-intra/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>
|
||||
);
|
||||
174
nest-intra/src/pages/LoginPage.tsx
Normal file
174
nest-intra/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { 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 [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Redirect if already logged in
|
||||
const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/intranet';
|
||||
|
||||
if (isAuthenticated) {
|
||||
navigate(from, { replace: true });
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!email.trim()) {
|
||||
setError('Email is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const result = await login(email.trim(), password);
|
||||
setLoading(false);
|
||||
|
||||
if (result.success) {
|
||||
navigate(from, { replace: true });
|
||||
} else {
|
||||
setError(result.error || 'Login failed.');
|
||||
}
|
||||
},
|
||||
[email, password, login, navigate, from]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--color-bg)',
|
||||
padding: '2rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||
{/* Header */}
|
||||
<div style={{ textAlign: 'center', marginBottom: '2.5rem' }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
color: 'var(--color-amber)',
|
||||
fontSize: '1.6rem',
|
||||
letterSpacing: '0.08em',
|
||||
marginBottom: '0.5rem',
|
||||
}}
|
||||
>
|
||||
INTRANET
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: '0.7rem',
|
||||
letterSpacing: '0.15em',
|
||||
}}
|
||||
>
|
||||
CROWMATE STUDIO — STAFF ACCESS
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login form */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '2rem',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: '1.25rem' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.7rem',
|
||||
color: 'var(--color-text-muted)',
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: '0.4rem',
|
||||
}}
|
||||
>
|
||||
EMAIL
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
className={`input-terminal${error ? ' error' : ''}`}
|
||||
placeholder="staff@crowmate.dev"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '1.25rem' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.7rem',
|
||||
color: 'var(--color-text-muted)',
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: '0.4rem',
|
||||
}}
|
||||
>
|
||||
PASSWORD
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input-terminal"
|
||||
placeholder="Enter password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--color-red)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.75rem',
|
||||
marginBottom: '1rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
background: 'rgba(220,38,38,0.06)',
|
||||
border: '1px solid rgba(220,38,38,0.2)',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-terminal btn-amber"
|
||||
disabled={loading}
|
||||
style={{ width: '100%', justifyContent: 'center', opacity: loading ? 0.6 : 1 }}
|
||||
>
|
||||
{loading ? 'Authenticating...' : '> Login'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
nest-intra/src/pages/intranet/IntranetBugs.tsx
Normal file
250
nest-intra/src/pages/intranet/IntranetBugs.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { formatDate, formatDateTime } from '../../utils/format';
|
||||
import type { BugReport, BugSeverity, BugStatus, BugReportNote } from '../../types';
|
||||
|
||||
function StatusBadge({ status }: { status: BugStatus }) {
|
||||
const map: Record<BugStatus, string> = { open: 'badge-open', in_progress: 'badge-progress', resolved: 'badge-resolved', closed: 'badge-closed' };
|
||||
const labels: Record<BugStatus, string> = { open: 'Open', in_progress: 'In Progress', resolved: 'Resolved', closed: 'Closed' };
|
||||
return <span className={`badge ${map[status]}`}>{labels[status]}</span>;
|
||||
}
|
||||
|
||||
function SeverityBadge({ severity }: { severity: BugSeverity }) {
|
||||
const map: Record<BugSeverity, string> = { low: 'badge-low', medium: 'badge-medium', high: 'badge-high', critical: 'badge-critical' };
|
||||
return <span className={`badge ${map[severity]}`}>{severity}</span>;
|
||||
}
|
||||
|
||||
const STATUSES: BugStatus[] = ['open', 'in_progress', 'resolved', 'closed'];
|
||||
const STAFF_MEMBERS: { id: string; username: string; role: string }[] = [];
|
||||
|
||||
export default function IntranetBugs() {
|
||||
const { user } = useAuth();
|
||||
|
||||
const [bugs, setBugs] = useState<BugReport[]>([]);
|
||||
const [selected, setSelected] = useState<BugReport | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<BugStatus | 'all'>('all');
|
||||
const [severityFilter, setSeverityFilter] = useState<BugSeverity | 'all'>('all');
|
||||
const [assignedFilter, setAssignedFilter] = useState<string | 'all'>('all');
|
||||
const [noteText, setNoteText] = useState('');
|
||||
|
||||
const openCount = bugs.filter((b) => b.status === 'open').length;
|
||||
const criticalCount = bugs.filter((b) => b.severity === 'critical').length;
|
||||
const myCount = bugs.filter((b) => b.assignedToId === user?.id).length;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return bugs.filter((b) => {
|
||||
if (statusFilter !== 'all' && b.status !== statusFilter) return false;
|
||||
if (severityFilter !== 'all' && b.severity !== severityFilter) return false;
|
||||
if (assignedFilter !== 'all') {
|
||||
if (assignedFilter === 'unassigned' && b.assignedToId) return false;
|
||||
if (assignedFilter !== 'unassigned' && b.assignedToId !== assignedFilter) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [bugs, statusFilter, severityFilter, assignedFilter]);
|
||||
|
||||
const updateBug = useCallback((id: string, changes: Partial<BugReport>) => {
|
||||
setBugs((prev) => prev.map((b) => b.id === id ? { ...b, ...changes, updatedAt: new Date().toISOString() } : b));
|
||||
setSelected((prev) => prev?.id === id ? { ...prev, ...changes, updatedAt: new Date().toISOString() } : prev);
|
||||
}, []);
|
||||
|
||||
const handleAssign = useCallback((bugId: string, staffId: string) => {
|
||||
const staff = STAFF_MEMBERS.find((s) => s.id === staffId);
|
||||
updateBug(bugId, { assignedToId: staffId || undefined, assignedToName: staff?.username });
|
||||
}, [updateBug]);
|
||||
|
||||
const handleStatusChange = useCallback((bugId: string, status: BugStatus) => {
|
||||
updateBug(bugId, { status });
|
||||
}, [updateBug]);
|
||||
|
||||
const handleAddNote = useCallback((bugId: string) => {
|
||||
if (!noteText.trim() || !user) return;
|
||||
const note: BugReportNote = {
|
||||
id: `n${Date.now()}`,
|
||||
bugReportId: bugId,
|
||||
authorId: user.id,
|
||||
authorName: user.username,
|
||||
content: noteText.trim(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setBugs((prev) => prev.map((b) => b.id === bugId ? { ...b, notes: [...(b.notes ?? []), note] } : b));
|
||||
setSelected((prev) => prev?.id === bugId ? { ...prev, notes: [...(prev.notes ?? []), note] } : prev);
|
||||
setNoteText('');
|
||||
}, [noteText, user]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: selected ? '1fr 400px' : '1fr', gap: '1.5rem', alignItems: 'start' }}>
|
||||
{/* Left panel */}
|
||||
<div>
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
|
||||
INTRANET / BUG REPORTS
|
||||
</div>
|
||||
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem', marginBottom: '1rem' }}>BUG DASHBOARD</h1>
|
||||
|
||||
{/* Stats */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.5rem', marginBottom: '1.25rem' }}>
|
||||
{[
|
||||
{ label: 'Open', value: openCount, color: 'var(--color-green)' },
|
||||
{ label: 'Critical', value: criticalCount, color: 'var(--color-red)' },
|
||||
{ label: 'Mine', value: myCount, color: 'var(--color-amber)' },
|
||||
].map(({ label, value, color }) => (
|
||||
<div key={label} style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '0.75rem', textAlign: 'center' }}>
|
||||
<div style={{ fontFamily: 'var(--font-heading)', color, fontSize: '1.8rem' }}>{value}</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.65rem', letterSpacing: '0.1em' }}>{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<select className="input-terminal" style={{ width: 'auto', fontSize: '0.75rem' }} value={statusFilter} onChange={(e) => setStatusFilter(e.target.value as BugStatus | 'all')}>
|
||||
<option value="all">All Statuses</option>
|
||||
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<select className="input-terminal" style={{ width: 'auto', fontSize: '0.75rem' }} value={severityFilter} onChange={(e) => setSeverityFilter(e.target.value as BugSeverity | 'all')}>
|
||||
<option value="all">All Severities</option>
|
||||
{(['critical','high','medium','low'] as BugSeverity[]).map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<select className="input-terminal" style={{ width: 'auto', fontSize: '0.75rem' }} value={assignedFilter} onChange={(e) => setAssignedFilter(e.target.value)}>
|
||||
<option value="all">All Assigned</option>
|
||||
<option value="unassigned">Unassigned</option>
|
||||
{STAFF_MEMBERS.map((s) => <option key={s.id} value={s.id}>{s.username}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bug list */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>
|
||||
No reports match filters.
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((bug) => (
|
||||
<div
|
||||
key={bug.id}
|
||||
onClick={() => setSelected(bug === selected ? null : bug)}
|
||||
style={{
|
||||
background: selected?.id === bug.id ? 'rgba(37,99,235,0.08)' : 'var(--color-surface)',
|
||||
border: `1px solid ${selected?.id === bug.id ? 'var(--color-yellow)' : 'var(--color-border)'}`,
|
||||
padding: '0.85rem 1.1rem',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === 'Enter' && setSelected(bug === selected ? null : bug)}
|
||||
aria-label={`Select bug report ${bug.uniqueCode}`}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '0.3rem' }}>
|
||||
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem' }}>{bug.uniqueCode}</span>
|
||||
<StatusBadge status={bug.status} />
|
||||
<SeverityBadge severity={bug.severity} />
|
||||
</div>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', flexShrink: 0 }}>
|
||||
{formatDate(bug.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text)', fontSize: '0.83rem', marginBottom: '0.2rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{bug.title}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem' }}>
|
||||
{bug.submittedByName} — Assigned: {bug.assignedToName ?? 'None'}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel — detail */}
|
||||
{selected && (
|
||||
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '1.5rem', position: 'sticky', top: '1rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', marginBottom: '0.25rem' }}>{selected.uniqueCode}</div>
|
||||
<h2 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.1rem' }}>{selected.title}</h2>
|
||||
</div>
|
||||
<button onClick={() => setSelected(null)} style={{ background: 'transparent', border: 'none', color: 'var(--color-text-muted)', cursor: 'pointer', fontSize: '1.1rem' }} aria-label="Close">✕</button>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div style={{ display: 'grid', gap: '0.75rem', marginBottom: '1.25rem' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', letterSpacing: '0.1em', marginBottom: '0.3rem' }}>STATUS</label>
|
||||
<select
|
||||
className="input-terminal"
|
||||
style={{ fontSize: '0.75rem' }}
|
||||
value={selected.status}
|
||||
onChange={(e) => handleStatusChange(selected.id, e.target.value as BugStatus)}
|
||||
>
|
||||
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', letterSpacing: '0.1em', marginBottom: '0.3rem' }}>ASSIGN TO</label>
|
||||
<select
|
||||
className="input-terminal"
|
||||
style={{ fontSize: '0.75rem' }}
|
||||
value={selected.assignedToId ?? ''}
|
||||
onChange={(e) => handleAssign(selected.id, e.target.value)}
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{STAFF_MEMBERS.map((s) => <option key={s.id} value={s.id}>{s.username} ({s.role})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.65rem', letterSpacing: '0.1em', marginBottom: '0.4rem' }}>DESCRIPTION</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.78rem', lineHeight: 1.7, whiteSpace: 'pre-wrap', background: 'var(--color-bg-alt)', padding: '0.75rem', borderRadius: '4px' }}>
|
||||
{selected.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.65rem', letterSpacing: '0.1em', marginBottom: '0.4rem' }}>STEPS TO REPRODUCE</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.78rem', lineHeight: 1.7, whiteSpace: 'pre-wrap', background: 'var(--color-bg-alt)', padding: '0.75rem', borderRadius: '4px' }}>
|
||||
{selected.stepsToReproduce}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Internal notes */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.65rem', letterSpacing: '0.1em', marginBottom: '0.5rem' }}>INTERNAL NOTES (staff only)</div>
|
||||
{(selected.notes ?? []).length === 0 ? (
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.75rem', padding: '0.5rem 0' }}>No notes yet.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '0.75rem' }}>
|
||||
{(selected.notes ?? []).map((note) => (
|
||||
<div key={note.id} style={{ background: 'rgba(217,119,6,0.08)', border: '1px solid rgba(217,119,6,0.2)', padding: '0.6rem', borderRadius: '4px' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.65rem', marginBottom: '0.2rem' }}>
|
||||
{note.authorName} — {formatDateTime(note.createdAt)}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.77rem', lineHeight: 1.6 }}>
|
||||
{note.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
className="input-terminal"
|
||||
rows={3}
|
||||
placeholder="Add an internal note..."
|
||||
value={noteText}
|
||||
onChange={(e) => setNoteText(e.target.value)}
|
||||
style={{ resize: 'vertical', fontSize: '0.8rem', marginBottom: '0.5rem' }}
|
||||
/>
|
||||
<button className="btn-terminal btn-amber" onClick={() => handleAddNote(selected.id)} style={{ padding: '0.35rem 0.9rem', fontSize: '0.75rem' }}>
|
||||
Add Note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
nest-intra/src/pages/intranet/IntranetDashboard.tsx
Normal file
142
nest-intra/src/pages/intranet/IntranetDashboard.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number | string;
|
||||
accent?: 'green' | 'amber' | 'red';
|
||||
}
|
||||
|
||||
function StatCard({ label, value, accent = 'green' }: StatCardProps) {
|
||||
const colors = {
|
||||
green: 'var(--color-green)',
|
||||
amber: 'var(--color-amber)',
|
||||
red: 'var(--color-red)',
|
||||
};
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '1.25rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', textTransform: 'uppercase' }}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-heading)', color: colors[accent], fontSize: '2.5rem', lineHeight: 1 }}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NavTileProps {
|
||||
to: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
function NavTile({ to, label, description, icon }: NavTileProps) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '1.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.6rem',
|
||||
textDecoration: 'none',
|
||||
transition: 'border-color 0.2s, background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLAnchorElement).style.borderColor = 'var(--color-yellow)';
|
||||
(e.currentTarget as HTMLAnchorElement).style.background = 'rgba(37,99,235,0.05)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLAnchorElement).style.borderColor = 'var(--color-border)';
|
||||
(e.currentTarget as HTMLAnchorElement).style.background = 'var(--color-surface)';
|
||||
}}
|
||||
>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.75rem', letterSpacing: '0.1em' }}>
|
||||
{icon}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.1rem' }}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem', lineHeight: 1.6 }}>
|
||||
{description}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IntranetDashboard() {
|
||||
const { user } = useAuth();
|
||||
|
||||
const openBugs = 0;
|
||||
const criticalBugs = 0;
|
||||
const assignedToMe = 0;
|
||||
const totalUsers = 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
|
||||
INTRANET / DASHBOARD
|
||||
</div>
|
||||
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem', marginBottom: '0.25rem' }}>
|
||||
Welcome, {user?.username}
|
||||
</h1>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.8rem' }}>
|
||||
{new Date().toLocaleString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '1rem' }}>
|
||||
QUICK STATS
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '0.75rem' }}>
|
||||
<StatCard label="Open Bugs" value={openBugs} accent="green" />
|
||||
<StatCard label="Critical" value={criticalBugs} accent="red" />
|
||||
<StatCard label="Assigned to Me" value={assignedToMe} accent="amber" />
|
||||
<StatCard label="Total Users" value={totalUsers} accent="green" />
|
||||
<StatCard label="Forum Threads" value={0} accent="green" />
|
||||
<StatCard label="Staff Posts Today" value={0} accent="amber" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation tiles */}
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '1rem' }}>
|
||||
SECTIONS
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '0.75rem' }}>
|
||||
<NavTile to="/intranet/bugs" label="Bug Reports" description="Review, assign, and update reported issues. Filter by severity and status." icon="[!]" />
|
||||
<NavTile to="/intranet/feed" label="Team Feed" description="Internal staff activity feed. Post updates visible only to the team." icon="[~]" />
|
||||
<NavTile to="/intranet/users" label="User Management" description="View all registered users. Promote or ban accounts." icon="[U]" />
|
||||
<NavTile to="/intranet/moderation" label="Forum Moderation" description="Pin, lock, and delete threads and replies from the public forum." icon="[M]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent staff posts */}
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '1rem' }}>
|
||||
RECENT TEAM ACTIVITY
|
||||
</div>
|
||||
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '2rem', textAlign: 'center', fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.8rem' }}>
|
||||
No recent activity.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
721
nest-intra/src/pages/intranet/IntranetEvents.tsx
Normal file
721
nest-intra/src/pages/intranet/IntranetEvents.tsx
Normal file
@@ -0,0 +1,721 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
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 ROLE_COLORS: Record<UserRole, string> = {
|
||||
dev: 'var(--color-green)',
|
||||
com: 'var(--color-amber)',
|
||||
user: 'var(--color-text-muted)',
|
||||
};
|
||||
|
||||
const EVENT_TYPE_LABELS: Record<EventType, string> = {
|
||||
announcement: 'ANNOUNCEMENT',
|
||||
update: 'DEV UPDATE',
|
||||
milestone: 'MILESTONE',
|
||||
poll: 'COMMUNITY POLL',
|
||||
};
|
||||
|
||||
// ── 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',
|
||||
}}
|
||||
>
|
||||
{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 ? 'pointer' : 'default',
|
||||
opacity: isEnded || !poll.isActive ? 0.7 : 1,
|
||||
}}
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{totalVotes} total votes
|
||||
{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>
|
||||
</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.25rem',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: '1rem',
|
||||
marginBottom: '0.75rem',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.4rem' }}>
|
||||
<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.15rem 0.4rem',
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
{EVENT_TYPE_LABELS[event.type]}
|
||||
</span>
|
||||
{event.isPublic && (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
background: 'rgba(34,197,94,0.1)',
|
||||
border: '1px solid rgba(34,197,94,0.25)',
|
||||
color: 'var(--color-green)',
|
||||
fontSize: '0.6rem',
|
||||
padding: '0.15rem 0.4rem',
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
PUBLIC
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
color: 'var(--color-text)',
|
||||
fontSize: '1.1rem',
|
||||
marginBottom: '0.25rem',
|
||||
}}
|
||||
>
|
||||
{event.title}
|
||||
</h3>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: '0.68rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: ROLE_COLORS[event.authorRole] }}>
|
||||
{event.authorName}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{formatDateTime(event.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-text-dim)',
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: 1.75,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{event.content}
|
||||
</div>
|
||||
|
||||
{/* Poll if exists */}
|
||||
{poll && <PollCard poll={poll} onVote={onVote} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function IntranetEvents() {
|
||||
const { user } = useAuth();
|
||||
const [events, setEvents] = useState<EventPost[]>([]);
|
||||
const [polls, setPolls] = useState<Poll[]>([]);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [eventType, setEventType] = useState<EventType>('announcement');
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [isPublic, setIsPublic] = useState(true);
|
||||
const [createPoll, setCreatePoll] = useState(false);
|
||||
const [pollQuestion, setPollQuestion] = useState('');
|
||||
const [pollOptions, setPollOptions] = useState<string[]>(['', '']);
|
||||
const [error, setError] = useState('');
|
||||
const [posting, setPosting] = useState(false);
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
// Validation
|
||||
if (!title.trim()) {
|
||||
setError('Title is required.');
|
||||
return;
|
||||
}
|
||||
if (!content.trim()) {
|
||||
setError('Content is required.');
|
||||
return;
|
||||
}
|
||||
if (createPoll) {
|
||||
if (!pollQuestion.trim()) {
|
||||
setError('Poll question is required.');
|
||||
return;
|
||||
}
|
||||
const validOptions = pollOptions.filter((opt) => opt.trim());
|
||||
if (validOptions.length < 2) {
|
||||
setError('Poll must have at least 2 options.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!user) return;
|
||||
|
||||
setError('');
|
||||
setPosting(true);
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
|
||||
const newEventId = `evt${Date.now()}`;
|
||||
let newPollId: string | undefined;
|
||||
|
||||
// Create poll if needed
|
||||
if (createPoll) {
|
||||
newPollId = `poll${Date.now()}`;
|
||||
const validOptions = pollOptions.filter((opt) => opt.trim());
|
||||
const newPoll: Poll = {
|
||||
id: newPollId,
|
||||
eventId: newEventId,
|
||||
question: pollQuestion.trim(),
|
||||
options: validOptions.map((opt, idx) => ({
|
||||
id: `opt${Date.now()}_${idx}`,
|
||||
text: opt.trim(),
|
||||
votes: 0,
|
||||
votedUserIds: [],
|
||||
})),
|
||||
isActive: true,
|
||||
allowMultipleVotes: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setPolls((prev) => [newPoll, ...prev]);
|
||||
}
|
||||
|
||||
// Create event
|
||||
const newEvent: EventPost = {
|
||||
id: newEventId,
|
||||
type: createPoll ? 'poll' : eventType,
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
authorId: user.id,
|
||||
authorName: user.username,
|
||||
authorRole: user.role,
|
||||
createdAt: new Date().toISOString(),
|
||||
isPublic,
|
||||
pollId: newPollId,
|
||||
};
|
||||
|
||||
setEvents((prev) => [newEvent, ...prev]);
|
||||
|
||||
// Reset form
|
||||
setTitle('');
|
||||
setContent('');
|
||||
setEventType('announcement');
|
||||
setIsPublic(true);
|
||||
setCreatePoll(false);
|
||||
setPollQuestion('');
|
||||
setPollOptions(['', '']);
|
||||
setPosting(false);
|
||||
setShowCreateForm(false);
|
||||
}, [title, content, eventType, isPublic, createPoll, pollQuestion, pollOptions, user]);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '800px' }}>
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: '0.7rem',
|
||||
letterSpacing: '0.15em',
|
||||
marginBottom: '0.5rem',
|
||||
}}
|
||||
>
|
||||
INTRANET / EVENTS
|
||||
</div>
|
||||
<h1
|
||||
style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
color: 'var(--color-text)',
|
||||
fontSize: '1.8rem',
|
||||
}}
|
||||
>
|
||||
COMMUNITY EVENTS
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: '0.78rem',
|
||||
marginTop: '0.4rem',
|
||||
}}
|
||||
>
|
||||
Post game development updates, announcements, and community polls. Public events are
|
||||
visible to all users.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Create Event Button */}
|
||||
{!showCreateForm && (
|
||||
<button
|
||||
className="btn-terminal btn-amber"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
style={{ marginBottom: '1.5rem' }}
|
||||
>
|
||||
+ Create New Event
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Create Event Form */}
|
||||
{showCreateForm && (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '1.25rem',
|
||||
marginBottom: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-amber)',
|
||||
fontSize: '0.7rem',
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
CREATE NEW EVENT
|
||||
</div>
|
||||
|
||||
{/* Event Type */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.7rem',
|
||||
color: 'var(--color-text-muted)',
|
||||
marginBottom: '0.4rem',
|
||||
}}
|
||||
>
|
||||
EVENT TYPE
|
||||
</label>
|
||||
<select
|
||||
className="input-terminal"
|
||||
value={eventType}
|
||||
onChange={(e) => setEventType(e.target.value as EventType)}
|
||||
style={{ fontSize: '0.8rem' }}
|
||||
disabled={createPoll}
|
||||
>
|
||||
<option value="announcement">Announcement</option>
|
||||
<option value="update">Development Update</option>
|
||||
<option value="milestone">Milestone</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.7rem',
|
||||
color: 'var(--color-text-muted)',
|
||||
marginBottom: '0.4rem',
|
||||
}}
|
||||
>
|
||||
TITLE
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-terminal"
|
||||
placeholder="Event title..."
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
style={{ fontSize: '0.85rem' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.7rem',
|
||||
color: 'var(--color-text-muted)',
|
||||
marginBottom: '0.4rem',
|
||||
}}
|
||||
>
|
||||
CONTENT
|
||||
</label>
|
||||
<textarea
|
||||
className="input-terminal"
|
||||
rows={4}
|
||||
placeholder="Event description and details..."
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
style={{ resize: 'vertical', fontSize: '0.85rem' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Public Toggle */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--color-text-dim)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPublic}
|
||||
onChange={(e) => setIsPublic(e.target.checked)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
Make event visible to public
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Poll Toggle */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--color-text-dim)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createPoll}
|
||||
onChange={(e) => setCreatePoll(e.target.checked)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
Include a community poll
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Poll Form */}
|
||||
{createPoll && (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-bg-alt)',
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '1rem',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.7rem',
|
||||
color: 'var(--color-amber)',
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: '0.75rem',
|
||||
}}
|
||||
>
|
||||
POLL DETAILS
|
||||
</div>
|
||||
|
||||
{/* Poll Question */}
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.7rem',
|
||||
color: 'var(--color-text-muted)',
|
||||
marginBottom: '0.4rem',
|
||||
}}
|
||||
>
|
||||
QUESTION
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-terminal"
|
||||
placeholder="What do you want to ask?"
|
||||
value={pollQuestion}
|
||||
onChange={(e) => setPollQuestion(e.target.value)}
|
||||
style={{ fontSize: '0.8rem' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Poll Options */}
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.7rem',
|
||||
color: 'var(--color-text-muted)',
|
||||
marginBottom: '0.4rem',
|
||||
}}
|
||||
>
|
||||
OPTIONS
|
||||
</label>
|
||||
{pollOptions.map((option, idx) => (
|
||||
<div key={idx} style={{ marginBottom: '0.4rem', display: 'flex', gap: '0.5rem' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="input-terminal"
|
||||
placeholder={`Option ${idx + 1}`}
|
||||
value={option}
|
||||
onChange={(e) => {
|
||||
const newOptions = [...pollOptions];
|
||||
newOptions[idx] = e.target.value;
|
||||
setPollOptions(newOptions);
|
||||
}}
|
||||
style={{ fontSize: '0.8rem', flex: 1 }}
|
||||
/>
|
||||
{pollOptions.length > 2 && (
|
||||
<button
|
||||
className="btn-terminal"
|
||||
onClick={() => {
|
||||
setPollOptions(pollOptions.filter((_, i) => i !== idx));
|
||||
}}
|
||||
style={{ padding: '0.4rem 0.6rem', fontSize: '0.7rem' }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{pollOptions.length < 6 && (
|
||||
<button
|
||||
className="btn-terminal"
|
||||
onClick={() => setPollOptions([...pollOptions, ''])}
|
||||
style={{ fontSize: '0.7rem', marginTop: '0.4rem' }}
|
||||
>
|
||||
+ Add Option
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--color-red)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.72rem',
|
||||
marginBottom: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
className="btn-terminal btn-amber"
|
||||
onClick={handleSubmit}
|
||||
disabled={posting}
|
||||
style={{ opacity: posting ? 0.6 : 1 }}
|
||||
>
|
||||
{posting ? 'Creating...' : '> Create Event'}
|
||||
</button>
|
||||
<button
|
||||
className="btn-terminal"
|
||||
onClick={() => {
|
||||
setShowCreateForm(false);
|
||||
setError('');
|
||||
setTitle('');
|
||||
setContent('');
|
||||
setCreatePoll(false);
|
||||
setPollQuestion('');
|
||||
setPollOptions(['', '']);
|
||||
}}
|
||||
disabled={posting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Events List */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
146
nest-intra/src/pages/intranet/IntranetFeed.tsx
Normal file
146
nest-intra/src/pages/intranet/IntranetFeed.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { formatDateTime } from '../../utils/format';
|
||||
import type { StaffPost, UserRole } from '../../types';
|
||||
|
||||
const ROLE_COLORS: Record<UserRole, string> = {
|
||||
dev: 'var(--color-green)',
|
||||
com: 'var(--color-amber)',
|
||||
user: 'var(--color-text-muted)',
|
||||
};
|
||||
|
||||
function FeedPost({ post }: { post: StaffPost }) {
|
||||
return (
|
||||
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '1.1rem 1.25rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '1rem', marginBottom: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '30px',
|
||||
height: '30px',
|
||||
background: 'rgba(217,119,6,0.1)',
|
||||
border: '1px solid rgba(217,119,6,0.25)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: 'var(--font-heading)',
|
||||
color: 'var(--color-amber)',
|
||||
fontSize: '0.85rem',
|
||||
flexShrink: 0,
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
{post.authorName[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', color: ROLE_COLORS[post.authorRole], fontSize: '0.82rem' }}>
|
||||
{post.authorName}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
background: 'rgba(217,119,6,0.1)',
|
||||
border: '1px solid rgba(217,119,6,0.25)',
|
||||
color: 'var(--color-amber)',
|
||||
fontSize: '0.6rem',
|
||||
padding: '0.05rem 0.35rem',
|
||||
marginLeft: '0.5rem',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
borderRadius: '3px',
|
||||
}}
|
||||
>
|
||||
{post.authorRole}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', flexShrink: 0 }}>
|
||||
{formatDateTime(post.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.85rem', lineHeight: 1.75, marginLeft: '36px' }}>
|
||||
{post.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IntranetFeed() {
|
||||
const { user } = useAuth();
|
||||
const [posts, setPosts] = useState<StaffPost[]>([]);
|
||||
const [content, setContent] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [posting, setPosting] = useState(false);
|
||||
|
||||
const handlePost = useCallback(async () => {
|
||||
if (!content.trim()) { setError('Post cannot be empty.'); return; }
|
||||
if (content.trim().length < 5) { setError('Post must be at least 5 characters.'); return; }
|
||||
if (!user) return;
|
||||
setError('');
|
||||
setPosting(true);
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
|
||||
const newPost: StaffPost = {
|
||||
id: `sp${Date.now()}`,
|
||||
authorId: user.id,
|
||||
authorName: user.username,
|
||||
authorRole: user.role as 'dev' | 'com',
|
||||
content: content.trim(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setPosts((prev) => [newPost, ...prev]);
|
||||
setContent('');
|
||||
setPosting(false);
|
||||
}, [content, user]);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '720px' }}>
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
|
||||
INTRANET / TEAM FEED
|
||||
</div>
|
||||
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem' }}>TEAM ACTIVITY</h1>
|
||||
<p style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem', marginTop: '0.4rem' }}>
|
||||
Staff-only internal feed. Posts are not visible to the public.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Compose */}
|
||||
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '1.25rem', marginBottom: '1.5rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.7rem', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||
[{user?.username} — {user?.role}] Post an update
|
||||
</div>
|
||||
<textarea
|
||||
className={`input-terminal${error ? ' error' : ''}`}
|
||||
rows={3}
|
||||
placeholder="What's happening? Share an update with the team..."
|
||||
value={content}
|
||||
onChange={(e) => { setContent(e.target.value); setError(''); }}
|
||||
style={{ resize: 'vertical', fontSize: '0.85rem', marginBottom: '0.75rem' }}
|
||||
disabled={posting}
|
||||
/>
|
||||
{error && (
|
||||
<div style={{ color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', marginBottom: '0.6rem' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem' }}>
|
||||
{content.length} chars
|
||||
</span>
|
||||
<button className="btn-terminal btn-amber" onClick={handlePost} disabled={posting} style={{ opacity: posting ? 0.6 : 1 }}>
|
||||
{posting ? 'Posting...' : '> Post'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feed */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{posts.map((post) => (
|
||||
<FeedPost key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
230
nest-intra/src/pages/intranet/IntranetModeration.tsx
Normal file
230
nest-intra/src/pages/intranet/IntranetModeration.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { formatDateTime } from '../../utils/format';
|
||||
import type { ForumThread, ForumReply } from '../../types';
|
||||
|
||||
export default function IntranetModeration() {
|
||||
const [threads, setThreads] = useState<ForumThread[]>([]);
|
||||
const [replies, setReplies] = useState<ForumReply[]>([]);
|
||||
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<'threads' | 'replies'>('threads');
|
||||
|
||||
const filteredThreads = useMemo(() => {
|
||||
if (!search.trim()) return threads;
|
||||
const q = search.toLowerCase();
|
||||
return threads.filter((t) => t.title.toLowerCase().includes(q) || t.authorName.toLowerCase().includes(q));
|
||||
}, [threads, search]);
|
||||
|
||||
const selectedThreadReplies = useMemo(() => {
|
||||
if (!selectedThreadId) return [];
|
||||
return replies.filter((r) => r.threadId === selectedThreadId);
|
||||
}, [replies, selectedThreadId]);
|
||||
|
||||
const deleteThread = useCallback((id: string) => {
|
||||
setThreads((prev) => prev.filter((t) => t.id !== id));
|
||||
setReplies((prev) => prev.filter((r) => r.threadId !== id));
|
||||
if (selectedThreadId === id) setSelectedThreadId(null);
|
||||
}, [selectedThreadId]);
|
||||
|
||||
const togglePin = useCallback((id: string) => {
|
||||
setThreads((prev) => prev.map((t) => t.id === id ? { ...t, isPinned: !t.isPinned } : t));
|
||||
}, []);
|
||||
|
||||
const toggleLock = useCallback((id: string) => {
|
||||
setThreads((prev) => prev.map((t) => t.id === id ? { ...t, isLocked: !t.isLocked } : t));
|
||||
}, []);
|
||||
|
||||
const deleteReply = useCallback((id: string) => {
|
||||
setReplies((prev) => prev.filter((r) => r.id !== id));
|
||||
}, []);
|
||||
|
||||
const recentReplies = useMemo(() => {
|
||||
return [...replies].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()).slice(0, 20);
|
||||
}, [replies]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '1.75rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
|
||||
INTRANET / MODERATION
|
||||
</div>
|
||||
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem', marginBottom: '0.5rem' }}>FORUM MODERATION</h1>
|
||||
<p style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem' }}>
|
||||
{threads.length} threads — {replies.length} replies
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--color-border)', marginBottom: '1.5rem', gap: 0 }}>
|
||||
{(['threads', 'replies'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === tab ? '2px solid var(--color-amber)' : '2px solid transparent',
|
||||
color: activeTab === tab ? 'var(--color-amber)' : 'var(--color-text-muted)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.75rem',
|
||||
padding: '0.55rem 1rem',
|
||||
cursor: 'pointer',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
{tab === 'threads' ? `Threads (${threads.length})` : `Replies (${replies.length})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'threads' && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: selectedThreadId ? '1fr 340px' : '1fr', gap: '1.25rem', alignItems: 'start' }}>
|
||||
{/* Thread list */}
|
||||
<div>
|
||||
<input
|
||||
className="input-terminal"
|
||||
type="search"
|
||||
placeholder="Search threads..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ marginBottom: '1rem', maxWidth: '300px' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
{filteredThreads.map((thread) => (
|
||||
<div
|
||||
key={thread.id}
|
||||
style={{
|
||||
background: selectedThreadId === thread.id ? 'rgba(37,99,235,0.08)' : 'var(--color-surface)',
|
||||
border: `1px solid ${selectedThreadId === thread.id ? 'var(--color-yellow)' : 'var(--color-border)'}`,
|
||||
padding: '0.85rem 1.1rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '0.3rem' }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', marginBottom: '0.2rem' }}>
|
||||
{thread.isPinned && <span className="badge badge-progress">Pinned</span>}
|
||||
{thread.isLocked && <span className="badge badge-closed">Locked</span>}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text)', fontSize: '0.83rem' }}>{thread.title}</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', marginTop: '0.2rem' }}>
|
||||
by {thread.authorName} — {thread.categoryName} — {thread.replyCount} replies
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.4rem', marginTop: '0.6rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="btn-terminal"
|
||||
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
|
||||
onClick={() => setSelectedThreadId(selectedThreadId === thread.id ? null : thread.id)}
|
||||
>
|
||||
Replies
|
||||
</button>
|
||||
<button
|
||||
className={`btn-terminal ${thread.isPinned ? 'btn-danger' : 'btn-amber'}`}
|
||||
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
|
||||
onClick={() => togglePin(thread.id)}
|
||||
>
|
||||
{thread.isPinned ? 'Unpin' : 'Pin'}
|
||||
</button>
|
||||
<button
|
||||
className="btn-terminal btn-amber"
|
||||
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
|
||||
onClick={() => toggleLock(thread.id)}
|
||||
>
|
||||
{thread.isLocked ? 'Unlock' : 'Lock'}
|
||||
</button>
|
||||
<button
|
||||
className="btn-terminal btn-danger"
|
||||
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
|
||||
onClick={() => deleteThread(thread.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filteredThreads.length === 0 && (
|
||||
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem', background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}>
|
||||
No threads found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thread replies panel */}
|
||||
{selectedThreadId && (
|
||||
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '1.25rem', position: 'sticky', top: '1rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.7rem', letterSpacing: '0.1em' }}>
|
||||
REPLIES ({selectedThreadReplies.length})
|
||||
</div>
|
||||
<button onClick={() => setSelectedThreadId(null)} style={{ background: 'transparent', border: 'none', color: 'var(--color-text-muted)', cursor: 'pointer' }} aria-label="Close">✕</button>
|
||||
</div>
|
||||
{selectedThreadReplies.length === 0 ? (
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem' }}>No replies.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{selectedThreadReplies.map((reply) => (
|
||||
<div key={reply.id} style={{ background: 'var(--color-surface-alt)', border: '1px solid var(--color-border)', padding: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.35rem' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.72rem' }}>{reply.authorName}</span>
|
||||
<button
|
||||
className="btn-terminal btn-danger"
|
||||
style={{ padding: '0.1rem 0.45rem', fontSize: '0.6rem' }}
|
||||
onClick={() => deleteReply(reply.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.77rem', lineHeight: 1.65 }}>
|
||||
{reply.content.slice(0, 150)}{reply.content.length > 150 ? '...' : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'replies' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
{recentReplies.map((reply) => {
|
||||
const thread = threads.find((t) => t.id === reply.threadId);
|
||||
return (
|
||||
<div key={reply.id} style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '0.85rem 1.1rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', marginBottom: '0.2rem' }}>
|
||||
by <span style={{ color: 'var(--color-text-dim)' }}>{reply.authorName}</span>
|
||||
{thread && <> in <span style={{ color: 'var(--color-text-dim)' }}>{thread.title}</span></>}
|
||||
{' '}— {formatDateTime(reply.createdAt)}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text)', fontSize: '0.82rem', lineHeight: 1.6, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{reply.content}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn-terminal btn-danger"
|
||||
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem', flexShrink: 0 }}
|
||||
onClick={() => deleteReply(reply.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{recentReplies.length === 0 && (
|
||||
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem', background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}>
|
||||
No replies found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
187
nest-intra/src/pages/intranet/IntranetServices.tsx
Normal file
187
nest-intra/src/pages/intranet/IntranetServices.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface Service {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface ServiceCategory {
|
||||
category: string;
|
||||
services: Service[];
|
||||
}
|
||||
|
||||
function getFaviconUrl(url: string): string {
|
||||
try {
|
||||
const domain = new URL(url).hostname;
|
||||
return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function ServiceCard({ service }: { service: Service }) {
|
||||
return (
|
||||
<a
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '1.25rem 1.5rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
textDecoration: 'none',
|
||||
transition: 'border-color 0.2s, background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--color-yellow)';
|
||||
e.currentTarget.style.background = 'rgba(37,99,235,0.05)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--color-border)';
|
||||
e.currentTarget.style.background = 'var(--color-surface)';
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={getFaviconUrl(service.url)}
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
style={{ flexShrink: 0, borderRadius: '4px' }}
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1rem' }}>
|
||||
{service.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: '0.7rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{service.url}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IntranetServices() {
|
||||
const [categories, setCategories] = useState<ServiceCategory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/services.json')
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Failed to load services.json');
|
||||
return res.json();
|
||||
})
|
||||
.then((data: ServiceCategory[]) => {
|
||||
setCategories(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const totalServices = categories.reduce((sum, cat) => sum + cat.services.length, 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: '0.7rem',
|
||||
letterSpacing: '0.15em',
|
||||
marginBottom: '0.5rem',
|
||||
}}
|
||||
>
|
||||
INTRANET / SERVICES
|
||||
</div>
|
||||
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem' }}>
|
||||
QUICK LINKS
|
||||
</h1>
|
||||
<p style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem', marginTop: '0.4rem' }}>
|
||||
External services and tools. Edit <code style={{ background: 'var(--color-bg-alt)', padding: '0.1rem 0.35rem', borderRadius: '3px', fontSize: '0.75rem' }}>public/services.json</code> to update this list.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.8rem' }}>
|
||||
Loading services...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--color-red)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.78rem',
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'rgba(220,38,38,0.06)',
|
||||
border: '1px solid rgba(220,38,38,0.2)',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && categories.map((cat) => (
|
||||
<div key={cat.category} style={{ marginBottom: '2rem' }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-amber)',
|
||||
fontSize: '0.7rem',
|
||||
letterSpacing: '0.15em',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{cat.category}
|
||||
<span style={{ color: 'var(--color-text-muted)', marginLeft: '0.5rem' }}>
|
||||
({cat.services.length})
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '0.75rem' }}>
|
||||
{cat.services.map((service) => (
|
||||
<ServiceCard key={service.url} service={service} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!loading && !error && totalServices === 0 && (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '2rem',
|
||||
textAlign: 'center',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.8rem',
|
||||
}}
|
||||
>
|
||||
No services configured. Add entries to public/services.json.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
330
nest-intra/src/pages/intranet/IntranetUsers.tsx
Normal file
330
nest-intra/src/pages/intranet/IntranetUsers.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { getToken } from '../../contexts/AuthContext';
|
||||
import { formatDate } from '../../utils/format';
|
||||
import type { User, UserRole } from '../../types';
|
||||
|
||||
function RoleBadge({ role, isAdmin }: { role: UserRole; isAdmin: boolean }) {
|
||||
if (isAdmin) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', gap: '0.3rem', alignItems: 'center' }}>
|
||||
<span className="badge badge-open">{role}</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
background: 'rgba(217,119,6,0.15)',
|
||||
border: '1px solid rgba(217,119,6,0.4)',
|
||||
color: 'var(--color-amber)',
|
||||
fontSize: '0.6rem',
|
||||
padding: '0.05rem 0.35rem',
|
||||
letterSpacing: '0.08em',
|
||||
borderRadius: '3px',
|
||||
}}
|
||||
>
|
||||
ADMIN
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const cls = role === 'dev' ? 'badge-open' : role === 'com' ? 'badge-medium' : 'badge-closed';
|
||||
return <span className={`badge ${cls}`}>{role}</span>;
|
||||
}
|
||||
|
||||
export default function IntranetUsers() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [roleFilter, setRoleFilter] = useState<UserRole | 'all'>('all');
|
||||
const [confirmAction, setConfirmAction] = useState<{ userId: string; action: 'promote' | 'ban' | 'unban' } | null>(null);
|
||||
|
||||
// Create staff form
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [createUsername, setCreateUsername] = useState('');
|
||||
const [createEmail, setCreateEmail] = useState('');
|
||||
const [createPassword, setCreatePassword] = useState('');
|
||||
const [createRole, setCreateRole] = useState<'dev' | 'com'>('dev');
|
||||
const [createError, setCreateError] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return users.filter((u) => {
|
||||
const matchSearch = !search.trim() || u.username.toLowerCase().includes(search.toLowerCase()) || u.email.toLowerCase().includes(search.toLowerCase());
|
||||
const matchRole = roleFilter === 'all' || u.role === roleFilter;
|
||||
return matchSearch && matchRole;
|
||||
});
|
||||
}, [users, search, roleFilter]);
|
||||
|
||||
const handlePromote = useCallback((userId: string, targetRole: UserRole) => {
|
||||
setUsers((prev) => prev.map((u) => u.id === userId ? { ...u, role: targetRole } : u));
|
||||
setConfirmAction(null);
|
||||
}, []);
|
||||
|
||||
const handleToggleBan = useCallback((userId: string, ban: boolean) => {
|
||||
setUsers((prev) => prev.map((u) => u.id === userId ? { ...u, isBanned: ban } : u));
|
||||
setConfirmAction(null);
|
||||
}, []);
|
||||
|
||||
const handleCreateStaff = useCallback(async () => {
|
||||
setCreateError('');
|
||||
if (!createUsername.trim()) { setCreateError('Username is required.'); return; }
|
||||
if (!createEmail.trim()) { setCreateError('Email is required.'); return; }
|
||||
if (createPassword.length < 6) { setCreateError('Password must be at least 6 characters.'); return; }
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${getToken()}`,
|
||||
},
|
||||
body: JSON.stringify({ username: createUsername.trim(), email: createEmail.trim(), password: createPassword, role: createRole }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setCreateError(data.error ?? 'Failed to create user.');
|
||||
return;
|
||||
}
|
||||
setUsers((prev) => [...prev, data as User]);
|
||||
setShowCreateForm(false);
|
||||
setCreateUsername('');
|
||||
setCreateEmail('');
|
||||
setCreatePassword('');
|
||||
setCreateRole('dev');
|
||||
} catch {
|
||||
setCreateError('Cannot reach the server.');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}, [createUsername, createEmail, createPassword, createRole]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '1.75rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
|
||||
INTRANET / USER MANAGEMENT
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '1rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
|
||||
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem', margin: 0 }}>USERS</h1>
|
||||
{currentUser?.isAdmin && (
|
||||
<button className="btn-terminal btn-amber" onClick={() => { setShowCreateForm(true); setCreateError(''); }}>
|
||||
+ Create Staff User
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
className="input-terminal"
|
||||
type="search"
|
||||
placeholder="Search username or email..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ maxWidth: '260px' }}
|
||||
/>
|
||||
<select className="input-terminal" style={{ width: 'auto', minWidth: '130px' }} value={roleFilter} onChange={(e) => setRoleFilter(e.target.value as UserRole | 'all')}>
|
||||
<option value="all">All Roles</option>
|
||||
<option value="user">Users</option>
|
||||
<option value="dev">Dev</option>
|
||||
<option value="com">Com</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create staff user modal */}
|
||||
{showCreateForm && (
|
||||
<div
|
||||
style={{ background: 'rgba(0,0,0,0.4)', position: 'fixed', inset: 0, zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
onClick={() => setShowCreateForm(false)}
|
||||
>
|
||||
<div
|
||||
style={{ background: 'var(--color-surface)', border: '2px solid var(--color-amber)', padding: '2rem', maxWidth: '420px', width: '90%', borderRadius: '8px' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-amber)', marginBottom: '1.5rem', fontSize: '1.1rem', letterSpacing: '0.08em' }}>
|
||||
CREATE STAFF USER
|
||||
</h3>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.85rem' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontFamily: 'var(--font-mono)', fontSize: '0.68rem', color: 'var(--color-text-muted)', letterSpacing: '0.1em', marginBottom: '0.3rem' }}>USERNAME</label>
|
||||
<input
|
||||
className="input-terminal"
|
||||
type="text"
|
||||
placeholder="username"
|
||||
value={createUsername}
|
||||
onChange={(e) => setCreateUsername(e.target.value)}
|
||||
style={{ fontSize: '0.85rem' }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontFamily: 'var(--font-mono)', fontSize: '0.68rem', color: 'var(--color-text-muted)', letterSpacing: '0.1em', marginBottom: '0.3rem' }}>EMAIL</label>
|
||||
<input
|
||||
className="input-terminal"
|
||||
type="email"
|
||||
placeholder="staff@crowmate.dev"
|
||||
value={createEmail}
|
||||
onChange={(e) => setCreateEmail(e.target.value)}
|
||||
style={{ fontSize: '0.85rem' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontFamily: 'var(--font-mono)', fontSize: '0.68rem', color: 'var(--color-text-muted)', letterSpacing: '0.1em', marginBottom: '0.3rem' }}>PASSWORD</label>
|
||||
<input
|
||||
className="input-terminal"
|
||||
type="password"
|
||||
placeholder="Min. 6 characters"
|
||||
value={createPassword}
|
||||
onChange={(e) => setCreatePassword(e.target.value)}
|
||||
style={{ fontSize: '0.85rem' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontFamily: 'var(--font-mono)', fontSize: '0.68rem', color: 'var(--color-text-muted)', letterSpacing: '0.1em', marginBottom: '0.3rem' }}>ROLE</label>
|
||||
<select
|
||||
className="input-terminal"
|
||||
value={createRole}
|
||||
onChange={(e) => setCreateRole(e.target.value as 'dev' | 'com')}
|
||||
style={{ fontSize: '0.85rem' }}
|
||||
>
|
||||
<option value="dev">Dev — full staff access</option>
|
||||
<option value="com">Com — community staff</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createError && (
|
||||
<div style={{ color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', marginTop: '0.85rem', padding: '0.5rem 0.75rem', background: 'rgba(220,38,38,0.06)', border: '1px solid rgba(220,38,38,0.2)', borderRadius: '4px' }}>
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1.5rem' }}>
|
||||
<button className="btn-terminal btn-amber" onClick={handleCreateStaff} disabled={creating} style={{ opacity: creating ? 0.6 : 1 }}>
|
||||
{creating ? 'Creating...' : '> Create'}
|
||||
</button>
|
||||
<button className="btn-terminal" onClick={() => setShowCreateForm(false)} disabled={creating}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirm dialog */}
|
||||
{confirmAction && (
|
||||
<div style={{ background: 'rgba(0,0,0,0.3)', position: 'fixed', inset: 0, zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
onClick={() => setConfirmAction(null)}>
|
||||
<div style={{ background: 'var(--color-surface)', border: '2px solid var(--color-yellow)', padding: '2rem', maxWidth: '380px', width: '90%', borderRadius: '8px' }}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<h3 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', marginBottom: '1rem' }}>CONFIRM ACTION</h3>
|
||||
<p style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.82rem', marginBottom: '1.5rem', lineHeight: 1.7 }}>
|
||||
{confirmAction.action === 'promote'
|
||||
? `Promote this user to staff? They will gain access to the intranet.`
|
||||
: confirmAction.action === 'ban'
|
||||
? `Ban this user? They will be unable to login.`
|
||||
: `Unban this user? They will regain access to their account.`}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||
<button
|
||||
className={`btn-terminal ${confirmAction.action === 'ban' ? 'btn-danger' : 'btn-amber'}`}
|
||||
onClick={() => {
|
||||
if (confirmAction.action === 'promote') handlePromote(confirmAction.userId, 'dev');
|
||||
else if (confirmAction.action === 'ban') handleToggleBan(confirmAction.userId, true);
|
||||
else handleToggleBan(confirmAction.userId, false);
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button className="btn-terminal" onClick={() => setConfirmAction(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-mono)', fontSize: '0.78rem' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
|
||||
{['Username', 'Email', 'Role', 'Joined', 'Threads', 'Bugs', 'Status', 'Actions'].map((h) => (
|
||||
<th key={h} style={{ padding: '0.6rem 0.75rem', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 'normal', letterSpacing: '0.1em', fontSize: '0.68rem', textTransform: 'uppercase', whiteSpace: 'nowrap' }}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((u) => {
|
||||
const isSelf = u.id === currentUser?.id;
|
||||
return (
|
||||
<tr
|
||||
key={u.id}
|
||||
style={{ borderBottom: '1px solid var(--color-border)', background: u.isBanned ? 'rgba(220,38,38,0.05)' : 'transparent' }}
|
||||
>
|
||||
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-text)', fontWeight: isSelf ? 'bold' : 'normal' }}>
|
||||
{u.username} {isSelf && <span style={{ color: 'var(--color-amber)', fontSize: '0.65rem' }}>(you)</span>}
|
||||
</td>
|
||||
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-text-muted)' }}>{u.email}</td>
|
||||
<td style={{ padding: '0.7rem 0.75rem' }}>
|
||||
<RoleBadge role={u.role} isAdmin={u.isAdmin} />
|
||||
</td>
|
||||
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>{formatDate(u.createdAt)}</td>
|
||||
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-green)', textAlign: 'center' }}>0</td>
|
||||
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-green)', textAlign: 'center' }}>0</td>
|
||||
<td style={{ padding: '0.7rem 0.75rem' }}>
|
||||
{u.isBanned ? (
|
||||
<span className="badge badge-critical">Banned</span>
|
||||
) : (
|
||||
<span className="badge badge-open">Active</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '0.7rem 0.75rem' }}>
|
||||
{!isSelf && !u.isAdmin && (
|
||||
<div style={{ display: 'flex', gap: '0.35rem', flexWrap: 'nowrap' }}>
|
||||
{u.role === 'user' && currentUser?.isAdmin && (
|
||||
<button
|
||||
className="btn-terminal btn-amber"
|
||||
style={{ padding: '0.15rem 0.5rem', fontSize: '0.62rem' }}
|
||||
onClick={() => setConfirmAction({ userId: u.id, action: 'promote' })}
|
||||
>
|
||||
Promote
|
||||
</button>
|
||||
)}
|
||||
{u.isBanned ? (
|
||||
<button
|
||||
className="btn-terminal"
|
||||
style={{ padding: '0.15rem 0.5rem', fontSize: '0.62rem' }}
|
||||
onClick={() => setConfirmAction({ userId: u.id, action: 'unban' })}
|
||||
>
|
||||
Unban
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn-terminal btn-danger"
|
||||
style={{ padding: '0.15rem 0.5rem', fontSize: '0.62rem' }}
|
||||
onClick={() => setConfirmAction({ userId: u.id, action: 'ban' })}
|
||||
>
|
||||
Ban
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>
|
||||
No users match the current filters.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
197
nest-intra/src/types/index.ts
Normal file
197
nest-intra/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-intra/src/utils/format.ts
Normal file
47
nest-intra/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-intra/tsconfig.app.json
Normal file
28
nest-intra/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-intra/tsconfig.json
Normal file
7
nest-intra/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
nest-intra/tsconfig.node.json
Normal file
26
nest-intra/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"]
|
||||
}
|
||||
16
nest-intra/vite.config.ts
Normal file
16
nest-intra/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user