Updated layout, added ability to make new sections, added new block types, increased layout size by 30%

This commit is contained in:
nathan
2026-05-01 14:37:36 -04:00
parent a472255a94
commit d0200f3975
7 changed files with 841 additions and 35 deletions

71
app.js
View File

@@ -60,6 +60,71 @@ class ProjectFriction {
if (!Array.isArray(this.data.sections) || this.data.sections.length === 0) { if (!Array.isArray(this.data.sections) || this.data.sections.length === 0) {
this.loadDefaultConfiguration(); this.loadDefaultConfiguration();
} }
this.mergeBuilderSectionsFromStorage();
}
mergeBuilderSectionsFromStorage() {
if (typeof localStorage === 'undefined') return;
var raw = localStorage.getItem('pf-builder-sections');
if (!raw) return;
try {
var parsed = JSON.parse(raw);
var order = Array.isArray(parsed.order) ? parsed.order : Object.keys(parsed.sections || {});
var sectionMeta = parsed.sections || {};
if (!Array.isArray(this.data.sections)) this.data.sections = [];
var existingById = {};
this.data.sections.forEach(function(section) {
existingById[section.id] = section;
});
order.forEach(function(id) {
var meta = sectionMeta[id];
if (!meta) return;
var 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
};
if (existingById[id]) {
existingById[id].title = meta.title || existingById[id].title;
existingById[id].subtitle = meta.subtitle || existingById[id].subtitle;
existingById[id].description = meta.description || existingById[id].description;
existingById[id].icon = meta.icon || existingById[id].icon;
existingById[id].color = meta.color || existingById[id].color;
existingById[id].stats = stats;
} else {
var newSection = {
id: id,
title: meta.title || id,
subtitle: meta.subtitle || '',
icon: meta.icon || '📚',
color: meta.color || '#7f8c8d',
description: meta.description || '',
stats: stats
};
this.data.sections.push(newSection);
existingById[id] = newSection;
}
}, this);
// Keep display order aligned with builder tabs for kiosk consistency.
var reordered = [];
order.forEach(function(id) {
if (existingById[id]) reordered.push(existingById[id]);
});
this.data.sections.forEach(function(section) {
if (order.indexOf(section.id) === -1) reordered.push(section);
});
this.data.sections = reordered;
} catch (error) {
console.warn('Could not parse builder section metadata from localStorage.');
}
} }
async loadJSON(url) { async loadJSON(url) {
@@ -434,6 +499,10 @@ class ProjectFriction {
} }
initializeSectionInteractions(sectionId) { initializeSectionInteractions(sectionId) {
if (typeof BlockRenderer !== 'undefined' && typeof BlockRenderer.initGalleries === 'function') {
BlockRenderer.initGalleries(document.getElementById('sectionContainer'));
}
// Wire map-button blocks to the Leaflet map // Wire map-button blocks to the Leaflet map
const self = this; const self = this;
document.querySelectorAll('.block-map-btn').forEach(function(btn) { document.querySelectorAll('.block-map-btn').forEach(function(btn) {
@@ -526,7 +595,7 @@ class ProjectFriction {
if (sidebar && toggleIcon) { if (sidebar && toggleIcon) {
sidebar.classList.toggle('collapsed'); sidebar.classList.toggle('collapsed');
toggleIcon.textContent = sidebar.classList.contains('collapsed') ? '' : ''; toggleIcon.textContent = sidebar.classList.contains('collapsed') ? '' : '';
} }
} }

View File

@@ -77,11 +77,85 @@
/* ── Quote Block ─────────────────────────────────────────────────────────── */ /* ── Quote Block ─────────────────────────────────────────────────────────── */
/* Uses existing .quote-block, .quote-text, .quote-attribution from components.css */ /* Uses existing .quote-block, .quote-text, .quote-attribution from components.css */
/* ── Fact Briefing Panel ─────────────────────────────────────────────────── */
.block-fact-panel {
margin: 1.25rem 0;
}
.block-fact-panel .fact-panel-list {
margin: 0;
padding: 0;
}
/* ── Timeline Card ───────────────────────────────────────────────────────── */
.block-timeline-card {
background: var(--color-white);
border: 1px solid var(--color-gray);
border-radius: var(--radius-md);
padding: 1rem 1.1rem;
margin: 1.25rem 0;
}
.block-timeline-header {
display: flex;
align-items: flex-start;
gap: 0.55rem;
margin-bottom: 0.45rem;
flex-wrap: wrap;
}
.block-timeline-date {
background: var(--color-accent);
color: var(--color-white);
padding: 0.18rem 0.45rem;
border-radius: var(--radius-sm);
font-size: 12px;
line-height: 1.2;
font-weight: 600;
font-family: var(--font-secondary);
white-space: nowrap;
margin-top: 0.05rem;
}
.block-timeline-title {
margin: 0;
color: var(--color-primary);
font-size: 1.55rem;
line-height: 1.2;
font-family: var(--font-primary);
flex: 1;
min-width: 220px;
}
.block-timeline-text {
margin: 0;
margin-left: calc(0.45rem + 0.55rem + 52px);
font-size: 1.1rem;
line-height: 1.45;
color: var(--color-primary);
font-family: var(--font-primary);
}
@media (max-width: 640px) {
.block-timeline-text {
margin-left: 0;
}
}
/* ── Gallery Block ───────────────────────────────────────────────────────── */ /* ── Gallery Block ───────────────────────────────────────────────────────── */
.block-gallery { .block-gallery {
margin: 1.25rem 0; margin: 1.25rem 0;
} }
.gallery-empty {
padding: 0.75rem;
border: 1px dashed var(--color-gray);
border-radius: var(--radius-sm);
color: var(--color-dark-gray);
font-family: var(--font-secondary);
font-size: 13px;
}
.gallery-scroll { .gallery-scroll {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
@@ -127,6 +201,92 @@
right: 0; right: 0;
} }
/* ── Carousel Gallery Mode ────────────────────────────────────────────────── */
.gallery-mode-carousel .gallery-carousel {
position: relative;
}
.gallery-mode-carousel .gallery-slides {
position: relative;
min-height: 220px;
border-radius: var(--radius-sm);
overflow: hidden;
box-shadow: var(--shadow-sm);
background: #0f1720;
}
.gallery-mode-carousel .gallery-slide {
display: none;
margin: 0;
position: relative;
}
.gallery-mode-carousel .gallery-slide.is-active {
display: block;
}
.gallery-mode-carousel .gallery-slide img {
width: 100%;
height: 260px;
object-fit: cover;
display: block;
}
.gallery-slide-caption {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to top, rgba(0,0,0,0.78), rgba(0,0,0,0));
color: #fff;
font-size: 12px;
line-height: 1.45;
padding: 0.75rem;
font-family: var(--font-secondary);
}
.gallery-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 34px;
height: 34px;
border: none;
border-radius: var(--radius-full);
background: rgba(0, 0, 0, 0.42);
color: #fff;
font-size: 24px;
line-height: 1;
cursor: pointer;
}
.gallery-nav:hover {
background: rgba(0, 0, 0, 0.62);
}
.gallery-nav-prev { left: 0.5rem; }
.gallery-nav-next { right: 0.5rem; }
.gallery-dots {
margin-top: 0.6rem;
display: flex;
justify-content: center;
gap: 0.4rem;
}
.gallery-dot {
width: 9px;
height: 9px;
border-radius: var(--radius-full);
border: none;
background: #cbd5e1;
cursor: pointer;
}
.gallery-dot.is-active {
background: var(--color-primary);
}
/* ── 4-Image Grid Block ──────────────────────────────────────────────────── */ /* ── 4-Image Grid Block ──────────────────────────────────────────────────── */
.block-grid4 { .block-grid4 {
margin: 1.25rem 0; margin: 1.25rem 0;

View File

@@ -147,6 +147,24 @@
.tab-icon { font-size: 16px; } .tab-icon { font-size: 16px; }
.new-section-btn {
width: 100%;
margin-top: 0.5rem;
padding: 0.5rem 0.7rem;
border: 1px dashed #3498db;
border-radius: 6px;
background: #f5fbff;
color: #2b6ea4;
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.new-section-btn:hover {
background: #eaf4fb;
border-color: #2f8dd8;
}
/* ── Block Palette ───────────────────────────────────────────────── */ /* ── Block Palette ───────────────────────────────────────────────── */
.palette-blocks { .palette-blocks {
display: flex; display: flex;
@@ -321,18 +339,33 @@
.canvas-block-preview { .canvas-block-preview {
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
pointer-events: none; pointer-events: auto;
max-height: 280px; max-height: 280px;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
} }
.canvas-block-preview button,
.canvas-block-preview a,
.canvas-block-preview input,
.canvas-block-preview select,
.canvas-block-preview textarea {
pointer-events: none;
}
.canvas-block-preview [data-gallery-prev],
.canvas-block-preview [data-gallery-next],
.canvas-block-preview [data-gallery-dot] {
pointer-events: auto;
}
.canvas-block-preview::after { .canvas-block-preview::after {
content: ''; content: '';
position: absolute; position: absolute;
bottom: 0; left: 0; right: 0; bottom: 0; left: 0; right: 0;
height: 30px; height: 30px;
background: linear-gradient(to bottom, transparent, rgba(255,255,255,0.8)); background: linear-gradient(to bottom, transparent, rgba(255,255,255,0.8));
pointer-events: none;
} }
/* ── Insert Strip ────────────────────────────────────────────────── */ /* ── Insert Strip ────────────────────────────────────────────────── */
@@ -412,7 +445,8 @@
color: #2c3e50; color: #2c3e50;
} }
#modalClose { #modalClose,
#sectionWizardClose {
background: none; background: none;
border: none; border: none;
font-size: 20px; font-size: 20px;
@@ -422,7 +456,8 @@
padding: 0 0.2rem; padding: 0 0.2rem;
} }
#modalClose:hover { color: #2c3e50; } #modalClose:hover,
#sectionWizardClose:hover { color: #2c3e50; }
.modal-body { .modal-body {
padding: 1.25rem; padding: 1.25rem;
@@ -482,6 +517,12 @@
gap: 0.75rem; gap: 0.75rem;
} }
.form-row-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
/* Gallery / Stats item rows */ /* Gallery / Stats item rows */
.item-row { .item-row {
display: flex; display: flex;
@@ -595,6 +636,7 @@
<div class="palette-section"> <div class="palette-section">
<div class="palette-title">Sections</div> <div class="palette-title">Sections</div>
<div id="sectionTabs"></div> <div id="sectionTabs"></div>
<button id="newSectionBtn" class="new-section-btn">+ New Section</button>
</div> </div>
<!-- Block Palette --> <!-- Block Palette -->
@@ -605,6 +647,8 @@
<button class="palette-block" data-type="paragraph"> ¶ Paragraph</button> <button class="palette-block" data-type="paragraph"> ¶ Paragraph</button>
<button class="palette-block" data-type="image"> 🖼 Image</button> <button class="palette-block" data-type="image"> 🖼 Image</button>
<button class="palette-block" data-type="quote"> " Quote</button> <button class="palette-block" data-type="quote"> " Quote</button>
<button class="palette-block" data-type="fact-panel"> ▸ Fact Briefing Panel</button>
<button class="palette-block" data-type="timeline-card"> 🗓 Timeline Card</button>
<button class="palette-block" data-type="gallery"> ⊞ Gallery</button> <button class="palette-block" data-type="gallery"> ⊞ Gallery</button>
<button class="palette-block" data-type="grid4"> ▦ 4-Image Grid</button> <button class="palette-block" data-type="grid4"> ▦ 4-Image Grid</button>
<button class="palette-block" data-type="statistics"> # Statistics</button> <button class="palette-block" data-type="statistics"> # Statistics</button>
@@ -646,6 +690,59 @@
</div> </div>
</div> </div>
<!-- ── New Section Wizard ─────────────────────────────────────────────── -->
<div id="sectionWizardModal" class="builder-modal hidden">
<div class="modal-overlay"></div>
<div class="modal-dialog">
<div class="modal-header">
<h3>Create New Section</h3>
<button id="sectionWizardClose" title="Close">&#x2715;</button>
</div>
<div class="modal-body" id="sectionWizardBody">
<div class="form-group">
<label>Title</label>
<input type="text" id="wizardTitle" placeholder="e.g. Intelligence & Reconnaissance">
</div>
<div class="form-group">
<label>Subtitle</label>
<input type="text" id="wizardSubtitle" placeholder="Short subheading shown on the card">
</div>
<div class="form-group">
<label>Description</label>
<textarea id="wizardDescription" rows="4" placeholder="Card preview description for visitors"></textarea>
</div>
<div class="form-row-2">
<div class="form-group">
<label>Icon (emoji)</label>
<input type="text" id="wizardIcon" value="&#x1F4DA;" maxlength="4">
</div>
<div class="form-group">
<label>Theme Color</label>
<input type="text" id="wizardColor" value="#7f8c8d" placeholder="#3498db">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Articles</label>
<input type="number" id="wizardArticles" value="0" min="0">
</div>
<div class="form-group">
<label>Story Maps</label>
<input type="number" id="wizardStoryMaps" value="0" min="0">
</div>
<div class="form-group">
<label>Images</label>
<input type="number" id="wizardImages" value="0" min="0">
</div>
</div>
</div>
<div class="modal-footer">
<button id="sectionWizardSave" class="btn btn-primary">Create Section</button>
<button id="sectionWizardCancel" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
<script src="js/block-renderer.js"></script> <script src="js/block-renderer.js"></script>
<script src="js/builder.js"></script> <script src="js/builder.js"></script>
</body> </body>

View File

@@ -15,7 +15,7 @@
<div class="sidebar" id="sidebar"> <div class="sidebar" id="sidebar">
<!-- Toggle Button --> <!-- Toggle Button -->
<div class="sidebar-toggle" id="sidebarToggle"> <div class="sidebar-toggle" id="sidebarToggle">
<span id="toggle-icon"></span> <span id="toggle-icon"></span>
</div> </div>
<!-- Header --> <!-- Header -->

View File

@@ -5,6 +5,8 @@
*/ */
class BlockRenderer { class BlockRenderer {
static galleryUid = 0;
static render(blocks) { static render(blocks) {
if (!Array.isArray(blocks)) return ''; if (!Array.isArray(blocks)) return '';
return blocks.map(function(block) { return BlockRenderer.renderBlock(block); }).join('\n'); return blocks.map(function(block) { return BlockRenderer.renderBlock(block); }).join('\n');
@@ -17,6 +19,8 @@ class BlockRenderer {
case 'paragraph': return BlockRenderer.renderParagraph(block); case 'paragraph': return BlockRenderer.renderParagraph(block);
case 'image': return BlockRenderer.renderImage(block); case 'image': return BlockRenderer.renderImage(block);
case 'quote': return BlockRenderer.renderQuote(block); case 'quote': return BlockRenderer.renderQuote(block);
case 'fact-panel': return BlockRenderer.renderFactPanel(block);
case 'timeline-card': return BlockRenderer.renderTimelineCard(block);
case 'gallery': return BlockRenderer.renderGallery(block); case 'gallery': return BlockRenderer.renderGallery(block);
case 'grid4': return BlockRenderer.renderGrid4(block); case 'grid4': return BlockRenderer.renderGrid4(block);
case 'statistics': return BlockRenderer.renderStatistics(block); case 'statistics': return BlockRenderer.renderStatistics(block);
@@ -48,15 +52,170 @@ class BlockRenderer {
+ '</blockquote>'; + '</blockquote>';
} }
static renderGallery(block) { static renderFactPanel(block) {
var images = block.images || []; var title = block.title || 'Fact Briefing';
var items = images.map(function(img) { var factItems = block.facts || block.items || [];
return '<div class="gallery-item">' var facts = factItems.map(function(item) {
+ '<img src="' + (img.src || '') + '" alt="' + (img.alt || '') + '" loading="lazy">' if (typeof item === 'string') return item;
+ (img.caption ? '<div class="gallery-item-caption">' + img.caption + '</div>' : '') return item && item.text ? item.text : '';
+ '</div>'; }).filter(function(text) {
return String(text).trim() !== '';
});
if (facts.length === 0) {
facts = ['Add fact entries in the builder to populate this panel.'];
}
var listHtml = facts.map(function(text) {
return '<li>' + text + '</li>';
}).join(''); }).join('');
return '<div class="block block-gallery"><div class="gallery-scroll">' + items + '</div></div>';
return '<section class="block block-fact-panel fact-box">'
+ '<h3>' + title + '</h3>'
+ '<ul class="fact-panel-list">' + listHtml + '</ul>'
+ '</section>';
}
static renderTimelineCard(block) {
return '<article class="block block-timeline-card">'
+ '<header class="block-timeline-header">'
+ '<span class="block-timeline-date">' + (block.date || '') + '</span>'
+ '<h4 class="block-timeline-title">' + (block.title || '') + '</h4>'
+ '</header>'
+ '<p class="block-timeline-text">' + (block.text || '') + '</p>'
+ '</article>';
}
static renderGallery(block) {
var images = Array.isArray(block.images) ? block.images.filter(function(img) {
return img && img.src;
}) : [];
if (images.length === 0) {
return '<div class="block block-gallery"><div class="gallery-empty">No gallery images configured.</div></div>';
}
// Backward compatible: explicit strip mode keeps the original horizontal gallery.
var mode = (block.mode || 'carousel').toLowerCase();
if (mode === 'strip') {
var stripItems = images.map(function(img) {
return '<div class="gallery-item">'
+ '<img src="' + (img.src || '') + '" alt="' + (img.alt || '') + '" loading="lazy">'
+ (img.caption ? '<div class="gallery-item-caption">' + img.caption + '</div>' : '')
+ '</div>';
}).join('');
return '<div class="block block-gallery"><div class="gallery-scroll">' + stripItems + '</div></div>';
}
var id = 'gallery-' + (++BlockRenderer.galleryUid);
var autoplay = block.autoplay === false ? '0' : '1';
var intervalSec = parseInt(block.intervalSec, 10);
if (isNaN(intervalSec)) intervalSec = 5;
intervalSec = Math.max(2, Math.min(intervalSec, 15));
var showDots = block.showDots === false ? '0' : '1';
var showArrows = block.showArrows === false ? '0' : '1';
var slides = images.map(function(img, index) {
return '<figure class="gallery-slide' + (index === 0 ? ' is-active' : '') + '" data-slide-index="' + index + '">'
+ '<img src="' + (img.src || '') + '" alt="' + (img.alt || '') + '" loading="lazy">'
+ (img.caption ? '<figcaption class="gallery-slide-caption">' + img.caption + '</figcaption>' : '')
+ '</figure>';
}).join('');
var dots = images.map(function(_, index) {
return '<button type="button" class="gallery-dot' + (index === 0 ? ' is-active' : '') + '" data-gallery-dot="' + index + '" aria-label="Go to image ' + (index + 1) + '"></button>';
}).join('');
return '<div class="block block-gallery gallery-mode-carousel" id="' + id + '" data-gallery="1" data-autoplay="' + autoplay + '" data-interval-ms="' + (intervalSec * 1000) + '">'
+ '<div class="gallery-carousel">'
+ '<div class="gallery-slides">' + slides + '</div>'
+ (showArrows === '1' && images.length > 1
? '<button type="button" class="gallery-nav gallery-nav-prev" data-gallery-prev aria-label="Previous image">&#x2039;</button>'
+ '<button type="button" class="gallery-nav gallery-nav-next" data-gallery-next aria-label="Next image">&#x203A;</button>'
: '')
+ '</div>'
+ (showDots === '1' && images.length > 1 ? '<div class="gallery-dots">' + dots + '</div>' : '')
+ '</div>';
}
static initGalleries(root) {
var scope = root || document;
if (!scope || !scope.querySelectorAll) return;
scope.querySelectorAll('[data-gallery="1"]').forEach(function(gallery) {
if (gallery.dataset.galleryInit === '1') return;
gallery.dataset.galleryInit = '1';
var slides = Array.from(gallery.querySelectorAll('.gallery-slide'));
if (slides.length <= 1) return;
var dots = Array.from(gallery.querySelectorAll('[data-gallery-dot]'));
var nextBtn = gallery.querySelector('[data-gallery-next]');
var prevBtn = gallery.querySelector('[data-gallery-prev]');
var index = 0;
var timerId = null;
var autoplay = gallery.dataset.autoplay !== '0';
var intervalMs = parseInt(gallery.dataset.intervalMs, 10);
if (isNaN(intervalMs)) intervalMs = 5000;
intervalMs = Math.max(2000, Math.min(intervalMs, 15000));
function showAt(newIndex) {
index = (newIndex + slides.length) % slides.length;
slides.forEach(function(slide, i) {
slide.classList.toggle('is-active', i === index);
});
dots.forEach(function(dot, i) {
dot.classList.toggle('is-active', i === index);
});
}
function stopTimer() {
if (timerId) {
clearTimeout(timerId);
timerId = null;
}
}
function scheduleNext() {
stopTimer();
if (!autoplay) return;
timerId = setTimeout(function tick() {
if (!document.body.contains(gallery)) {
stopTimer();
return;
}
showAt(index + 1);
timerId = setTimeout(tick, intervalMs);
}, intervalMs);
}
if (nextBtn) {
nextBtn.addEventListener('click', function() {
showAt(index + 1);
scheduleNext();
});
}
if (prevBtn) {
prevBtn.addEventListener('click', function() {
showAt(index - 1);
scheduleNext();
});
}
dots.forEach(function(dot) {
dot.addEventListener('click', function() {
showAt(parseInt(dot.dataset.galleryDot, 10) || 0);
scheduleNext();
});
});
gallery.addEventListener('pointerenter', stopTimer);
gallery.addEventListener('pointerleave', scheduleNext);
showAt(0);
scheduleNext();
});
} }
static renderGrid4(block) { static renderGrid4(block) {

View File

@@ -6,29 +6,75 @@
class SectionBuilder { class SectionBuilder {
constructor() { constructor() {
this.sections = { this.sections = this.buildDefaultSections();
context: { title: 'Historical Context', icon: '🌍', color: '#d4a574', blocks: [] }, this.sectionsStorageKey = 'pf-builder-sections';
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.currentSection = 'context';
this.editingIndex = null; this.editingIndex = null;
this.defaultContent = null; this.defaultContent = null;
this.init(); 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() { async init() {
try { try {
var res = await fetch('data/sections-content.json'); var res = await fetch('data/sections-content.json');
if (res.ok) this.defaultContent = await res.json(); if (res.ok) this.defaultContent = await res.json();
} catch (e) { console.warn('Could not load default content'); } } catch (e) { console.warn('Could not load default content'); }
this.loadSectionsManifest();
this.loadAllFromStorage(); this.loadAllFromStorage();
this.renderSectionTabs(); this.renderSectionTabs();
this.bindGlobalEvents(); this.bindGlobalEvents();
this.loadSection('context'); this.loadSection(this.sections.context ? 'context' : Object.keys(this.sections)[0]);
} }
// ── Storage ──────────────────────────────────────────────────────────── // ── Storage ────────────────────────────────────────────────────────────
@@ -45,8 +91,72 @@ class SectionBuilder {
}); });
} }
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) { saveSection(id) {
localStorage.setItem('pf-section-' + id, JSON.stringify(this.sections[id].blocks)); localStorage.setItem('pf-section-' + id, JSON.stringify(this.sections[id].blocks));
this.saveSectionsManifest();
this.showToast(this.sections[id].title + ' saved!'); this.showToast(this.sections[id].title + ' saved!');
} }
@@ -92,6 +202,9 @@ class SectionBuilder {
+ '</div>'; + '</div>';
canvas.innerHTML = html; canvas.innerHTML = html;
if (typeof BlockRenderer !== 'undefined' && typeof BlockRenderer.initGalleries === 'function') {
BlockRenderer.initGalleries(canvas);
}
this.bindCanvasEvents(); this.bindCanvasEvents();
} }
@@ -173,7 +286,33 @@ class SectionBuilder {
case 'paragraph': return { id: id, type: type, text: 'Enter paragraph text here.' }; case 'paragraph': return { id: id, type: type, text: 'Enter paragraph text here.' };
case 'image': return { id: id, type: type, src: '', alt: '', caption: '' }; case 'image': return { id: id, type: type, src: '', alt: '', caption: '' };
case 'quote': return { id: id, type: type, text: 'Quote text here.', author: 'Source' }; 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 '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: [ case 'grid4': return { id: id, type: type, images: [
{ src: '', alt: '', caption: '' }, { src: '', alt: '', caption: '' }, { src: '', alt: '', caption: '' }, { src: '', alt: '', caption: '' },
{ src: '', alt: '', caption: '' }, { src: '', alt: '', caption: '' } { src: '', alt: '', caption: '' }, { src: '', alt: '', caption: '' }
@@ -237,8 +376,46 @@ class SectionBuilder {
return this.formGroup('Quote Text', '<textarea name="text" rows="4">' + this.esc(block.text || '') + '</textarea>') 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">'); + 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': case 'gallery':
return this.formGroup('Gallery Images', 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">' '<div id="galleryItems">'
+ (block.images || []).map(function(img, i) { return SectionBuilder.imageRow(i, img, true); }).join('') + (block.images || []).map(function(img, i) { return SectionBuilder.imageRow(i, img, true); }).join('')
+ '</div>' + '</div>'
@@ -289,6 +466,13 @@ class SectionBuilder {
+ '</div>'; + '</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) { bindDynamicFormEvents(container) {
var self = this; var self = this;
@@ -312,6 +496,16 @@ class SectionBuilder {
}); });
} }
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); this.bindRemoveBtns(container);
} }
@@ -348,7 +542,23 @@ class SectionBuilder {
block.text = body.querySelector('[name="text"]').value; block.text = body.querySelector('[name="text"]').value;
block.author = body.querySelector('[name="author"]').value; block.author = body.querySelector('[name="author"]').value;
break; 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': 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']); block.images = this.readItemRows(body.querySelector('#galleryItems'), ['src','alt','caption']);
break; break;
case 'grid4': case 'grid4':
@@ -437,12 +647,46 @@ class SectionBuilder {
reader.onload = function(evt) { reader.onload = function(evt) {
try { try {
var data = JSON.parse(evt.target.result); var data = JSON.parse(evt.target.result);
Object.keys(self.sections).forEach(function(id) { Object.keys(data).forEach(function(id) {
if (data[id] && data[id].blocks) { var entry = data[id] || {};
self.sections[id].blocks = data[id].blocks; var blocks = Array.isArray(entry.blocks) ? entry.blocks : (Array.isArray(entry) ? entry : []);
localStorage.setItem('pf-section-' + id, JSON.stringify(data[id].blocks)); 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.loadSection(self.currentSection);
self.showToast('Content imported!'); self.showToast('Content imported!');
} catch (err) { } catch (err) {
@@ -460,13 +704,25 @@ class SectionBuilder {
}); });
}); });
document.getElementById('newSectionBtn').addEventListener('click', function() {
self.openSectionWizard();
});
document.getElementById('modalSave').addEventListener('click', function() { self.saveModalBlock(); }); document.getElementById('modalSave').addEventListener('click', function() { self.saveModalBlock(); });
document.getElementById('modalCancel').addEventListener('click', function() { self.closeModal(); }); document.getElementById('modalCancel').addEventListener('click', function() { self.closeModal(); });
document.getElementById('modalClose').addEventListener('click', function() { self.closeModal(); }); document.getElementById('modalClose').addEventListener('click', function() { self.closeModal(); });
document.querySelector('.modal-overlay').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) { document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') self.closeModal(); if (e.key === 'Escape') {
self.closeModal();
self.closeSectionWizard();
}
if ((e.ctrlKey || e.metaKey) && e.key === 's') { if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault(); e.preventDefault();
if (self.currentSection) self.saveSection(self.currentSection); if (self.currentSection) self.saveSection(self.currentSection);
@@ -474,10 +730,73 @@ class SectionBuilder {
}); });
} }
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() { exportJSON() {
var output = {}; var output = {};
Object.keys(this.sections).forEach(function(id) { Object.keys(this.sections).forEach(function(id) {
output[id] = { blocks: this.sections[id].blocks }; output[id] = {
meta: this.sectionMeta(id),
blocks: this.sections[id].blocks
};
}, this); }, this);
var blob = new Blob([JSON.stringify(output, null, 2)], { type: 'application/json' }); var blob = new Blob([JSON.stringify(output, null, 2)], { type: 'application/json' });
var url = URL.createObjectURL(blob); var url = URL.createObjectURL(blob);
@@ -503,7 +822,7 @@ class SectionBuilder {
blockTypeLabel(type) { blockTypeLabel(type) {
var labels = { var labels = {
heading: 'H Heading', paragraph: '¶ Paragraph', image: '🖼 Image', heading: 'H Heading', paragraph: '¶ Paragraph', image: '🖼 Image',
quote: '" Quote', gallery: '⊞ Gallery', grid4: '▦ Grid 4', quote: '" Quote', 'fact-panel': '▸ Fact Briefing Panel', 'timeline-card': '🗓 Timeline Card', gallery: '⊞ Gallery', grid4: '▦ Grid 4',
statistics: '# Statistics', 'map-button': '📍 Map Button' statistics: '# Statistics', 'map-button': '📍 Map Button'
}; };
return labels[type] || type; return labels[type] || type;

View File

@@ -77,7 +77,7 @@ body {
/* Sidebar Styling */ /* Sidebar Styling */
.sidebar { .sidebar {
width: 420px; width: 35%;
background: var(--color-white); background: var(--color-white);
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
display: flex; display: flex;
@@ -85,20 +85,21 @@ body {
z-index: 100; z-index: 100;
transition: transform var(--transition-normal); transition: transform var(--transition-normal);
position: relative; position: relative;
order: 2;
} }
.sidebar.collapsed { .sidebar.collapsed {
transform: translateX(-370px); transform: translateX(370px);
} }
.sidebar-toggle { .sidebar-toggle {
position: absolute; position: absolute;
right: -30px; left: -30px;
top: 20px; top: 20px;
width: 30px; width: 30px;
height: 60px; height: 60px;
background: var(--color-white); background: var(--color-white);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0; border-radius: var(--radius-sm) 0 0 var(--radius-sm);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
display: flex; display: flex;
align-items: center; align-items: center;
@@ -266,6 +267,7 @@ body {
flex: 1; flex: 1;
position: relative; position: relative;
background: var(--color-secondary); background: var(--color-secondary);
order: 1;
} }
#map { #map {