chore: merge nest-intra history into monorepo

This commit is contained in:
Thibault Pouch
2026-03-03 09:11:10 +01:00
32 changed files with 8001 additions and 0 deletions

24
nest-intra/.gitignore vendored Normal file
View File

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

18
nest-intra/Dockerfile Normal file
View 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
View 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

View File

@@ -0,0 +1,6 @@
services:
nest-intra:
build: .
ports:
- "5174:5174"
restart: unless-stopped

View File

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

16
nest-intra/index.html Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

33
nest-intra/package.json Normal file
View 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"
}
}

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

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

View File

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

View File

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

View File

@@ -0,0 +1,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;
}

View 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
View 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
View File

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

View File

@@ -0,0 +1,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>
);
}

View 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} &mdash; 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">&#x2715;</button>
</div>
{/* Controls */}
<div style={{ display: 'grid', gap: '0.75rem', marginBottom: '1.25rem' }}>
<div>
<label style={{ display: 'block', fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', letterSpacing: '0.1em', marginBottom: '0.3rem' }}>STATUS</label>
<select
className="input-terminal"
style={{ fontSize: '0.75rem' }}
value={selected.status}
onChange={(e) => handleStatusChange(selected.id, e.target.value as BugStatus)}
>
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<div>
<label style={{ display: 'block', fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', letterSpacing: '0.1em', marginBottom: '0.3rem' }}>ASSIGN TO</label>
<select
className="input-terminal"
style={{ fontSize: '0.75rem' }}
value={selected.assignedToId ?? ''}
onChange={(e) => handleAssign(selected.id, e.target.value)}
>
<option value="">Unassigned</option>
{STAFF_MEMBERS.map((s) => <option key={s.id} value={s.id}>{s.username} ({s.role})</option>)}
</select>
</div>
</div>
{/* Description */}
<div style={{ marginBottom: '1rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.65rem', letterSpacing: '0.1em', marginBottom: '0.4rem' }}>DESCRIPTION</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.78rem', lineHeight: 1.7, whiteSpace: 'pre-wrap', background: '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} &mdash; {formatDateTime(note.createdAt)}
</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.77rem', lineHeight: 1.6 }}>
{note.content}
</div>
</div>
))}
</div>
)}
<textarea
className="input-terminal"
rows={3}
placeholder="Add an internal note..."
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
style={{ resize: 'vertical', fontSize: '0.8rem', marginBottom: '0.5rem' }}
/>
<button className="btn-terminal btn-amber" onClick={() => handleAddNote(selected.id)} style={{ padding: '0.35rem 0.9rem', fontSize: '0.75rem' }}>
Add Note
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,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>
);
}

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

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

View 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 &mdash; {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} &mdash; {thread.categoryName} &mdash; {thread.replyCount} replies
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '0.4rem', marginTop: '0.6rem', flexWrap: 'wrap' }}>
<button
className="btn-terminal"
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
onClick={() => setSelectedThreadId(selectedThreadId === thread.id ? null : thread.id)}
>
Replies
</button>
<button
className={`btn-terminal ${thread.isPinned ? 'btn-danger' : 'btn-amber'}`}
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
onClick={() => togglePin(thread.id)}
>
{thread.isPinned ? 'Unpin' : 'Pin'}
</button>
<button
className="btn-terminal btn-amber"
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
onClick={() => toggleLock(thread.id)}
>
{thread.isLocked ? 'Unlock' : 'Lock'}
</button>
<button
className="btn-terminal btn-danger"
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
onClick={() => deleteThread(thread.id)}
>
Delete
</button>
</div>
</div>
))}
{filteredThreads.length === 0 && (
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem', background: '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">&#x2715;</button>
</div>
{selectedThreadReplies.length === 0 ? (
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem' }}>No replies.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{selectedThreadReplies.map((reply) => (
<div key={reply.id} style={{ background: '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></>}
{' '}&mdash; {formatDateTime(reply.createdAt)}
</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text)', fontSize: '0.82rem', lineHeight: 1.6, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{reply.content}
</div>
</div>
<button
className="btn-terminal btn-danger"
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem', flexShrink: 0 }}
onClick={() => deleteReply(reply.id)}
>
Delete
</button>
</div>
</div>
);
})}
{recentReplies.length === 0 && (
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem', background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}>
No replies found.
</div>
)}
</div>
)}
</div>
);
}

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

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

View 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';
}

View File

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

View File

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

7
nest-intra/tsconfig.json Normal file
View File

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

View File

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

16
nest-intra/vite.config.ts Normal file
View 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',
},
},
})