feat: Admin SynthQuest level management + user dropdown with admin access

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) <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-21 21:05:36 +01:00
parent f43a315047
commit 12569dba76
8 changed files with 374 additions and 8 deletions

View File

@@ -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 }) {
</span>
)}
{isLoggedIn ? (
<div className="user-badge" onClick={logout} title="Cerrar sesion">
<div className="user-avatar">{user.username?.[0]?.toUpperCase()}</div>
<span className="user-name">{user.username}</span>
<div className="user-dropdown">
<div className="user-badge">
<div className="user-avatar">{user.username?.[0]?.toUpperCase()}</div>
<span className="user-name">{user.username}</span>
</div>
<div className="user-dropdown-menu">
{isAdmin && onSwitchToAdmin && <button onClick={onSwitchToAdmin}>🛠 Admin</button>}
<button onClick={logout}>Cerrar sesion</button>
</div>
</div>
) : (
<button className="login-btn" onClick={openAuth}>Entrar</button>

View File

@@ -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 (
<div className="adm-sidebar">
@@ -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 (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
<h2 className="adm-page-title" style={{ margin: 0 }}>SynthQuest Niveles</h2>
<span style={{ fontSize: 11, color: 'var(--text2)', background: 'var(--surface)', padding: '4px 10px', borderRadius: 12 }}>
{levels.length} custom
</span>
<div style={{ flex: 1 }} />
<button className="ws-share-btn" onClick={() => setShowCreate(true)}>+ Nuevo Nivel</button>
</div>
{showCreate && (
<LevelForm onSave={handleCreate} onCancel={() => setShowCreate(false)} />
)}
{editing && (
<LevelForm level={editing} onSave={(form) => handleUpdate(editing.id, form)} onCancel={() => setEditing(null)} />
)}
<div className="adm-table">
<div className="adm-table-head">
<span className="adm-col-xs">MUNDO</span>
<span className="adm-col-sm">ID</span>
<span className="adm-col-grow">TITULO</span>
<span className="adm-col-sm">PATCH</span>
<span className="adm-col-xs">ACCIONES</span>
</div>
{levels.map(lvl => (
<div key={lvl.id} className="adm-table-row">
<span className="adm-col-xs" style={{ fontFamily: 'JetBrains Mono', fontSize: 11, color: 'var(--accent)' }}>
{lvl.worldId}
</span>
<span className="adm-col-sm" style={{ fontFamily: 'JetBrains Mono', fontSize: 11, color: 'var(--text2)' }}>
{lvl.levelId}
</span>
<div className="adm-col-grow">
<strong style={{ color: lvl.isBoss ? 'var(--yellow)' : 'var(--text)', fontSize: 13 }}>
{lvl.isBoss ? '👑 ' : ''}{lvl.title}
</strong>
{lvl.subtitle && <div style={{ fontSize: 11, color: 'var(--text2)' }}>{lvl.subtitle}</div>}
</div>
<span className="adm-col-sm">
{lvl.preplacedData ? (
<span style={{ fontSize: 10, color: 'var(--green)' }}>
{lvl.preplacedData.modules?.length || 0} modules
</span>
) : (
<button className="adm-act-btn green" onClick={() => handleImportPatch(lvl.id)}>
📥 Importar
</button>
)}
</span>
<div className="adm-col-xs adm-actions">
<button className="adm-act-btn" style={{ borderColor: 'var(--accent)', color: 'var(--accent)' }}
onClick={() => setEditing(lvl)}>Editar</button>
<button className="adm-act-btn red" onClick={() => handleDelete(lvl.id)}></button>
</div>
</div>
))}
{levels.length === 0 && (
<p style={{ padding: 20, color: 'var(--text2)', textAlign: 'center' }}>
No hay niveles custom. Los 96 niveles base estan hardcoded en el codigo.
</p>
)}
</div>
</div>
);
}
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 (
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 12, padding: 20, marginBottom: 16 }}>
<h3 style={{ color: 'var(--text)', margin: '0 0 16px', fontSize: 16 }}>
{level ? 'Editar Nivel' : 'Nuevo Nivel'}
</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label className="auth-label">MUNDO</label>
<select className="adm-action-select" style={{ width: '100%', padding: 8 }} value={form.worldId} onChange={e => set('worldId', e.target.value)}>
{Array.from({ length: 12 }, (_, i) => <option key={i} value={`w${i + 1}`}>Mundo {i + 1}</option>)}
</select>
</div>
<div>
<label className="auth-label">LEVEL ID</label>
<input className="auth-input" value={form.levelId} onChange={e => set('levelId', e.target.value)}
placeholder="w1-9" disabled={!!level} />
</div>
<div>
<label className="auth-label">TITULO</label>
<input className="auth-input" value={form.title} onChange={e => set('title', e.target.value)} />
</div>
<div>
<label className="auth-label">SUBTITULO</label>
<input className="auth-input" value={form.subtitle} onChange={e => set('subtitle', e.target.value)} />
</div>
<div style={{ gridColumn: '1/-1' }}>
<label className="auth-label">DESCRIPCION (MISION)</label>
<textarea className="auth-input" rows={3} value={form.description} onChange={e => set('description', e.target.value)}
style={{ resize: 'vertical', fontFamily: 'inherit' }} />
</div>
<div style={{ gridColumn: '1/-1' }}>
<label className="auth-label">PISTA (CONCEPTO)</label>
<textarea className="auth-input" rows={2} value={form.concept} onChange={e => set('concept', e.target.value)}
style={{ resize: 'vertical', fontFamily: 'inherit' }} />
</div>
<div style={{ gridColumn: '1/-1' }}>
<label className="auth-label">MODULOS DISPONIBLES</label>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 6 }}>
{form.availableModules.map(m => (
<span key={m} className="ws-tag active" onClick={() => removeMod(m)} style={{ cursor: 'pointer' }}>
{m}
</span>
))}
</div>
<div style={{ display: 'flex', gap: 6 }}>
<input className="auth-input" style={{ flex: 1 }} placeholder="oscillator, filter, vca..."
value={modInput} onChange={e => setModInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addMod())} />
<button className="adm-act-btn green" onClick={addMod} type="button">+ Añadir</button>
</div>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--text2)', fontSize: 12 }}>
<input type="checkbox" checked={form.isBoss} onChange={e => set('isBoss', e.target.checked)} />
Boss Level
</label>
<label className="auth-label" style={{ margin: 0 }}>ORDEN</label>
<input className="auth-input" type="number" style={{ width: 60 }} value={form.sortOrder}
onChange={e => set('sortOrder', parseInt(e.target.value) || 0)} />
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
<button className="auth-submit" style={{ flex: 1 }} onClick={() => onSave(form)}>
{level ? 'Guardar' : 'Crear Nivel'}
</button>
<button className="adm-act-btn" style={{ padding: '10px 20px' }} onClick={onCancel}>Cancelar</button>
</div>
</div>
);
}
export default function AdminPanel2({ onBack }) {
const { isAdmin } = useAuth();
const [page, setPage] = useState('dashboard');
@@ -238,6 +459,7 @@ export default function AdminPanel2({ onBack }) {
{page === 'dashboard' && <DashboardView />}
{page === 'users' && <UsersView />}
{page === 'workshop' && <WorkshopModView />}
{page === 'levels' && <LevelsView />}
</div>
</div>
);

View File

@@ -891,6 +891,21 @@ input, textarea, [contenteditable] { -webkit-user-select: text; user-select: tex
}
.user-name { font-size: 11px; font-weight: 600; color: var(--text); }
.user-dropdown { position: relative; }
.user-dropdown-menu {
display: none; position: absolute; top: 100%; right: 0; margin-top: 4px;
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
padding: 4px; min-width: 150px; z-index: 100;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
.user-dropdown:hover .user-dropdown-menu { display: flex; flex-direction: column; }
.user-dropdown-menu button {
background: none; border: none; padding: 8px 12px; color: var(--text);
font-size: 12px; font-family: inherit; cursor: pointer; text-align: left;
border-radius: 4px;
}
.user-dropdown-menu button:hover { background: var(--surface); }
.login-btn {
padding: 4px 12px; border: 1px solid var(--accent); border-radius: 6px;
background: transparent; color: var(--accent); cursor: pointer;

View File

@@ -20,7 +20,7 @@ function Root() {
return (
<AuthProvider>
{mode === 'sandbox' && <App onSwitchToGame={nav.toGame} onSwitchToWorkshop={nav.toWorkshop} />}
{mode === 'sandbox' && <App onSwitchToGame={nav.toGame} onSwitchToWorkshop={nav.toWorkshop} onSwitchToAdmin={nav.toAdmin} />}
{mode === 'game' && <GameApp onSwitchToSandbox={nav.toSandbox} onSwitchToWorkshop={nav.toWorkshop} />}
{mode === 'workshop' && <Workshop onSwitchToSandbox={nav.toSandbox} onSwitchToGame={nav.toGame} onSwitchToAdmin={nav.toAdmin} />}
{mode === 'admin' && <AdminPanel2 onBack={nav.toGame} />}

View File

@@ -84,6 +84,15 @@ export const workshop = {
report: (id) => request('POST', `/workshop/${id}/report`),
};
// Admin Levels
export const levels = {
list: () => request('GET', '/admin/levels'),
create: (data) => request('POST', '/admin/levels', data),
update: (id, data) => request('PATCH', `/admin/levels/${id}`, data),
remove: (id) => request('DELETE', `/admin/levels/${id}`),
importPatch: (id, patchData) => request('POST', `/admin/levels/${id}/import-patch`, patchData),
};
// Admin
export const admin = {
stats: () => request('GET', '/admin/stats'),