529 lines
24 KiB
JavaScript
529 lines
24 KiB
JavaScript
/**
|
||
* 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 = '<div class="canvas-empty">'
|
||
+ '<p>No blocks yet.</p>'
|
||
+ '<p>Click a block type in the sidebar to add one.</p>'
|
||
+ '</div>';
|
||
return;
|
||
}
|
||
|
||
var html = blocks.map(function(block, index) {
|
||
return self.renderCanvasBlock(block, index, blocks.length);
|
||
}).join('');
|
||
|
||
html += '<div class="canvas-insert canvas-insert-bottom">'
|
||
+ '<button class="insert-btn" data-index="' + blocks.length + '">+ Add Block</button>'
|
||
+ '</div>';
|
||
|
||
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 '<div class="canvas-block" data-index="' + index + '">'
|
||
+ '<div class="canvas-block-controls">'
|
||
+ '<span class="block-type-badge">' + typeLabel + '</span>'
|
||
+ '<div class="block-actions">'
|
||
+ '<button data-action="up" data-index="' + index + '" title="Move Up" ' + (isFirst ? 'disabled' : '') + '>↑</button>'
|
||
+ '<button data-action="down" data-index="' + index + '" title="Move Down" ' + (isLast ? 'disabled' : '') + '>↓</button>'
|
||
+ '<button data-action="edit" data-index="' + index + '" class="btn-edit" title="Edit">✏ Edit</button>'
|
||
+ '<button data-action="delete" data-index="' + index + '" class="btn-delete" title="Delete">🗑</button>'
|
||
+ '</div>'
|
||
+ '</div>'
|
||
+ '<div class="canvas-block-preview">' + preview + '</div>'
|
||
+ '<div class="canvas-insert">'
|
||
+ '<button class="insert-btn" data-index="' + (index + 1) + '">+ Insert Block</button>'
|
||
+ '</div>'
|
||
+ '</div>';
|
||
}
|
||
|
||
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',
|
||
'<select name="level">'
|
||
+ '<option value="1"' + (block.level == 1 ? ' selected' : '') + '>H1 — Page Title</option>'
|
||
+ '<option value="2"' + (block.level == 2 ? ' selected' : '') + '>H2 — Section Title</option>'
|
||
+ '<option value="3"' + (block.level == 3 ? ' selected' : '') + '>H3 — Sub-heading</option>'
|
||
+ '</select>')
|
||
+ this.formGroup('Text', '<input type="text" name="text" value="' + this.esc(block.text || '') + '">');
|
||
|
||
case 'paragraph':
|
||
return this.formGroup('Text', '<textarea name="text" rows="6">' + this.esc(block.text || '') + '</textarea>');
|
||
|
||
case 'image':
|
||
return this.formGroup('Image URL', '<input type="text" name="src" value="' + this.esc(block.src || '') + '" placeholder="https://... or assets/images/...">')
|
||
+ this.formGroup('Alt Text (accessibility)', '<input type="text" name="alt" value="' + this.esc(block.alt || '') + '">')
|
||
+ this.formGroup('Caption (optional)', '<input type="text" name="caption" value="' + this.esc(block.caption || '') + '">');
|
||
|
||
case 'quote':
|
||
return this.formGroup('Quote Text', '<textarea name="text" rows="4">' + this.esc(block.text || '') + '</textarea>')
|
||
+ this.formGroup('Attribution (optional)', '<input type="text" name="author" value="' + this.esc(block.author || '') + '" placeholder="Name, Title, Date">');
|
||
|
||
case 'gallery':
|
||
return this.formGroup('Gallery Images',
|
||
'<div id="galleryItems">'
|
||
+ (block.images || []).map(function(img, i) { return SectionBuilder.imageRow(i, img, true); }).join('')
|
||
+ '</div>'
|
||
+ '<button type="button" class="add-item-btn btn-add-img">+ Add Image</button>');
|
||
|
||
case 'grid4':
|
||
return this.formGroup('4-Image Grid',
|
||
'<div id="grid4Items">'
|
||
+ [0,1,2,3].map(function(i) { return SectionBuilder.imageRow(i, (block.images || [])[i] || {}, false); }).join('')
|
||
+ '</div>');
|
||
|
||
case 'statistics':
|
||
return this.formGroup('Statistics',
|
||
'<div id="statsItems">'
|
||
+ (block.stats || []).map(function(s, i) { return SectionBuilder.statRow(i, s); }).join('')
|
||
+ '</div>'
|
||
+ '<button type="button" class="add-item-btn btn-add-stat">+ Add Statistic</button>');
|
||
|
||
case 'map-button':
|
||
return this.formGroup('Button Label', '<input type="text" name="label" value="' + this.esc(block.label || '') + '" placeholder="e.g. Show Kuwait Invasion Routes">')
|
||
+ '<div class="form-row">'
|
||
+ this.formGroup('Latitude', '<input type="number" name="lat" value="' + (block.lat || 0) + '" step="0.0001">')
|
||
+ this.formGroup('Longitude', '<input type="number" name="lng" value="' + (block.lng || 0) + '" step="0.0001">')
|
||
+ this.formGroup('Zoom (1–18)', '<input type="number" name="zoom" value="' + (block.zoom || 6) + '" min="1" max="18">')
|
||
+ '</div>';
|
||
|
||
default:
|
||
return '<p>Unknown block type.</p>';
|
||
}
|
||
}
|
||
|
||
static imageRow(index, img, removable) {
|
||
return '<div class="item-row" data-index="' + index + '">'
|
||
+ '<div class="item-row-fields">'
|
||
+ '<input type="text" data-field="src" data-index="' + index + '" value="' + (img.src || '') + '" placeholder="Image URL">'
|
||
+ '<input type="text" data-field="alt" data-index="' + index + '" value="' + (img.alt || '') + '" placeholder="Alt text">'
|
||
+ '<input type="text" data-field="caption" data-index="' + index + '" value="' + (img.caption || '') + '" placeholder="Caption (optional)">'
|
||
+ '</div>'
|
||
+ (removable ? '<button type="button" class="btn-remove-item">✕</button>' : '')
|
||
+ '</div>';
|
||
}
|
||
|
||
static statRow(index, stat) {
|
||
return '<div class="item-row" data-index="' + index + '">'
|
||
+ '<input type="text" data-field="number" data-index="' + index + '" value="' + (stat.number || '') + '" placeholder="Number (e.g. 4,500+)">'
|
||
+ '<input type="text" data-field="label" data-index="' + index + '" value="' + (stat.label || '') + '" placeholder="Label (e.g. Personnel)">'
|
||
+ '<button type="button" class="btn-remove-item">✕</button>'
|
||
+ '</div>';
|
||
}
|
||
|
||
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 '<button class="section-tab" data-section="' + id + '" style="--tab-color:' + sec.color + '">'
|
||
+ '<span class="tab-icon">' + sec.icon + '</span>'
|
||
+ '<span>' + sec.title + '</span>'
|
||
+ '</button>';
|
||
}).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 '<div class="form-group"><label>' + label + '</label>' + inputHtml + '</div>';
|
||
}
|
||
|
||
esc(str) {
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/"/g, '"')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>');
|
||
}
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
window.builder = new SectionBuilder();
|
||
});
|
||
|