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)} />
+
+
+
+
+
+
+
+
+
+
+ {form.availableModules.map(m => (
+ removeMod(m)} style={{ cursor: 'pointer' }}>
+ {m} ✕
+
+ ))}
+
+
+ setModInput(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addMod())} />
+
+
+
+
+
+
+ set('sortOrder', parseInt(e.target.value) || 0)} />
+
+
+
+
+
+
+
+ );
+}
+
export default function AdminPanel2({ onBack }) {
const { isAdmin } = useAuth();
const [page, setPage] = useState('dashboard');
@@ -238,6 +459,7 @@ export default function AdminPanel2({ onBack }) {
{page === 'dashboard' &&
}
{page === 'users' &&
}
{page === 'workshop' &&
}
+ {page === 'levels' &&
}
);
diff --git a/packages/client/src/index.css b/packages/client/src/index.css
index 45e51c5..79d857c 100644
--- a/packages/client/src/index.css
+++ b/packages/client/src/index.css
@@ -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;
diff --git a/packages/client/src/main.jsx b/packages/client/src/main.jsx
index 11a324c..5cb4159 100644
--- a/packages/client/src/main.jsx
+++ b/packages/client/src/main.jsx
@@ -20,7 +20,7 @@ function Root() {
return (