/** * 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 = { context: { title: 'Historical Context', icon: '🌍', color: '#d4a574', blocks: [] }, rcaf: { title: 'RCAF Desert Cats', icon: '✈️', color: '#3498db', blocks: [] }, rcn: { title: 'RCN Task Group', icon: 'βš“', color: '#2c3e50', blocks: [] }, army: { title: 'Canadian Army', icon: 'πŸ₯', color: '#27ae60', blocks: [] }, legacy: { title: 'After the War', icon: '🍁', color: '#c0392b', blocks: [] } }; this.currentSection = 'context'; this.editingIndex = null; this.defaultContent = null; this.init(); } 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.loadAllFromStorage(); this.renderSectionTabs(); this.bindGlobalEvents(); this.loadSection('context'); } // ── 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)); } }); } saveSection(id) { localStorage.setItem('pf-section-' + id, JSON.stringify(this.sections[id].blocks)); 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; 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 'gallery': return { id: id, type: type, 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 'gallery': return 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 '
' + '' + '' + '' + '
'; } 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); }); } 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 'gallery': 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(self.sections).forEach(function(id) { if (data[id] && data[id].blocks) { self.sections[id].blocks = data[id].blocks; localStorage.setItem('pf-section-' + id, JSON.stringify(data[id].blocks)); } }); 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('modalSave').addEventListener('click', function() { self.saveModalBlock(); }); document.getElementById('modalCancel').addEventListener('click', function() { self.closeModal(); }); document.getElementById('modalClose').addEventListener('click', function() { self.closeModal(); }); document.querySelector('.modal-overlay').addEventListener('click', function() { self.closeModal(); }); document.addEventListener('keydown', function(e) { if (e.key === 'Escape') self.closeModal(); if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); if (self.currentSection) self.saveSection(self.currentSection); } }); } exportJSON() { var output = {}; Object.keys(this.sections).forEach(function(id) { output[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', 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(); });