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:
244
packages/client/src/components/AdminPanel2.jsx
Normal file
244
packages/client/src/components/AdminPanel2.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user