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'),

View File

@@ -54,6 +54,23 @@ export const likes = pgTable('likes', {
pk: primaryKey({ columns: [table.userId, table.patchId] }),
}));
export const customLevels = pgTable('custom_levels', {
id: uuid('id').defaultRandom().primaryKey(),
worldId: varchar('world_id', { length: 20 }).notNull(),
levelId: varchar('level_id', { length: 50 }).unique().notNull(),
title: varchar('title', { length: 200 }).notNull(),
subtitle: varchar('subtitle', { length: 200 }),
description: text('description'),
concept: text('concept'),
availableModules: text('available_modules').array(),
preplacedData: jsonb('preplaced_data'), // imported from sandbox export
targetData: jsonb('target_data'),
sortOrder: integer('sort_order').default(0),
isBoss: boolean('is_boss').default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const refreshTokens = pgTable('refresh_tokens', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),

View File

@@ -13,6 +13,7 @@ import userRoutes from './routes/users.js';
import adminRoutes from './routes/admin.js';
import syncRoutes from './routes/sync.js';
import workshopRoutes from './routes/workshop.js';
import levelRoutes from './routes/levels.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = process.env.PORT || 3001;
@@ -43,6 +44,7 @@ await fastify.register(userRoutes, { prefix: '/api/v1/users' });
await fastify.register(adminRoutes, { prefix: '/api/v1/admin' });
await fastify.register(syncRoutes, { prefix: '/api/v1/sync' });
await fastify.register(workshopRoutes, { prefix: '/api/v1/workshop' });
await fastify.register(levelRoutes, { prefix: '/api/v1/admin/levels' });
// Rate limit auth endpoints more aggressively
fastify.addHook('onRoute', (routeOptions) => {

View File

@@ -0,0 +1,95 @@
import { eq, asc } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { requireAdmin } from '../middleware/auth.js';
export default async function levelRoutes(fastify) {
// List all custom levels
fastify.get('/', { preHandler: [requireAdmin] }, async () => {
const levels = await db.select().from(schema.customLevels)
.orderBy(asc(schema.customLevels.worldId), asc(schema.customLevels.sortOrder));
return { levels };
});
// Create a new level (import from sandbox JSON)
fastify.post('/', { preHandler: [requireAdmin] }, async (request, reply) => {
const { worldId, levelId, title, subtitle, description, concept,
availableModules, preplacedData, targetData, sortOrder, isBoss } = request.body;
if (!worldId || !levelId || !title) {
return reply.code(400).send({ error: 'worldId, levelId and title required' });
}
// Check for duplicate levelId
const [existing] = await db.select().from(schema.customLevels)
.where(eq(schema.customLevels.levelId, levelId)).limit(1);
if (existing) {
return reply.code(409).send({ error: `Level ${levelId} already exists` });
}
const [level] = await db.insert(schema.customLevels).values({
worldId,
levelId,
title,
subtitle: subtitle || '',
description: description || '',
concept: concept || '',
availableModules: availableModules || [],
preplacedData: preplacedData || null,
targetData: targetData || null,
sortOrder: sortOrder || 0,
isBoss: isBoss || false,
}).returning();
return level;
});
// Update a level
fastify.patch('/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
const updates = { ...request.body, updatedAt: new Date() };
delete updates.id;
delete updates.createdAt;
const [level] = await db.update(schema.customLevels)
.set(updates)
.where(eq(schema.customLevels.id, request.params.id))
.returning();
if (!level) return reply.code(404).send({ error: 'Level not found' });
return level;
});
// Delete a level
fastify.delete('/:id', { preHandler: [requireAdmin] }, async (request) => {
await db.delete(schema.customLevels)
.where(eq(schema.customLevels.id, request.params.id));
return { ok: true };
});
// Import preplaced modules from sandbox JSON export
fastify.post('/:id/import-patch', { preHandler: [requireAdmin] }, async (request, reply) => {
const { modules, connections } = request.body;
if (!modules) return reply.code(400).send({ error: 'modules required' });
// Convert sandbox export to preplacedModules format
const preplacedData = {
modules: modules.map(m => ({
id: m.id,
type: m.type,
x: m.x,
y: m.y,
params: m.params || {},
locked: true,
})),
connections: connections || [],
};
const [level] = await db.update(schema.customLevels)
.set({ preplacedData, updatedAt: new Date() })
.where(eq(schema.customLevels.id, request.params.id))
.returning();
if (!level) return reply.code(404).send({ error: 'Level not found' });
return level;
});
}