From e53ec600ad7fa212201a0876bd3f63077305663c Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 20:53:32 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=204=20=E2=80=94=20Admin=20panel?= =?UTF-8?q?=20(dashboard,=20users,=20moderation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../client/src/components/AdminPanel2.jsx | 244 ++++++++++++++++++ packages/client/src/components/Workshop.jsx | 7 +- packages/client/src/index.css | 94 +++++++ packages/client/src/main.jsx | 7 +- 4 files changed, 348 insertions(+), 4 deletions(-) create mode 100644 packages/client/src/components/AdminPanel2.jsx diff --git a/packages/client/src/components/AdminPanel2.jsx b/packages/client/src/components/AdminPanel2.jsx new file mode 100644 index 0000000..6ffb6f7 --- /dev/null +++ b/packages/client/src/components/AdminPanel2.jsx @@ -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 ( +
+
+
~
+ Admin +
+ {items.map(item => ( + + ))} +
+ +
+ ); +} + +function DashboardView() { + const [stats, setStats] = useState(null); + + useEffect(() => { + adminApi.stats().then(setStats).catch(() => {}); + }, []); + + if (!stats) return

Cargando...

; + + 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 ( +
+

Dashboard

+
+ {kpis.map(k => ( +
+ {k.label} + {k.value} +
+ ))} +
+
+ ); +} + +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 ( +
+

Usuarios

+
+
+ 🔍 + setSearch(e.target.value)} /> +
+
+ + + +
+
+ +
+
+ USUARIO + EMAIL + ROL + REGISTRO + ACCIONES +
+ {users.map(u => ( +
+
+
+ {u.username?.[0]?.toUpperCase()} +
+ {u.username} +
+ {u.email} + + + {u.role === 'premium' ? '★ ' : u.role === 'banned' ? '🚫 ' : ''}{u.role} + + + + {new Date(u.createdAt).toLocaleDateString('es')} + +
+ +
+
+ ))} +
+
+ ); +} + +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 ( +
+

Workshop — Moderacion

+
+
+ + + +
+
+ +
+
+ PATCH + LIKES + ESTADO + ACCIONES +
+ {patches.map(p => ( +
+
+ {p.title} + {p.tags?.length > 0 && ( +
+ {p.tags.map(t => {t})} +
+ )} +
+ ♥ {p.likesCount} + + {p.isDeleted + ? 🚫 Eliminado + : p.isFlagged + ? ⚠ Reportado + : ✓ Activo + } + +
+ {p.isDeleted ? ( + + ) : ( + <> + {p.isFlagged && } + + + )} +
+
+ ))} + {patches.length === 0 && ( +

No hay patches

+ )} +
+
+ ); +} + +export default function AdminPanel2({ onBack }) { + const { isAdmin } = useAuth(); + const [page, setPage] = useState('dashboard'); + + if (!isAdmin) { + return ( +
+
+

🔒

+

Acceso restringido a administradores

+ +
+
+ ); + } + + return ( +
+ +
+ {page === 'dashboard' && } + {page === 'users' && } + {page === 'workshop' && } +
+
+ ); +} diff --git a/packages/client/src/components/Workshop.jsx b/packages/client/src/components/Workshop.jsx index f39abac..05b37bc 100644 --- a/packages/client/src/components/Workshop.jsx +++ b/packages/client/src/components/Workshop.jsx @@ -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 }) {
+ {isAdmin && onSwitchToAdmin && ( + + )} {isLoggedIn ? (
{user?.username?.[0]?.toUpperCase()}
diff --git a/packages/client/src/index.css b/packages/client/src/index.css index cc1765a..6336088 100644 --- a/packages/client/src/index.css +++ b/packages/client/src/index.css @@ -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; diff --git a/packages/client/src/main.jsx b/packages/client/src/main.jsx index fe2d268..11a324c 100644 --- a/packages/client/src/main.jsx +++ b/packages/client/src/main.jsx @@ -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 ( {mode === 'sandbox' && } {mode === 'game' && } - {mode === 'workshop' && } + {mode === 'workshop' && } + {mode === 'admin' && } );