chore : move all to root
This commit is contained in:
251
nest-front/src/pages/intranet/IntranetBugs.tsx
Normal file
251
nest-front/src/pages/intranet/IntranetBugs.tsx
Normal 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} — Assigned: {bug.assignedToName ?? 'None'}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel — detail */}
|
||||
{selected && (
|
||||
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '1.5rem', position: 'sticky', top: '1rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', marginBottom: '0.25rem' }}>{selected.uniqueCode}</div>
|
||||
<h2 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.1rem' }}>{selected.title}</h2>
|
||||
</div>
|
||||
<button onClick={() => setSelected(null)} style={{ background: 'transparent', border: 'none', color: 'var(--color-text-muted)', cursor: 'pointer', fontSize: '1.1rem' }} aria-label="Close">✕</button>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div style={{ display: 'grid', gap: '0.75rem', marginBottom: '1.25rem' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', letterSpacing: '0.1em', marginBottom: '0.3rem' }}>STATUS</label>
|
||||
<select
|
||||
className="input-terminal"
|
||||
style={{ fontSize: '0.75rem' }}
|
||||
value={selected.status}
|
||||
onChange={(e) => handleStatusChange(selected.id, e.target.value as BugStatus)}
|
||||
>
|
||||
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', letterSpacing: '0.1em', marginBottom: '0.3rem' }}>ASSIGN TO</label>
|
||||
<select
|
||||
className="input-terminal"
|
||||
style={{ fontSize: '0.75rem' }}
|
||||
value={selected.assignedToId ?? ''}
|
||||
onChange={(e) => handleAssign(selected.id, e.target.value)}
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{STAFF_MEMBERS.map((s) => <option key={s.id} value={s.id}>{s.username} ({s.role})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.65rem', letterSpacing: '0.1em', marginBottom: '0.4rem' }}>DESCRIPTION</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.78rem', lineHeight: 1.7, whiteSpace: 'pre-wrap', background: 'var(--color-bg-alt)', padding: '0.75rem', borderRadius: '4px' }}>
|
||||
{selected.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.65rem', letterSpacing: '0.1em', marginBottom: '0.4rem' }}>STEPS TO REPRODUCE</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.78rem', lineHeight: 1.7, whiteSpace: 'pre-wrap', background: 'var(--color-bg-alt)', padding: '0.75rem', borderRadius: '4px' }}>
|
||||
{selected.stepsToReproduce}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Internal notes */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.65rem', letterSpacing: '0.1em', marginBottom: '0.5rem' }}>INTERNAL NOTES (staff only)</div>
|
||||
{(selected.notes ?? []).length === 0 ? (
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.75rem', padding: '0.5rem 0' }}>No notes yet.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '0.75rem' }}>
|
||||
{(selected.notes ?? []).map((note) => (
|
||||
<div key={note.id} style={{ background: 'rgba(217,119,6,0.08)', border: '1px solid rgba(217,119,6,0.2)', padding: '0.6rem', borderRadius: '4px' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.65rem', marginBottom: '0.2rem' }}>
|
||||
{note.authorName} — {formatDateTime(note.createdAt)}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.77rem', lineHeight: 1.6 }}>
|
||||
{note.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
className="input-terminal"
|
||||
rows={3}
|
||||
placeholder="Add an internal note..."
|
||||
value={noteText}
|
||||
onChange={(e) => setNoteText(e.target.value)}
|
||||
style={{ resize: 'vertical', fontSize: '0.8rem', marginBottom: '0.5rem' }}
|
||||
/>
|
||||
<button className="btn-terminal btn-amber" onClick={() => handleAddNote(selected.id)} style={{ padding: '0.35rem 0.9rem', fontSize: '0.75rem' }}>
|
||||
Add Note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
164
nest-front/src/pages/intranet/IntranetDashboard.tsx
Normal file
164
nest-front/src/pages/intranet/IntranetDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
722
nest-front/src/pages/intranet/IntranetEvents.tsx
Normal file
722
nest-front/src/pages/intranet/IntranetEvents.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
nest-front/src/pages/intranet/IntranetFeed.tsx
Normal file
147
nest-front/src/pages/intranet/IntranetFeed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
231
nest-front/src/pages/intranet/IntranetModeration.tsx
Normal file
231
nest-front/src/pages/intranet/IntranetModeration.tsx
Normal 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 — {replies.length} replies
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--color-border)', marginBottom: '1.5rem', gap: 0 }}>
|
||||
{(['threads', 'replies'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderBottom: activeTab === tab ? '2px solid var(--color-amber)' : '2px solid transparent',
|
||||
color: activeTab === tab ? 'var(--color-amber)' : 'var(--color-text-muted)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.75rem',
|
||||
padding: '0.55rem 1rem',
|
||||
cursor: 'pointer',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
{tab === 'threads' ? `Threads (${threads.length})` : `Replies (${replies.length})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'threads' && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: selectedThreadId ? '1fr 340px' : '1fr', gap: '1.25rem', alignItems: 'start' }}>
|
||||
{/* Thread list */}
|
||||
<div>
|
||||
<input
|
||||
className="input-terminal"
|
||||
type="search"
|
||||
placeholder="Search threads..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ marginBottom: '1rem', maxWidth: '300px' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
{filteredThreads.map((thread) => (
|
||||
<div
|
||||
key={thread.id}
|
||||
style={{
|
||||
background: selectedThreadId === thread.id ? 'rgba(37,99,235,0.08)' : 'var(--color-surface)',
|
||||
border: `1px solid ${selectedThreadId === thread.id ? 'var(--color-yellow)' : 'var(--color-border)'}`,
|
||||
padding: '0.85rem 1.1rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '0.3rem' }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', marginBottom: '0.2rem' }}>
|
||||
{thread.isPinned && <span className="badge badge-progress">Pinned</span>}
|
||||
{thread.isLocked && <span className="badge badge-closed">Locked</span>}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text)', fontSize: '0.83rem' }}>{thread.title}</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', marginTop: '0.2rem' }}>
|
||||
by {thread.authorName} — {thread.categoryName} — {thread.replyCount} replies
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.4rem', marginTop: '0.6rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="btn-terminal"
|
||||
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
|
||||
onClick={() => setSelectedThreadId(selectedThreadId === thread.id ? null : thread.id)}
|
||||
>
|
||||
Replies
|
||||
</button>
|
||||
<button
|
||||
className={`btn-terminal ${thread.isPinned ? 'btn-danger' : 'btn-amber'}`}
|
||||
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
|
||||
onClick={() => togglePin(thread.id)}
|
||||
>
|
||||
{thread.isPinned ? 'Unpin' : 'Pin'}
|
||||
</button>
|
||||
<button
|
||||
className="btn-terminal btn-amber"
|
||||
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
|
||||
onClick={() => toggleLock(thread.id)}
|
||||
>
|
||||
{thread.isLocked ? 'Unlock' : 'Lock'}
|
||||
</button>
|
||||
<button
|
||||
className="btn-terminal btn-danger"
|
||||
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem' }}
|
||||
onClick={() => deleteThread(thread.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filteredThreads.length === 0 && (
|
||||
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem', background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}>
|
||||
No threads found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thread replies panel */}
|
||||
{selectedThreadId && (
|
||||
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '1.25rem', position: 'sticky', top: '1rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.7rem', letterSpacing: '0.1em' }}>
|
||||
REPLIES ({selectedThreadReplies.length})
|
||||
</div>
|
||||
<button onClick={() => setSelectedThreadId(null)} style={{ background: 'transparent', border: 'none', color: 'var(--color-text-muted)', cursor: 'pointer' }} aria-label="Close">✕</button>
|
||||
</div>
|
||||
{selectedThreadReplies.length === 0 ? (
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem' }}>No replies.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{selectedThreadReplies.map((reply) => (
|
||||
<div key={reply.id} style={{ background: 'var(--color-surface-alt)', border: '1px solid var(--color-border)', padding: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.35rem' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.72rem' }}>{reply.authorName}</span>
|
||||
<button
|
||||
className="btn-terminal btn-danger"
|
||||
style={{ padding: '0.1rem 0.45rem', fontSize: '0.6rem' }}
|
||||
onClick={() => deleteReply(reply.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.77rem', lineHeight: 1.65 }}>
|
||||
{reply.content.slice(0, 150)}{reply.content.length > 150 ? '...' : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'replies' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
{recentReplies.map((reply) => {
|
||||
const thread = threads.find((t) => t.id === reply.threadId);
|
||||
return (
|
||||
<div key={reply.id} style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '0.85rem 1.1rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', marginBottom: '0.2rem' }}>
|
||||
by <span style={{ color: 'var(--color-text-dim)' }}>{reply.authorName}</span>
|
||||
{thread && <> in <span style={{ color: 'var(--color-text-dim)' }}>{thread.title}</span></>}
|
||||
{' '}— {formatDateTime(reply.createdAt)}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text)', fontSize: '0.82rem', lineHeight: 1.6, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{reply.content}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn-terminal btn-danger"
|
||||
style={{ padding: '0.15rem 0.55rem', fontSize: '0.65rem', flexShrink: 0 }}
|
||||
onClick={() => deleteReply(reply.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{recentReplies.length === 0 && (
|
||||
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem', background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}>
|
||||
No replies found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
nest-front/src/pages/intranet/IntranetUsers.tsx
Normal file
188
nest-front/src/pages/intranet/IntranetUsers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
248
nest-front/src/pages/public/AccountPage.tsx
Normal file
248
nest-front/src/pages/public/AccountPage.tsx
Normal 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} — {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>
|
||||
);
|
||||
}
|
||||
347
nest-front/src/pages/public/BugDetailPage.tsx
Normal file
347
nest-front/src/pages/public/BugDetailPage.tsx
Normal 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>
|
||||
{' '}>{' '}
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
✓ You reported this too
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="btn-terminal"
|
||||
onClick={handleMeToo}
|
||||
style={{ fontSize: '0.78rem', padding: '0.3rem 0.9rem' }}
|
||||
>
|
||||
▶ 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...' : '▶ 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>
|
||||
);
|
||||
}
|
||||
440
nest-front/src/pages/public/BugReportPage.tsx
Normal file
440
nest-front/src/pages/public/BugReportPage.tsx
Normal 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',
|
||||
}}
|
||||
>
|
||||
▶ {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} — {timeAgo(bug.createdAt)} — v{bug.gameVersion}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-cyan)', fontSize: '0.75rem', flexShrink: 0, paddingTop: '2px' }}>
|
||||
VIEW >
|
||||
</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' }}>
|
||||
▶ 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">
|
||||
▶ 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 —
|
||||
<span style={{ color: 'var(--color-yellow)' }}>{inProgressCount}</span> in progress —
|
||||
<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' : '▶ 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' }}>
|
||||
▶ 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>
|
||||
);
|
||||
}
|
||||
366
nest-front/src/pages/public/EventsPage.tsx
Normal file
366
nest-front/src/pages/public/EventsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
nest-front/src/pages/public/ForumPage.tsx
Normal file
188
nest-front/src/pages/public/ForumPage.tsx
Normal 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>
|
||||
{' '}— {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>
|
||||
);
|
||||
}
|
||||
460
nest-front/src/pages/public/HomePage.tsx
Normal file
460
nest-front/src/pages/public/HomePage.tsx
Normal 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',
|
||||
}}
|
||||
>
|
||||
>> 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' }}
|
||||
>
|
||||
> Learn More
|
||||
</a>
|
||||
<Link
|
||||
to="/forum"
|
||||
className="btn-terminal btn-amber"
|
||||
style={{ fontSize: '0.9rem', padding: '0.65rem 2rem' }}
|
||||
>
|
||||
> 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)',
|
||||
}}
|
||||
>
|
||||
■■■ CLASSIFIED — LEVEL 9 CLEARANCE REQUIRED ■■■
|
||||
</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)' }}>> </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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
151
nest-front/src/pages/public/LoginPage.tsx
Normal file
151
nest-front/src/pages/public/LoginPage.tsx
Normal 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%',
|
||||
}}
|
||||
>
|
||||
> {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>
|
||||
);
|
||||
}
|
||||
41
nest-front/src/pages/public/NotFoundPage.tsx
Normal file
41
nest-front/src/pages/public/NotFoundPage.tsx
Normal 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">> Return to Base</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
nest-front/src/pages/public/RegisterPage.tsx
Normal file
153
nest-front/src/pages/public/RegisterPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
206
nest-front/src/pages/public/StudioPage.tsx
Normal file
206
nest-front/src/pages/public/StudioPage.tsx
Normal 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)' }}>>></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>
|
||||
);
|
||||
}
|
||||
225
nest-front/src/pages/public/ThreadPage.tsx
Normal file
225
nest-front/src/pages/public/ThreadPage.tsx
Normal 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>
|
||||
{' '}>{' '}
|
||||
<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)' }}> — </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)' }}> — {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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user