refactor : Remove intranet components along with their associated styles and logic
This commit is contained in:
@@ -3,7 +3,6 @@ import { Routes, Route } from 'react-router-dom';
|
|||||||
import { AuthProvider } from './contexts/AuthContext';
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
import { ProtectedRoute } from './components/shared/ProtectedRoute';
|
import { ProtectedRoute } from './components/shared/ProtectedRoute';
|
||||||
import { PublicLayout } from './components/layout/PublicLayout';
|
import { PublicLayout } from './components/layout/PublicLayout';
|
||||||
import { IntranetLayout } from './components/layout/IntranetLayout';
|
|
||||||
import { PageLoader } from './components/shared/PageLoader';
|
import { PageLoader } from './components/shared/PageLoader';
|
||||||
|
|
||||||
// ── Public Pages (lazy-loaded) ────────────────────────────────────────────────
|
// ── Public Pages (lazy-loaded) ────────────────────────────────────────────────
|
||||||
@@ -20,15 +19,6 @@ const LoginPage = lazy(() => import('./pages/public/LoginPage'));
|
|||||||
const RegisterPage = lazy(() => import('./pages/public/RegisterPage'));
|
const RegisterPage = lazy(() => import('./pages/public/RegisterPage'));
|
||||||
const NotFoundPage = lazy(() => import('./pages/public/NotFoundPage'));
|
const NotFoundPage = lazy(() => import('./pages/public/NotFoundPage'));
|
||||||
|
|
||||||
// ── Intranet Pages (lazy-loaded) ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
const IntranetDashboard = lazy(() => import('./pages/intranet/IntranetDashboard'));
|
|
||||||
const IntranetBugs = lazy(() => import('./pages/intranet/IntranetBugs'));
|
|
||||||
const IntranetFeed = lazy(() => import('./pages/intranet/IntranetFeed'));
|
|
||||||
const IntranetEvents = lazy(() => import('./pages/intranet/IntranetEvents'));
|
|
||||||
const IntranetUsers = lazy(() => import('./pages/intranet/IntranetUsers'));
|
|
||||||
const IntranetModeration = lazy(() => import('./pages/intranet/IntranetModeration'));
|
|
||||||
|
|
||||||
// ── App ────────────────────────────────────────────────────────────────────────
|
// ── App ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -57,23 +47,6 @@ export default function App() {
|
|||||||
<Route path="register" element={<RegisterPage />} />
|
<Route path="register" element={<RegisterPage />} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Intranet Routes — staff only */}
|
|
||||||
<Route
|
|
||||||
path="intranet"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute staffOnly redirectTo="/">
|
|
||||||
<IntranetLayout />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Route index element={<IntranetDashboard />} />
|
|
||||||
<Route path="bugs" element={<IntranetBugs />} />
|
|
||||||
<Route path="feed" element={<IntranetFeed />} />
|
|
||||||
<Route path="events" element={<IntranetEvents />} />
|
|
||||||
<Route path="users" element={<IntranetUsers />} />
|
|
||||||
<Route path="moderation" element={<IntranetModeration />} />
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
|
||||||
import { useCallback } from 'react';
|
|
||||||
|
|
||||||
const INTRANET_LINKS = [
|
|
||||||
{ to: '/intranet', label: 'Dashboard', icon: '[>]', end: true },
|
|
||||||
{ to: '/intranet/bugs', label: 'Bug Reports', icon: '[!]', end: false },
|
|
||||||
{ to: '/intranet/feed', label: 'Team Feed', icon: '[~]', end: false },
|
|
||||||
{ to: '/intranet/events', label: 'Events', icon: '[E]', end: false },
|
|
||||||
{ to: '/intranet/users', label: 'Users', icon: '[U]', end: false },
|
|
||||||
{ to: '/intranet/moderation', label: 'Moderation', icon: '[M]', end: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function IntranetLayout() {
|
|
||||||
const { user, logout } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const handleLogout = useCallback(() => {
|
|
||||||
logout();
|
|
||||||
navigate('/');
|
|
||||||
}, [logout, navigate]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', minHeight: '100vh', background: 'var(--color-bg)' }}>
|
|
||||||
{/* Sidebar */}
|
|
||||||
<aside
|
|
||||||
style={{
|
|
||||||
width: '220px',
|
|
||||||
flexShrink: 0,
|
|
||||||
background: 'var(--color-bg-alt)',
|
|
||||||
borderRight: '2px solid var(--color-border)',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
padding: '1.5rem 0',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Logo */}
|
|
||||||
<div style={{ padding: '0 1.25rem 1.25rem', borderBottom: '1px solid var(--color-border)' }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: 'var(--font-heading)',
|
|
||||||
color: 'var(--color-yellow)',
|
|
||||||
fontSize: '1.3rem',
|
|
||||||
letterSpacing: '0.08em',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
INTRANET
|
|
||||||
</div>
|
|
||||||
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.62rem', marginTop: '2px' }}>
|
|
||||||
CROWMATE STUDIO
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Nav links */}
|
|
||||||
<nav style={{ flex: 1, padding: '0.75rem 0' }}>
|
|
||||||
{INTRANET_LINKS.map(({ to, label, icon, end }) => (
|
|
||||||
<NavLink
|
|
||||||
key={to}
|
|
||||||
to={to}
|
|
||||||
end={end}
|
|
||||||
style={({ isActive }) => ({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.6rem',
|
|
||||||
padding: '0.55rem 1.25rem',
|
|
||||||
fontFamily: 'var(--font-mono)',
|
|
||||||
fontSize: '0.78rem',
|
|
||||||
color: isActive ? 'var(--color-yellow)' : 'var(--color-text-muted)',
|
|
||||||
background: isActive ? 'rgba(37,99,235,0.08)' : 'transparent',
|
|
||||||
borderLeft: isActive ? '3px solid var(--color-yellow)' : '3px solid transparent',
|
|
||||||
textDecoration: 'none',
|
|
||||||
transition: 'color 0.1s, background 0.1s',
|
|
||||||
letterSpacing: '0.05em',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<span style={{ opacity: 0.6, fontSize: '0.68rem' }}>{icon}</span>
|
|
||||||
{label}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* User info */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '1rem 1.25rem',
|
|
||||||
borderTop: '1px solid var(--color-border)',
|
|
||||||
fontFamily: 'var(--font-mono)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ color: 'var(--color-text-dim)', fontSize: '0.75rem', marginBottom: '0.4rem' }}>
|
|
||||||
{user?.username}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'inline-block',
|
|
||||||
background: 'rgba(255,255,0,0.08)',
|
|
||||||
border: '1px solid var(--color-yellow)',
|
|
||||||
color: 'var(--color-yellow)',
|
|
||||||
fontSize: '0.6rem',
|
|
||||||
padding: '0.1rem 0.4rem',
|
|
||||||
letterSpacing: '0.1em',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
marginBottom: '0.75rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user?.role}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
style={{
|
|
||||||
background: 'transparent',
|
|
||||||
border: '1px solid var(--color-red)',
|
|
||||||
color: 'var(--color-red)',
|
|
||||||
fontFamily: 'var(--font-mono)',
|
|
||||||
fontSize: '0.63rem',
|
|
||||||
padding: '0.2rem 0.5rem',
|
|
||||||
cursor: 'pointer',
|
|
||||||
letterSpacing: '0.05em',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
<NavLink
|
|
||||||
to="/"
|
|
||||||
style={{
|
|
||||||
background: 'transparent',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
color: 'var(--color-text-muted)',
|
|
||||||
fontFamily: 'var(--font-mono)',
|
|
||||||
fontSize: '0.63rem',
|
|
||||||
padding: '0.2rem 0.5rem',
|
|
||||||
letterSpacing: '0.05em',
|
|
||||||
textDecoration: 'none',
|
|
||||||
display: 'inline-block',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Public
|
|
||||||
</NavLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<main style={{ flex: 1, overflowY: 'auto', padding: '2rem', background: 'var(--color-bg)' }}>
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,7 @@ const NAV_LINKS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const { user, isAuthenticated, isStaff, logout } = useAuth();
|
const { user, isAuthenticated, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
@@ -90,42 +90,6 @@ export function Navbar() {
|
|||||||
{label}
|
{label}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Intranet — visually separated, highlighted button */}
|
|
||||||
{isStaff && (
|
|
||||||
<>
|
|
||||||
{/* Vertical divider */}
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
display: 'inline-block',
|
|
||||||
width: '1px',
|
|
||||||
height: '18px',
|
|
||||||
background: 'var(--color-border)',
|
|
||||||
margin: '0 0.25rem',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<NavLink
|
|
||||||
to="/intranet"
|
|
||||||
style={({ isActive }) => ({
|
|
||||||
fontFamily: 'var(--font-mono)',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.1em',
|
|
||||||
textDecoration: 'none',
|
|
||||||
background: isActive ? 'var(--color-yellow)' : 'var(--color-yellow)',
|
|
||||||
color: 'var(--color-bg)',
|
|
||||||
border: '2px solid var(--color-yellow)',
|
|
||||||
padding: '0.2rem 0.65rem',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
opacity: isActive ? 1 : 0.85,
|
|
||||||
transition: 'opacity 0.1s',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
▮ INTRANET
|
|
||||||
</NavLink>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop Auth */}
|
{/* Desktop Auth */}
|
||||||
@@ -230,32 +194,6 @@ export function Navbar() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Intranet button — mobile: separated at the bottom */}
|
|
||||||
{isStaff && (
|
|
||||||
<div style={{ borderTop: '2px solid var(--color-yellow)', paddingTop: '0.85rem' }}>
|
|
||||||
<NavLink
|
|
||||||
to="/intranet"
|
|
||||||
onClick={closeMenu}
|
|
||||||
style={({ isActive }) => ({
|
|
||||||
display: 'inline-block',
|
|
||||||
fontFamily: 'var(--font-mono)',
|
|
||||||
fontSize: '0.8rem',
|
|
||||||
textTransform: 'uppercase' as const,
|
|
||||||
letterSpacing: '0.1em',
|
|
||||||
textDecoration: 'none',
|
|
||||||
background: 'var(--color-yellow)',
|
|
||||||
color: 'var(--color-bg)',
|
|
||||||
border: '2px solid var(--color-yellow)',
|
|
||||||
padding: '0.3rem 0.85rem',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
opacity: isActive ? 0.85 : 1,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
▮ INTRANET
|
|
||||||
</NavLink>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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