chore : move all to root

This commit is contained in:
Thibault Pouch
2026-02-26 16:16:44 +01:00
parent 308a758e79
commit c2d94a349c
44 changed files with 0 additions and 0 deletions

View File

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

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

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

@@ -0,0 +1,231 @@
import { useState, useMemo, useCallback } from 'react';
import { MOCK_THREADS, MOCK_REPLIES } from '../../data/mockData';
import { formatDateTime } from '../../utils/format';
import type { ForumThread, ForumReply } from '../../types';
export default function IntranetModeration() {
const [threads, setThreads] = useState<ForumThread[]>(MOCK_THREADS);
const [replies, setReplies] = useState<ForumReply[]>(MOCK_REPLIES);
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [activeTab, setActiveTab] = useState<'threads' | 'replies'>('threads');
const filteredThreads = useMemo(() => {
if (!search.trim()) return threads;
const q = search.toLowerCase();
return threads.filter((t) => t.title.toLowerCase().includes(q) || t.authorName.toLowerCase().includes(q));
}, [threads, search]);
const selectedThreadReplies = useMemo(() => {
if (!selectedThreadId) return [];
return replies.filter((r) => r.threadId === selectedThreadId);
}, [replies, selectedThreadId]);
const deleteThread = useCallback((id: string) => {
setThreads((prev) => prev.filter((t) => t.id !== id));
setReplies((prev) => prev.filter((r) => r.threadId !== id));
if (selectedThreadId === id) setSelectedThreadId(null);
}, [selectedThreadId]);
const togglePin = useCallback((id: string) => {
setThreads((prev) => prev.map((t) => t.id === id ? { ...t, isPinned: !t.isPinned } : t));
}, []);
const toggleLock = useCallback((id: string) => {
setThreads((prev) => prev.map((t) => t.id === id ? { ...t, isLocked: !t.isLocked } : t));
}, []);
const deleteReply = useCallback((id: string) => {
setReplies((prev) => prev.filter((r) => r.id !== id));
}, []);
const recentReplies = useMemo(() => {
return [...replies].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()).slice(0, 20);
}, [replies]);
return (
<div>
<div style={{ marginBottom: '1.75rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
INTRANET / MODERATION
</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem', marginBottom: '0.5rem' }}>FORUM MODERATION</h1>
<p style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem' }}>
{threads.length} threads &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,188 @@
import { useState, useMemo, useCallback } from 'react';
import { MOCK_USERS, MOCK_THREADS, MOCK_BUGS } from '../../data/mockData';
import { useAuth } from '../../contexts/AuthContext';
import { formatDate } from '../../utils/format';
import type { User, UserRole } from '../../types';
export default function IntranetUsers() {
const { user: currentUser } = useAuth();
const [users, setUsers] = useState<User[]>(MOCK_USERS);
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState<UserRole | 'all'>('all');
const [confirmAction, setConfirmAction] = useState<{ userId: string; action: 'promote' | 'ban' | 'unban' } | null>(null);
const threadCounts = useMemo(() => {
const map: Record<string, number> = {};
MOCK_THREADS.forEach((t) => { map[t.authorId] = (map[t.authorId] ?? 0) + 1; });
return map;
}, []);
const bugCounts = useMemo(() => {
const map: Record<string, number> = {};
MOCK_BUGS.forEach((b) => { map[b.submittedById] = (map[b.submittedById] ?? 0) + 1; });
return map;
}, []);
const filtered = useMemo(() => {
return users.filter((u) => {
const matchSearch = !search.trim() || u.username.toLowerCase().includes(search.toLowerCase()) || u.email.toLowerCase().includes(search.toLowerCase());
const matchRole = roleFilter === 'all' || u.role === roleFilter;
return matchSearch && matchRole;
});
}, [users, search, roleFilter]);
const handlePromote = useCallback((userId: string, targetRole: UserRole) => {
setUsers((prev) => prev.map((u) => u.id === userId ? { ...u, role: targetRole } : u));
setConfirmAction(null);
}, []);
const handleToggleBan = useCallback((userId: string, ban: boolean) => {
setUsers((prev) => prev.map((u) => u.id === userId ? { ...u, isBanned: ban } : u));
setConfirmAction(null);
}, []);
return (
<div>
<div style={{ marginBottom: '1.75rem' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
INTRANET / USER MANAGEMENT
</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem', marginBottom: '1rem' }}>USERS</h1>
{/* Filters */}
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<input
className="input-terminal"
type="search"
placeholder="Search username or email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ maxWidth: '260px' }}
/>
<select className="input-terminal" style={{ width: 'auto', minWidth: '130px' }} value={roleFilter} onChange={(e) => setRoleFilter(e.target.value as UserRole | 'all')}>
<option value="all">All Roles</option>
<option value="user">Users</option>
<option value="dev">Dev</option>
<option value="com">Com</option>
</select>
</div>
</div>
{/* Confirm dialog */}
{confirmAction && (
<div style={{ background: 'rgba(0,0,0,0.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>
);
}

View File

@@ -0,0 +1,248 @@
import { useState, useCallback } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { MOCK_THREADS, MOCK_BUGS } from '../../data/mockData';
import { formatDate } from '../../utils/format';
import { Link } from 'react-router-dom';
type Tab = 'profile' | 'threads' | 'bugs' | 'password';
export default function AccountPage() {
const { user, updateUsername } = useAuth();
const [activeTab, setActiveTab] = useState<Tab>('profile');
const userThreads = MOCK_THREADS.filter((t) => t.authorId === user?.id);
const userBugs = MOCK_BUGS.filter((b) => b.submittedById === user?.id);
const tabs: { id: Tab; label: string }[] = [
{ id: 'profile', label: 'Profile' },
{ id: 'threads', label: `Threads (${userThreads.length})` },
{ id: 'bugs', label: `Bug Reports (${userBugs.length})` },
{ id: 'password', label: 'Change Password' },
];
if (!user) return null;
return (
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '4rem 1.5rem' }}>
<div className="section-label">My Account</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: 'clamp(2rem, 5vw, 3rem)', marginTop: '0.5rem', marginBottom: '2rem' }}>
{user.username}
</h1>
{/* Tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid var(--color-border)', marginBottom: '2rem', gap: '0', flexWrap: 'wrap' }}>
{tabs.map(({ id, label }) => (
<button
key={id}
onClick={() => setActiveTab(id)}
style={{
background: 'transparent',
border: 'none',
borderBottom: activeTab === id ? '2px solid var(--color-green)' : '2px solid transparent',
color: activeTab === id ? 'var(--color-green)' : 'var(--color-text-muted)',
fontFamily: 'var(--font-mono)',
fontSize: '0.78rem',
padding: '0.6rem 1rem',
cursor: 'pointer',
letterSpacing: '0.05em',
textTransform: 'uppercase',
transition: 'all 0.2s',
}}
>
{label}
</button>
))}
</div>
{/* Profile Tab */}
{activeTab === 'profile' && (
<ProfileTab user={user} updateUsername={updateUsername} />
)}
{/* Threads Tab */}
{activeTab === 'threads' && (
<div>
{userThreads.length === 0 ? (
<div className="crt-box" style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)' }}>
You haven't posted any threads yet.{' '}
<Link to="/forum" style={{ color: 'var(--color-green)' }}>Go to Forum</Link>
</div>
) : (
userThreads.map((t) => (
<div key={t.id} className="crt-box" style={{ padding: '1rem 1.25rem', marginBottom: '0.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
<Link to={`/forum/thread/${t.id}`} style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text)', fontSize: '0.87rem' }}>
{t.title}
</Link>
<span style={{ color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', flexShrink: 0 }}>
{formatDate(t.createdAt)}
</span>
</div>
<div style={{ color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', marginTop: '0.25rem' }}>
{t.categoryName} &mdash; {t.replyCount} replies
</div>
</div>
))
)}
</div>
)}
{/* Bug Reports Tab */}
{activeTab === 'bugs' && (
<div>
{userBugs.length === 0 ? (
<div className="crt-box" style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)' }}>
You haven't submitted any bug reports.{' '}
<Link to="/bugs" style={{ color: 'var(--color-green)' }}>Report a Bug</Link>
</div>
) : (
userBugs.map((b) => (
<div key={b.id} className="crt-box" style={{ padding: '1rem 1.25rem', marginBottom: '0.5rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap', marginBottom: '0.25rem' }}>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text)', fontSize: '0.87rem' }}>{b.title}</span>
<span style={{ color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', flexShrink: 0 }}>{formatDate(b.createdAt)}</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem' }}>{b.uniqueCode}</span>
<span className={`badge badge-${b.status === 'in_progress' ? 'progress' : b.status}`}>{b.status}</span>
<span className={`badge badge-${b.severity}`}>{b.severity}</span>
</div>
</div>
))
)}
</div>
)}
{/* Password Tab */}
{activeTab === 'password' && <ChangePasswordForm />}
</div>
);
}
// ── Profile Tab ────────────────────────────────────────────────────────────────
function ProfileTab({ user, updateUsername }: { user: NonNullable<ReturnType<typeof useAuth>['user']>; updateUsername: (u: string) => void }) {
const [editing, setEditing] = useState(false);
const [username, setUsername] = useState(user.username);
const [error, setError] = useState('');
const [saved, setSaved] = useState(false);
const handleSave = useCallback(() => {
if (!username.trim()) { setError('Username cannot be empty.'); return; }
if (username.length < 3) { setError('Must be at least 3 characters.'); return; }
updateUsername(username.trim());
setEditing(false);
setSaved(true);
setTimeout(() => setSaved(false), 3000);
}, [username, updateUsername]);
return (
<div className="crt-box" style={{ padding: '2rem' }}>
{saved && (
<div style={{ color: 'var(--color-green)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem', marginBottom: '1rem', background: 'rgba(0,255,65,0.07)', border: '1px solid rgba(0,255,65,0.2)', padding: '0.6rem 0.75rem' }}>
[OK] Username updated successfully.
</div>
)}
<div style={{ display: 'grid', gap: '1rem' }}>
{/* Username */}
<div style={{ display: 'grid', gridTemplateColumns: '140px 1fr', gap: '0.5rem', alignItems: 'center' }}>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.75rem', letterSpacing: '0.1em' }}>USERNAME</span>
{editing ? (
<div style={{ display: 'flex', gap: '0.5rem' }}>
<input
className={`input-terminal${error ? ' error' : ''}`}
value={username}
onChange={(e) => { setUsername(e.target.value); setError(''); }}
style={{ flex: 1 }}
autoFocus
/>
<button className="btn-terminal" onClick={handleSave} style={{ padding: '0.35rem 0.75rem', fontSize: '0.75rem' }}>Save</button>
<button className="btn-terminal btn-danger" onClick={() => { setEditing(false); setUsername(user.username); setError(''); }} style={{ padding: '0.35rem 0.75rem', fontSize: '0.75rem' }}>Cancel</button>
</div>
) : (
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text)', fontSize: '0.87rem' }}>{user.username}</span>
<button
className="btn-terminal"
onClick={() => setEditing(true)}
style={{ padding: '0.2rem 0.6rem', fontSize: '0.65rem' }}
>
Edit
</button>
</div>
)}
</div>
{error && <div style={{ color: 'var(--color-red)', fontSize: '0.72rem', gridColumn: '2' }}>{error}</div>}
{/* Static fields */}
{[
{ label: 'EMAIL', value: user.email },
{ label: 'ROLE', value: user.role.toUpperCase() },
{ label: 'MEMBER SINCE', value: formatDate(user.createdAt) },
{ label: 'ADMIN', value: user.isAdmin ? 'Yes' : 'No' },
].map(({ label, value }) => (
<div key={label} style={{ display: 'grid', gridTemplateColumns: '140px 1fr', gap: '0.5rem', alignItems: 'center' }}>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.75rem', letterSpacing: '0.1em' }}>{label}</span>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.87rem' }}>{value}</span>
</div>
))}
</div>
</div>
);
}
// ── Change Password Form ───────────────────────────────────────────────────────
function ChangePasswordForm() {
const [form, setForm] = useState({ current: '', next: '', confirm: '' });
const [errors, setErrors] = useState<Partial<typeof form & { success: string }>>({});
const [loading, setLoading] = useState(false);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
const next: typeof errors = {};
if (!form.current) next.current = 'Current password required.';
if (!form.next) next.next = 'New password required.';
else if (form.next.length < 8) next.next = 'Must be at least 8 characters.';
if (form.next !== form.confirm) next.confirm = 'Passwords do not match.';
setErrors(next);
if (Object.keys(next).length > 0) return;
setLoading(true);
await new Promise((r) => setTimeout(r, 400));
setLoading(false);
setForm({ current: '', next: '', confirm: '' });
setErrors({ success: 'Password changed successfully.' });
}, [form]);
return (
<form onSubmit={handleSubmit} noValidate className="crt-box" style={{ padding: '2rem' }}>
{errors.success && (
<div style={{ color: 'var(--color-green)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem', marginBottom: '1.25rem', background: 'rgba(0,255,65,0.07)', border: '1px solid rgba(0,255,65,0.2)', padding: '0.6rem 0.75rem' }}>
[OK] {errors.success}
</div>
)}
{[
{ key: 'current' as const, label: 'Current Password', auto: 'current-password' },
{ key: 'next' as const, label: 'New Password', auto: 'new-password' },
{ key: 'confirm' as const, label: 'Confirm New Password', auto: 'new-password' },
].map(({ key, label, auto }) => (
<div key={key} style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.75rem', marginBottom: '0.4rem' }}>{label}</label>
<input
className={`input-terminal${errors[key] ? ' error' : ''}`}
type="password"
autoComplete={auto}
value={form[key]}
onChange={(e) => { setForm((p) => ({ ...p, [key]: e.target.value })); setErrors((p) => ({ ...p, [key]: undefined })); }}
/>
{errors[key] && <div style={{ color: 'var(--color-red)', fontSize: '0.72rem', marginTop: '0.25rem' }}>{errors[key]}</div>}
</div>
))}
<button type="submit" className="btn-terminal" disabled={loading} style={{ marginTop: '0.5rem', opacity: loading ? 0.7 : 1 }}>
{loading ? 'Saving...' : '> Update Password'}
</button>
</form>
);
}

View File

@@ -0,0 +1,347 @@
import { useState, useCallback, useMemo } from 'react';
import { Link, Navigate, useParams } from 'react-router-dom';
import { MOCK_BUGS, MOCK_BUG_COMMENTS } from '../../data/mockData';
import { useAuth } from '../../contexts/AuthContext';
import { formatDate, formatDateTime } from '../../utils/format';
import type { BugComment, BugReport, BugSeverity, BugStatus } from '../../types';
// ── Helpers ────────────────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: BugStatus }) {
const map: Record<BugStatus, string> = {
open: 'badge-open', in_progress: 'badge-progress', resolved: 'badge-resolved', closed: 'badge-closed',
};
const labels: Record<BugStatus, string> = {
open: 'Open', in_progress: 'In Progress', resolved: 'Resolved', closed: 'Closed',
};
return <span className={`badge ${map[status]}`}>{labels[status]}</span>;
}
function SeverityBadge({ severity }: { severity: BugSeverity }) {
const map: Record<BugSeverity, string> = {
low: 'badge-low', medium: 'badge-medium', high: 'badge-high', critical: 'badge-critical',
};
return <span className={`badge ${map[severity]}`}>{severity}</span>;
}
// ── Comment component ──────────────────────────────────────────────────────────
function CommentItem({ comment }: { comment: BugComment }) {
return (
<div
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
padding: '0.9rem 1.1rem',
marginBottom: '0.5rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem', gap: '1rem', flexWrap: 'wrap' }}>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-cyan)', fontSize: '0.78rem' }}>
{comment.authorName}
</span>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', flexShrink: 0 }}>
{formatDateTime(comment.createdAt)}
</span>
</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.83rem', lineHeight: 1.75 }}>
{comment.content}
</div>
</div>
);
}
// ── Bug Detail Page ────────────────────────────────────────────────────────────
export default function BugDetailPage() {
const { id } = useParams<{ id: string }>();
const { user, isAuthenticated } = useAuth();
// Local state — mirrors the global bug list in memory
const [bugs, setBugs] = useState<BugReport[]>(MOCK_BUGS);
const [comments, setComments] = useState<BugComment[]>(MOCK_BUG_COMMENTS);
const [newComment, setNewComment] = useState('');
const [commentError, setCommentError] = useState('');
const [submitting, setSubmitting] = useState(false);
const bug = useMemo(() => bugs.find((b) => b.id === id), [bugs, id]);
const bugComments = useMemo(
() => comments.filter((c) => c.bugReportId === id).sort((a, b) => a.createdAt.localeCompare(b.createdAt)),
[comments, id]
);
// "I have this too" logic
const alreadyVoted = useMemo(
() => !!user && !!bug && bug.meTooBugs.includes(user.id),
[user, bug]
);
const isOwnReport = useMemo(
() => !!user && !!bug && bug.submittedById === user.id,
[user, bug]
);
const handleMeToo = useCallback(() => {
if (!user || !bug || alreadyVoted || isOwnReport) return;
setBugs((prev) =>
prev.map((b) =>
b.id === bug.id ? { ...b, meTooBugs: [...b.meTooBugs, user.id] } : b
)
);
}, [user, bug, alreadyVoted, isOwnReport]);
const handleComment = useCallback(async () => {
if (!user) return;
if (!newComment.trim()) { setCommentError('Comment cannot be empty.'); return; }
if (newComment.trim().length < 5) { setCommentError('Comment must be at least 5 characters.'); return; }
setCommentError('');
setSubmitting(true);
await new Promise((r) => setTimeout(r, 250));
const comment: BugComment = {
id: `bc${Date.now()}`,
bugReportId: id!,
authorId: user.id,
authorName: user.username,
content: newComment.trim(),
createdAt: new Date().toISOString(),
};
setComments((prev) => [...prev, comment]);
setNewComment('');
setSubmitting(false);
}, [user, newComment, id]);
if (!bug) {
return <Navigate to="/bugs" replace />;
}
const metooCount = bug.meTooBugs.length;
return (
<div style={{ maxWidth: '860px', margin: '0 auto', padding: '3rem 1.5rem' }}>
{/* Breadcrumb */}
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--color-text-muted)', marginBottom: '1.5rem' }}>
<Link to="/bugs" style={{ color: 'var(--color-cyan)' }}>Bug Reports</Link>
{' '}&gt;{' '}
<span style={{ color: 'var(--color-text-dim)' }}>{bug.uniqueCode}</span>
</div>
{/* Header */}
<div className="crt-box" style={{ padding: '1.75rem', marginBottom: '1rem' }}>
{/* Badges */}
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', alignItems: 'center', marginBottom: '0.75rem' }}>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.72rem' }}>
{bug.uniqueCode}
</span>
<StatusBadge status={bug.status} />
<SeverityBadge severity={bug.severity} />
</div>
{/* Title */}
<h1
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: 'clamp(1.4rem, 4vw, 2rem)',
marginBottom: '1.25rem',
}}
>
{bug.title}
</h1>
{/* Meta grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: '0.6rem', marginBottom: '1.5rem' }}>
{[
{ label: 'Submitted by', value: bug.submittedByName },
{ label: 'Date', value: formatDate(bug.createdAt) },
{ label: 'Game Version', value: `v${bug.gameVersion}` },
{ label: 'Assigned to', value: bug.assignedToName ?? 'Unassigned' },
].map(({ label, value }) => (
<div
key={label}
style={{
background: 'var(--color-surface-alt)',
border: '1px solid var(--color-border)',
padding: '0.55rem 0.7rem',
}}
>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.62rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: '0.2rem' }}>
{label}
</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.8rem' }}>{value}</div>
</div>
))}
</div>
{/* Description */}
<div style={{ marginBottom: '1.25rem' }}>
<div className="section-label" style={{ marginBottom: '0.4rem' }}>Description</div>
<div
style={{
background: 'var(--color-surface-alt)',
border: '1px solid var(--color-border)',
padding: '0.9rem 1rem',
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-dim)',
fontSize: '0.83rem',
lineHeight: 1.8,
whiteSpace: 'pre-wrap',
borderRadius: '6px',
}}
>
{bug.description}
</div>
</div>
{/* Steps to reproduce */}
<div style={{ marginBottom: '1.5rem' }}>
<div className="section-label" style={{ marginBottom: '0.4rem' }}>Steps to Reproduce</div>
<div
style={{
background: 'var(--color-surface-alt)',
border: '1px solid var(--color-border)',
padding: '0.9rem 1rem',
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-dim)',
fontSize: '0.83rem',
lineHeight: 1.8,
whiteSpace: 'pre-wrap',
borderRadius: '6px',
}}
>
{bug.stepsToReproduce}
</div>
</div>
{/* "I have this too" section */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '1rem',
flexWrap: 'wrap',
padding: '0.9rem 1rem',
background: 'rgba(5,150,105,0.05)',
border: '1px solid rgba(5,150,105,0.2)',
borderRadius: '6px',
}}
>
{/* Count */}
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-green)', fontSize: '0.82rem' }}>
<span style={{ fontSize: '1.1rem' }}>{metooCount}</span>{' '}
{metooCount === 1 ? 'user has' : 'users have'} this issue
</div>
{/* Button logic */}
{!isAuthenticated ? (
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem' }}>
<Link to="/login">Login</Link> to confirm you have this issue
</div>
) : isOwnReport ? (
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem' }}>
(this is your report)
</span>
) : alreadyVoted ? (
<div
style={{
fontFamily: 'var(--font-mono)',
fontSize: '0.78rem',
color: 'var(--color-green)',
border: '1px solid var(--color-green)',
padding: '0.25rem 0.75rem',
background: 'rgba(5,150,105,0.08)',
cursor: 'default',
borderRadius: '4px',
}}
>
&#10003; You reported this too
</div>
) : (
<button
className="btn-terminal"
onClick={handleMeToo}
style={{ fontSize: '0.78rem', padding: '0.3rem 0.9rem' }}
>
&#9654; I have this too
</button>
)}
</div>
</div>
{/* Comments section */}
<div style={{ marginTop: '2rem' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
borderBottom: '2px solid var(--color-border)',
paddingBottom: '0.5rem',
marginBottom: '1rem',
}}
>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-cyan)', fontSize: '0.72rem', letterSpacing: '0.15em', textTransform: 'uppercase' }}>
Discussion
</span>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.65rem', border: '1px solid var(--color-border)', padding: '0.05rem 0.4rem' }}>
{bugComments.length}
</span>
</div>
{/* Comment list */}
{bugComments.length === 0 ? (
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.8rem', padding: '1rem 0' }}>
No comments yet. Be the first to comment.
</div>
) : (
bugComments.map((comment) => (
<CommentItem key={comment.id} comment={comment} />
))
)}
{/* Add comment */}
<div style={{ marginTop: '1.25rem' }}>
{isAuthenticated ? (
<div className="crt-box" style={{ padding: '1.25rem' }}>
<div className="section-label" style={{ marginBottom: '0.6rem' }}>Add a Comment</div>
<textarea
className={`input-terminal${commentError ? ' error' : ''}`}
rows={4}
placeholder="Write your comment..."
value={newComment}
onChange={(e) => { setNewComment(e.target.value); setCommentError(''); }}
style={{ resize: 'vertical', marginBottom: '0.6rem' }}
disabled={submitting}
aria-label="Comment text"
/>
{commentError && (
<div style={{ color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', marginBottom: '0.6rem' }}>
{commentError}
</div>
)}
<button
className="btn-terminal"
onClick={handleComment}
disabled={submitting}
style={{ opacity: submitting ? 0.6 : 1 }}
>
{submitting ? 'Posting...' : '&#9654; Post Comment'}
</button>
</div>
) : (
<div className="crt-box" style={{ padding: '1.25rem', textAlign: 'center' }}>
<p style={{ color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.82rem', marginBottom: '0.75rem' }}>
You must be logged in to comment.
</p>
<div style={{ display: 'flex', gap: '0.6rem', justifyContent: 'center' }}>
<Link to="/login" className="btn-terminal">Login</Link>
<Link to="/register" className="btn-terminal btn-amber">Register</Link>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,440 @@
import { useState, useMemo, useCallback } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { MOCK_BUGS } from '../../data/mockData';
import { useAuth } from '../../contexts/AuthContext';
import { timeAgo } from '../../utils/format';
import type { BugReport, BugSeverity, BugStatus, BugReportFormData } from '../../types';
// ── Helpers ────────────────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: BugStatus }) {
const map: Record<BugStatus, string> = {
open: 'badge-open',
in_progress: 'badge-progress',
resolved: 'badge-resolved',
closed: 'badge-closed',
};
const labels: Record<BugStatus, string> = {
open: 'Open', in_progress: 'In Progress', resolved: 'Resolved', closed: 'Closed',
};
return <span className={`badge ${map[status]}`}>{labels[status]}</span>;
}
function SeverityBadge({ severity }: { severity: BugSeverity }) {
const map: Record<BugSeverity, string> = {
low: 'badge-low', medium: 'badge-medium', high: 'badge-high', critical: 'badge-critical',
};
return <span className={`badge ${map[severity]}`}>{severity}</span>;
}
// ── Bug List Card ──────────────────────────────────────────────────────────────
interface BugCardProps {
bug: BugReport;
highlight?: boolean;
}
function BugCard({ bug, highlight }: BugCardProps) {
const navigate = useNavigate();
const handleClick = useCallback(() => {
navigate(`/bugs/${bug.id}`);
}, [bug.id, navigate]);
return (
<div
onClick={handleClick}
onKeyDown={(e) => e.key === 'Enter' && handleClick()}
role="button"
tabIndex={0}
aria-label={`View bug report ${bug.uniqueCode}: ${bug.title}`}
style={{
background: highlight ? 'rgba(255,255,0,0.04)' : 'var(--color-surface)',
border: `2px solid ${highlight ? 'var(--color-yellow)' : 'var(--color-border)'}`,
padding: '0.9rem 1.1rem',
cursor: 'pointer',
marginBottom: '0.5rem',
transition: 'border-color 0.1s, background 0.1s',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '1rem', flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: 0 }}>
{/* Badges row */}
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', flexWrap: 'wrap', marginBottom: '0.3rem' }}>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem' }}>
{bug.uniqueCode}
</span>
<StatusBadge status={bug.status} />
<SeverityBadge severity={bug.severity} />
{/* MeToo count */}
<span
style={{
fontFamily: 'var(--font-mono)',
fontSize: '0.65rem',
color: 'var(--color-green)',
border: '1px solid var(--color-green)',
padding: '0.05rem 0.4rem',
background: 'rgba(5,150,105,0.08)',
whiteSpace: 'nowrap',
borderRadius: '3px',
}}
>
&#9654; {bug.meTooBugs.length} {bug.meTooBugs.length === 1 ? 'user' : 'users'} have this
</span>
</div>
{/* Title */}
<div
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text)',
fontSize: '0.87rem',
marginBottom: '0.2rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{bug.title}
</div>
{/* Meta */}
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem' }}>
by {bug.submittedByName} &mdash; {timeAgo(bug.createdAt)} &mdash; v{bug.gameVersion}
</div>
</div>
{/* Arrow */}
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-cyan)', fontSize: '0.75rem', flexShrink: 0, paddingTop: '2px' }}>
VIEW &gt;
</div>
</div>
</div>
);
}
// ── Submit Form ────────────────────────────────────────────────────────────────
const GAME_VERSIONS = ['0.9.3-alpha', '0.9.2-alpha', '0.9.1-alpha'];
const SEVERITIES: BugSeverity[] = ['low', 'medium', 'high', 'critical'];
function SubmitBugForm({ onSubmit }: { onSubmit: (data: BugReportFormData) => void }) {
const [form, setForm] = useState<BugReportFormData>({
title: '',
description: '',
stepsToReproduce: '',
severity: 'medium',
gameVersion: '0.9.3-alpha',
});
const [errors, setErrors] = useState<Partial<Record<keyof BugReportFormData, string>>>({});
const [submitted, setSubmitted] = useState(false);
const set = useCallback(<K extends keyof BugReportFormData>(key: K, value: BugReportFormData[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
setErrors((prev) => ({ ...prev, [key]: undefined }));
}, []);
const validate = (): boolean => {
const next: typeof errors = {};
if (!form.title.trim()) next.title = 'Title is required.';
else if (form.title.length < 10) next.title = 'Title must be at least 10 characters.';
if (!form.description.trim()) next.description = 'Description is required.';
else if (form.description.length < 20) next.description = 'Description must be at least 20 characters.';
if (!form.stepsToReproduce.trim()) next.stepsToReproduce = 'Steps to reproduce are required.';
setErrors(next);
return Object.keys(next).length === 0;
};
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
await new Promise((r) => setTimeout(r, 400));
onSubmit(form);
setSubmitted(true);
}, [form, onSubmit]);
const labelStyle: React.CSSProperties = {
display: 'block',
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-muted)',
fontSize: '0.72rem',
letterSpacing: '0.1em',
marginBottom: '0.35rem',
textTransform: 'uppercase',
};
if (submitted) {
return (
<div className="crt-box" style={{ padding: '2rem', textAlign: 'center' }}>
<div style={{ color: 'var(--color-cyan)', fontFamily: 'var(--font-mono)', fontSize: '0.9rem', marginBottom: '0.5rem' }}>
[OK] Bug report submitted.
</div>
<div style={{ color: 'var(--color-text-muted)', fontSize: '0.8rem', marginBottom: '1rem' }}>
A unique code has been assigned. The team will review it shortly.
</div>
<button className="btn-terminal" onClick={() => setSubmitted(false)}>
Submit Another
</button>
</div>
);
}
return (
<form onSubmit={handleSubmit} noValidate>
<div className="crt-box" style={{ padding: '1.5rem' }}>
<div className="section-label" style={{ marginBottom: '1.25rem' }}>
&#9654; Submit a Bug Report
</div>
<div style={{ marginBottom: '0.85rem' }}>
<label style={labelStyle}>Title *</label>
<input
className={`input-terminal${errors.title ? ' error' : ''}`}
type="text"
placeholder="Short, descriptive title..."
value={form.title}
onChange={(e) => set('title', e.target.value)}
/>
{errors.title && <div style={{ color: 'var(--color-red)', fontSize: '0.7rem', marginTop: '0.2rem' }}>{errors.title}</div>}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.85rem', marginBottom: '0.85rem' }}>
<div>
<label style={labelStyle}>Severity *</label>
<select className="input-terminal" value={form.severity} onChange={(e) => set('severity', e.target.value as BugSeverity)}>
{SEVERITIES.map((s) => <option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>)}
</select>
</div>
<div>
<label style={labelStyle}>Game Version *</label>
<select className="input-terminal" value={form.gameVersion} onChange={(e) => set('gameVersion', e.target.value)}>
{GAME_VERSIONS.map((v) => <option key={v} value={v}>{v}</option>)}
</select>
</div>
</div>
<div style={{ marginBottom: '0.85rem' }}>
<label style={labelStyle}>Description *</label>
<textarea
className={`input-terminal${errors.description ? ' error' : ''}`}
rows={4}
placeholder="Describe what happened..."
value={form.description}
onChange={(e) => set('description', e.target.value)}
style={{ resize: 'vertical' }}
/>
{errors.description && <div style={{ color: 'var(--color-red)', fontSize: '0.7rem', marginTop: '0.2rem' }}>{errors.description}</div>}
</div>
<div style={{ marginBottom: '0.85rem' }}>
<label style={labelStyle}>Steps to Reproduce *</label>
<textarea
className={`input-terminal${errors.stepsToReproduce ? ' error' : ''}`}
rows={4}
placeholder={'1. Go to...\n2. Click on...\n3. Observe...'}
value={form.stepsToReproduce}
onChange={(e) => set('stepsToReproduce', e.target.value)}
style={{ resize: 'vertical' }}
/>
{errors.stepsToReproduce && <div style={{ color: 'var(--color-red)', fontSize: '0.7rem', marginTop: '0.2rem' }}>{errors.stepsToReproduce}</div>}
</div>
<div style={{ marginBottom: '1.25rem' }}>
<label style={labelStyle}>Screenshot URL (optional)</label>
<input
className="input-terminal"
type="url"
placeholder="https://..."
value={form.screenshotUrl ?? ''}
onChange={(e) => set('screenshotUrl', e.target.value || undefined)}
/>
</div>
<button type="submit" className="btn-terminal">
&#9654; Submit Report
</button>
</div>
</form>
);
}
// ── Bug Report Page ────────────────────────────────────────────────────────────
export default function BugReportPage() {
const { user, isAuthenticated } = useAuth();
const [bugs, setBugs] = useState(MOCK_BUGS);
const [statusFilter, setStatusFilter] = useState<BugStatus | 'all'>('all');
const [severityFilter, setSeverityFilter] = useState<BugSeverity | 'all'>('all');
const [showForm, setShowForm] = useState(false);
// Separate: user's own bugs and all others, both filtered
const { myBugs, otherBugs } = useMemo(() => {
const passes = (b: BugReport) => {
if (statusFilter !== 'all' && b.status !== statusFilter) return false;
if (severityFilter !== 'all' && b.severity !== severityFilter) return false;
return true;
};
const my: BugReport[] = [];
const other: BugReport[] = [];
bugs.forEach((b) => {
if (!passes(b)) return;
if (user && b.submittedById === user.id) my.push(b);
else other.push(b);
});
return { myBugs: my, otherBugs: other };
}, [bugs, statusFilter, severityFilter, user]);
const handleNewReport = useCallback((data: BugReportFormData) => {
const newBug: BugReport = {
id: `bug${Date.now()}`,
uniqueCode: `HH-${String(bugs.length + 1).padStart(4, '0')}`,
...data,
status: 'open',
submittedById: user?.id ?? 'unknown',
submittedByName: user?.username ?? 'You',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
notes: [],
meTooBugs: [],
};
setBugs((prev) => [newBug, ...prev]);
setShowForm(false);
}, [bugs.length, user]);
const openCount = bugs.filter((b) => b.status === 'open').length;
const inProgressCount = bugs.filter((b) => b.status === 'in_progress').length;
const resolvedCount = bugs.filter((b) => b.status === 'resolved').length;
return (
<div style={{ maxWidth: '960px', margin: '0 auto', padding: '3rem 1.5rem' }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', flexWrap: 'wrap', gap: '1.5rem', marginBottom: '2rem' }}>
<div>
<div className="section-label">Issue Tracker</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: 'clamp(2rem, 6vw, 3.5rem)', marginTop: '0.25rem' }}>
BUG REPORTS
</h1>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.78rem', marginTop: '0.5rem', fontFamily: 'var(--font-mono)' }}>
<span style={{ color: 'var(--color-cyan)' }}>{openCount}</span> open &nbsp;&mdash;&nbsp;
<span style={{ color: 'var(--color-yellow)' }}>{inProgressCount}</span> in progress &nbsp;&mdash;&nbsp;
<span style={{ color: 'var(--color-green)' }}>{resolvedCount}</span> resolved
</p>
</div>
{isAuthenticated ? (
<button
className={`btn-terminal ${showForm ? 'btn-danger' : 'btn-amber'}`}
onClick={() => setShowForm((v) => !v)}
>
{showForm ? 'Cancel' : '&#9654; Submit Bug'}
</button>
) : (
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.78rem', color: 'var(--color-text-muted)' }}>
<Link to="/login">Login</Link> to submit a report
</div>
)}
</div>
{/* Submit form */}
{showForm && isAuthenticated && (
<div style={{ marginBottom: '1.75rem' }}>
<SubmitBugForm onSubmit={handleNewReport} />
</div>
)}
{/* Filters */}
<div style={{ display: 'flex', gap: '0.6rem', flexWrap: 'wrap', marginBottom: '1.5rem' }}>
<select
className="input-terminal"
style={{ width: 'auto', minWidth: '130px' }}
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as BugStatus | 'all')}
aria-label="Filter by status"
>
<option value="all">All Statuses</option>
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
</select>
<select
className="input-terminal"
style={{ width: 'auto', minWidth: '130px' }}
value={severityFilter}
onChange={(e) => setSeverityFilter(e.target.value as BugSeverity | 'all')}
aria-label="Filter by severity"
>
<option value="all">All Severities</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
{/* "Your Reports" section — only for logged-in users with their own bugs */}
{isAuthenticated && myBugs.length > 0 && (
<section style={{ marginBottom: '2rem' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
marginBottom: '0.75rem',
paddingBottom: '0.4rem',
borderBottom: '2px solid var(--color-yellow)',
}}
>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-yellow)', fontSize: '0.72rem', letterSpacing: '0.15em', textTransform: 'uppercase' }}>
&#9654; Your Reports
</span>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-yellow)', fontSize: '0.65rem', background: 'rgba(255,255,0,0.1)', border: '1px solid var(--color-yellow)', padding: '0.05rem 0.4rem' }}>
{myBugs.length}
</span>
</div>
{myBugs.map((bug) => (
<BugCard key={bug.id} bug={bug} highlight />
))}
</section>
)}
{/* All other reports */}
<section>
{isAuthenticated && myBugs.length > 0 && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
marginBottom: '0.75rem',
paddingBottom: '0.4rem',
borderBottom: '2px solid var(--color-border)',
}}
>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.72rem', letterSpacing: '0.15em', textTransform: 'uppercase' }}>
All Reports
</span>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.65rem', border: '1px solid var(--color-border)', padding: '0.05rem 0.4rem' }}>
{otherBugs.length}
</span>
</div>
)}
{otherBugs.length === 0 && myBugs.length === 0 ? (
<div className="crt-box" style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)' }}>
No bug reports match the selected filters.
</div>
) : otherBugs.length === 0 && isAuthenticated ? (
<div className="crt-box" style={{ padding: '1.5rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>
No other reports match the selected filters.
</div>
) : (
otherBugs.map((bug) => (
<BugCard key={bug.id} bug={bug} />
))
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,366 @@
import { useState, useCallback } from 'react';
import { MOCK_EVENTS, MOCK_POLLS } from '../../data/mockData';
import { useAuth } from '../../contexts/AuthContext';
import { formatDateTime } from '../../utils/format';
import type { EventPost, EventType, Poll, UserRole } from '../../types';
const EVENT_TYPE_COLORS: Record<EventType, string> = {
announcement: 'var(--color-yellow)',
update: 'var(--color-blue)',
milestone: 'var(--color-green)',
poll: 'var(--color-amber)',
};
const EVENT_TYPE_LABELS: Record<EventType, string> = {
announcement: 'ANNOUNCEMENT',
update: 'DEV UPDATE',
milestone: 'MILESTONE',
poll: 'COMMUNITY POLL',
};
const ROLE_COLORS: Record<UserRole, string> = {
dev: 'var(--color-green)',
com: 'var(--color-amber)',
user: 'var(--color-text-muted)',
};
// ── Poll Component ─────────────────────────────────────────────────────────────
function PollCard({ poll, onVote }: { poll: Poll; onVote: (pollId: string, optionId: string) => void }) {
const { user } = useAuth();
const totalVotes = poll.options.reduce((sum, opt) => sum + opt.votes, 0);
const isEnded = poll.endsAt ? new Date(poll.endsAt) < new Date() : false;
return (
<div
style={{
background: 'var(--color-bg-alt)',
border: '1px solid var(--color-border)',
padding: '1rem',
marginTop: '0.75rem',
}}
>
<div
style={{
fontFamily: 'var(--font-mono)',
fontSize: '0.85rem',
color: 'var(--color-text)',
marginBottom: '0.85rem',
fontWeight: 500,
}}
>
{poll.question}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{poll.options.map((option) => {
const percentage = totalVotes > 0 ? Math.round((option.votes / totalVotes) * 100) : 0;
const userVoted = option.votedUserIds.includes(user?.id || '');
return (
<div
key={option.id}
style={{
position: 'relative',
background: 'var(--color-surface)',
border: `1px solid ${userVoted ? 'var(--color-amber)' : 'var(--color-border)'}`,
padding: '0.6rem 0.75rem',
cursor: !isEnded && poll.isActive && user ? 'pointer' : 'default',
opacity: isEnded || !poll.isActive ? 0.7 : 1,
transition: 'border-color 0.2s',
}}
onClick={() => {
if (!isEnded && poll.isActive && user) {
onVote(poll.id, option.id);
}
}}
>
{/* Progress bar */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
width: `${percentage}%`,
background: userVoted
? 'rgba(217,119,6,0.15)'
: 'rgba(59,130,246,0.1)',
transition: 'width 0.3s ease',
pointerEvents: 'none',
}}
/>
<div
style={{
position: 'relative',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontFamily: 'var(--font-mono)',
fontSize: '0.75rem',
}}
>
<span style={{ color: 'var(--color-text-dim)' }}>
{userVoted && '✓ '}
{option.text}
</span>
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.7rem' }}>
{option.votes} ({percentage}%)
</span>
</div>
</div>
);
})}
</div>
<div
style={{
marginTop: '0.75rem',
fontFamily: 'var(--font-mono)',
fontSize: '0.65rem',
color: 'var(--color-text-muted)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: '0.5rem',
}}
>
<span>
{totalVotes} total vote{totalVotes !== 1 ? 's' : ''}
{poll.allowMultipleVotes && ' • Multiple votes allowed'}
</span>
{poll.endsAt && (
<span style={{ color: isEnded ? 'var(--color-red)' : 'var(--color-amber)' }}>
{isEnded ? 'Poll Ended' : `Ends ${formatDateTime(poll.endsAt)}`}
</span>
)}
</div>
{!user && !isEnded && poll.isActive && (
<div
style={{
marginTop: '0.75rem',
padding: '0.5rem',
background: 'rgba(217,119,6,0.1)',
border: '1px solid rgba(217,119,6,0.25)',
fontFamily: 'var(--font-mono)',
fontSize: '0.7rem',
color: 'var(--color-amber)',
textAlign: 'center',
}}
>
Please <a href="/login" style={{ color: 'var(--color-amber)', textDecoration: 'underline' }}>log in</a> to vote
</div>
)}
</div>
);
}
// ── Event Card Component ───────────────────────────────────────────────────────
function EventCard({
event,
poll,
onVote,
}: {
event: EventPost;
poll?: Poll;
onVote: (pollId: string, optionId: string) => void;
}) {
return (
<div
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
padding: '1.5rem',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
marginBottom: '1rem',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<span
style={{
fontFamily: 'var(--font-mono)',
background: `${EVENT_TYPE_COLORS[event.type]}15`,
border: `1px solid ${EVENT_TYPE_COLORS[event.type]}40`,
color: EVENT_TYPE_COLORS[event.type],
fontSize: '0.6rem',
padding: '0.2rem 0.5rem',
letterSpacing: '0.08em',
}}
>
{EVENT_TYPE_LABELS[event.type]}
</span>
</div>
<h2
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: 'clamp(1.25rem, 4vw, 1.5rem)',
lineHeight: 1.2,
}}
>
{event.title}
</h2>
<div
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-muted)',
fontSize: '0.7rem',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
flexWrap: 'wrap',
}}
>
<span style={{ color: ROLE_COLORS[event.authorRole] }}>
{event.authorName}
</span>
<span></span>
<span>{formatDateTime(event.createdAt)}</span>
</div>
</div>
{/* Content */}
<div
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-dim)',
fontSize: '0.88rem',
lineHeight: 1.75,
whiteSpace: 'pre-wrap',
}}
>
{event.content}
</div>
{/* Poll if exists */}
{poll && <PollCard poll={poll} onVote={onVote} />}
</div>
);
}
// ── Main Component ─────────────────────────────────────────────────────────────
export default function EventsPage() {
const { user } = useAuth();
// Filter to show only public events
const publicEvents = MOCK_EVENTS.filter((event) => event.isPublic);
const [events] = useState<EventPost[]>(publicEvents);
const [polls, setPolls] = useState<Poll[]>(MOCK_POLLS);
const handleVote = useCallback(
(pollId: string, optionId: string) => {
if (!user) return;
setPolls((prevPolls) =>
prevPolls.map((poll) => {
if (poll.id !== pollId) return poll;
const hasVotedForOption = poll.options.some((opt) =>
opt.votedUserIds.includes(user.id)
);
return {
...poll,
options: poll.options.map((opt) => {
if (opt.id === optionId) {
// Add vote to this option
return {
...opt,
votes: opt.votedUserIds.includes(user.id) ? opt.votes : opt.votes + 1,
votedUserIds: opt.votedUserIds.includes(user.id)
? opt.votedUserIds
: [...opt.votedUserIds, user.id],
};
} else if (!poll.allowMultipleVotes && hasVotedForOption) {
// Remove vote from other options if single vote
return {
...opt,
votes: opt.votedUserIds.includes(user.id) ? opt.votes - 1 : opt.votes,
votedUserIds: opt.votedUserIds.filter((id) => id !== user.id),
};
}
return opt;
}),
};
})
);
},
[user]
);
return (
<div className="page-wrapper">
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
{/* Header */}
<div style={{ marginBottom: '3rem', textAlign: 'center' }}>
<div
className="section-label"
style={{ marginBottom: '0.75rem' }}
>
DEVELOPMENT UPDATES
</div>
<h1
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: 'clamp(2rem, 6vw, 3rem)',
marginBottom: '1rem',
letterSpacing: '0.05em',
}}
>
COMMUNITY EVENTS
</h1>
<p
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-muted)',
fontSize: '0.85rem',
maxWidth: '600px',
margin: '0 auto',
lineHeight: 1.6,
}}
>
Stay up to date with the latest game development news, announcements, and participate
in community polls to help shape the future of Headless Hazard.
</p>
</div>
{/* Events Grid */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{events.length === 0 ? (
<div
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
padding: '3rem 2rem',
textAlign: 'center',
}}
>
<div
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-muted)',
fontSize: '0.85rem',
}}
>
No events available at the moment. Check back soon!
</div>
</div>
) : (
events.map((event) => {
const poll = event.pollId ? polls.find((p) => p.id === event.pollId) : undefined;
return <EventCard key={event.id} event={event} poll={poll} onVote={handleVote} />;
})
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,188 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { MOCK_CATEGORIES, MOCK_THREADS } from '../../data/mockData';
import { timeAgo } from '../../utils/format';
import type { ForumCategory, ForumThread } from '../../types';
// ── Sub-components ─────────────────────────────────────────────────────────────
function CategoryCard({ category, threads }: { category: ForumCategory; threads: ForumThread[] }) {
const pinned = threads.filter((t) => t.isPinned && t.categoryId === category.id);
const regular = threads.filter((t) => !t.isPinned && t.categoryId === category.id);
const categoryThreads = [...pinned, ...regular];
return (
<section className="crt-box" style={{ marginBottom: '1.5rem' }}>
{/* Category header */}
<div
style={{
padding: '1rem 1.5rem',
borderBottom: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
flexWrap: 'wrap',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<span
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-green)',
fontSize: '0.75rem',
opacity: 0.7,
}}
>
{category.icon}
</span>
<div>
<h2
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: '1.1rem',
margin: 0,
}}
>
{category.name}
</h2>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem', margin: 0 }}>
{category.description}
</p>
</div>
</div>
<div
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-muted)',
fontSize: '0.72rem',
textAlign: 'right',
}}
>
<span style={{ color: 'var(--color-green)' }}>{category.threadCount}</span> threads
</div>
</div>
{/* Threads */}
<div>
{categoryThreads.length === 0 ? (
<div style={{ padding: '1.5rem', color: 'var(--color-text-muted)', fontSize: '0.8rem', textAlign: 'center' }}>
No threads yet. Be the first to post.
</div>
) : (
categoryThreads.map((thread, idx) => (
<div
key={thread.id}
style={{
padding: '0.85rem 1.5rem',
borderBottom: idx < categoryThreads.length - 1 ? '1px solid var(--color-border)' : 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
flexWrap: 'wrap',
background: thread.isPinned ? 'rgba(217,119,6,0.05)' : 'transparent',
}}
>
<div style={{ flex: 1, minWidth: '0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '0.25rem' }}>
{thread.isPinned && (
<span className="badge badge-progress">Pinned</span>
)}
{thread.isLocked && (
<span className="badge badge-closed">Locked</span>
)}
<Link
to={`/forum/thread/${thread.id}`}
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text)',
fontSize: '0.87rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{thread.title}
</Link>
</div>
<div style={{ color: 'var(--color-text-muted)', fontSize: '0.72rem', fontFamily: 'var(--font-mono)' }}>
by <span style={{ color: 'var(--color-text-dim)' }}>{thread.authorName}</span>
{' '}&mdash; {timeAgo(thread.createdAt)}
</div>
</div>
<div style={{ textAlign: 'right', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', color: 'var(--color-text-muted)', flexShrink: 0 }}>
<div style={{ color: 'var(--color-green)' }}>{thread.replyCount}</div>
<div>replies</div>
</div>
</div>
))
)}
</div>
</section>
);
}
// ── Forum Page ─────────────────────────────────────────────────────────────────
export default function ForumPage() {
const [search, setSearch] = useState('');
const filteredCategories = useMemo(() => {
if (!search.trim()) return MOCK_CATEGORIES;
const q = search.toLowerCase();
return MOCK_CATEGORIES.filter((cat) =>
cat.name.toLowerCase().includes(q) ||
MOCK_THREADS.some((t) => t.categoryId === cat.id && t.title.toLowerCase().includes(q))
);
}, [search]);
return (
<div style={{ maxWidth: '960px', margin: '0 auto', padding: '4rem 1.5rem' }}>
{/* Header */}
<div style={{ marginBottom: '2.5rem', display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', flexWrap: 'wrap', gap: '1.5rem' }}>
<div>
<div className="section-label">Community</div>
<h1
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: 'clamp(2rem, 5vw, 3rem)',
marginTop: '0.5rem',
}}
>
FORUM
</h1>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8rem', marginTop: '0.5rem', fontFamily: 'var(--font-mono)' }}>
Read freely. Login to post.
</p>
</div>
{/* Search */}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<input
className="input-terminal"
type="search"
placeholder="Search threads..."
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ width: '220px' }}
aria-label="Search forum threads"
/>
</div>
</div>
{/* Categories */}
{filteredCategories.length === 0 ? (
<div className="crt-box" style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)' }}>
No results found for "{search}"
</div>
) : (
filteredCategories.map((cat) => (
<CategoryCard key={cat.id} category={cat} threads={MOCK_THREADS} />
))
)}
</div>
);
}

