848 lines
40 KiB
JavaScript
848 lines
40 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 = 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' : '') + '>↑</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 '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 (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>';
|
||
}
|
||
|
||
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">✕</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, '&')
|
||
.replace(/"/g, '"')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>');
|
||
}
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
window.builder = new SectionBuilder();
|
||
});
|
||
|