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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user