View File

@@ -0,0 +1,460 @@
import { Link } from 'react-router-dom';
// ── Sub-components ─────────────────────────────────────────────────────────────
function Redacted({ children }: { children: string }) {
return (
<span className="redacted" aria-label="censored" title="[BLEEP]">
{children}
</span>
);
}
function SectionDivider() {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '1rem',
margin: '0 auto 3rem',
maxWidth: '200px',
}}
>
<div style={{ flex: 1, height: '1px', background: 'var(--color-border)' }} />
<span style={{ color: 'var(--color-green)', fontFamily: 'var(--font-mono)', fontSize: '0.7rem' }}>///</span>
<div style={{ flex: 1, height: '1px', background: 'var(--color-border)' }} />
</div>
);
}
// ── Hero Section ───────────────────────────────────────────────────────────────
function HeroSection() {
return (
<section
className="scanlines vhs-grain"
style={{
position: 'relative',
minHeight: '92vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
overflow: 'hidden',
padding: '4rem 1.5rem',
}}
>
{/* Subtle gradient background */}
<div
style={{
position: 'absolute',
inset: 0,
background: `
radial-gradient(ellipse 70% 50% at 50% 40%, rgba(37,99,235,0.05) 0%, transparent 70%),
var(--color-bg)
`,
zIndex: 0,
}}
/>
{/* Grid lines — subtle pattern */}
<div
style={{
position: 'absolute',
inset: 0,
backgroundImage: `
linear-gradient(rgba(200,200,200,0.3) 1px, transparent 1px),
linear-gradient(90deg, rgba(200,200,200,0.3) 1px, transparent 1px)
`,
backgroundSize: '40px 40px',
zIndex: 0,
}}
/>
<div style={{ position: 'relative', zIndex: 3, maxWidth: '900px', width: '100%' }}>
{/* Pre-title */}
<div className="section-label" style={{ marginBottom: '1.5rem' }}>
CrowMate Studio presents
</div>
{/* Game Title */}
<h1
className="glitch-text glow-green flicker"
data-text="HEADLESS HAZARD"
style={{
fontSize: 'clamp(3rem, 10vw, 8rem)',
fontFamily: 'var(--font-heading)',
color: 'var(--color-green)',
lineHeight: 1,
marginBottom: '0.5rem',
letterSpacing: '0.08em',
}}
>
HEADLESS HAZARD
</h1>
{/* Subtitle */}
<div
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-amber)',
fontSize: 'clamp(0.85rem, 2.5vw, 1.1rem)',
letterSpacing: '0.3em',
marginBottom: '2.5rem',
textTransform: 'uppercase',
}}
>
&gt;&gt; LOSE YOUR HEAD. KEEP YOUR BODY.
</div>
{/* Tagline */}
<p
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-dim)',
fontSize: 'clamp(0.85rem, 2vw, 1rem)',
maxWidth: '600px',
margin: '0 auto 3rem',
lineHeight: 1.8,
}}
>
Navigate a sprawling underground complex. Control a detached robotic head.
Survive bureaucratic hell. Save the girl. <br />
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.85em' }}>
(or don't — the corporation doesn't care either way)
</span>
</p>
{/* CTA buttons */}
<div style={{ display: 'flex', gap: '1rem', justifyContent: 'center', flexWrap: 'wrap' }}>
<a
href="#gameplay"
className="btn-terminal"
style={{ fontSize: '0.9rem', padding: '0.65rem 2rem' }}
>
&gt; Learn More
</a>
<Link
to="/forum"
className="btn-terminal btn-amber"
style={{ fontSize: '0.9rem', padding: '0.65rem 2rem' }}
>
&gt; Join Community
</Link>
</div>
{/* Version tag */}
<div
style={{
marginTop: '3rem',
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-muted)',
fontSize: '0.7rem',
letterSpacing: '0.15em',
}}
>
ALPHA v0.9.3 EARLY ACCESS COMING SOON
</div>
</div>
</section>
);
}
// ── Lore Section ───────────────────────────────────────────────────────────────
function LoreSection() {
return (
<section
id="lore"
style={{ padding: '6rem 1.5rem', maxWidth: '900px', margin: '0 auto' }}
>
<div style={{ textAlign: 'center', marginBottom: '3.5rem' }}>
<div className="section-label">The World</div>
<h2
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: 'clamp(2rem, 5vw, 3rem)',
marginTop: '0.5rem',
}}
>
SOMEWHERE UNDERGROUND
</h2>
</div>
<div className="crt-box" style={{ padding: '2.5rem' }}>
{/* Classification header */}
<div
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-red)',
fontSize: '0.7rem',
letterSpacing: '0.2em',
marginBottom: '1.5rem',
paddingBottom: '0.75rem',
borderBottom: '2px solid var(--color-red)',
}}
>
&#9632;&#9632;&#9632; CLASSIFIED LEVEL 9 CLEARANCE REQUIRED &#9632;&#9632;&#9632;
</div>
<div
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-dim)',
fontSize: '0.9rem',
lineHeight: 2,
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
}}
>
<p>
Deep beneath the surface, an underground megacomplex spans 47 floors of corridors, server rooms,
cafeterias, and security checkpoints all operated by{' '}
<Redacted>AMALGAM INDUSTRIES CORP</Redacted>
{', '}a corporation so powerful it has legally removed its own name from public record.
</p>
<p>
UNIT-7 is a security enforcement robot. Standard model. Bipedal, armored,
designed to neutralize threats with efficiency and no questions asked.
There is one small problem: its head is no longer attached to its body.
This is, officially, a{' '}
<span style={{ color: 'var(--color-amber)' }}>NON-CRITICAL OPERATIONAL DEVIATION</span>.
The head still works. The body still works. They just work... separately.
</p>
<p>
Then there is the girl. Eight years old. Lost. She wandered into the complex
through an unsecured maintenance hatch and when she found the central computer,
she did what any eight-year-old would do:{' '}
<span style={{ color: 'var(--color-green)' }}>she started pressing buttons</span>.
All of them. At once. The terminal, she explained later, looked exactly like
an arcade cabinet. This triggered{' '}
<span style={{ color: 'var(--color-red)' }}>PROTOCOL OMEGA</span> activating
every automated defense system, locking every door, and sealing every exit
in the facility.
</p>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.82rem', borderLeft: '2px solid var(--color-border)', paddingLeft: '1rem' }}>
The girl is currently located in Sector 12-C. She is eating from the emergency ration
storage and appears to be having the time of her life. UNIT-7 has been tasked with
extraction. Corporate does not know she exists. This is everyone's problem now.
</p>
</div>
</div>
</section>
);
}
// ── Gameplay Section ───────────────────────────────────────────────────────────
function GameplaySection() {
const mechanics = [
{
icon: '[CAM]',
title: 'Head as Camera',
desc: 'Roll, bounce, and launch your detached head through vents and around corners. The head sees everything your body cannot.',
},
{
icon: '[BOT]',
title: 'Body Controls',
desc: 'Direct your headless body remotely. Lift, punch, carry, operate terminals but it\'s blind. The head is its only eyes.',
},
{
icon: '[CO-OP]',
title: 'Multiplayer Chaos',
desc: 'One player controls the head, another the body. Communication is everything. Miscommunication is hilarious.',
},
{
icon: '[SLO]',
title: 'Solo Campaign',
desc: 'Switch between head and body control at will. 12 floors of escalating complexity, optional challenge rooms, and a full narrative.',
},
];
return (
<section
id="gameplay"
style={{
padding: '6rem 1.5rem',
background: 'var(--color-bg-alt)',
borderTop: '1px solid var(--color-border)',
borderBottom: '1px solid var(--color-border)',
}}
>
<div style={{ maxWidth: '1100px', margin: '0 auto' }}>
<div style={{ textAlign: 'center', marginBottom: '3.5rem' }}>
<div className="section-label">How It Works</div>
<h2
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: 'clamp(2rem, 5vw, 3rem)',
marginTop: '0.5rem',
}}
>
GAMEPLAY MECHANICS
</h2>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.85rem', marginTop: '1rem', maxWidth: '500px', margin: '1rem auto 0' }}>
Second-person perspective puzzle-platformer with asymmetric co-op support.
Control the detached head as a camera drone. Direct the headless body remotely.
</p>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
gap: '1.5rem',
}}
>
{mechanics.map(({ icon, title, desc }) => (
<div key={title} className="crt-box" style={{ padding: '1.75rem' }}>
<div
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-green)',
fontSize: '0.75rem',
letterSpacing: '0.15em',
marginBottom: '0.75rem',
}}
>
{icon}
</div>
<h3
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: '1.1rem',
marginBottom: '0.6rem',
}}
>
{title}
</h3>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.82rem', lineHeight: 1.75, margin: 0 }}>
{desc}
</p>
</div>
))}
</div>
</div>
</section>
);
}
// ── Visual Style Section ───────────────────────────────────────────────────────
function VisualStyleSection() {
const attributes = [
{ label: 'Aesthetic', value: 'Retro-Futuristic / 1980s Megacorp' },
{ label: 'Visual Effect', value: 'VHS Tape Artifacts + CRT Scanlines' },
{ label: 'Color Palette', value: 'Terminal Green, Amber Warning, Void Black' },
{ label: 'Typography', value: 'Monospace + Condensed Industrial' },
{ label: 'Architecture', value: 'Brutalist Bunker + Corporate Bureaucracy' },
{ label: 'Tone', value: 'Dark Comedy / Kafkaesque Horror' },
];
return (
<section style={{ padding: '6rem 1.5rem', maxWidth: '900px', margin: '0 auto' }}>
<div style={{ textAlign: 'center', marginBottom: '3.5rem' }}>
<div className="section-label">Aesthetic</div>
<h2
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: 'clamp(2rem, 5vw, 3rem)',
marginTop: '0.5rem',
}}
>
THE VISUAL IDENTITY
</h2>
</div>
<div className="crt-box" style={{ padding: '2rem' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr',
gap: 0,
}}
>
{attributes.map(({ label, value }, i) => (
<div
key={label}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.85rem 0',
borderBottom: i < attributes.length - 1 ? '1px solid var(--color-border)' : 'none',
gap: '1rem',
flexWrap: 'wrap',
}}
>
<span
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-muted)',
fontSize: '0.75rem',
textTransform: 'uppercase',
letterSpacing: '0.15em',
flexShrink: 0,
}}
>
{label}
</span>
<span
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-green)',
fontSize: '0.85rem',
textAlign: 'right',
}}
>
{value}
</span>
</div>
))}
</div>
<div
style={{
marginTop: '2rem',
padding: '1.25rem',
background: 'rgba(5,150,105,0.05)',
border: '1px solid rgba(5,150,105,0.15)',
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-muted)',
fontSize: '0.8rem',
lineHeight: 1.8,
borderRadius: '6px',
}}
>
<span style={{ color: 'var(--color-green)' }}>&gt; </span>
The world of Headless Hazard is a love letter to the aesthetic of late-80s science fiction.
Think corporate cafeterias lit by flickering fluorescent tubes. Think instruction manuals
written in Comic Sans translated from Japanese. Think a DANGER warning label on a door
that has been there so long nobody remembers what the danger was.
</div>
</div>
</section>
);
}
// ── Home Page ──────────────────────────────────────────────────────────────────
export default function HomePage() {
return (
<>
<HeroSection />
<LoreSection />
<SectionDivider />
<GameplaySection />
<SectionDivider />
<VisualStyleSection />
</>
);
}

