feat: Phase 4 — Admin panel (dashboard, users, moderation)

AdminPanel2 component with sidebar navigation:
- Dashboard: KPI cards (users, patches, premium, flagged)
- Users: search, filter by role, table with role dropdown to
  change user/premium/admin/banned per user
- Workshop moderation: filter flagged/deleted, approve/delete/restore
  actions per patch with status badges

Features:
- Role-protected: non-admins see 🔒 locked screen
- Sidebar nav: Dashboard / Usuarios / Workshop / Volver
- Admin button visible in Workshop nav for admin users
- Responsive: sidebar becomes horizontal tabs on mobile,
  KPIs 2x2 grid, table rows wrap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-21 20:53:32 +01:00
parent c673745b09
commit e53ec600ad
4 changed files with 348 additions and 4 deletions

View File

@@ -0,0 +1,244 @@
import React, { useState, useEffect, useCallback } from 'react';
import { admin as adminApi } from '../services/api.js';
import { useAuth } from '../services/AuthContext.jsx';
function Sidebar({ active, onNavigate, onBack }) {
const items = [
{ id: 'dashboard', icon: '📊', label: 'Dashboard' },
{ id: 'users', icon: '👥', label: 'Usuarios' },
{ id: 'workshop', icon: '🎛', label: 'Workshop' },
];
return (
<div className="adm-sidebar">
<div className="adm-sidebar-logo">
<div className="auth-logo-box" style={{ width: 28, height: 28, fontSize: 14 }}>~</div>
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text)' }}>Admin</span>
</div>
{items.map(item => (
<button key={item.id}
className={`adm-sidebar-item ${active === item.id ? 'active' : ''}`}
onClick={() => onNavigate(item.id)}>
<span>{item.icon}</span> {item.label}
</button>
))}
<div style={{ flex: 1 }} />
<button className="adm-sidebar-item" onClick={onBack}>
Volver a la app
</button>
</div>
);
}
function DashboardView() {
const [stats, setStats] = useState(null);
useEffect(() => {
adminApi.stats().then(setStats).catch(() => {});
}, []);
if (!stats) return <p style={{ color: 'var(--text2)' }}>Cargando...</p>;
const kpis = [
{ label: 'USUARIOS TOTALES', value: stats.users, color: 'var(--text)' },
{ label: 'PATCHES COMPARTIDOS', value: stats.patches, color: 'var(--text)' },
{ label: 'PREMIUM', value: stats.premium, color: 'var(--yellow)' },
{ label: 'REPORTADOS', value: stats.flagged, color: 'var(--red)' },
];
return (
<div>
<h2 className="adm-page-title">Dashboard</h2>
<div className="adm-kpi-grid">
{kpis.map(k => (
<div key={k.label} className="adm-kpi-card">
<span className="adm-kpi-label">{k.label}</span>
<span className="adm-kpi-value" style={{ color: k.color }}>{k.value}</span>
</div>
))}
</div>
</div>
);
}
function UsersView() {
const [users, setUsers] = useState([]);
const [search, setSearch] = useState('');
const [filter, setFilter] = useState('');
const load = useCallback(async () => {
const params = new URLSearchParams();
if (search) params.set('q', search);
if (filter) params.set('role', filter);
const data = await adminApi.users(params.toString());
setUsers(data.users || []);
}, [search, filter]);
useEffect(() => { load(); }, [load]);
const changeRole = async (id, role) => {
await adminApi.updateUser(id, { role });
load();
};
return (
<div>
<h2 className="adm-page-title">Usuarios</h2>
<div className="adm-toolbar">
<div className="ws-search" style={{ flex: 1 }}>
<span>🔍</span>
<input placeholder="Buscar usuario..." value={search}
onChange={e => setSearch(e.target.value)} />
</div>
<div className="ws-tags">
<button className={`ws-tag ${!filter ? 'active' : ''}`} onClick={() => setFilter('')}>Todos</button>
<button className={`ws-tag ${filter === 'premium' ? 'active' : ''}`} onClick={() => setFilter('premium')} style={{ color: 'var(--yellow)' }}>Premium</button>
<button className={`ws-tag ${filter === 'banned' ? 'active' : ''}`} onClick={() => setFilter('banned')} style={{ color: 'var(--red)' }}>Banned</button>
</div>
</div>
<div className="adm-table">
<div className="adm-table-head">
<span className="adm-col-grow">USUARIO</span>
<span className="adm-col-md">EMAIL</span>
<span className="adm-col-sm">ROL</span>
<span className="adm-col-sm">REGISTRO</span>
<span className="adm-col-xs">ACCIONES</span>
</div>
{users.map(u => (
<div key={u.id} className={`adm-table-row ${u.role === 'banned' ? 'banned' : ''}`}>
<div className="adm-col-grow adm-user-cell">
<div className="user-avatar" style={{ background: u.role === 'banned' ? 'var(--red)' : 'var(--accent)' }}>
{u.username?.[0]?.toUpperCase()}
</div>
<span>{u.username}</span>
</div>
<span className="adm-col-md adm-text-muted">{u.email}</span>
<span className="adm-col-sm">
<span className={`adm-role-badge ${u.role}`}>
{u.role === 'premium' ? '★ ' : u.role === 'banned' ? '🚫 ' : ''}{u.role}
</span>
</span>
<span className="adm-col-sm adm-text-muted">
{new Date(u.createdAt).toLocaleDateString('es')}
</span>
<div className="adm-col-xs">
<select className="adm-action-select" value={u.role}
onChange={e => changeRole(u.id, e.target.value)}>
<option value="user">User</option>
<option value="premium">Premium</option>
<option value="admin">Admin</option>
<option value="banned">Banned</option>
</select>
</div>
</div>
))}
</div>
</div>
);
}
function WorkshopModView() {
const [patches, setPatches] = useState([]);
const [filter, setFilter] = useState('');
const load = useCallback(async () => {
const params = new URLSearchParams();
if (filter === 'flagged') params.set('flagged', 'true');
if (filter === 'deleted') params.set('deleted', 'true');
const data = await adminApi.patches(params.toString());
setPatches(data.patches || []);
}, [filter]);
useEffect(() => { load(); }, [load]);
const moderate = async (id, action) => {
await adminApi.updatePatch(id, { action });
load();
};
return (
<div>
<h2 className="adm-page-title">Workshop Moderacion</h2>
<div className="adm-toolbar">
<div className="ws-tags">
<button className={`ws-tag ${!filter ? 'active' : ''}`} onClick={() => setFilter('')}>Todos</button>
<button className={`ws-tag ${filter === 'flagged' ? 'active' : ''}`}
onClick={() => setFilter('flagged')} style={{ color: 'var(--yellow)' }}> Reportados</button>
<button className={`ws-tag ${filter === 'deleted' ? 'active' : ''}`}
onClick={() => setFilter('deleted')} style={{ color: 'var(--red)' }}>🚫 Eliminados</button>
</div>
</div>
<div className="adm-table">
<div className="adm-table-head">
<span className="adm-col-grow">PATCH</span>
<span className="adm-col-sm">LIKES</span>
<span className="adm-col-sm">ESTADO</span>
<span className="adm-col-xs">ACCIONES</span>
</div>
{patches.map(p => (
<div key={p.id} className={`adm-table-row ${p.isDeleted ? 'banned' : ''}`}>
<div className="adm-col-grow">
<strong style={{ color: 'var(--text)' }}>{p.title}</strong>
{p.tags?.length > 0 && (
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
{p.tags.map(t => <span key={t} className="ws-tag-pill">{t}</span>)}
</div>
)}
</div>
<span className="adm-col-sm" style={{ color: 'var(--red)' }}> {p.likesCount}</span>
<span className="adm-col-sm">
{p.isDeleted
? <span className="adm-role-badge banned">🚫 Eliminado</span>
: p.isFlagged
? <span className="adm-role-badge" style={{ background: 'rgba(255,204,0,0.15)', color: 'var(--yellow)' }}> Reportado</span>
: <span className="adm-role-badge" style={{ background: 'rgba(68,255,136,0.15)', color: 'var(--green)' }}> Activo</span>
}
</span>
<div className="adm-col-xs adm-actions">
{p.isDeleted ? (
<button className="adm-act-btn green" onClick={() => moderate(p.id, 'restore')}>Restaurar</button>
) : (
<>
{p.isFlagged && <button className="adm-act-btn green" onClick={() => moderate(p.id, 'unflag')}>Aprobar</button>}
<button className="adm-act-btn red" onClick={() => moderate(p.id, 'delete')}>Eliminar</button>
</>
)}
</div>
</div>
))}
{patches.length === 0 && (
<p style={{ padding: 20, color: 'var(--text2)', textAlign: 'center' }}>No hay patches</p>
)}
</div>
</div>
);
}
export default function AdminPanel2({ onBack }) {
const { isAdmin } = useAuth();
const [page, setPage] = useState('dashboard');
if (!isAdmin) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', background: 'var(--bg)' }}>
<div style={{ textAlign: 'center', color: 'var(--text2)' }}>
<p style={{ fontSize: 48 }}>🔒</p>
<p>Acceso restringido a administradores</p>
<button className="login-btn" onClick={onBack} style={{ marginTop: 16 }}>Volver</button>
</div>
</div>
);
}
return (
<div className="adm-layout">
<Sidebar active={page} onNavigate={setPage} onBack={onBack} />
<div className="adm-main">
{page === 'dashboard' && <DashboardView />}
{page === 'users' && <UsersView />}
{page === 'workshop' && <WorkshopModView />}
</div>
</div>
);
}

