diff --git a/frontend/js/views/layout-editor.js b/frontend/js/views/layout-editor.js index 33bfe5c..51f4900 100644 --- a/frontend/js/views/layout-editor.js +++ b/frontend/js/views/layout-editor.js @@ -293,15 +293,21 @@ async function renderEditor(container, layoutId) { document.getElementById('saveLayoutBtn').onclick = async () => { try { - for (const z of layout.zones || []) { - await API(`/layouts/${layoutId}/zones/${z.id}`, { method: 'DELETE' }); - } - for (const z of zones) { - await API(`/layouts/${layoutId}/zones`, { method: 'POST', body: JSON.stringify(z) }); - } + // Single atomic update: send the full zone set and the server replaces them + // exactly. The old per-zone delete-then-add loop could accumulate zones + // (and regenerated every zone id each save). Keep each zone's id so + // device->zone assignments survive. + const updated = await API(`/layouts/${layoutId}`, { + method: 'PUT', + body: JSON.stringify({ zones }), + }); + if (updated && updated.error) { showToast(updated.error, 'error'); return; } + layout = updated; + zones = layout.zones || []; + selectedZone = null; showToast(t('layout.toast.saved'), 'success'); - layout = await API(`/layouts/${layoutId}`); - zones = layout.zones; + renderZones(); + updateProperties(); } catch (err) { showToast(err.message, 'error'); } diff --git a/server/db/database.js b/server/db/database.js index 5169016..84c4f73 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -719,6 +719,40 @@ function pruneScreenshots(deviceId) { `).run(deviceId, deviceId); } +// De-duplicate built-in template zones. A prior layout-editor save regenerated +// every zone id on save; schema.sql's INSERT OR IGNORE then re-seeded the +// canonical zone on the next boot, so template layouts accumulated positional +// duplicates (e.g. a 2-zone split template grew to 4+). For each position in a +// template, keep ONE zone, preferring the canonical seeded id (the built-in +// template zones use 'z-...' ids; bug copies are uuids) so schema.sql's re-seed +// stays an idempotent no-op; tiebreak by earliest rowid. One-time; the atomic +// id-preserving save prevents recurrence. +try { + const DEDUPE_ID = 'dedupe_template_zones_v1'; + if (!db.prepare('SELECT 1 FROM schema_migrations WHERE id = ?').get(DEDUPE_ID)) { + const removed = db.prepare(` + DELETE FROM layout_zones WHERE id IN ( + SELECT z.id FROM layout_zones z + JOIN layouts l ON l.id = z.layout_id + WHERE l.is_template = 1 AND EXISTS ( + SELECT 1 FROM layout_zones z2 + WHERE z2.layout_id = z.layout_id AND z2.id != z.id + AND z2.x_percent = z.x_percent AND z2.y_percent = z.y_percent + AND z2.width_percent = z.width_percent AND z2.height_percent = z.height_percent + AND ( + -- z2 is canonical and z is not -> keep z2, drop z + (z2.id LIKE 'z-%' AND z.id NOT LIKE 'z-%') + -- same canonical-ness -> keep the earliest, drop the rest + OR ((CASE WHEN z2.id LIKE 'z-%' THEN 1 ELSE 0 END) = (CASE WHEN z.id LIKE 'z-%' THEN 1 ELSE 0 END) AND z2.rowid < z.rowid) + ) + ) + ) + `).run().changes; + if (removed > 0) console.log(`[migrate] removed ${removed} duplicate template zone(s)`); + db.prepare('INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)').run(DEDUPE_ID); + } +} catch (e) { console.error('[migrate] template-zone dedupe failed:', e.message); } + // #37: fail fast (loud) if migrations left the DB missing schema the code needs. const { verifyAndRepairSchema } = require('../lib/schema-check'); verifyAndRepairSchema(db); diff --git a/server/routes/layouts.js b/server/routes/layouts.js index 7653aa6..c3e1595 100644 --- a/server/routes/layouts.js +++ b/server/routes/layouts.js @@ -116,10 +116,32 @@ router.put('/:id', (req, res) => { if (!layout) return; if (layout.is_template && !PLATFORM_ROLES.includes(req.user.role)) return res.status(403).json({ error: 'Cannot edit templates' }); - const { name, width, height } = req.body; - if (name) db.prepare('UPDATE layouts SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(name, req.params.id); - if (width) db.prepare('UPDATE layouts SET width = ? WHERE id = ?').run(width, req.params.id); - if (height) db.prepare('UPDATE layouts SET height = ? WHERE id = ?').run(height, req.params.id); + const { name, width, height, zones } = req.body; + const txn = db.transaction(() => { + if (name) db.prepare('UPDATE layouts SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(name, req.params.id); + if (width) db.prepare('UPDATE layouts SET width = ? WHERE id = ?').run(width, req.params.id); + if (height) db.prepare('UPDATE layouts SET height = ? WHERE id = ?').run(height, req.params.id); + + // Atomic zone replace: the editor sends the FULL desired set, so the layout + // ends up with EXACTLY those zones - no accumulation from a per-zone + // delete/add loop. Reuse each zone's id when supplied so device->zone + // assignments survive an edit (a fresh uuid per save would orphan them). + if (Array.isArray(zones)) { + db.prepare('DELETE FROM layout_zones WHERE layout_id = ?').run(req.params.id); + const stmt = db.prepare(` + INSERT INTO layout_zones (id, layout_id, name, x_percent, y_percent, width_percent, height_percent, z_index, zone_type, fit_mode, background_color, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + zones.forEach((z, i) => { + stmt.run(z.id || uuidv4(), req.params.id, z.name || `Zone ${i + 1}`, + z.x_percent || 0, z.y_percent || 0, z.width_percent || 100, z.height_percent || 100, + z.z_index || 0, z.zone_type || 'content', z.fit_mode || 'contain', + z.background_color || '#000000', i); + }); + db.prepare('UPDATE layouts SET updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(req.params.id); + } + }); + txn(); const updated = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id); updated.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(req.params.id);