/** * Section Builder - Project Friction * Block-based page builder for the 5 exhibit sections. * Saves to localStorage; export/import as JSON. */ class SectionBuilder { constructor() { this.sections = this.buildDefaultSections(); this.sectionsStorageKey = 'pf-builder-sections'; this.currentSection = 'context'; this.editingIndex = null; this.defaultContent = null; this.init(); } buildDefaultSections() { return { context: { title: 'Historical Context', subtitle: 'Coalition Building & Diplomacy', icon: '🌍', color: '#d4a574', description: 'Understand how Iraq\'s invasion of Kuwait triggered the largest military coalition since WWII and Canada\'s diplomatic role.', stats: { articles: 7, storyMaps: 3, images: 45 }, blocks: [] }, rcaf: { title: 'RCAF Desert Cats', subtitle: '409 Squadron Air Operations', icon: '✈️', color: '#3498db', description: 'Follow the Desert Cats from defensive patrols to strike missions, including the TNC-45 engagement and bombing runs.', stats: { articles: 8, storyMaps: 4, images: 60 }, blocks: [] }, rcn: { title: 'RCN Task Group', subtitle: 'Naval Blockade Operations', icon: 'βš“', color: '#2c3e50', description: 'The transformation of HMCS Terra Nova into a guided-missile destroyer and enforcement of UN sanctions.', stats: { articles: 7, storyMaps: 2, images: 35 }, blocks: [] }, army: { title: 'Canadian Army', subtitle: 'Medical & Security Forces', icon: 'πŸ₯', color: '#27ae60', description: '1 Canadian Field Hospital\'s life-saving work and security operations protecting coalition installations.', stats: { articles: 7, storyMaps: 3, images: 40 }, blocks: [] }, legacy: { title: 'After the War', subtitle: 'Veterans & Long-term Impact', icon: '🍁', color: '#c0392b', description: 'Gulf War Syndrome, environmental cleanup, peacekeeping missions, and ongoing support for veterans.', stats: { articles: 7, storyMaps: 2, images: 30 }, blocks: [] } }; } async init() { try { var res = await fetch('data/sections-content.json'); if (res.ok) this.defaultContent = await res.json(); } catch (e) { console.warn('Could not load default content'); } this.loadSectionsManifest(); this.loadAllFromStorage(); this.renderSectionTabs(); this.bindGlobalEvents(); this.loadSection(this.sections.context ? 'context' : Object.keys(this.sections)[0]); } // ── Storage ──────────────────────────────────────────────────────────── loadAllFromStorage() { var self = this; Object.keys(this.sections).forEach(function(id) { var saved = localStorage.getItem('pf-section-' + id); if (saved) { try { self.sections[id].blocks = JSON.parse(saved); } catch(e) {} } else if (self.defaultContent && self.defaultContent[id]) { self.sections[id].blocks = JSON.parse(JSON.stringify(self.defaultContent[id].blocks)); } }); } loadSectionsManifest() { var saved = localStorage.getItem(this.sectionsStorageKey); if (!saved) return; try { var parsed = JSON.parse(saved); var existing = this.sections; var merged = {}; var order = Array.isArray(parsed.order) ? parsed.order : Object.keys(parsed.sections || {}); var sectionMeta = parsed.sections || {}; order.forEach(function(id) { if (!sectionMeta[id]) return; merged[id] = { title: sectionMeta[id].title || id, subtitle: sectionMeta[id].subtitle || '', description: sectionMeta[id].description || '', icon: sectionMeta[id].icon || 'πŸ“š', color: sectionMeta[id].color || '#7f8c8d', stats: { articles: parseInt(sectionMeta[id].stats && sectionMeta[id].stats.articles, 10) || 0, storyMaps: parseInt(sectionMeta[id].stats && sectionMeta[id].stats.storyMaps, 10) || 0, images: parseInt(sectionMeta[id].stats && sectionMeta[id].stats.images, 10) || 0 }, blocks: (existing[id] && existing[id].blocks) || [] }; }); Object.keys(existing).forEach(function(id) { if (!merged[id]) merged[id] = existing[id]; }); this.sections = merged; } catch (e) { console.warn('Could not parse saved section metadata'); } } saveSectionsManifest() { var payload = { order: Object.keys(this.sections), sections: {} }; var self = this; payload.order.forEach(function(id) { payload.sections[id] = self.sectionMeta(id); }); localStorage.setItem(this.sectionsStorageKey, JSON.stringify(payload)); } sectionMeta(id) { var section = this.sections[id] || {}; return { title: section.title || id, subtitle: section.subtitle || '', description: section.description || '', icon: section.icon || 'πŸ“š', color: section.color || '#7f8c8d', stats: { articles: parseInt(section.stats && section.stats.articles, 10) || 0, storyMaps: parseInt(section.stats && section.stats.storyMaps, 10) || 0, images: parseInt(section.stats && section.stats.images, 10) || 0 } }; } saveSection(id) { localStorage.setItem('pf-section-' + id, JSON.stringify(this.sections[id].blocks)); this.saveSectionsManifest(); this.showToast(this.sections[id].title + ' saved!'); } // ── Section Loading ──────────────────────────────────────────────────── loadSection(id) { this.currentSection = id; var sec = this.sections[id]; document.querySelectorAll('.section-tab').forEach(function(t) { t.classList.toggle('active', t.dataset.section === id); }); document.getElementById('canvasSectionTitle').textContent = sec.icon + ' ' + sec.title; var badge = document.getElementById('canvasBadge'); badge.textContent = sec.blocks.length + ' block' + (sec.blocks.length !== 1 ? 's' : ''); badge.style.background = sec.color; this.renderCanvas(); } // ── Canvas Rendering ─────────────────────────────────────────────────── renderCanvas() { var canvas = document.getElementById('builderCanvas'); var blocks = this.sections[this.currentSection].blocks; var self = this; if (blocks.length === 0) { canvas.innerHTML = '
' + '

No blocks yet.

' + '

Click a block type in the sidebar to add one.

' + '
'; return; } var html = blocks.map(function(block, index) { return self.renderCanvasBlock(block, index, blocks.length); }).join(''); html += '
' + '' + '
'; canvas.innerHTML = html; if (typeof BlockRenderer !== 'undefined' && typeof BlockRenderer.initGalleries === 'function') { BlockRenderer.initGalleries(canvas); } this.bindCanvasEvents(); } renderCanvasBlock(block, index, total) { var preview = BlockRenderer.renderBlock(block); var isFirst = index === 0; var isLast = index === total - 1; var typeLabel = this.blockTypeLabel(block.type); return '
' + '
' + '' + typeLabel + '' + '
' + '' + '' + '' + '' + '
' + '
' + '
' + preview + '
' + '
' + '' + '
' + '
'; } bindCanvasEvents() { var self = this; document.querySelectorAll('[data-action]').forEach(function(btn) { btn.addEventListener('click', function() { var action = btn.dataset.action; var idx = parseInt(btn.dataset.index); if (action === 'edit') self.openEditModal(idx); if (action === 'delete') self.deleteBlock(idx); if (action === 'up') self.moveBlock(idx, -1); if (action === 'down') self.moveBlock(idx, 1); }); }); document.querySelectorAll('.insert-btn').forEach(function(btn) { btn.addEventListener('click', function() { self.pendingInsertIndex = parseInt(btn.dataset.index); self.highlightPalette(); }); }); } highlightPalette() { var palette = document.querySelector('.palette-blocks'); palette.classList.add('palette-highlight'); setTimeout(function() { palette.classList.remove('palette-highlight'); }, 1200); palette.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } // ── Block CRUD ───────────────────────────────────────────────────────── addBlock(type) { var block = this.createDefaultBlock(type); var blocks = this.sections[this.currentSection].blocks; var atIdx = (this.pendingInsertIndex !== undefined) ? this.pendingInsertIndex : blocks.length; this.pendingInsertIndex = undefined; if (atIdx >= 0 && atIdx <= blocks.length) { blocks.splice(atIdx, 0, block); } else { blocks.push(block); } this.renderCanvas(); var newIdx = blocks.indexOf(block); this.openEditModal(newIdx); } createDefaultBlock(type) { var id = 'b' + Date.now(); switch (type) { case 'heading': return { id: id, type: type, level: 2, text: 'New Heading' }; case 'paragraph': return { id: id, type: type, text: 'Enter paragraph text here.' }; case 'image': return { id: id, type: type, src: '', alt: '', caption: '' }; case 'quote': return { id: id, type: type, text: 'Quote text here.', author: 'Source' }; case 'fact-panel': return { id: id, type: type, title: 'Fact Briefing', facts: [ { text: 'Day 1: 4,000 U.S. troops' }, { text: 'Day 30: 72,000 coalition troops' }, { text: 'Day 60: 185,000 coalition troops' } ] }; case 'timeline-card': return { id: id, type: type, date: 'Sept 24', title: 'Canadian Naval Task Group Arrives', text: 'HMCS Terra Nova, Athabaskan, and Protecteur begin patrol operations in the central Persian Gulf.' }; case 'gallery': return { id: id, type: type, mode: 'carousel', autoplay: true, intervalSec: 5, showDots: true, showArrows: true, images: [{ src: '', alt: '', caption: '' }] }; case 'grid4': return { id: id, type: type, images: [ { src: '', alt: '', caption: '' }, { src: '', alt: '', caption: '' }, { src: '', alt: '', caption: '' }, { src: '', alt: '', caption: '' } ]}; case 'statistics': return { id: id, type: type, stats: [{ number: '0', label: 'Label' }, { number: '0', label: 'Label' }] }; case 'map-button': return { id: id, type: type, label: 'View on Map', lat: 29.3759, lng: 47.9774, zoom: 6 }; default: return { id: id, type: type }; } } deleteBlock(index) { if (!confirm('Delete this block?')) return; this.sections[this.currentSection].blocks.splice(index, 1); this.renderCanvas(); } moveBlock(index, direction) { var blocks = this.sections[this.currentSection].blocks; var newIndex = index + direction; if (newIndex < 0 || newIndex >= blocks.length) return; var tmp = blocks[index]; blocks[index] = blocks[newIndex]; blocks[newIndex] = tmp; this.renderCanvas(); // Scroll moved block into view var el = document.querySelector('[data-index="' + newIndex + '"]'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } // ── Modal Editor ─────────────────────────────────────────────────────── openEditModal(index) { this.editingIndex = index; var block = this.sections[this.currentSection].blocks[index]; document.getElementById('modalTitle').textContent = 'Edit: ' + this.blockTypeLabel(block.type); document.getElementById('modalBody').innerHTML = this.buildModalForm(block); document.getElementById('builderModal').classList.remove('hidden'); this.bindDynamicFormEvents(document.getElementById('modalBody')); var first = document.getElementById('modalBody').querySelector('input, textarea, select'); if (first) first.focus(); } buildModalForm(block) { switch (block.type) { case 'heading': return this.formGroup('Level', '') + this.formGroup('Text', ''); case 'paragraph': return this.formGroup('Text', ''); case 'image': return this.formGroup('Image URL', '') + this.formGroup('Alt Text (accessibility)', '') + this.formGroup('Caption (optional)', ''); case 'quote': return this.formGroup('Quote Text', '') + this.formGroup('Attribution (optional)', ''); case 'fact-panel': return this.formGroup('Panel Title', '') + this.formGroup('Facts', '
' + ((block.facts || block.items || []).map(function(fact, i) { var text = (typeof fact === 'string') ? fact : (fact && fact.text) || ''; return SectionBuilder.factRow(i, text); }).join('') || SectionBuilder.factRow(0, '')) + '
' + ''); case 'timeline-card': return this.formGroup('Date Label', '') + this.formGroup('Heading', '') + this.formGroup('Details', ''); case 'gallery': return this.formGroup('Display Mode', '') + this.formGroup('Auto-rotate', '') + this.formGroup('Rotate Every (seconds)', '') + this.formGroup('Show Dots', '') + this.formGroup('Show Arrows', '') + this.formGroup('Gallery Images', '
' + (block.images || []).map(function(img, i) { return SectionBuilder.imageRow(i, img, true); }).join('') + '
' + ''); case 'grid4': return this.formGroup('4-Image Grid', '
' + [0,1,2,3].map(function(i) { return SectionBuilder.imageRow(i, (block.images || [])[i] || {}, false); }).join('') + '
'); case 'statistics': return this.formGroup('Statistics', '
' + (block.stats || []).map(function(s, i) { return SectionBuilder.statRow(i, s); }).join('') + '
' + ''); case 'map-button': return this.formGroup('Button Label', '') + '
' + this.formGroup('Latitude', '') + this.formGroup('Longitude', '') + this.formGroup('Zoom (1–18)', '') + '
'; default: return '

Unknown block type.

'; } } static imageRow(index, img, removable) { return '
' + '
' + '' + '' + '' + '
' + (removable ? '' : '') + '
'; } static statRow(index, stat) { return '
' + '' + '' + '' + '
'; } static factRow(index, text) { return '
' + '' + '' + '
'; } bindDynamicFormEvents(container) { var self = this; var addImgBtn = container.querySelector('.btn-add-img'); if (addImgBtn) { addImgBtn.addEventListener('click', function() { var items = container.querySelector('#galleryItems'); var newIdx = items.querySelectorAll('.item-row').length; items.insertAdjacentHTML('beforeend', SectionBuilder.imageRow(newIdx, {}, true)); self.bindRemoveBtns(container); }); } var addStatBtn = container.querySelector('.btn-add-stat'); if (addStatBtn) { addStatBtn.addEventListener('click', function() { var items = container.querySelector('#statsItems'); var newIdx = items.querySelectorAll('.item-row').length; items.insertAdjacentHTML('beforeend', SectionBuilder.statRow(newIdx, {})); self.bindRemoveBtns(container); }); } var addFactBtn = container.querySelector('.btn-add-fact'); if (addFactBtn) { addFactBtn.addEventListener('click', function() { var items = container.querySelector('#factItems'); var newIdx = items.querySelectorAll('.item-row').length; items.insertAdjacentHTML('beforeend', SectionBuilder.factRow(newIdx, '')); self.bindRemoveBtns(container); }); } this.bindRemoveBtns(container); } bindRemoveBtns(container) { container.querySelectorAll('.btn-remove-item').forEach(function(btn) { btn.onclick = function() { btn.closest('.item-row').remove(); container.querySelectorAll('.item-row').forEach(function(row, i) { row.dataset.index = i; row.querySelectorAll('[data-index]').forEach(function(el) { el.dataset.index = i; }); }); }; }); } saveModalBlock() { var block = this.sections[this.currentSection].blocks[this.editingIndex]; var body = document.getElementById('modalBody'); switch (block.type) { case 'heading': block.level = parseInt(body.querySelector('[name="level"]').value); block.text = body.querySelector('[name="text"]').value; break; case 'paragraph': block.text = body.querySelector('[name="text"]').value; break; case 'image': block.src = body.querySelector('[name="src"]').value; block.alt = body.querySelector('[name="alt"]').value; block.caption = body.querySelector('[name="caption"]').value; break; case 'quote': block.text = body.querySelector('[name="text"]').value; block.author = body.querySelector('[name="author"]').value; break; case 'fact-panel': block.title = body.querySelector('[name="title"]').value; block.facts = this.readItemRows(body.querySelector('#factItems'), ['text']) .filter(function(f) { return (f.text || '').trim() !== ''; }); break; case 'timeline-card': block.date = body.querySelector('[name="date"]').value; block.title = body.querySelector('[name="title"]').value; block.text = body.querySelector('[name="text"]').value; break; case 'gallery': block.mode = body.querySelector('[name="mode"]').value || 'carousel'; block.autoplay = body.querySelector('[name="autoplay"]').value === 'true'; block.intervalSec = parseInt(body.querySelector('[name="intervalSec"]').value, 10) || 5; block.intervalSec = Math.max(2, Math.min(block.intervalSec, 15)); block.showDots = body.querySelector('[name="showDots"]').value === 'true'; block.showArrows = body.querySelector('[name="showArrows"]').value === 'true'; block.images = this.readItemRows(body.querySelector('#galleryItems'), ['src','alt','caption']); break; case 'grid4': block.images = this.readItemRows(body.querySelector('#grid4Items'), ['src','alt','caption']); break; case 'statistics': block.stats = this.readItemRows(body.querySelector('#statsItems'), ['number','label']); break; case 'map-button': block.label = body.querySelector('[name="label"]').value; block.lat = parseFloat(body.querySelector('[name="lat"]').value); block.lng = parseFloat(body.querySelector('[name="lng"]').value); block.zoom = parseInt(body.querySelector('[name="zoom"]').value); break; } this.closeModal(); this.renderCanvas(); } readItemRows(container, fields) { if (!container) return []; return Array.from(container.querySelectorAll('.item-row')).map(function(row) { var obj = {}; fields.forEach(function(field) { var el = row.querySelector('[data-field="' + field + '"]'); obj[field] = el ? el.value : ''; }); return obj; }); } closeModal() { document.getElementById('builderModal').classList.add('hidden'); this.editingIndex = null; } // ── UI Helpers ───────────────────────────────────────────────────────── renderSectionTabs() { var self = this; var container = document.getElementById('sectionTabs'); container.innerHTML = Object.entries(this.sections).map(function(entry) { var id = entry[0], sec = entry[1]; return ''; }).join(''); container.querySelectorAll('.section-tab').forEach(function(btn) { btn.addEventListener('click', function() { self.loadSection(btn.dataset.section); }); }); } bindGlobalEvents() { var self = this; document.getElementById('saveBtn').addEventListener('click', function() { if (self.currentSection) self.saveSection(self.currentSection); }); document.getElementById('resetBtn').addEventListener('click', function() { if (!self.currentSection) return; if (!confirm('Reset to default content? Saved changes will be lost.')) return; localStorage.removeItem('pf-section-' + self.currentSection); if (self.defaultContent && self.defaultContent[self.currentSection]) { self.sections[self.currentSection].blocks = JSON.parse(JSON.stringify(self.defaultContent[self.currentSection].blocks)); } else { self.sections[self.currentSection].blocks = []; } self.renderCanvas(); self.showToast('Section reset to default.'); }); document.getElementById('exportBtn').addEventListener('click', function() { self.exportJSON(); }); document.getElementById('importBtn').addEventListener('click', function() { document.getElementById('importFile').click(); }); document.getElementById('importFile').addEventListener('change', function(e) { var file = e.target.files[0]; if (!file) return; var reader = new FileReader(); reader.onload = function(evt) { try { var data = JSON.parse(evt.target.result); Object.keys(data).forEach(function(id) { var entry = data[id] || {}; var blocks = Array.isArray(entry.blocks) ? entry.blocks : (Array.isArray(entry) ? entry : []); if (!self.sections[id]) { var meta = entry.meta || { title: id, icon: 'πŸ“š', color: '#7f8c8d' }; self.sections[id] = { title: meta.title || id, subtitle: meta.subtitle || '', description: meta.description || '', icon: meta.icon || 'πŸ“š', color: meta.color || '#7f8c8d', stats: { articles: parseInt(meta.stats && meta.stats.articles, 10) || 0, storyMaps: parseInt(meta.stats && meta.stats.storyMaps, 10) || 0, images: parseInt(meta.stats && meta.stats.images, 10) || 0 }, blocks: [] }; } else if (entry.meta) { self.sections[id] = Object.assign({}, self.sections[id], { title: entry.meta.title || self.sections[id].title, subtitle: entry.meta.subtitle || self.sections[id].subtitle, description: entry.meta.description || self.sections[id].description, icon: entry.meta.icon || self.sections[id].icon, color: entry.meta.color || self.sections[id].color, stats: { articles: parseInt(entry.meta.stats && entry.meta.stats.articles, 10) || 0, storyMaps: parseInt(entry.meta.stats && entry.meta.stats.storyMaps, 10) || 0, images: parseInt(entry.meta.stats && entry.meta.stats.images, 10) || 0 } }); } self.sections[id].blocks = blocks; localStorage.setItem('pf-section-' + id, JSON.stringify(blocks)); }); self.saveSectionsManifest(); self.renderSectionTabs(); if (!self.sections[self.currentSection]) self.currentSection = Object.keys(self.sections)[0]; self.loadSection(self.currentSection); self.showToast('Content imported!'); } catch (err) { alert('Invalid JSON file: ' + err.message); } }; reader.readAsText(file); e.target.value = ''; }); document.querySelectorAll('.palette-block').forEach(function(btn) { btn.addEventListener('click', function() { if (!self.currentSection) return; self.addBlock(btn.dataset.type); }); }); document.getElementById('newSectionBtn').addEventListener('click', function() { self.openSectionWizard(); }); document.getElementById('modalSave').addEventListener('click', function() { self.saveModalBlock(); }); document.getElementById('modalCancel').addEventListener('click', function() { self.closeModal(); }); document.getElementById('modalClose').addEventListener('click', function() { self.closeModal(); }); document.querySelector('#builderModal .modal-overlay').addEventListener('click', function() { self.closeModal(); }); document.getElementById('sectionWizardSave').addEventListener('click', function() { self.createSectionFromWizard(); }); document.getElementById('sectionWizardCancel').addEventListener('click', function() { self.closeSectionWizard(); }); document.getElementById('sectionWizardClose').addEventListener('click', function() { self.closeSectionWizard(); }); document.querySelector('#sectionWizardModal .modal-overlay').addEventListener('click', function() { self.closeSectionWizard(); }); document.addEventListener('keydown', function(e) { if (e.key === 'Escape') { self.closeModal(); self.closeSectionWizard(); } if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); if (self.currentSection) self.saveSection(self.currentSection); } }); } openSectionWizard() { document.getElementById('wizardTitle').value = ''; document.getElementById('wizardSubtitle').value = ''; document.getElementById('wizardDescription').value = ''; document.getElementById('wizardIcon').value = 'πŸ“š'; document.getElementById('wizardColor').value = '#7f8c8d'; document.getElementById('wizardArticles').value = '0'; document.getElementById('wizardStoryMaps').value = '0'; document.getElementById('wizardImages').value = '0'; document.getElementById('sectionWizardModal').classList.remove('hidden'); document.getElementById('wizardTitle').focus(); } closeSectionWizard() { document.getElementById('sectionWizardModal').classList.add('hidden'); } createSectionFromWizard() { var title = (document.getElementById('wizardTitle').value || '').trim(); if (!title) { alert('Please enter a title for the new section.'); return; } var idBase = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); if (!idBase) idBase = 'section'; var id = idBase; var i = 2; while (this.sections[id]) { id = idBase + '-' + i; i++; } var color = (document.getElementById('wizardColor').value || '').trim() || '#7f8c8d'; if (!/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(color)) { alert('Please enter a valid hex color (for example: #3498db).'); return; } this.sections[id] = { title: title, subtitle: (document.getElementById('wizardSubtitle').value || '').trim(), description: (document.getElementById('wizardDescription').value || '').trim(), icon: (document.getElementById('wizardIcon').value || '').trim() || 'πŸ“š', color: color, stats: { articles: parseInt(document.getElementById('wizardArticles').value, 10) || 0, storyMaps: parseInt(document.getElementById('wizardStoryMaps').value, 10) || 0, images: parseInt(document.getElementById('wizardImages').value, 10) || 0 }, blocks: [] }; this.saveSectionsManifest(); this.renderSectionTabs(); this.closeSectionWizard(); this.loadSection(id); this.showToast('Section created. Add blocks and save when ready.'); } exportJSON() { var output = {}; Object.keys(this.sections).forEach(function(id) { output[id] = { meta: this.sectionMeta(id), blocks: this.sections[id].blocks }; }, this); var blob = new Blob([JSON.stringify(output, null, 2)], { type: 'application/json' }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = 'sections-content.json'; a.click(); URL.revokeObjectURL(url); } showToast(message) { var toast = document.createElement('div'); toast.className = 'builder-toast'; toast.textContent = message; document.body.appendChild(toast); setTimeout(function() { toast.classList.add('show'); }, 10); setTimeout(function() { toast.classList.remove('show'); setTimeout(function() { toast.remove(); }, 300); }, 2500); } blockTypeLabel(type) { var labels = { heading: 'H Heading', paragraph: 'ΒΆ Paragraph', image: 'πŸ–Ό Image', quote: '" Quote', 'fact-panel': 'β–Έ Fact Briefing Panel', 'timeline-card': 'πŸ—“ Timeline Card', gallery: '⊞ Gallery', grid4: 'β–¦ Grid 4', statistics: '# Statistics', 'map-button': 'πŸ“ Map Button' }; return labels[type] || type; } formGroup(label, inputHtml) { return '
' + inputHtml + '
'; } esc(str) { return String(str) .replace(/&/g, '&') .replace(/"/g, '"') .replace(//g, '>'); } } document.addEventListener('DOMContentLoaded', function() { window.builder = new SectionBuilder(); });