View File

@@ -142,8 +142,8 @@ function PatchCard({ patch, onLoad, onLike }) {
);
}
export default function Workshop({ onSwitchToSandbox, onSwitchToGame }) {
const { isLoggedIn, openAuth, logout, user } = useAuth();
export default function Workshop({ onSwitchToSandbox, onSwitchToGame, onSwitchToAdmin }) {
const { isLoggedIn, isAdmin, openAuth, logout, user } = useAuth();
const [patches, setPatches] = useState([]);
const [search, setSearch] = useState('');
const [activeTag, setActiveTag] = useState('');
@@ -209,6 +209,9 @@ export default function Workshop({ onSwitchToSandbox, onSwitchToGame }) {
<button className="ws-nav-tab active">Workshop</button>
</div>
<div style={{ flex: 1 }} />
{isAdmin && onSwitchToAdmin && (
<button className="ws-nav-tab" onClick={onSwitchToAdmin} style={{ color: 'var(--yellow)' }}>🛠 Admin</button>
)}
{isLoggedIn ? (
<div className="user-badge" onClick={logout} title="Cerrar sesion">
<div className="user-avatar">{user?.username?.[0]?.toUpperCase()}</div>

View File

@@ -1026,6 +1026,100 @@ input, textarea, [contenteditable] { -webkit-user-select: text; user-select: tex
.auth-card { padding: 24px 20px; max-height: 90vh; overflow-y: auto; }
}
/* ===== Admin Panel v2 ===== */
.adm-layout { display: flex; height: 100vh; background: var(--bg); }
.adm-sidebar {
width: 220px; background: var(--panel); display: flex; flex-direction: column;
padding: 20px 16px; gap: 4px; flex-shrink: 0;
}
.adm-sidebar-logo { display: flex; align-items: center; gap: 8px; padding-bottom: 16px; }
.adm-sidebar-item {
display: flex; align-items: center; gap: 10px; padding: 10px 12px;
border: none; border-radius: 6px; background: transparent; color: var(--text2);
font-size: 13px; font-weight: 500; cursor: pointer; font-family: inherit;
text-align: left; width: 100%; transition: all 0.15s;
}
.adm-sidebar-item:hover { color: var(--text); }
.adm-sidebar-item.active { background: var(--surface); color: var(--accent); font-weight: 600; }
.adm-main { flex: 1; padding: 24px 32px; overflow-y: auto; }
.adm-page-title { font-size: 24px; font-weight: 700; color: var(--text); margin: 0 0 20px; }
.adm-kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
.adm-kpi-card {
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 20px; display: flex; flex-direction: column; gap: 4px;
}
.adm-kpi-label { font-size: 10px; font-weight: 700; color: var(--text2); letter-spacing: 1px; }
.adm-kpi-value { font-size: 32px; font-weight: 700; font-family: 'JetBrains Mono', monospace; }
.adm-toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
.adm-table {
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
overflow: hidden;
}
.adm-table-head {
display: flex; padding: 12px 20px; gap: 12px; align-items: center;
border-bottom: 1px solid var(--border);
}
.adm-table-head span {
font-size: 10px; font-weight: 700; color: var(--text2); letter-spacing: 1px;
}
.adm-table-row {
display: flex; padding: 10px 20px; gap: 12px; align-items: center;
border-bottom: 1px solid rgba(37,37,69,0.5);
}
.adm-table-row:last-child { border-bottom: none; }
.adm-table-row.banned { opacity: 0.4; }
.adm-col-grow { flex: 1; min-width: 0; }
.adm-col-md { width: 200px; flex-shrink: 0; }
.adm-col-sm { width: 120px; flex-shrink: 0; }
.adm-col-xs { width: 100px; flex-shrink: 0; }
.adm-text-muted { font-size: 12px; color: var(--text2); }
.adm-user-cell { display: flex; align-items: center; gap: 8px; }
.adm-user-cell span { font-size: 12px; font-weight: 500; color: var(--text); }
.adm-role-badge {
display: inline-block; padding: 3px 8px; border-radius: 10px;
font-size: 10px; font-weight: 600;
}
.adm-role-badge.user { background: rgba(100,116,139,0.15); color: var(--text2); }
.adm-role-badge.premium { background: rgba(255,204,0,0.15); color: var(--yellow); }
.adm-role-badge.admin { background: rgba(0,229,255,0.15); color: var(--accent); }
.adm-role-badge.banned { background: rgba(255,68,102,0.15); color: var(--red); }
.adm-action-select {
padding: 4px 8px; background: var(--bg); border: 1px solid var(--border);
border-radius: 4px; color: var(--text); font-size: 11px; cursor: pointer;
font-family: inherit;
}
.adm-action-select:focus { outline: none; border-color: var(--accent); }
.adm-actions { display: flex; gap: 6px; }
.adm-act-btn {
padding: 4px 10px; border-radius: 4px; border: 1px solid var(--border);
background: var(--bg); cursor: pointer; font-size: 10px; font-family: inherit;
}
.adm-act-btn.green { border-color: var(--green); color: var(--green); }
.adm-act-btn.red { border-color: var(--red); color: var(--red); }
@media (max-width: 768px) {
.adm-layout { flex-direction: column; }
.adm-sidebar { width: 100%; flex-direction: row; padding: 8px 12px; gap: 4px; overflow-x: auto; }
.adm-sidebar-logo { display: none; }
.adm-sidebar-item { white-space: nowrap; padding: 8px 12px; font-size: 12px; }
.adm-main { padding: 16px; }
.adm-kpi-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; }
.adm-kpi-value { font-size: 24px; }
.adm-table-head { display: none; }
.adm-table-row { flex-wrap: wrap; gap: 6px; }
.adm-col-md, .adm-col-sm, .adm-col-xs { width: auto; flex-shrink: 1; }
.adm-col-grow { width: 100%; }
}
/* ===== Fullscreen Keyboard ===== */
.keyboard-fullscreen {
position: fixed; inset: 0; z-index: 500;

View File

@@ -3,24 +3,27 @@ import { createRoot } from 'react-dom/client';
import App from './App';
import GameApp from './game/GameApp.jsx';
import Workshop from './components/Workshop.jsx';
import AdminPanel2 from './components/AdminPanel2.jsx';
import { AuthProvider } from './services/AuthContext.jsx';
import AuthModal from './components/AuthModal.jsx';
import './index.css';
function Root() {
const [mode, setMode] = useState('game'); // 'game' | 'sandbox' | 'workshop'
const [mode, setMode] = useState('game'); // 'game' | 'sandbox' | 'workshop' | 'admin'
const nav = {
toGame: () => setMode('game'),
toSandbox: () => setMode('sandbox'),
toWorkshop: () => setMode('workshop'),
toAdmin: () => setMode('admin'),
};
return (
<AuthProvider>
{mode === 'sandbox' && <App onSwitchToGame={nav.toGame} onSwitchToWorkshop={nav.toWorkshop} />}
{mode === 'game' && <GameApp onSwitchToSandbox={nav.toSandbox} onSwitchToWorkshop={nav.toWorkshop} />}
{mode === 'workshop' && <Workshop onSwitchToSandbox={nav.toSandbox} onSwitchToGame={nav.toGame} />}
{mode === 'workshop' && <Workshop onSwitchToSandbox={nav.toSandbox} onSwitchToGame={nav.toGame} onSwitchToAdmin={nav.toAdmin} />}
{mode === 'admin' && <AdminPanel2 onBack={nav.toGame} />}
<AuthModal />
</AuthProvider>
);