refactor : Remove intranet components along with their associated styles and logic
This commit is contained in:
@@ -1,251 +0,0 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { MOCK_BUGS, MOCK_USERS } from '../../data/mockData';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { formatDate, formatDateTime } from '../../utils/format';
|
||||
import type { BugReport, BugSeverity, BugStatus, BugReportNote } from '../../types';
|
||||
|
||||
function StatusBadge({ status }: { status: BugStatus }) {
|
||||
const map: Record<BugStatus, string> = { open: 'badge-open', in_progress: 'badge-progress', resolved: 'badge-resolved', closed: 'badge-closed' };
|
||||
const labels: Record<BugStatus, string> = { open: 'Open', in_progress: 'In Progress', resolved: 'Resolved', closed: 'Closed' };
|
||||
return <span className={`badge ${map[status]}`}>{labels[status]}</span>;
|
||||
}
|
||||
|
||||
function SeverityBadge({ severity }: { severity: BugSeverity }) {
|
||||
const map: Record<BugSeverity, string> = { low: 'badge-low', medium: 'badge-medium', high: 'badge-high', critical: 'badge-critical' };
|
||||
return <span className={`badge ${map[severity]}`}>{severity}</span>;
|
||||
}
|
||||
|
||||
const STATUSES: BugStatus[] = ['open', 'in_progress', 'resolved', 'closed'];
|
||||
const STAFF_MEMBERS = MOCK_USERS.filter((u) => u.role === 'dev' || u.role === 'com');
|
||||
|
||||
export default function IntranetBugs() {
|
||||
const { user } = useAuth();
|
||||
|
||||
const [bugs, setBugs] = useState<BugReport[]>(MOCK_BUGS);
|
||||
const [selected, setSelected] = useState<BugReport | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<BugStatus | 'all'>('all');
|
||||
const [severityFilter, setSeverityFilter] = useState<BugSeverity | 'all'>('all');
|
||||
const [assignedFilter, setAssignedFilter] = useState<string | 'all'>('all');
|
||||
const [noteText, setNoteText] = useState('');
|
||||
|
||||
const openCount = bugs.filter((b) => b.status === 'open').length;
|
||||
const criticalCount = bugs.filter((b) => b.severity === 'critical').length;
|
||||
const myCount = bugs.filter((b) => b.assignedToId === user?.id).length;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return bugs.filter((b) => {
|
||||
if (statusFilter !== 'all' && b.status !== statusFilter) return false;
|
||||
if (severityFilter !== 'all' && b.severity !== severityFilter) return false;
|
||||
if (assignedFilter !== 'all') {
|
||||
if (assignedFilter === 'unassigned' && b.assignedToId) return false;
|
||||
if (assignedFilter !== 'unassigned' && b.assignedToId !== assignedFilter) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [bugs, statusFilter, severityFilter, assignedFilter]);
|
||||
|
||||
const updateBug = useCallback((id: string, changes: Partial<BugReport>) => {
|
||||
setBugs((prev) => prev.map((b) => b.id === id ? { ...b, ...changes, updatedAt: new Date().toISOString() } : b));
|
||||
setSelected((prev) => prev?.id === id ? { ...prev, ...changes, updatedAt: new Date().toISOString() } : prev);
|
||||
}, []);
|
||||
|
||||
const handleAssign = useCallback((bugId: string, staffId: string) => {
|
||||
const staff = STAFF_MEMBERS.find((s) => s.id === staffId);
|
||||
updateBug(bugId, { assignedToId: staffId || undefined, assignedToName: staff?.username });
|
||||
}, [updateBug]);
|
||||
|
||||
const handleStatusChange = useCallback((bugId: string, status: BugStatus) => {
|
||||
updateBug(bugId, { status });
|
||||
}, [updateBug]);
|
||||
|
||||
const handleAddNote = useCallback((bugId: string) => {
|
||||
if (!noteText.trim() || !user) return;
|
||||
const note: BugReportNote = {
|
||||
id: `n${Date.now()}`,
|
||||
bugReportId: bugId,
|
||||
authorId: user.id,
|
||||
authorName: user.username,
|
||||
content: noteText.trim(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setBugs((prev) => prev.map((b) => b.id === bugId ? { ...b, notes: [...(b.notes ?? []), note] } : b));
|
||||
setSelected((prev) => prev?.id === bugId ? { ...prev, notes: [...(prev.notes ?? []), note] } : prev);
|
||||
setNoteText('');
|
||||
}, [noteText, user]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: selected ? '1fr 400px' : '1fr', gap: '1.5rem', alignItems: 'start' }}>
|
||||
{/* Left panel */}
|
||||
<div>
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
|
||||
INTRANET / BUG REPORTS
|
||||
</div>
|
||||
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem', marginBottom: '1rem' }}>BUG DASHBOARD</h1>
|
||||
|
||||
{/* Stats */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.5rem', marginBottom: '1.25rem' }}>
|
||||
{[
|
||||
{ label: 'Open', value: openCount, color: 'var(--color-green)' },
|
||||
{ label: 'Critical', value: criticalCount, color: 'var(--color-red)' },
|
||||
{ label: 'Mine', value: myCount, color: 'var(--color-amber)' },
|
||||
].map(({ label, value, color }) => (
|
||||
<div key={label} style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '0.75rem', textAlign: 'center' }}>
|
||||
<div style={{ fontFamily: 'var(--font-heading)', color, fontSize: '1.8rem' }}>{value}</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.65rem', letterSpacing: '0.1em' }}>{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<select className="input-terminal" style={{ width: 'auto', fontSize: '0.75rem' }} value={statusFilter} onChange={(e) => setStatusFilter(e.target.value as BugStatus | 'all')}>
|
||||
<option value="all">All Statuses</option>
|
||||
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<select className="input-terminal" style={{ width: 'auto', fontSize: '0.75rem' }} value={severityFilter} onChange={(e) => setSeverityFilter(e.target.value as BugSeverity | 'all')}>
|
||||
<option value="all">All Severities</option>
|
||||
{(['critical','high','medium','low'] as BugSeverity[]).map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<select className="input-terminal" style={{ width: 'auto', fontSize: '0.75rem' }} value={assignedFilter} onChange={(e) => setAssignedFilter(e.target.value)}>
|
||||
<option value="all">All Assigned</option>
|
||||
<option value="unassigned">Unassigned</option>
|
||||
{STAFF_MEMBERS.map((s) => <option key={s.id} value={s.id}>{s.username}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bug list */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>
|
||||
No reports match filters.
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((bug) => (
|
||||
<div
|
||||
key={bug.id}
|
||||
onClick={() => setSelected(bug === selected ? null : bug)}
|
||||
style={{
|
||||
background: selected?.id === bug.id ? 'rgba(37,99,235,0.08)' : 'var(--color-surface)',
|
||||
border: `1px solid ${selected?.id === bug.id ? 'var(--color-yellow)' : 'var(--color-border)'}`,
|
||||
padding: '0.85rem 1.1rem',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === 'Enter' && setSelected(bug === selected ? null : bug)}
|
||||
aria-label={`Select bug report ${bug.uniqueCode}`}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '0.3rem' }}>
|
||||
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem' }}>{bug.uniqueCode}</span>
|
||||
<StatusBadge status={bug.status} />
|
||||
<SeverityBadge severity={bug.severity} />
|
||||
</div>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', flexShrink: 0 }}>
|
||||
{formatDate(bug.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text)', fontSize: '0.83rem', marginBottom: '0.2rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{bug.title}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem' }}>
|
||||
{bug.submittedByName} — 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>
|
||||
);
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { MOCK_BUGS, MOCK_STAFF_POSTS, MOCK_USERS, MOCK_THREADS } from '../../data/mockData';
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number | string;
|
||||
accent?: 'green' | 'amber' | 'red';
|
||||
}
|
||||
|
||||
function StatCard({ label, value, accent = 'green' }: StatCardProps) {
|
||||
const colors = {
|
||||
green: 'var(--color-green)',
|
||||
amber: 'var(--color-amber)',
|
||||
red: 'var(--color-red)',
|
||||
};
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '1.25rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', textTransform: 'uppercase' }}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-heading)', color: colors[accent], fontSize: '2.5rem', lineHeight: 1 }}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NavTileProps {
|
||||
to: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
function NavTile({ to, label, description, icon }: NavTileProps) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '1.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.6rem',
|
||||
textDecoration: 'none',
|
||||
transition: 'border-color 0.2s, background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLAnchorElement).style.borderColor = 'var(--color-yellow)';
|
||||
(e.currentTarget as HTMLAnchorElement).style.background = 'rgba(37,99,235,0.05)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLAnchorElement).style.borderColor = 'var(--color-border)';
|
||||
(e.currentTarget as HTMLAnchorElement).style.background = 'var(--color-surface)';
|
||||
}}
|
||||
>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.75rem', letterSpacing: '0.1em' }}>
|
||||
{icon}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.1rem' }}>
|
||||
{label}
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem', lineHeight: 1.6 }}>
|
||||
{description}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IntranetDashboard() {
|
||||
const { user } = useAuth();
|
||||
|
||||
const openBugs = MOCK_BUGS.filter((b) => b.status === 'open').length;
|
||||
const criticalBugs = MOCK_BUGS.filter((b) => b.severity === 'critical').length;
|
||||
const assignedToMe = MOCK_BUGS.filter((b) => b.assignedToId === user?.id).length;
|
||||
const totalUsers = MOCK_USERS.filter((u) => !u.isAdmin).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
|
||||
INTRANET / DASHBOARD
|
||||
</div>
|
||||
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem', marginBottom: '0.25rem' }}>
|
||||
Welcome, {user?.username}
|
||||
</h1>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.8rem' }}>
|
||||
{new Date().toLocaleString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '1rem' }}>
|
||||
QUICK STATS
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '0.75rem' }}>
|
||||
<StatCard label="Open Bugs" value={openBugs} accent="green" />
|
||||
<StatCard label="Critical" value={criticalBugs} accent="red" />
|
||||
<StatCard label="Assigned to Me" value={assignedToMe} accent="amber" />
|
||||
<StatCard label="Total Users" value={totalUsers} accent="green" />
|
||||
<StatCard label="Forum Threads" value={MOCK_THREADS.length} accent="green" />
|
||||
<StatCard label="Staff Posts Today" value={MOCK_STAFF_POSTS.filter((p) => p.createdAt.startsWith('2026-02-18')).length} accent="amber" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation tiles */}
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '1rem' }}>
|
||||
SECTIONS
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '0.75rem' }}>
|
||||
<NavTile to="/intranet/bugs" label="Bug Reports" description="Review, assign, and update reported issues. Filter by severity and status." icon="[!]" />
|
||||
<NavTile to="/intranet/feed" label="Team Feed" description="Internal staff activity feed. Post updates visible only to the team." icon="[~]" />
|
||||
<NavTile to="/intranet/users" label="User Management" description="View all registered users. Promote or ban accounts." icon="[U]" />
|
||||
<NavTile to="/intranet/moderation" label="Forum Moderation" description="Pin, lock, and delete threads and replies from the public forum." icon="[M]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent staff posts */}
|
||||
<div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '1rem' }}>
|
||||
RECENT TEAM ACTIVITY
|
||||
</div>
|
||||
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)' }}>
|
||||
{MOCK_STAFF_POSTS.slice(0, 4).map((post, idx) => (
|
||||
<div
|
||||
key={post.id}
|
||||
style={{
|
||||
padding: '0.85rem 1.25rem',
|
||||
borderBottom: idx < 3 ? '1px solid var(--color-border)' : 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '1rem', marginBottom: '0.3rem', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.78rem' }}>
|
||||
{post.authorName}
|
||||
<span style={{ color: 'var(--color-text-muted)', marginLeft: '0.4rem', fontSize: '0.65rem' }}>[{post.authorRole}]</span>
|
||||
</span>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem' }}>
|
||||
{new Date(post.createdAt).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.82rem', lineHeight: 1.7 }}>
|
||||
{post.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,722 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { MOCK_EVENTS, MOCK_POLLS } from '../../data/mockData';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { formatDateTime } from '../../utils/format';
|
||||
import type { EventPost, EventType, Poll, UserRole } from '../../types';
|
||||
|
||||
const EVENT_TYPE_COLORS: Record<EventType, string> = {
|
||||
announcement: 'var(--color-yellow)',
|
||||
update: 'var(--color-blue)',
|
||||
milestone: 'var(--color-green)',
|
||||
poll: 'var(--color-amber)',
|
||||
};
|
||||
|
||||
const ROLE_COLORS: Record<UserRole, string> = {
|
||||
dev: 'var(--color-green)',
|
||||
com: 'var(--color-amber)',
|
||||
user: 'var(--color-text-muted)',
|
||||
};
|
||||
|
||||
const EVENT_TYPE_LABELS: Record<EventType, string> = {
|
||||
announcement: 'ANNOUNCEMENT',
|
||||
update: 'DEV UPDATE',
|
||||
milestone: 'MILESTONE',
|
||||
poll: 'COMMUNITY POLL',
|
||||
};
|
||||
|
||||
// ── Poll Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
function PollCard({ poll, onVote }: { poll: Poll; onVote: (pollId: string, optionId: string) => void }) {
|
||||
const { user } = useAuth();
|
||||
const totalVotes = poll.options.reduce((sum, opt) => sum + opt.votes, 0);
|
||||
const isEnded = poll.endsAt ? new Date(poll.endsAt) < new Date() : false;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-bg-alt)',
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '1rem',
|
||||
marginTop: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--color-text)',
|
||||
marginBottom: '0.85rem',
|
||||
}}
|
||||
>
|
||||
{poll.question}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{poll.options.map((option) => {
|
||||
const percentage = totalVotes > 0 ? Math.round((option.votes / totalVotes) * 100) : 0;
|
||||
const userVoted = option.votedUserIds.includes(user?.id || '');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
style={{
|
||||
position: 'relative',
|
||||
background: 'var(--color-surface)',
|
||||
border: `1px solid ${userVoted ? 'var(--color-amber)' : 'var(--color-border)'}`,
|
||||
padding: '0.6rem 0.75rem',
|
||||
cursor: !isEnded && poll.isActive ? 'pointer' : 'default',
|
||||
opacity: isEnded || !poll.isActive ? 0.7 : 1,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!isEnded && poll.isActive && user) {
|
||||
onVote(poll.id, option.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Progress bar */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
width: `${percentage}%`,
|
||||
background: userVoted
|
||||
? 'rgba(217,119,6,0.15)'
|
||||
: 'rgba(59,130,246,0.1)',
|
||||
transition: 'width 0.3s ease',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--color-text-dim)' }}>
|
||||
{userVoted && '✓ '}
|
||||
{option.text}
|
||||
</span>
|
||||
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.7rem' }}>
|
||||
{option.votes} ({percentage}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.75rem',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.65rem',
|
||||
color: 'var(--color-text-muted)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{totalVotes} total votes
|
||||
{poll.allowMultipleVotes && ' • Multiple votes allowed'}
|
||||
</span>
|
||||
{poll.endsAt && (
|
||||
<span style={{ color: isEnded ? 'var(--color-red)' : 'var(--color-amber)' }}>
|
||||
{isEnded ? 'Poll Ended' : `Ends ${formatDateTime(poll.endsAt)}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Event Card Component ───────────────────────────────────────────────────────
|
||||
|
||||
function EventCard({
|
||||
event,
|
||||
poll,
|
||||
onVote,
|
||||
}: {
|
||||
event: EventPost;
|
||||
poll?: Poll;
|
||||
onVote: (pollId: string, optionId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '1.25rem',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
gap: '1rem',
|
||||
marginBottom: '0.75rem',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.4rem' }}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
background: `${EVENT_TYPE_COLORS[event.type]}15`,
|
||||
border: `1px solid ${EVENT_TYPE_COLORS[event.type]}40`,
|
||||
color: EVENT_TYPE_COLORS[event.type],
|
||||
fontSize: '0.6rem',
|
||||
padding: '0.15rem 0.4rem',
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
{EVENT_TYPE_LABELS[event.type]}
|
||||
</span>
|
||||
{event.isPublic && (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
background: 'rgba(34,197,94,0.1)',
|
||||
border: '1px solid rgba(34,197,94,0.25)',
|
||||
color: 'var(--color-green)',
|
||||
fontSize: '0.6rem',
|
||||
padding: '0.15rem 0.4rem',
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
PUBLIC
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
color: 'var(--color-text)',
|
||||
fontSize: '1.1rem',
|
||||
marginBottom: '0.25rem',
|
||||
}}
|
||||
>
|
||||
{event.title}
|
||||
</h3>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: '0.68rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: ROLE_COLORS[event.authorRole] }}>
|
||||
{event.authorName}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{formatDateTime(event.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-text-dim)',
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: 1.75,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{event.content}
|
||||
</div>
|
||||
|
||||
{/* Poll if exists */}
|
||||
{poll && <PollCard poll={poll} onVote={onVote} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function IntranetEvents() {
|
||||
const { user } = useAuth();
|
||||
const [events, setEvents] = useState<EventPost[]>(MOCK_EVENTS);
|
||||
const [polls, setPolls] = useState<Poll[]>(MOCK_POLLS);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [eventType, setEventType] = useState<EventType>('announcement');
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [isPublic, setIsPublic] = useState(true);
|
||||
const [createPoll, setCreatePoll] = useState(false);
|
||||
const [pollQuestion, setPollQuestion] = useState('');
|
||||
const [pollOptions, setPollOptions] = useState<string[]>(['', '']);
|
||||
const [error, setError] = useState('');
|
||||
const [posting, setPosting] = useState(false);
|
||||
|
||||
const handleVote = useCallback(
|
||||
(pollId: string, optionId: string) => {
|
||||
if (!user) return;
|
||||
|
||||
setPolls((prevPolls) =>
|
||||
prevPolls.map((poll) => {
|
||||
if (poll.id !== pollId) return poll;
|
||||
|
||||
const hasVotedForOption = poll.options.some((opt) =>
|
||||
opt.votedUserIds.includes(user.id)
|
||||
);
|
||||
|
||||
return {
|
||||
...poll,
|
||||
options: poll.options.map((opt) => {
|
||||
if (opt.id === optionId) {
|
||||
// Add vote to this option
|
||||
return {
|
||||
...opt,
|
||||
votes: opt.votedUserIds.includes(user.id) ? opt.votes : opt.votes + 1,
|
||||
votedUserIds: opt.votedUserIds.includes(user.id)
|
||||
? opt.votedUserIds
|
||||
: [...opt.votedUserIds, user.id],
|
||||
};
|
||||
} else if (!poll.allowMultipleVotes && hasVotedForOption) {
|
||||
// Remove vote from other options if single vote
|
||||
return {
|
||||
...opt,
|
||||
votes: opt.votedUserIds.includes(user.id) ? opt.votes - 1 : opt.votes,
|
||||
votedUserIds: opt.votedUserIds.filter((id) => id !== user.id),
|
||||
};
|
||||
}
|
||||
return opt;
|
||||
}),
|
||||
};
|
||||
})
|
||||
);
|
||||
},
|
||||
[user]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
// Validation
|
||||
if (!title.trim()) {
|
||||
setError('Title is required.');
|
||||
return;
|
||||
}
|
||||
if (!content.trim()) {
|
||||
setError('Content is required.');
|
||||
return;
|
||||
}
|
||||
if (createPoll) {
|
||||
if (!pollQuestion.trim()) {
|
||||
setError('Poll question is required.');
|
||||
return;
|
||||
}
|
||||
const validOptions = pollOptions.filter((opt) => opt.trim());
|
||||
if (validOptions.length < 2) {
|
||||
setError('Poll must have at least 2 options.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!user) return;
|
||||
|
||||
setError('');
|
||||
setPosting(true);
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
|
||||
const newEventId = `evt${Date.now()}`;
|
||||
let newPollId: string | undefined;
|
||||
|
||||
// Create poll if needed
|
||||
if (createPoll) {
|
||||
newPollId = `poll${Date.now()}`;
|
||||
const validOptions = pollOptions.filter((opt) => opt.trim());
|
||||
const newPoll: Poll = {
|
||||
id: newPollId,
|
||||
eventId: newEventId,
|
||||
question: pollQuestion.trim(),
|
||||
options: validOptions.map((opt, idx) => ({
|
||||
id: `opt${Date.now()}_${idx}`,
|
||||
text: opt.trim(),
|
||||
votes: 0,
|
||||
votedUserIds: [],
|
||||
})),
|
||||
isActive: true,
|
||||
allowMultipleVotes: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setPolls((prev) => [newPoll, ...prev]);
|
||||
}
|
||||
|
||||
// Create event
|
||||
const newEvent: EventPost = {
|
||||
id: newEventId,
|
||||
type: createPoll ? 'poll' : eventType,
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
authorId: user.id,
|
||||
authorName: user.username,
|
||||
authorRole: user.role,
|
||||
createdAt: new Date().toISOString(),
|
||||
isPublic,
|
||||
pollId: newPollId,
|
||||
};
|
||||
|
||||
setEvents((prev) => [newEvent, ...prev]);
|
||||
|
||||
// Reset form
|
||||
setTitle('');
|
||||
setContent('');
|
||||
setEventType('announcement');
|
||||
setIsPublic(true);
|
||||
setCreatePoll(false);
|
||||
setPollQuestion('');
|
||||
setPollOptions(['', '']);
|
||||
setPosting(false);
|
||||
setShowCreateForm(false);
|
||||
}, [title, content, eventType, isPublic, createPoll, pollQuestion, pollOptions, user]);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '800px' }}>
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: '0.7rem',
|
||||
letterSpacing: '0.15em',
|
||||
marginBottom: '0.5rem',
|
||||
}}
|
||||
>
|
||||
INTRANET / EVENTS
|
||||
</div>
|
||||
<h1
|
||||
style={{
|
||||
fontFamily: 'var(--font-heading)',
|
||||
color: 'var(--color-text)',
|
||||
fontSize: '1.8rem',
|
||||
}}
|
||||
>
|
||||
COMMUNITY EVENTS
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: '0.78rem',
|
||||
marginTop: '0.4rem',
|
||||
}}
|
||||
>
|
||||
Post game development updates, announcements, and community polls. Public events are
|
||||
visible to all users.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Create Event Button */}
|
||||
{!showCreateForm && (
|
||||
<button
|
||||
className="btn-terminal btn-amber"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
style={{ marginBottom: '1.5rem' }}
|
||||
>
|
||||
+ Create New Event
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Create Event Form */}
|
||||
{showCreateForm && (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '1.25rem',
|
||||
marginBottom: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--color-amber)',
|
||||
fontSize: '0.7rem',
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
CREATE NEW EVENT
|
||||
</div>
|
||||
|
||||
{/* Event Type */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.7rem',
|
||||
color: 'var(--color-text-muted)',
|
||||
marginBottom: '0.4rem',
|
||||
}}
|
||||
>
|
||||
EVENT TYPE
|
||||
</label>
|
||||
<select
|
||||
className="input-terminal"
|
||||
value={eventType}
|
||||
onChange={(e) => setEventType(e.target.value as EventType)}
|
||||
style={{ fontSize: '0.8rem' }}
|
||||
disabled={createPoll}
|
||||
>
|
||||
<option value="announcement">Announcement</option>
|
||||
<option value="update">Development Update</option>
|
||||
<option value="milestone">Milestone</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.7rem',
|
||||
color: 'var(--color-text-muted)',
|
||||
marginBottom: '0.4rem',
|
||||
}}
|
||||
>
|
||||
TITLE
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-terminal"
|
||||
placeholder="Event title..."
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
style={{ fontSize: '0.85rem' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.7rem',
|
||||
color: 'var(--color-text-muted)',
|
||||
marginBottom: '0.4rem',
|
||||
}}
|
||||
>
|
||||
CONTENT
|
||||
</label>
|
||||
<textarea
|
||||
className="input-terminal"
|
||||
rows={4}
|
||||
placeholder="Event description and details..."
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
style={{ resize: 'vertical', fontSize: '0.85rem' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Public Toggle */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--color-text-dim)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPublic}
|
||||
onChange={(e) => setIsPublic(e.target.checked)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
Make event visible to public
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Poll Toggle */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--color-text-dim)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createPoll}
|
||||
onChange={(e) => setCreatePoll(e.target.checked)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
Include a community poll
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Poll Form */}
|
||||
{createPoll && (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-bg-alt)',
|
||||
border: '1px solid var(--color-border)',
|
||||
padding: '1rem',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.7rem',
|
||||
color: 'var(--color-amber)',
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: '0.75rem',
|
||||
}}
|
||||
>
|
||||
POLL DETAILS
|
||||
</div>
|
||||
|
||||
{/* Poll Question */}
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.7rem',
|
||||
color: 'var(--color-text-muted)',
|
||||
marginBottom: '0.4rem',
|
||||
}}
|
||||
>
|
||||
QUESTION
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-terminal"
|
||||
placeholder="What do you want to ask?"
|
||||
value={pollQuestion}
|
||||
onChange={(e) => setPollQuestion(e.target.value)}
|
||||
style={{ fontSize: '0.8rem' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Poll Options */}
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.7rem',
|
||||
color: 'var(--color-text-muted)',
|
||||
marginBottom: '0.4rem',
|
||||
}}
|
||||
>
|
||||
OPTIONS
|
||||
</label>
|
||||
{pollOptions.map((option, idx) => (
|
||||
<div key={idx} style={{ marginBottom: '0.4rem', display: 'flex', gap: '0.5rem' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="input-terminal"
|
||||
placeholder={`Option ${idx + 1}`}
|
||||
value={option}
|
||||
onChange={(e) => {
|
||||
const newOptions = [...pollOptions];
|
||||
newOptions[idx] = e.target.value;
|
||||
setPollOptions(newOptions);
|
||||
}}
|
||||
style={{ fontSize: '0.8rem', flex: 1 }}
|
||||
/>
|
||||
{pollOptions.length > 2 && (
|
||||
<button
|
||||
className="btn-terminal"
|
||||
onClick={() => {
|
||||
setPollOptions(pollOptions.filter((_, i) => i !== idx));
|
||||
}}
|
||||
style={{ padding: '0.4rem 0.6rem', fontSize: '0.7rem' }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{pollOptions.length < 6 && (
|
||||
<button
|
||||
className="btn-terminal"
|
||||
onClick={() => setPollOptions([...pollOptions, ''])}
|
||||
style={{ fontSize: '0.7rem', marginTop: '0.4rem' }}
|
||||
>
|
||||
+ Add Option
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--color-red)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '0.72rem',
|
||||
marginBottom: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
className="btn-terminal btn-amber"
|
||||
onClick={handleSubmit}
|
||||
disabled={posting}
|
||||
style={{ opacity: posting ? 0.6 : 1 }}
|
||||
>
|
||||
{posting ? 'Creating...' : '> Create Event'}
|
||||
</button>
|
||||
<button
|
||||
className="btn-terminal"
|
||||
onClick={() => {
|
||||
setShowCreateForm(false);
|
||||
setError('');
|
||||
setTitle('');
|
||||
setContent('');
|
||||
setCreatePoll(false);
|
||||
setPollQuestion('');
|
||||
setPollOptions(['', '']);
|
||||
}}
|
||||
disabled={posting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Events List */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{events.map((event) => {
|
||||
const poll = event.pollId ? polls.find((p) => p.id === event.pollId) : undefined;
|
||||
return <EventCard key={event.id} event={event} poll={poll} onVote={handleVote} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { MOCK_STAFF_POSTS } from '../../data/mockData';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { formatDateTime } from '../../utils/format';
|
||||
import type { StaffPost, UserRole } from '../../types';
|
||||
|
||||
const ROLE_COLORS: Record<UserRole, string> = {
|
||||
dev: 'var(--color-green)',
|
||||
com: 'var(--color-amber)',
|
||||
user: 'var(--color-text-muted)',
|
||||
};
|
||||
|
||||
function FeedPost({ post }: { post: StaffPost }) {
|
||||
return (
|
||||
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '1.1rem 1.25rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '1rem', marginBottom: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '30px',
|
||||
height: '30px',
|
||||
background: 'rgba(217,119,6,0.1)',
|
||||
border: '1px solid rgba(217,119,6,0.25)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: 'var(--font-heading)',
|
||||
color: 'var(--color-amber)',
|
||||
fontSize: '0.85rem',
|
||||
flexShrink: 0,
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
{post.authorName[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', color: ROLE_COLORS[post.authorRole], fontSize: '0.82rem' }}>
|
||||
{post.authorName}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
background: 'rgba(217,119,6,0.1)',
|
||||
border: '1px solid rgba(217,119,6,0.25)',
|
||||
color: 'var(--color-amber)',
|
||||
fontSize: '0.6rem',
|
||||
padding: '0.05rem 0.35rem',
|
||||
marginLeft: '0.5rem',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
borderRadius: '3px',
|
||||
}}
|
||||
>
|
||||
{post.authorRole}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', flexShrink: 0 }}>
|
||||
{formatDateTime(post.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.85rem', lineHeight: 1.75, marginLeft: '36px' }}>
|
||||
{post.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IntranetFeed() {
|
||||
const { user } = useAuth();
|
||||
const [posts, setPosts] = useState<StaffPost[]>(MOCK_STAFF_POSTS);
|
||||
const [content, setContent] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [posting, setPosting] = useState(false);
|
||||
|
||||
const handlePost = useCallback(async () => {
|
||||
if (!content.trim()) { setError('Post cannot be empty.'); return; }
|
||||
if (content.trim().length < 5) { setError('Post must be at least 5 characters.'); return; }
|
||||
if (!user) return;
|
||||
setError('');
|
||||
setPosting(true);
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
|
||||
const newPost: StaffPost = {
|
||||
id: `sp${Date.now()}`,
|
||||
authorId: user.id,
|
||||
authorName: user.username,
|
||||
authorRole: user.role as 'dev' | 'com',
|
||||
content: content.trim(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setPosts((prev) => [newPost, ...prev]);
|
||||
setContent('');
|
||||
setPosting(false);
|
||||
}, [content, user]);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '720px' }}>
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
|
||||
INTRANET / TEAM FEED
|
||||
</div>
|
||||
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem' }}>TEAM ACTIVITY</h1>
|
||||
<p style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem', marginTop: '0.4rem' }}>
|
||||
Staff-only internal feed. Posts are not visible to the public.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Compose */}
|
||||
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '1.25rem', marginBottom: '1.5rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.7rem', letterSpacing: '0.1em', marginBottom: '0.75rem' }}>
|
||||
[{user?.username} — {user?.role}] Post an update
|
||||
</div>
|
||||
<textarea
|
||||
className={`input-terminal${error ? ' error' : ''}`}
|
||||
rows={3}
|
||||
placeholder="What's happening? Share an update with the team..."
|
||||
value={content}
|
||||
onChange={(e) => { setContent(e.target.value); setError(''); }}
|
||||
style={{ resize: 'vertical', fontSize: '0.85rem', marginBottom: '0.75rem' }}
|
||||
disabled={posting}
|
||||
/>
|
||||
{error && (
|
||||
<div style={{ color: 'var(--color-red)', fontFamily: 'var(--font-mono)', fontSize: '0.72rem', marginBottom: '0.6rem' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem' }}>
|
||||
{content.length} chars
|
||||
</span>
|
||||
<button className="btn-terminal btn-amber" onClick={handlePost} disabled={posting} style={{ opacity: posting ? 0.6 : 1 }}>
|
||||
{posting ? 'Posting...' : '> Post'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feed */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
{posts.map((post) => (
|
||||
<FeedPost key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { MOCK_THREADS, MOCK_REPLIES } from '../../data/mockData';
|
||||
import { formatDateTime } from '../../utils/format';
|
||||
import type { ForumThread, ForumReply } from '../../types';
|
||||
|
||||
export default function IntranetModeration() {
|
||||
const [threads, setThreads] = useState<ForumThread[]>(MOCK_THREADS);
|
||||
const [replies, setReplies] = useState<ForumReply[]>(MOCK_REPLIES);
|
||||
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<'threads' | 'replies'>('threads');
|
||||
|
||||
const filteredThreads = useMemo(() => {
|
||||
if (!search.trim()) return threads;
|
||||
const q = search.toLowerCase();
|
||||
return threads.filter((t) => t.title.toLowerCase().includes(q) || t.authorName.toLowerCase().includes(q));
|
||||
}, [threads, search]);
|
||||
|
||||
const selectedThreadReplies = useMemo(() => {
|
||||
if (!selectedThreadId) return [];
|
||||
return replies.filter((r) => r.threadId === selectedThreadId);
|
||||
}, [replies, selectedThreadId]);
|
||||
|
||||
const deleteThread = useCallback((id: string) => {
|
||||
setThreads((prev) => prev.filter((t) => t.id !== id));
|
||||
setReplies((prev) => prev.filter((r) => r.threadId !== id));
|
||||
if (selectedThreadId === id) setSelectedThreadId(null);
|
||||
}, [selectedThreadId]);
|
||||
|
||||
const togglePin = useCallback((id: string) => {
|
||||
setThreads((prev) => prev.map((t) => t.id === id ? { ...t, isPinned: !t.isPinned } : t));
|
||||
}, []);
|
||||
|
||||
const toggleLock = useCallback((id: string) => {
|
||||
setThreads((prev) => prev.map((t) => t.id === id ? { ...t, isLocked: !t.isLocked } : t));
|
||||
}, []);
|
||||
|
||||
const deleteReply = useCallback((id: string) => {
|
||||
setReplies((prev) => prev.filter((r) => r.id !== id));
|
||||
}, []);
|
||||
|
||||
const recentReplies = useMemo(() => {
|
||||
return [...replies].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()).slice(0, 20);
|
||||
}, [replies]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '1.75rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
|
||||
INTRANET / MODERATION
|
||||
</div>
|
||||
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem', marginBottom: '0.5rem' }}>FORUM MODERATION</h1>
|
||||
<p style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.78rem' }}>
|
||||
{threads.length} threads — {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>
|
||||
);
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { MOCK_USERS, MOCK_THREADS, MOCK_BUGS } from '../../data/mockData';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { formatDate } from '../../utils/format';
|
||||
import type { User, UserRole } from '../../types';
|
||||
|
||||
export default function IntranetUsers() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [users, setUsers] = useState<User[]>(MOCK_USERS);
|
||||
const [search, setSearch] = useState('');
|
||||
const [roleFilter, setRoleFilter] = useState<UserRole | 'all'>('all');
|
||||
const [confirmAction, setConfirmAction] = useState<{ userId: string; action: 'promote' | 'ban' | 'unban' } | null>(null);
|
||||
|
||||
const threadCounts = useMemo(() => {
|
||||
const map: Record<string, number> = {};
|
||||
MOCK_THREADS.forEach((t) => { map[t.authorId] = (map[t.authorId] ?? 0) + 1; });
|
||||
return map;
|
||||
}, []);
|
||||
|
||||
const bugCounts = useMemo(() => {
|
||||
const map: Record<string, number> = {};
|
||||
MOCK_BUGS.forEach((b) => { map[b.submittedById] = (map[b.submittedById] ?? 0) + 1; });
|
||||
return map;
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return users.filter((u) => {
|
||||
const matchSearch = !search.trim() || u.username.toLowerCase().includes(search.toLowerCase()) || u.email.toLowerCase().includes(search.toLowerCase());
|
||||
const matchRole = roleFilter === 'all' || u.role === roleFilter;
|
||||
return matchSearch && matchRole;
|
||||
});
|
||||
}, [users, search, roleFilter]);
|
||||
|
||||
const handlePromote = useCallback((userId: string, targetRole: UserRole) => {
|
||||
setUsers((prev) => prev.map((u) => u.id === userId ? { ...u, role: targetRole } : u));
|
||||
setConfirmAction(null);
|
||||
}, []);
|
||||
|
||||
const handleToggleBan = useCallback((userId: string, ban: boolean) => {
|
||||
setUsers((prev) => prev.map((u) => u.id === userId ? { ...u, isBanned: ban } : u));
|
||||
setConfirmAction(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '1.75rem' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
|
||||
INTRANET / USER MANAGEMENT
|
||||
</div>
|
||||
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem', marginBottom: '1rem' }}>USERS</h1>
|
||||
|
||||
{/* Filters */}
|
||||
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
className="input-terminal"
|
||||
type="search"
|
||||
placeholder="Search username or email..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ maxWidth: '260px' }}
|
||||
/>
|
||||
<select className="input-terminal" style={{ width: 'auto', minWidth: '130px' }} value={roleFilter} onChange={(e) => setRoleFilter(e.target.value as UserRole | 'all')}>
|
||||
<option value="all">All Roles</option>
|
||||
<option value="user">Users</option>
|
||||
<option value="dev">Dev</option>
|
||||
<option value="com">Com</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm dialog */}
|
||||
{confirmAction && (
|
||||
<div style={{ background: 'rgba(0,0,0,0.3)', position: 'fixed', inset: 0, zIndex: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
onClick={() => setConfirmAction(null)}>
|
||||
<div style={{ background: 'var(--color-surface)', border: '2px solid var(--color-yellow)', padding: '2rem', maxWidth: '380px', width: '90%', borderRadius: '8px' }}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<h3 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', marginBottom: '1rem' }}>CONFIRM ACTION</h3>
|
||||
<p style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.82rem', marginBottom: '1.5rem', lineHeight: 1.7 }}>
|
||||
{confirmAction.action === 'promote'
|
||||
? `Promote this user to staff? They will gain access to the intranet.`
|
||||
: confirmAction.action === 'ban'
|
||||
? `Ban this user? They will be unable to login.`
|
||||
: `Unban this user? They will regain access to their account.`}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||
<button
|
||||
className={`btn-terminal ${confirmAction.action === 'ban' ? 'btn-danger' : 'btn-amber'}`}
|
||||
onClick={() => {
|
||||
if (confirmAction.action === 'promote') handlePromote(confirmAction.userId, 'dev');
|
||||
else if (confirmAction.action === 'ban') handleToggleBan(confirmAction.userId, true);
|
||||
else handleToggleBan(confirmAction.userId, false);
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button className="btn-terminal" onClick={() => setConfirmAction(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-mono)', fontSize: '0.78rem' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
|
||||
{['Username', 'Email', 'Role', 'Joined', 'Threads', 'Bugs', 'Status', 'Actions'].map((h) => (
|
||||
<th key={h} style={{ padding: '0.6rem 0.75rem', textAlign: 'left', color: 'var(--color-text-muted)', fontWeight: 'normal', letterSpacing: '0.1em', fontSize: '0.68rem', textTransform: 'uppercase', whiteSpace: 'nowrap' }}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((u) => {
|
||||
const isSelf = u.id === currentUser?.id;
|
||||
return (
|
||||
<tr
|
||||
key={u.id}
|
||||
style={{ borderBottom: '1px solid var(--color-border)', background: u.isBanned ? 'rgba(220,38,38,0.05)' : 'transparent' }}
|
||||
>
|
||||
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-text)', fontWeight: isSelf ? 'bold' : 'normal' }}>
|
||||
{u.username} {isSelf && <span style={{ color: 'var(--color-amber)', fontSize: '0.65rem' }}>(you)</span>}
|
||||
{u.isAdmin && <span style={{ color: 'var(--color-green)', fontSize: '0.62rem', marginLeft: '0.3rem' }}>[admin]</span>}
|
||||
</td>
|
||||
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-text-muted)' }}>{u.email}</td>
|
||||
<td style={{ padding: '0.7rem 0.75rem' }}>
|
||||
<span className={`badge ${u.role === 'dev' ? 'badge-open' : u.role === 'com' ? 'badge-medium' : 'badge-closed'}`}>
|
||||
{u.role}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>{formatDate(u.createdAt)}</td>
|
||||
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-green)', textAlign: 'center' }}>{threadCounts[u.id] ?? 0}</td>
|
||||
<td style={{ padding: '0.7rem 0.75rem', color: 'var(--color-green)', textAlign: 'center' }}>{bugCounts[u.id] ?? 0}</td>
|
||||
<td style={{ padding: '0.7rem 0.75rem' }}>
|
||||
{u.isBanned ? (
|
||||
<span className="badge badge-critical">Banned</span>
|
||||
) : (
|
||||
<span className="badge badge-open">Active</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '0.7rem 0.75rem' }}>
|
||||
{!isSelf && !u.isAdmin && (
|
||||
<div style={{ display: 'flex', gap: '0.35rem', flexWrap: 'nowrap' }}>
|
||||
{u.role === 'user' && currentUser?.isAdmin && (
|
||||
<button
|
||||
className="btn-terminal btn-amber"
|
||||
style={{ padding: '0.15rem 0.5rem', fontSize: '0.62rem' }}
|
||||
onClick={() => setConfirmAction({ userId: u.id, action: 'promote' })}
|
||||
>
|
||||
Promote
|
||||
</button>
|
||||
)}
|
||||
{u.isBanned ? (
|
||||
<button
|
||||
className="btn-terminal"
|
||||
style={{ padding: '0.15rem 0.5rem', fontSize: '0.62rem' }}
|
||||
onClick={() => setConfirmAction({ userId: u.id, action: 'unban' })}
|
||||
>
|
||||
Unban
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn-terminal btn-danger"
|
||||
style={{ padding: '0.15rem 0.5rem', fontSize: '0.62rem' }}
|
||||
onClick={() => setConfirmAction({ userId: u.id, action: 'ban' })}
|
||||
>
|
||||
Ban
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>
|
||||
No users match the current filters.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user