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'),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
95
packages/server/src/routes/levels.js
Normal file
95
packages/server/src/routes/levels.js
Normal 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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user