Files
project-friction-design/js/builder.js
2026-04-30 19:44:31 -04:00

529 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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' : '') + '>&#x2191;</button>'
+ '<button data-action="down" data-index="' + index + '" title="Move Down" ' + (isLast ? 'disabled' : '') + '>&#x2193;</button>'
+ '<button data-action="edit" data-index="' + index + '" class="btn-edit" title="Edit">&#x270F; Edit</button>'
+ '<button data-action="delete" data-index="' + index + '" class="btn-delete" title="Delete">&#x1F5D1;</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 (118)', '<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">&#x2715;</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">&#x2715;</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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
}
document.addEventListener('DOMContentLoaded', function() {
window.builder = new SectionBuilder();
});