From 12569dba7620876a47cc6ec9d24cd6a766f3c715 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 21:05:36 +0100 Subject: [PATCH] feat: Admin SynthQuest level management + user dropdown with admin access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SynthQuest admin: - New "🎮 SynthQuest" section in admin sidebar - List custom levels with world, ID, title, patch status - Create new level: world selector, title, subtitle, description, concept (hint), available modules (tag input), boss flag, sort order - Edit existing levels inline - Import patch base from sandbox JSON export (📥 button per level) - Delete levels with confirmation Server: - custom_levels table (PostgreSQL) - CRUD API at /api/v1/admin/levels - POST /:id/import-patch to import sandbox JSON as preplaced modules Admin access: - User badge is now a hover dropdown with "🛠 Admin" + "Cerrar sesion" - Admin visible in Sandbox toolbar, Workshop nav, and user dropdown - onSwitchToAdmin passed through navigation chain Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/client/src/App.jsx | 16 +- .../client/src/components/AdminPanel2.jsx | 226 +++++++++++++++++- packages/client/src/index.css | 15 ++ packages/client/src/main.jsx | 2 +- packages/client/src/services/api.js | 9 + packages/server/src/db/schema.js | 17 ++ packages/server/src/index.js | 2 + packages/server/src/routes/levels.js | 95 ++++++++ 8 files changed, 374 insertions(+), 8 deletions(-) create mode 100644 packages/server/src/routes/levels.js diff --git a/packages/client/src/App.jsx b/packages/client/src/App.jsx index b12a535..d7490e6 100644 --- a/packages/client/src/App.jsx +++ b/packages/client/src/App.jsx @@ -15,14 +15,14 @@ import { usePinchZoom } from './hooks/usePinchZoom.js'; import { getModulesByCategory } from './engine/moduleRegistry.js'; import { useAuth } from './services/AuthContext.jsx'; -export default function App({ onSwitchToGame, onSwitchToWorkshop }) { +export default function App({ onSwitchToGame, onSwitchToWorkshop, onSwitchToAdmin }) { const [, forceUpdate] = useState(0); const containerRef = useRef(null); const portPositions = useRef({}); const [tempWire, setTempWire] = useState(null); const connectingRef = useRef(null); const [presetModal, setPresetModal] = useState(null); - const { user, isLoggedIn, openAuth, logout } = useAuth(); + const { user, isLoggedIn, isAdmin, openAuth, logout } = useAuth(); const importRef = useRef(null); const isMobile = useIsMobile(); const [menuOpen, setMenuOpen] = useState(false); @@ -324,9 +324,15 @@ export default function App({ onSwitchToGame, onSwitchToWorkshop }) { )} {isLoggedIn ? ( -
-
{user.username?.[0]?.toUpperCase()}
- {user.username} +
+
+
{user.username?.[0]?.toUpperCase()}
+ {user.username} +
+
+ {isAdmin && onSwitchToAdmin && } + +
) : ( diff --git a/packages/client/src/components/AdminPanel2.jsx b/packages/client/src/components/AdminPanel2.jsx index 6ffb6f7..ea7065c 100644 --- a/packages/client/src/components/AdminPanel2.jsx +++ b/packages/client/src/components/AdminPanel2.jsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { admin as adminApi } from '../services/api.js'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { admin as adminApi, levels as levelsApi } from '../services/api.js'; import { useAuth } from '../services/AuthContext.jsx'; function Sidebar({ active, onNavigate, onBack }) { @@ -7,6 +7,7 @@ function Sidebar({ active, onNavigate, onBack }) { { id: 'dashboard', icon: '📊', label: 'Dashboard' }, { id: 'users', icon: '👥', label: 'Usuarios' }, { id: 'workshop', icon: '🎛', label: 'Workshop' }, + { id: 'levels', icon: '🎮', label: 'SynthQuest' }, ]; return (
@@ -215,6 +216,226 @@ function WorkshopModView() { ); } +function LevelsView() { + const [levels, setLevels] = useState([]); + const [editing, setEditing] = useState(null); // level being edited + const [showCreate, setShowCreate] = useState(false); + const fileRef = useRef(null); + + const load = useCallback(async () => { + try { + const data = await levelsApi.list(); + setLevels(data.levels || []); + } catch {} + }, []); + + useEffect(() => { load(); }, [load]); + + const handleCreate = async (form) => { + await levelsApi.create(form); + setShowCreate(false); + load(); + }; + + const handleUpdate = async (id, form) => { + await levelsApi.update(id, form); + setEditing(null); + load(); + }; + + const handleDelete = async (id) => { + if (!confirm('Eliminar este nivel?')) return; + await levelsApi.remove(id); + load(); + }; + + const handleImportPatch = async (levelId) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + input.onchange = async (e) => { + const file = e.target.files[0]; + if (!file) return; + const text = await file.text(); + try { + const data = JSON.parse(text); + await levelsApi.importPatch(levelId, { + modules: data.modules || [], + connections: data.connections || [], + }); + load(); + } catch (err) { + alert('Error importando: ' + err.message); + } + }; + input.click(); + }; + + return ( +
+
+

SynthQuest — Niveles

+ + {levels.length} custom + +
+ +
+ + {showCreate && ( + setShowCreate(false)} /> + )} + + {editing && ( + handleUpdate(editing.id, form)} onCancel={() => setEditing(null)} /> + )} + +
+
+ MUNDO + ID + TITULO + PATCH + ACCIONES +
+ {levels.map(lvl => ( +
+ + {lvl.worldId} + + + {lvl.levelId} + +
+ + {lvl.isBoss ? '👑 ' : ''}{lvl.title} + + {lvl.subtitle &&
{lvl.subtitle}
} +
+ + {lvl.preplacedData ? ( + + ✓ {lvl.preplacedData.modules?.length || 0} modules + + ) : ( + + )} + +
+ + +
+
+ ))} + {levels.length === 0 && ( +

+ No hay niveles custom. Los 96 niveles base estan hardcoded en el codigo. +

+ )} +
+
+ ); +} + +function LevelForm({ level, onSave, onCancel }) { + const [form, setForm] = useState({ + worldId: level?.worldId || 'w1', + levelId: level?.levelId || '', + title: level?.title || '', + subtitle: level?.subtitle || '', + description: level?.description || '', + concept: level?.concept || '', + availableModules: level?.availableModules || [], + isBoss: level?.isBoss || false, + sortOrder: level?.sortOrder || 0, + }); + const [modInput, setModInput] = useState(''); + + const set = (k, v) => setForm(f => ({ ...f, [k]: v })); + + const addMod = () => { + if (modInput.trim() && !form.availableModules.includes(modInput.trim())) { + set('availableModules', [...form.availableModules, modInput.trim()]); + setModInput(''); + } + }; + + const removeMod = (m) => set('availableModules', form.availableModules.filter(x => x !== m)); + + return ( +
+

+ {level ? 'Editar Nivel' : 'Nuevo Nivel'} +

+
+
+ + +
+
+ + set('levelId', e.target.value)} + placeholder="w1-9" disabled={!!level} /> +
+
+ + set('title', e.target.value)} /> +
+
+ + set('subtitle', e.target.value)} /> +
+
+ +