Files
project-friction-design/js/builder.js

848 lines
40 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 = 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 = '<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;
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 '<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 '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',
'<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 'fact-panel':
return this.formGroup('Panel Title', '<input type="text" name="title" value="' + this.esc(block.title || '') + '" placeholder="e.g. Desert Shield Force Buildup">')
+ this.formGroup('Facts',
'<div id="factItems">'
+ ((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, ''))
+ '</div>'
+ '<button type="button" class="add-item-btn btn-add-fact">+ Add Fact</button>');
case 'timeline-card':
return this.formGroup('Date Label', '<input type="text" name="date" value="' + this.esc(block.date || '') + '" placeholder="e.g. Sept 24">')
+ this.formGroup('Heading', '<input type="text" name="title" value="' + this.esc(block.title || '') + '" placeholder="Timeline event title">')
+ this.formGroup('Details', '<textarea name="text" rows="4">' + this.esc(block.text || '') + '</textarea>');
case 'gallery':
return this.formGroup('Display Mode',
'<select name="mode">'
+ '<option value="carousel"' + ((block.mode || 'carousel') === 'carousel' ? ' selected' : '') + '>Carousel (single image, rotates)</option>'
+ '<option value="strip"' + ((block.mode || 'carousel') === 'strip' ? ' selected' : '') + '>Strip (horizontal scroll)</option>'
+ '</select>')
+ this.formGroup('Auto-rotate',
'<select name="autoplay">'
+ '<option value="true"' + (block.autoplay === false ? '' : ' selected') + '>On</option>'
+ '<option value="false"' + (block.autoplay === false ? ' selected' : '') + '>Off</option>'
+ '</select>')
+ this.formGroup('Rotate Every (seconds)',
'<input type="number" name="intervalSec" min="2" max="15" value="' + (parseInt(block.intervalSec, 10) || 5) + '">')
+ this.formGroup('Show Dots',
'<select name="showDots">'
+ '<option value="true"' + (block.showDots === false ? '' : ' selected') + '>On</option>'
+ '<option value="false"' + (block.showDots === false ? ' selected' : '') + '>Off</option>'
+ '</select>')
+ this.formGroup('Show Arrows',
'<select name="showArrows">'
+ '<option value="true"' + (block.showArrows === false ? '' : ' selected') + '>On</option>'
+ '<option value="false"' + (block.showArrows === false ? ' selected' : '') + '>Off</option>'
+ '</select>')
+ 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>';
}
static factRow(index, text) {
return '<div class="item-row" data-index="' + index + '">'
+ '<input type="text" data-field="text" data-index="' + index + '" value="' + (text || '') + '" placeholder="Fact line (e.g. Day 90: 320,000 coalition troops)">'
+ '<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);
});
}
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 '<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(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 '<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();
});