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