View File

@@ -0,0 +1,151 @@
import { useState, useCallback, useEffect } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
export default function LoginPage() {
const { login, isAuthenticated } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const from = (location.state as { from?: Location })?.from?.pathname ?? '/';
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<{ email?: string; password?: string; form?: string }>({});
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isAuthenticated) navigate(from, { replace: true });
}, [isAuthenticated, from, navigate]);
const validate = (): boolean => {
const next: typeof errors = {};
if (!email.trim()) next.email = 'Email is required.';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) next.email = 'Enter a valid email address.';
if (!password) next.password = 'Password is required.';
setErrors(next);
return Object.keys(next).length === 0;
};
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
setLoading(true);
const result = await login(email, password);
setLoading(false);
if (!result.success) {
setErrors({ form: result.error });
}
}, [email, password, login]);
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem 1rem',
}}
>
<div style={{ width: '100%', maxWidth: '420px' }}>
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
<div className="section-label">Authentication</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '2rem', marginTop: '0.5rem' }}>
LOGIN
</h1>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.72rem', marginTop: '0.5rem' }}>
CROWMATE STUDIO / HEADLESS HAZARD COMMUNITY
</div>
</div>
<form onSubmit={handleSubmit} noValidate className="crt-box" style={{ padding: '2rem' }}>
{/* Demo hint */}
<div style={{ background: 'rgba(217,119,6,0.08)', border: '1px solid rgba(217,119,6,0.2)', padding: '0.75rem', marginBottom: '1.5rem', borderRadius: '6px' }}>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.7rem', letterSpacing: '0.05em', marginBottom: '0.4rem' }}>
[DEMO] Quick login emails:
</div>
{[
{ label: 'Dev/Admin', email: 'kestrel@crowmate.dev' },
{ label: 'Com Staff', email: 'vesper@crowmate.dev' },
{ label: 'User', email: 'glitch@mail.com' },
].map(({ label, email: e }) => (
<button
key={e}
type="button"
onClick={() => { setEmail(e); setPassword('password'); }}
style={{
background: 'transparent',
border: 'none',
color: 'var(--color-text-muted)',
cursor: 'pointer',
fontFamily: 'var(--font-mono)',
fontSize: '0.65rem',
display: 'block',
textAlign: 'left',
padding: '0.1rem 0',
width: '100%',
}}
>
&gt; {label}: {e}
</button>
))}
</div>
{errors.form && (
<div style={{ background: 'rgba(220,38,38,0.1)', border: '1px solid rgba(220,38,38,0.3)', color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem', padding: '0.75rem', marginBottom: '1.25rem', borderRadius: '6px' }}>
[ERROR] {errors.form}
</div>
)}
{/* Email */}
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.75rem', marginBottom: '0.4rem' }}>
Email Address
</label>
<input
className={`input-terminal${errors.email ? ' error' : ''}`}
type="email"
autoComplete="email"
placeholder="your@email.com"
value={email}
onChange={(e) => { setEmail(e.target.value); setErrors((p) => ({ ...p, email: undefined })); }}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && <div id="email-error" style={{ color: 'var(--color-red)', fontSize: '0.72rem', marginTop: '0.25rem' }}>{errors.email}</div>}
</div>
{/* Password */}
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.75rem', marginBottom: '0.4rem' }}>
Password
</label>
<input
className={`input-terminal${errors.password ? ' error' : ''}`}
type="password"
autoComplete="current-password"
placeholder="••••••••"
value={password}
onChange={(e) => { setPassword(e.target.value); setErrors((p) => ({ ...p, password: undefined })); }}
aria-describedby={errors.password ? 'pass-error' : undefined}
/>
{errors.password && <div id="pass-error" style={{ color: 'var(--color-red)', fontSize: '0.72rem', marginTop: '0.25rem' }}>{errors.password}</div>}
</div>
<button
type="submit"
className="btn-terminal"
disabled={loading}
style={{ width: '100%', justifyContent: 'center', opacity: loading ? 0.7 : 1, marginBottom: '1.25rem' }}
>
{loading ? 'Authenticating...' : '> Login'}
</button>
<div style={{ textAlign: 'center', fontFamily: 'var(--font-mono)', fontSize: '0.78rem', color: 'var(--color-text-muted)' }}>
No account?{' '}
<Link to="/register" style={{ color: 'var(--color-green)' }}>Register here</Link>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { Link } from 'react-router-dom';
export default function NotFoundPage() {
return (
<div
style={{
minHeight: 'calc(100vh - 56px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '4rem 1.5rem',
textAlign: 'center',
}}
>
<div>
<div
className="glow-green flicker"
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-green)',
fontSize: 'clamp(5rem, 20vw, 12rem)',
lineHeight: 1,
marginBottom: '1rem',
}}
>
404
</div>
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.9rem', letterSpacing: '0.2em', marginBottom: '1.5rem' }}>
SECTOR NOT FOUND
</div>
<p style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.85rem', maxWidth: '400px', margin: '0 auto 2rem', lineHeight: 1.8 }}>
The page you're looking for doesn't exist, has been moved, or was redacted by{' '}
<span style={{ background: '#6b7280', padding: '0 4px', color: 'transparent', border: '1px solid var(--color-red)' }}>
AMALGAM CORP
</span>.
</p>
<Link to="/" className="btn-terminal">&gt; Return to Base</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,153 @@
import { useState, useCallback, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
export default function RegisterPage() {
const { register, isAuthenticated } = useAuth();
const navigate = useNavigate();
const [form, setForm] = useState({ username: '', email: '', password: '', confirmPassword: '' });
const [errors, setErrors] = useState<Partial<typeof form & { form: string }>>({});
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isAuthenticated) navigate('/', { replace: true });
}, [isAuthenticated, navigate]);
const set = (key: keyof typeof form, value: string) => {
setForm((prev) => ({ ...prev, [key]: value }));
setErrors((prev) => ({ ...prev, [key]: undefined }));
};
const validate = (): boolean => {
const next: typeof errors = {};
if (!form.username.trim()) next.username = 'Username is required.';
else if (form.username.length < 3) next.username = 'Username must be at least 3 characters.';
else if (!/^[a-zA-Z0-9_-]+$/.test(form.username)) next.username = 'Only letters, numbers, _ and - allowed.';
if (!form.email.trim()) next.email = 'Email is required.';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) next.email = 'Enter a valid email.';
if (!form.password) next.password = 'Password is required.';
else if (form.password.length < 8) next.password = 'Password must be at least 8 characters.';
if (form.password !== form.confirmPassword) next.confirmPassword = 'Passwords do not match.';
setErrors(next);
return Object.keys(next).length === 0;
};
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
setLoading(true);
const result = await register(form.username, form.email, form.password);
setLoading(false);
if (!result.success) {
setErrors({ form: result.error });
}
}, [form, register]);
const inputStyle = (_field?: keyof typeof form) => ({
display: 'block' as const,
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-muted)',
fontSize: '0.75rem',
marginBottom: '0.4rem',
});
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem 1rem',
}}
>
<div style={{ width: '100%', maxWidth: '440px' }}>
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
<div className="section-label">Create Account</div>
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '2rem', marginTop: '0.5rem' }}>
REGISTER
</h1>
</div>
<form onSubmit={handleSubmit} noValidate className="crt-box" style={{ padding: '2rem' }}>
{errors.form && (
<div style={{ background: 'rgba(220,38,38,0.1)', border: '1px solid rgba(220,38,38,0.3)', color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.78rem', padding: '0.75rem', marginBottom: '1.25rem', borderRadius: '6px' }}>
[ERROR] {errors.form}
</div>
)}
{/* Username */}
<div style={{ marginBottom: '1rem' }}>
<label style={inputStyle('username')}>Username</label>
<input
className={`input-terminal${errors.username ? ' error' : ''}`}
type="text"
autoComplete="username"
placeholder="YourCallsign"
value={form.username}
onChange={(e) => set('username', e.target.value)}
/>
{errors.username && <div style={{ color: 'var(--color-red)', fontSize: '0.72rem', marginTop: '0.25rem' }}>{errors.username}</div>}
</div>
{/* Email */}
<div style={{ marginBottom: '1rem' }}>
<label style={inputStyle('email')}>Email Address</label>
<input
className={`input-terminal${errors.email ? ' error' : ''}`}
type="email"
autoComplete="email"
placeholder="your@email.com"
value={form.email}
onChange={(e) => set('email', e.target.value)}
/>
{errors.email && <div style={{ color: 'var(--color-red)', fontSize: '0.72rem', marginTop: '0.25rem' }}>{errors.email}</div>}
</div>
{/* Password */}
<div style={{ marginBottom: '1rem' }}>
<label style={inputStyle('password')}>Password</label>
<input
className={`input-terminal${errors.password ? ' error' : ''}`}
type="password"
autoComplete="new-password"
placeholder="At least 8 characters"
value={form.password}
onChange={(e) => set('password', e.target.value)}
/>
{errors.password && <div style={{ color: 'var(--color-red)', fontSize: '0.72rem', marginTop: '0.25rem' }}>{errors.password}</div>}
</div>
{/* Confirm Password */}
<div style={{ marginBottom: '1.5rem' }}>
<label style={inputStyle('confirmPassword')}>Confirm Password</label>
<input
className={`input-terminal${errors.confirmPassword ? ' error' : ''}`}
type="password"
autoComplete="new-password"
placeholder="Repeat password"
value={form.confirmPassword}
onChange={(e) => set('confirmPassword', e.target.value)}
/>
{errors.confirmPassword && <div style={{ color: 'var(--color-red)', fontSize: '0.72rem', marginTop: '0.25rem' }}>{errors.confirmPassword}</div>}
</div>
<button
type="submit"
className="btn-terminal btn-amber"
disabled={loading}
style={{ width: '100%', justifyContent: 'center', opacity: loading ? 0.7 : 1, marginBottom: '1.25rem' }}
>
{loading ? 'Creating account...' : '> Create Account'}
</button>
<div style={{ textAlign: 'center', fontFamily: 'var(--font-mono)', fontSize: '0.78rem', color: 'var(--color-text-muted)' }}>
Already registered?{' '}
<Link to="/login" style={{ color: 'var(--color-green)' }}>Login here</Link>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,206 @@
import { TEAM_MEMBERS } from '../../data/mockData';
export default function StudioPage() {
return (
<div style={{ maxWidth: '1000px', margin: '0 auto', padding: '4rem 1.5rem' }}>
{/* Header */}
<div style={{ marginBottom: '4rem' }}>
<div className="section-label">About</div>
<h1
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: 'clamp(2.5rem, 6vw, 4rem)',
marginTop: '0.5rem',
marginBottom: '1.5rem',
}}
>
CROWMATE STUDIO
</h1>
<div
className="crt-box"
style={{ padding: '2rem', marginBottom: '3rem' }}
>
<p
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-dim)',
fontSize: '0.95rem',
lineHeight: 2,
margin: 0,
marginBottom: '1rem',
}}
>
CrowMate Studio is an independent game studio founded in 2023 by a team of six developers
united by a shared obsession: games that are strange, atmospheric, and actually interesting.
We are headquartered somewhere in Europe and operate fully remote.
</p>
<p
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-dim)',
fontSize: '0.95rem',
lineHeight: 2,
margin: 0,
}}
>
<span style={{ color: 'var(--color-green)' }}>&gt;&gt;</span>{' '}
Our debut title, <strong style={{ color: 'var(--color-text)' }}>Headless Hazard</strong>,
is currently in development. We believe that constraints breed creativity and that
you don't need a $200 million budget to make something that sticks.
</p>
</div>
</div>
{/* History & Vision */}
<div style={{ marginBottom: '4rem' }}>
<div className="section-label" style={{ marginBottom: '1rem' }}>Our Vision</div>
<h2
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: '1.8rem',
marginBottom: '1.5rem',
}}
>
WHY WE BUILD
</h2>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
gap: '1.25rem',
}}
>
{[
{
title: 'Strange Mechanics',
content: 'We look for the game ideas that make people say "wait, how does that even work?" then we find out.',
},
{
title: 'Atmospheric Worlds',
content: 'Every pixel, every sound, every line of UI text should reinforce the world. Atmosphere is not decoration, it is the game.',
},
{
title: 'Community First',
content: 'We build in public. We listen to our players. Bug reports are not annoyances they are conversations.',
},
].map(({ title, content }) => (
<div key={title} className="crt-box" style={{ padding: '1.5rem' }}>
<h3
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-amber)',
fontSize: '1rem',
marginBottom: '0.75rem',
}}
>
{title}
</h3>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.83rem', lineHeight: 1.75, margin: 0 }}>
{content}
</p>
</div>
))}
</div>
</div>
{/* Team */}
<div>
<div className="section-label" style={{ marginBottom: '1rem' }}>The Team</div>
<h2
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: '1.8rem',
marginBottom: '2rem',
}}
>
MEET THE CREW
</h2>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
gap: '1.25rem',
}}
>
{TEAM_MEMBERS.map((member) => (
<div key={member.id} className="crt-box" style={{ padding: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
{/* Avatar */}
<div
style={{
width: '48px',
height: '48px',
background: 'rgba(0,255,65,0.08)',
border: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: 'var(--font-heading)',
color: 'var(--color-green)',
fontSize: '1rem',
flexShrink: 0,
}}
>
{member.avatarInitials}
</div>
<div>
<div
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: '1rem',
}}
>
{member.name}
</div>
<div
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-green)',
fontSize: '0.7rem',
letterSpacing: '0.05em',
}}
>
{member.role}
</div>
</div>
</div>
{member.bio && (
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.8rem', lineHeight: 1.7, margin: '0 0 1rem' }}>
{member.bio}
</p>
)}
{member.social && (
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
{member.social.twitter && (
<a
href="#"
style={{ color: 'var(--color-text-muted)', fontSize: '0.72rem', fontFamily: 'var(--font-mono)' }}
>
{member.social.twitter}
</a>
)}
{member.social.github && (
<a
href="#"
style={{ color: 'var(--color-text-muted)', fontSize: '0.72rem', fontFamily: 'var(--font-mono)' }}
>
gh/{member.social.github}
</a>
)}
</div>
)}
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,225 @@
import { useState, useCallback } from 'react';
import { Link, useParams, Navigate } from 'react-router-dom';
import { MOCK_THREADS, MOCK_REPLIES } from '../../data/mockData';
import { useAuth } from '../../contexts/AuthContext';
import { formatDateTime, timeAgo } from '../../utils/format';
export default function ThreadPage() {
const { id } = useParams<{ id: string }>();
const { user, isAuthenticated } = useAuth();
const thread = MOCK_THREADS.find((t) => t.id === id);
// Local state for new reply (stored in memory, not persisted)
const [replies, setReplies] = useState(
MOCK_REPLIES.filter((r) => r.threadId === id)
);
const [newReply, setNewReply] = useState('');
const [replyError, setReplyError] = useState('');
const [submitting, setSubmitting] = useState(false);
const handleReply = useCallback(async () => {
if (!newReply.trim()) {
setReplyError('Reply cannot be empty.');
return;
}
if (newReply.trim().length < 10) {
setReplyError('Reply must be at least 10 characters.');
return;
}
setReplyError('');
setSubmitting(true);
await new Promise((r) => setTimeout(r, 300));
const reply = {
id: `r${Date.now()}`,
content: newReply.trim(),
authorId: user!.id,
authorName: user!.username,
threadId: id!,
createdAt: new Date().toISOString(),
};
setReplies((prev) => [...prev, reply]);
setNewReply('');
setSubmitting(false);
}, [newReply, user, id]);
if (!thread) {
return <Navigate to="/forum" replace />;
}
const category = thread.categoryName;
return (
<div style={{ maxWidth: '860px', margin: '0 auto', padding: '4rem 1.5rem' }}>
{/* Breadcrumb */}
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.75rem', color: 'var(--color-text-muted)', marginBottom: '1.5rem' }}>
<Link to="/forum" style={{ color: 'var(--color-text-muted)' }}>Forum</Link>
{' '}&gt;{' '}
<span style={{ color: 'var(--color-text-dim)' }}>{category}</span>
</div>
{/* Thread Header */}
<div className="crt-box" style={{ padding: '2rem', marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '0.75rem' }}>
{thread.isPinned && <span className="badge badge-progress">Pinned</span>}
{thread.isLocked && <span className="badge badge-closed">Locked</span>}
<span className="badge badge-open">{category}</span>
</div>
<h1
style={{
fontFamily: 'var(--font-heading)',
color: 'var(--color-text)',
fontSize: 'clamp(1.4rem, 4vw, 2rem)',
marginBottom: '1rem',
}}
>
{thread.title}
</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '1.5rem' }}>
<div
style={{
width: '32px',
height: '32px',
background: 'rgba(0,255,65,0.08)',
border: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: 'var(--font-heading)',
color: 'var(--color-green)',
fontSize: '0.85rem',
flexShrink: 0,
}}
>
{thread.authorName[0].toUpperCase()}
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>
<span style={{ color: 'var(--color-text-dim)' }}>{thread.authorName}</span>
<span style={{ color: 'var(--color-text-muted)' }}> &mdash; </span>
<span style={{ color: 'var(--color-text-muted)' }}>{formatDateTime(thread.createdAt)}</span>
</div>
</div>
<div
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-dim)',
fontSize: '0.88rem',
lineHeight: 1.85,
whiteSpace: 'pre-wrap',
}}
>
{thread.content}
</div>
</div>
{/* Replies */}
<div style={{ marginBottom: '2rem' }}>
<div className="section-label" style={{ marginBottom: '1rem' }}>
{replies.length} {replies.length === 1 ? 'Reply' : 'Replies'}
</div>
{replies.length === 0 ? (
<div className="crt-box" style={{ padding: '1.5rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>
No replies yet. Be the first to respond.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{replies.map((reply) => (
<div key={reply.id} className="crt-box" style={{ padding: '1.25rem 1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.75rem' }}>
<div
style={{
width: '28px',
height: '28px',
background: 'rgba(0,255,65,0.06)',
border: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: 'var(--font-heading)',
color: 'var(--color-green)',
fontSize: '0.75rem',
flexShrink: 0,
}}
>
{reply.authorName[0].toUpperCase()}
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.77rem' }}>
<span style={{ color: 'var(--color-text-dim)' }}>{reply.authorName}</span>
<span style={{ color: 'var(--color-text-muted)' }}> &mdash; {timeAgo(reply.createdAt)}</span>
</div>
</div>
<div
style={{
fontFamily: 'var(--font-mono)',
color: 'var(--color-text-dim)',
fontSize: '0.85rem',
lineHeight: 1.8,
whiteSpace: 'pre-wrap',
}}
>
{reply.content}
</div>
</div>
))}
</div>
)}
</div>
{/* Reply form */}
{thread.isLocked ? (
<div className="crt-box" style={{ padding: '1.25rem', textAlign: 'center' }}>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.8rem' }}>
This thread is locked. No new replies can be posted.
</span>
</div>
) : isAuthenticated ? (
<div className="crt-box" style={{ padding: '1.5rem' }}>
<div className="section-label" style={{ marginBottom: '0.75rem' }}>Post a Reply</div>
<textarea
className={`input-terminal${replyError ? ' error' : ''}`}
rows={5}
placeholder="Write your reply..."
value={newReply}
onChange={(e) => {
setNewReply(e.target.value);
if (replyError) setReplyError('');
}}
style={{ resize: 'vertical', marginBottom: '0.75rem' }}
aria-label="Reply content"
disabled={submitting}
/>
{replyError && (
<div style={{ color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.75rem', marginBottom: '0.75rem' }}>
[ERROR] {replyError}
</div>
)}
<button
className="btn-terminal"
onClick={handleReply}
disabled={submitting}
style={{ opacity: submitting ? 0.6 : 1 }}
>
{submitting ? 'Posting...' : '> Post Reply'}
</button>
</div>
) : (
<div className="crt-box" style={{ padding: '1.5rem', textAlign: 'center' }}>
<p style={{ color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.85rem', marginBottom: '1rem' }}>
You must be logged in to reply.
</p>
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}>
<Link to="/login" className="btn-terminal">Login</Link>
<Link to="/register" className="btn-terminal btn-amber">Register</Link>
</div>
</div>
)}
</div>
);
}