refactor : Remove intranet components along with their associated styles and logic

This commit is contained in:
Thibault Pouch
2026-02-26 16:23:48 +01:00
parent c2d94a349c
commit 6ed13d1ffc
9 changed files with 1 additions and 1943 deletions

View File

@@ -3,7 +3,6 @@ import { Routes, Route } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
import { ProtectedRoute } from './components/shared/ProtectedRoute';
import { PublicLayout } from './components/layout/PublicLayout';
import { IntranetLayout } from './components/layout/IntranetLayout';
import { PageLoader } from './components/shared/PageLoader';
// ── Public Pages (lazy-loaded) ────────────────────────────────────────────────
@@ -20,15 +19,6 @@ const LoginPage = lazy(() => import('./pages/public/LoginPage'));
const RegisterPage = lazy(() => import('./pages/public/RegisterPage'));
const NotFoundPage = lazy(() => import('./pages/public/NotFoundPage'));
// ── Intranet Pages (lazy-loaded) ──────────────────────────────────────────────
const IntranetDashboard = lazy(() => import('./pages/intranet/IntranetDashboard'));
const IntranetBugs = lazy(() => import('./pages/intranet/IntranetBugs'));
const IntranetFeed = lazy(() => import('./pages/intranet/IntranetFeed'));
const IntranetEvents = lazy(() => import('./pages/intranet/IntranetEvents'));
const IntranetUsers = lazy(() => import('./pages/intranet/IntranetUsers'));
const IntranetModeration = lazy(() => import('./pages/intranet/IntranetModeration'));
// ── App ────────────────────────────────────────────────────────────────────────
export default function App() {
@@ -57,23 +47,6 @@ export default function App() {
<Route path="register" element={<RegisterPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
{/* Intranet Routes — staff only */}
<Route
path="intranet"
element={
<ProtectedRoute staffOnly redirectTo="/">
<IntranetLayout />
</ProtectedRoute>
}
>
<Route index element={<IntranetDashboard />} />
<Route path="bugs" element={<IntranetBugs />} />
<Route path="feed" element={<IntranetFeed />} />
<Route path="events" element={<IntranetEvents />} />
<Route path="users" element={<IntranetUsers />} />
<Route path="moderation" element={<IntranetModeration />} />
</Route>
</Routes>
</Suspense>
</AuthProvider>

View File

@@ -1,150 +0,0 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { useCallback } from 'react';
const INTRANET_LINKS = [
{ to: '/intranet', label: 'Dashboard', icon: '[>]', end: true },
{ to: '/intranet/bugs', label: 'Bug Reports', icon: '[!]', end: false },
{ to: '/intranet/feed', label: 'Team Feed', icon: '[~]', end: false },
{ to: '/intranet/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 },
];
export function IntranetLayout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = useCallback(() => {
logout();
navigate('/');
}, [logout, navigate]);
return (
<div style={{ display: 'flex', minHeight: '100vh', background: 'var(--color-bg)' }}>
{/* Sidebar */}
<aside
style={{
width: '220px',
flexShrink: 0,
background: 'var(--color-bg-alt)',
borderRight: '2px solid var(--color-border)',
display: 'flex',
flexDirection: 'column',
padding: '1.5rem 0',
}}
>
{/* Logo */}
<div style={{ padding: '0 1.25rem 1.25rem', borderBottom: '1px solid var(--color-border)' }}>
<div
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-yellow)',
fontSize: '1.3rem',
letterSpacing: '0.08em',
}}
>
INTRANET
</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.62rem', marginTop: '2px' }}>
CROWMATE STUDIO
</div>
</div>
{/* Nav links */}
<nav style={{ flex: 1, padding: '0.75rem 0' }}>
{INTRANET_LINKS.map(({ to, label, icon, end }) => (
<NavLink
key={to}
to={to}
end={end}
style={({ isActive }) => ({
display: 'flex',
alignItems: 'center',
gap: '0.6rem',
padding: '0.55rem 1.25rem',
fontFamily: 'var(--font-mono)',
fontSize: '0.78rem',
color: isActive ? 'var(--color-yellow)' : 'var(--color-text-muted)',
background: isActive ? 'rgba(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 */}
<main style={{ flex: 1, overflowY: 'auto', padding: '2rem', background: 'var(--color-bg)' }}>
<Outlet />
</main>
</div>
);
}

View File

@@ -11,7 +11,7 @@ const NAV_LINKS = [
];
export function Navbar() {
const { user, isAuthenticated, isStaff, logout } = useAuth();
const { user, isAuthenticated, logout } = useAuth();
const navigate = useNavigate();
const [menuOpen, setMenuOpen] = useState(false);
@@ -90,42 +90,6 @@ export function Navbar() {
{label}
</NavLink>
))}
{/* Intranet — visually separated, highlighted button */}
{isStaff && (
<>
{/* Vertical divider */}
<span
aria-hidden="true"
style={{
display: 'inline-block',
width: '1px',
height: '18px',
background: 'var(--color-border)',
margin: '0 0.25rem',
}}
/>
<NavLink
to="/intranet"
style={({ isActive }) => ({
fontFamily: 'var(--font-mono)',
fontSize: '0.75rem',
textTransform: 'uppercase',
letterSpacing: '0.1em',
textDecoration: 'none',
background: isActive ? 'var(--color-yellow)' : 'var(--color-yellow)',
color: 'var(--color-bg)',
border: '2px solid var(--color-yellow)',
padding: '0.2rem 0.65rem',
fontWeight: 'bold',
opacity: isActive ? 1 : 0.85,
transition: 'opacity 0.1s',
})}
>
&#9646; INTRANET
</NavLink>
</>
)}
</div>
{/* Desktop Auth */}
@@ -230,32 +194,6 @@ export function Navbar() {
</>
)}
</div>
{/* Intranet button — mobile: separated at the bottom */}
{isStaff && (
<div style={{ borderTop: '2px solid var(--color-yellow)', paddingTop: '0.85rem' }}>
<NavLink
to="/intranet"
onClick={closeMenu}
style={({ isActive }) => ({
display: 'inline-block',
fontFamily: 'var(--font-mono)',
fontSize: '0.8rem',
textTransform: 'uppercase' as const,
letterSpacing: '0.1em',
textDecoration: 'none',
background: 'var(--color-yellow)',
color: 'var(--color-bg)',
border: '2px solid var(--color-yellow)',
padding: '0.3rem 0.85rem',
fontWeight: 'bold',
opacity: isActive ? 0.85 : 1,
})}
>
&#9646; INTRANET
</NavLink>
</div>
)}
</div>
</div>
)}

View File

@@ -1,251 +0,0 @@
import { useState, useMemo, useCallback } from 'react';
import { MOCK_BUGS, MOCK_USERS } from '../../data/mockData';
import { useAuth } from '../../contexts/AuthContext';
import { formatDate, formatDateTime } from '../../utils/format';
import type { BugReport, BugSeverity, BugStatus, BugReportNote } from '../../types';
function StatusBadge({ status }: { status: BugStatus }) {
const map: Record<BugStatus, string> = { open: 'badge-open', in_progress: 'badge-progress', resolved: 'badge-resolved', closed: 'badge-closed' };
const labels: Record<BugStatus, string> = { open: 'Open', in_progress: 'In Progress', resolved: 'Resolved', closed: 'Closed' };
return <span className={`badge ${map[status]}`}>{labels[status]}</span>;
}
function SeverityBadge({ severity }: { severity: BugSeverity }) {
const map: Record<BugSeverity, string> = { low: 'badge-low', medium: 'badge-medium', high: 'badge-high', critical: 'badge-critical' };
return <span className={`badge ${map[severity]}`}>{severity}</span>;
}
const STATUSES: BugStatus[] = ['open', 'in_progress', 'resolved', 'closed'];
const STAFF_MEMBERS = MOCK_USERS.filter((u) => u.role === 'dev' || u.role === 'com');
export default function IntranetBugs() {
const { user } = useAuth();
const [bugs, setBugs] = useState<BugReport[]>(MOCK_BUGS);
const [selected, setSelected] = useState<BugReport | null>(null);
const [statusFilter, setStatusFilter] = useState<BugStatus | 'all'>('all');
const [severityFilter, setSeverityFilter] = useState<BugSeverity | 'all'>('all');
const [assignedFilter, setAssignedFilter] = useState<string | 'all'>('all');
const [noteText, setNoteText] = useState('');
const openCount = bugs.filter((b) => b.status === 'open').length;
const criticalCount = bugs.filter((b) => b.severity === 'critical').length;
const myCount = bugs.filter((b) => b.assignedToId === user?.id).length;
const filtered = useMemo(() => {
return bugs.filter((b) => {
if (statusFilter !== 'all' && b.status !== statusFilter) return false;
if (severityFilter !== 'all' && b.severity !== severityFilter) return false;
if (assignedFilter !== 'all') {
if (assignedFilter === 'unassigned' && b.assignedToId) return false;
if (assignedFilter !== 'unassigned' && b.assignedToId !== assignedFilter) return false;
}
return true;
});
}, [bugs, statusFilter, severityFilter, assignedFilter]);
const updateBug = useCallback((id: string, changes: Partial<BugReport>) => {
setBugs((prev) => prev.map((b) => b.id === id ? { ...b, ...changes, updatedAt: new Date().toISOString() } : b));
setSelected((prev) => prev?.id === id ? { ...prev, ...changes, updatedAt: new Date().toISOString() } : prev);
}, []);
const handleAssign = useCallback((bugId: string, staffId: string) => {
const staff = STAFF_MEMBERS.find((s) => s.id === staffId);
updateBug(bugId, { assignedToId: staffId || undefined, assignedToName: staff?.username });
}, [updateBug]);
const handleStatusChange = useCallback((bugId: string, status: BugStatus) => {
updateBug(bugId, { status });
}, [updateBug]);
const handleAddNote = useCallback((bugId: string) => {
if (!noteText.trim() || !user) return;
const note: BugReportNote = {
id: `n${Date.now()}`,
bugReportId: bugId,
authorId: user.id,
authorName: user.username,
content: noteText.trim(),
createdAt: new Date().toISOString(),
};
setBugs((prev) => prev.map((b) => b.id === bugId ? { ...b, notes: [...(b.notes ?? []), note] } : b));
setSelected((prev) => prev?.id === bugId ? { ...prev, notes: [...(prev.notes ?? []), note] } : prev);
setNoteText('');
}, [noteText, user]);
return (
<div style={{ display: 'grid', gridTemplateColumns: selected ? '1fr 400px' : '1fr', gap: '1.5rem', alignItems: 'start' }}>
{/* Left panel */}
<div>
<div style={{ marginBottom: '1.5rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
INTRANET / BUG REPORTS
</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem', marginBottom: '1rem' }}>BUG DASHBOARD</h1>
{/* Stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.5rem', marginBottom: '1.25rem' }}>
{[
{ label: 'Open', value: openCount, color: 'var(--color-green)' },
{ label: 'Critical', value: criticalCount, color: 'var(--color-red)' },
{ label: 'Mine', value: myCount, color: 'var(--color-amber)' },
].map(({ label, value, color }) => (
<div key={label} style={{ background: '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

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

View File

@@ -1,722 +0,0 @@
import { useState, useCallback } from 'react';
import { MOCK_EVENTS, MOCK_POLLS } from '../../data/mockData';
import { useAuth } from '../../contexts/AuthContext';
import { formatDateTime } from '../../utils/format';
import type { EventPost, EventType, Poll, UserRole } from '../../types';
const EVENT_TYPE_COLORS: Record<EventType, string> = {
announcement: 'var(--color-yellow)',
update: 'var(--color-blue)',
milestone: 'var(--color-green)',
poll: 'var(--color-amber)',
};
const 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[]>(MOCK_EVENTS);
const [polls, setPolls] = useState<Poll[]>(MOCK_POLLS);
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

@@ -1,147 +0,0 @@
import { useState, useCallback } from 'react';
import { MOCK_STAFF_POSTS } from '../../data/mockData';
import { useAuth } from '../../contexts/AuthContext';
import { formatDateTime } from '../../utils/format';
import type { StaffPost, UserRole } from '../../types';
const ROLE_COLORS: Record<UserRole, string> = {
dev: 'var(--color-green)',
com: 'var(--color-amber)',
user: 'var(--color-text-muted)',
};
function FeedPost({ post }: { post: StaffPost }) {
return (
<div style={{ background: '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[]>(MOCK_STAFF_POSTS);
const [content, setContent] = useState('');
const [error, setError] = useState('');
const [posting, setPosting] = useState(false);
const handlePost = useCallback(async () => {
if (!content.trim()) { setError('Post cannot be empty.'); return; }
if (content.trim().length < 5) { setError('Post must be at least 5 characters.'); return; }
if (!user) return;
setError('');
setPosting(true);
await new Promise((r) => setTimeout(r, 250));
const newPost: StaffPost = {
id: `sp${Date.now()}`,
authorId: user.id,
authorName: user.username,
authorRole: user.role as 'dev' | 'com',
content: content.trim(),
createdAt: new Date().toISOString(),
};
setPosts((prev) => [newPost, ...prev]);
setContent('');
setPosting(false);
}, [content, user]);
return (
<div style={{ maxWidth: '720px' }}>
<div style={{ marginBottom: '2rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
INTRANET / TEAM FEED
</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem' }}>TEAM ACTIVITY</h1>
<p style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem', marginTop: '0.4rem' }}>
Staff-only internal feed. Posts are not visible to the public.
</p>
</div>
{/* Compose */}
<div style={{ background: '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

@@ -1,231 +0,0 @@
import { useState, useMemo, useCallback } from 'react';
import { MOCK_THREADS, MOCK_REPLIES } from '../../data/mockData';
import { formatDateTime } from '../../utils/format';
import type { ForumThread, ForumReply } from '../../types';
export default function IntranetModeration() {
const [threads, setThreads] = useState<ForumThread[]>(MOCK_THREADS);
const [replies, setReplies] = useState<ForumReply[]>(MOCK_REPLIES);
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [activeTab, setActiveTab] = useState<'threads' | 'replies'>('threads');
const filteredThreads = useMemo(() => {
if (!search.trim()) return threads;
const q = search.toLowerCase();
return threads.filter((t) => t.title.toLowerCase().includes(q) || t.authorName.toLowerCase().includes(q));
}, [threads, search]);
const selectedThreadReplies = useMemo(() => {
if (!selectedThreadId) return [];
return replies.filter((r) => r.threadId === selectedThreadId);
}, [replies, selectedThreadId]);
const deleteThread = useCallback((id: string) => {
setThreads((prev) => prev.filter((t) => t.id !== id));
setReplies((prev) => prev.filter((r) => r.threadId !== id));
if (selectedThreadId === id) setSelectedThreadId(null);
}, [selectedThreadId]);
const togglePin = useCallback((id: string) => {
setThreads((prev) => prev.map((t) => t.id === id ? { ...t, isPinned: !t.isPinned } : t));
}, []);
const toggleLock = useCallback((id: string) => {
setThreads((prev) => prev.map((t) => t.id === id ? { ...t, isLocked: !t.isLocked } : t));
}, []);
const deleteReply = useCallback((id: string) => {
setReplies((prev) => prev.filter((r) => r.id !== id));
}, []);
const recentReplies = useMemo(() => {
return [...replies].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()).slice(0, 20);
}, [replies]);
return (
<div>
<div style={{ marginBottom: '1.75rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
INTRANET / MODERATION
</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem', marginBottom: '0.5rem' }}>FORUM MODERATION</h1>
<p style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem' }}>
{threads.length} threads &mdash; {replies.length} replies
</p>
</div>
{/* Tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid 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

@@ -1,188 +0,0 @@
import { useState, useMemo, useCallback } from 'react';
import { MOCK_USERS, MOCK_THREADS, MOCK_BUGS } from '../../data/mockData';
import { useAuth } from '../../contexts/AuthContext';
import { formatDate } from '../../utils/format';
import type { User, UserRole } from '../../types';
export default function IntranetUsers() {
const { user: currentUser } = useAuth();
const [users, setUsers] = useState<User[]>(MOCK_USERS);
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState<UserRole | 'all'>('all');
const [confirmAction, setConfirmAction] = useState<{ userId: string; action: 'promote' | 'ban' | 'unban' } | null>(null);
const threadCounts = useMemo(() => {
const map: Record<string, number> = {};
MOCK_THREADS.forEach((t) => { map[t.authorId] = (map[t.authorId] ?? 0) + 1; });
return map;
}, []);
const bugCounts = useMemo(() => {
const map: Record<string, number> = {};
MOCK_BUGS.forEach((b) => { map[b.submittedById] = (map[b.submittedById] ?? 0) + 1; });
return map;
}, []);
const filtered = useMemo(() => {
return users.filter((u) => {
const matchSearch = !search.trim() || u.username.toLowerCase().includes(search.toLowerCase()) || u.email.toLowerCase().includes(search.toLowerCase());
const matchRole = roleFilter === 'all' || u.role === roleFilter;
return matchSearch && matchRole;
});
}, [users, search, roleFilter]);
const handlePromote = useCallback((userId: string, targetRole: UserRole) => {
setUsers((prev) => prev.map((u) => u.id === userId ? { ...u, role: targetRole } : u));
setConfirmAction(null);
}, []);
const handleToggleBan = useCallback((userId: string, ban: boolean) => {
setUsers((prev) => prev.map((u) => u.id === userId ? { ...u, isBanned: ban } : u));
setConfirmAction(null);
}, []);
return (
<div>
<div style={{ marginBottom: '1.75rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
INTRANET / USER MANAGEMENT
</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem', marginBottom: '1rem' }}>USERS</h1>
{/* Filters */}
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<input
className="input-terminal"
type="search"
placeholder="Search username or email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ maxWidth: '260px' }}
/>
<select className="input-terminal" style={{ width: 'auto', minWidth: '130px' }} value={roleFilter} onChange={(e) => setRoleFilter(e.target.value as UserRole | 'all')}>
<option value="all">All Roles</option>
<option value="user">Users</option>
<option value="dev">Dev</option>
<option value="com">Com</option>
</select>
</div>
</div>
{/* Confirm dialog */}
{confirmAction && (
<div style={{ background: 'rgba(0,0,0,0.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>}
{u.isAdmin && <span style={{ color: 'var(--color-green)', fontSize: '0.62rem', marginLeft: '0.3rem' }}>[admin]</span>}
</td>
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-text-muted)' }}>{u.email}</td>
<td style={{ padding: '0.7rem 0.75rem' }}>
<span className={`badge ${u.role === 'dev' ? 'badge-open' : u.role === 'com' ? 'badge-medium' : 'badge-closed'}`}>
{u.role}
</span>
</td>
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>{formatDate(u.createdAt)}</td>
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-green)', textAlign: 'center' }}>{threadCounts[u.id] ?? 0}</td>
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-green)', textAlign: 'center' }}>{bugCounts[u.id] ?? 0}</td>
<td style={{ padding: '0.7rem 0.75rem' }}>
{u.isBanned ? (
<span className="badge badge-critical">Banned</span>
) : (
<span className="badge badge-open">Active</span>
)}
</td>
<td style={{ padding: '0.7rem 0.75rem' }}>
{!isSelf && !u.isAdmin && (
<div style={{ display: 'flex', gap: '0.35rem', flexWrap: 'nowrap' }}>
{u.role === 'user' && currentUser?.isAdmin && (
<button
className="btn-terminal btn-amber"
style={{ padding: '0.15rem 0.5rem', fontSize: '0.62rem' }}
onClick={() => setConfirmAction({ userId: u.id, action: 'promote' })}
>
Promote
</button>
)}
{u.isBanned ? (
<button
className="btn-terminal"
style={{ padding: '0.15rem 0.5rem', fontSize: '0.62rem' }}
onClick={() => setConfirmAction({ userId: u.id, action: 'unban' })}
>
Unban
</button>
) : (
<button
className="btn-terminal btn-danger"
style={{ padding: '0.15rem 0.5rem', fontSize: '0.62rem' }}
onClick={() => setConfirmAction({ userId: u.id, action: 'ban' })}
>
Ban
</button>
)}
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
{filtered.length === 0 && (
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>
No users match the current filters.
</div>
)}
</div>
</div>
);
}