first version

This commit is contained in:
Nathan
2026-04-30 19:44:31 -04:00
parent 676746c180
commit a472255a94
11 changed files with 3586 additions and 0 deletions

587
app.js Normal file
View File

@@ -0,0 +1,587 @@
// Project Friction - Main Application
// Museum-quality digital exhibit for Canada's Gulf War role
class ProjectFriction {
constructor() {
this.currentSection = null;
this.map = null;
this.mapLayers = {};
this.timeline = null;
this.data = {
sections: [],
mapData: {},
timelineEvents: [],
articles: {}
};
this.init();
}
async init() {
try {
// Show loading screen
this.showLoadingScreen();
// Load data
await this.loadApplicationData();
// Initialize components
this.initializeMap();
this.initializeNavigation();
this.initializeTimeline();
this.initializeInteractions();
// Setup event listeners
this.setupEventListeners();
// Load initial state
this.loadMainNavigation();
// Hide loading screen
this.hideLoadingScreen();
console.log('Project Friction initialized successfully');
} catch (error) {
console.error('Error initializing Project Friction:', error);
this.showErrorMessage('Failed to load exhibition. Please refresh the page.');
}
}
async loadApplicationData() {
// Load optional datasets without failing app startup if files are missing.
this.data.sections = await this.loadOptionalJSON(['data/sections.json'], []);
this.data.mapData = await this.loadOptionalJSON(['data/map-data.json', 'map-data.json'], {});
this.data.timelineEvents = await this.loadOptionalJSON(['data/timeline-events.json'], []);
this.data.articles = await this.loadOptionalJSON(['data/articles.json'], {});
// Load block-based section content (used by the block renderer)
this.data.sectionsContent = await this.loadOptionalJSON(['data/sections-content.json'], {});
if (!Array.isArray(this.data.sections) || this.data.sections.length === 0) {
this.loadDefaultConfiguration();
}
}
async loadJSON(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to load ${url}: ${response.status}`);
}
return await response.json();
}
async loadOptionalJSON(paths, fallbackValue) {
for (const path of paths) {
try {
return await this.loadJSON(path);
} catch (error) {
console.warn(`Optional data file not available: ${path}`);
}
}
return fallbackValue;
}
loadDefaultConfiguration() {
// Fallback configuration if data files fail to load
this.data.sections = [
{
id: '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 }
},
{
id: '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 }
},
{
id: '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 }
},
{
id: '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 }
},
{
id: 'legacy',
title: 'After the War',
subtitle: 'Veterans & Long-term Impact',
icon: '🍁',
color: '#c0392b',
description: 'Gulf War Syndrome, environmental cleanup, peacekeeping missions, and the ongoing support for veterans.',
stats: { articles: 7, storyMaps: 2, images: 30 }
}
];
// Default stats for main navigation
this.data.globalStats = {
personnel: '4,500+',
sorties: '2,700',
medals: '4,449',
duration: '7'
};
}
initializeMap() {
if (!window.L) {
console.warn('Leaflet is unavailable. Map features are disabled.');
return;
}
const mapElement = document.getElementById('map');
if (!mapElement) {
console.warn('Map container not found.');
return;
}
// Initialize Leaflet map
this.map = L.map('map', {
center: [29.3759, 47.9774], // Kuwait City
zoom: 6,
zoomControl: false
});
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 18
}).addTo(this.map);
// Initialize layer groups
this.mapLayers = {
coalition: L.layerGroup(),
iraqi: L.layerGroup(),
canadian: L.layerGroup(),
events: L.layerGroup(),
routes: L.layerGroup()
};
// Add initial overview markers
this.loadOverviewMarkers();
}
loadOverviewMarkers() {
if (!this.map || !window.L) return;
// Add basic overview markers
const overviewMarkers = [
{ lat: 29.3759, lng: 47.9774, title: 'Kuwait City', type: 'liberation' },
{ lat: 25.2731, lng: 51.6080, title: 'Doha Air Base', type: 'canadian' },
{ lat: 26.2285, lng: 50.5860, title: 'Naval Operations', type: 'canadian' }
];
overviewMarkers.forEach(marker => {
const mapMarker = L.marker([marker.lat, marker.lng])
.bindPopup(`<strong>${marker.title}</strong>`)
.addTo(this.map);
});
}
initializeNavigation() {
if (typeof NavigationController === 'undefined') {
console.warn('NavigationController is unavailable. Using built-in navigation only.');
return;
}
this.navigation = new NavigationController(this);
this.navigation.init();
}
initializeTimeline() {
if (typeof TimelineController === 'undefined') {
console.warn('TimelineController is unavailable.');
return;
}
this.timeline = new TimelineController(this);
this.timeline.init();
}
initializeInteractions() {
if (typeof InteractionController === 'undefined') {
console.warn('InteractionController is unavailable.');
return;
}
this.interactions = new InteractionController(this);
this.interactions.init();
}
setupEventListeners() {
// Sidebar toggle
const sidebarToggle = document.getElementById('sidebarToggle');
if (sidebarToggle) {
sidebarToggle.addEventListener('click', () => this.toggleSidebar());
}
// Back button
const backButton = document.getElementById('backButton');
if (backButton) {
backButton.addEventListener('click', () => this.showMainNavigation());
}
// Window resize handler
window.addEventListener('resize', () => this.handleResize());
// Keyboard navigation
document.addEventListener('keydown', (e) => this.handleKeyboard(e));
}
loadMainNavigation() {
// Show main navigation
const mainNav = document.getElementById('mainNavigation');
const sectionContainer = document.getElementById('sectionContainer');
const backButton = document.getElementById('backButton');
if (mainNav) mainNav.classList.remove('hidden');
if (sectionContainer) sectionContainer.classList.add('hidden');
if (backButton) backButton.classList.remove('show');
// Load navigation cards and stats
this.renderQuickStats();
this.renderNavigationCards();
// Reset map to overview
this.resetMapView();
this.currentSection = null;
}
renderQuickStats() {
const statsContainer = document.getElementById('quickStats');
if (!statsContainer) return;
const stats = this.data.globalStats || {
personnel: '4,500+',
sorties: '2,700',
medals: '4,449',
duration: '7'
};
statsContainer.innerHTML = `
<div class="stat-card">
<div class="stat-number">${stats.personnel}</div>
<div class="stat-label">Personnel</div>
</div>
<div class="stat-card">
<div class="stat-number">${stats.sorties}</div>
<div class="stat-label">Sorties</div>
</div>
<div class="stat-card">
<div class="stat-number">${stats.medals}</div>
<div class="stat-label">Medals</div>
</div>
<div class="stat-card">
<div class="stat-number">${stats.duration}</div>
<div class="stat-label">Months</div>
</div>
`;
}
renderNavigationCards() {
const cardsContainer = document.getElementById('navigationCards');
if (!cardsContainer) return;
const cardsHTML = this.data.sections.map(section => `
<div class="nav-card" data-section="${section.id}">
<div class="nav-card-header">
<span class="nav-card-icon">${section.icon}</span>
<div class="nav-card-title">
<h3>${section.title}</h3>
<span>${section.subtitle}</span>
</div>
</div>
<div class="nav-card-preview">
${section.description}
</div>
<div class="nav-card-links">
<div class="nav-card-link">📄 ${section.stats.articles} Articles</div>
<div class="nav-card-link">🗺️ ${section.stats.storyMaps} Story Maps</div>
<div class="nav-card-link">📷 ${section.stats.images} Images</div>
</div>
</div>
`).join('');
cardsContainer.innerHTML = cardsHTML;
// Add click handlers
cardsContainer.querySelectorAll('.nav-card').forEach(card => {
card.addEventListener('click', () => {
const sectionId = card.dataset.section;
this.showSection(sectionId);
});
});
}
async showSection(sectionId) {
try {
const section = this.data.sections.find(s => s.id === sectionId);
if (!section) {
throw new Error(`Section ${sectionId} not found`);
}
// Hide main navigation
const mainNav = document.getElementById('mainNavigation');
if (mainNav) mainNav.classList.add('hidden');
// Show section container and back button
const sectionContainer = document.getElementById('sectionContainer');
const backButton = document.getElementById('backButton');
if (sectionContainer) sectionContainer.classList.remove('hidden');
if (backButton) backButton.classList.add('show');
// Load section content
await this.loadSectionContent(section);
// Update map for section
this.updateMapForSection(section);
this.currentSection = sectionId;
} catch (error) {
console.error(`Error loading section ${sectionId}:`, error);
this.showErrorMessage(`Failed to load ${sectionId} section. Please try again.`);
}
}
async loadSectionContent(section) {
const sectionContainer = document.getElementById('sectionContainer');
if (!sectionContainer) return;
// 1. Check localStorage for builder-saved blocks
const saved = localStorage.getItem('pf-section-' + section.id);
if (saved) {
try {
const blocks = JSON.parse(saved);
if (typeof BlockRenderer !== 'undefined') {
sectionContainer.innerHTML = this.wrapSectionContent(section, BlockRenderer.render(blocks));
this.initializeSectionInteractions(section.id);
return;
}
} catch (e) { /* fall through */ }
}
// 2. Use sections-content.json block data
const contentData = this.data.sectionsContent && this.data.sectionsContent[section.id];
if (contentData && contentData.blocks && typeof BlockRenderer !== 'undefined') {
sectionContainer.innerHTML = this.wrapSectionContent(section, BlockRenderer.render(contentData.blocks));
this.initializeSectionInteractions(section.id);
return;
}
// 3. Try legacy HTML file
try {
const response = await fetch('sections/' + section.id + '.html');
if (!response.ok) throw new Error('No legacy HTML');
sectionContainer.innerHTML = await response.text();
this.initializeSectionInteractions(section.id);
} catch (error) {
// 4. Fallback placeholder
this.loadFallbackSectionContent(section);
}
}
wrapSectionContent(section, blocksHtml) {
return '<div class="section-content-inner" style="--theme-color:' + section.color + '">'
+ '<div class="section-page-header" style="border-left:4px solid ' + section.color + '; padding: 1rem 1.25rem; background:#f8f9fb; margin-bottom:1rem;">'
+ '<div style="display:flex;align-items:center;gap:0.75rem;">'
+ '<span style="font-size:28px">' + section.icon + '</span>'
+ '<div><h2 style="margin:0;font-size:18px;color:#2c3e50">' + section.title + '</h2>'
+ '<p style="margin:0;font-size:13px;color:#6c757d">' + section.subtitle + '</p></div>'
+ '</div></div>'
+ blocksHtml
+ '</div>';
}
loadFallbackSectionContent(section) {
const sectionContainer = document.getElementById('sectionContainer');
if (!sectionContainer) return;
sectionContainer.innerHTML = `
<div class="section-header" style="--theme-color: ${section.color};">
<div class="section-title">
<span class="section-icon">${section.icon}</span>
<span class="section-name">${section.title}</span>
</div>
<div class="section-subtitle">${section.subtitle}</div>
</div>
<div class="content-area">
<div class="article-section">
<h3>Content Loading</h3>
<p>This section is being developed. Please check back later for complete content.</p>
</div>
</div>
`;
}
initializeSectionInteractions(sectionId) {
// Wire map-button blocks to the Leaflet map
const self = this;
document.querySelectorAll('.block-map-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
const lat = parseFloat(btn.dataset.lat);
const lng = parseFloat(btn.dataset.lng);
const zoom = parseInt(btn.dataset.zoom) || 6;
if (self.map) {
self.map.setView([lat, lng], zoom);
btn.classList.add('active');
setTimeout(function() { btn.classList.remove('active'); }, 1500);
}
});
});
}
updateMapForSection(section) {
if (!this.map) return;
// Clear existing layers
Object.values(this.mapLayers).forEach(layer => {
this.map.removeLayer(layer);
layer.clearLayers();
});
// Add section-specific map data
const sectionMapData = this.data.mapData[section.id];
if (sectionMapData) {
this.loadMapData(sectionMapData);
}
// Update map view for section
this.setMapViewForSection(section.id);
}
setMapViewForSection(sectionId) {
if (!this.map) return;
const mapViews = {
context: { center: [29.3759, 47.9774], zoom: 6 },
rcaf: { center: [25.2731, 51.6080], zoom: 8 },
rcn: { center: [27.0, 50.0], zoom: 7 },
army: { center: [32.0, 45.0], zoom: 6 },
legacy: { center: [35.0, 45.0], zoom: 5 }
};
const view = mapViews[sectionId] || mapViews.context;
this.map.setView(view.center, view.zoom);
}
loadMapData(mapData) {
if (!this.map || !window.L) return;
// Load markers, routes, and other geographic data
if (mapData.markers) {
mapData.markers.forEach(marker => {
const mapMarker = L.marker([marker.lat, marker.lng])
.bindPopup(`<strong>${marker.title}</strong><br>${marker.description || ''}`);
const layerGroup = this.mapLayers[marker.type] || this.mapLayers.events;
layerGroup.addLayer(mapMarker);
});
}
// Add all layers to map
Object.values(this.mapLayers).forEach(layer => {
this.map.addLayer(layer);
});
}
resetMapView() {
if (!this.map) return;
// Reset to overview
this.map.setView([29.3759, 47.9774], 6);
// Clear section-specific layers
Object.values(this.mapLayers).forEach(layer => {
this.map.removeLayer(layer);
layer.clearLayers();
});
// Reload overview markers
this.loadOverviewMarkers();
}
toggleSidebar() {
const sidebar = document.getElementById('sidebar');
const toggleIcon = document.getElementById('toggle-icon');
if (sidebar && toggleIcon) {
sidebar.classList.toggle('collapsed');
toggleIcon.textContent = sidebar.classList.contains('collapsed') ? '▶' : '◀';
}
}
showMainNavigation() {
this.loadMainNavigation();
}
handleResize() {
// Handle responsive behavior
if (this.map) {
this.map.invalidateSize();
}
}
handleKeyboard(event) {
// Handle keyboard navigation
if (event.key === 'Escape') {
if (this.currentSection) {
this.showMainNavigation();
}
}
}
showLoadingScreen() {
const loadingScreen = document.getElementById('loadingScreen');
if (loadingScreen) {
loadingScreen.style.display = 'flex';
}
}
hideLoadingScreen() {
const loadingScreen = document.getElementById('loadingScreen');
if (loadingScreen) {
setTimeout(() => {
loadingScreen.classList.add('fade-out');
setTimeout(() => {
loadingScreen.style.display = 'none';
}, 500);
}, 1000);
}
}
showErrorMessage(message) {
console.error(message);
// In production, this would show a user-friendly error dialog
alert(message);
}
}
// Initialize the application when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
window.projectFriction = new ProjectFriction();
});
// Export for module usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = ProjectFriction;
}

194
blocks.css Normal file
View File

@@ -0,0 +1,194 @@
/* Project Friction - Block Styles */
/* Styles for all 8 content block types rendered by BlockRenderer */
/* ── Shared block spacing ────────────────────────────────────────────────── */
.block {
margin-bottom: 1.5rem;
}
/* ── Heading Block ───────────────────────────────────────────────────────── */
.block-heading-1 {
font-size: 26px;
font-weight: normal;
color: var(--color-primary);
font-family: var(--font-primary);
border-bottom: 3px solid var(--theme-color, var(--color-accent));
padding-bottom: 0.5rem;
margin-top: 0.5rem;
margin-bottom: 1.25rem;
}
.block-heading-2 {
font-size: 20px;
font-weight: normal;
color: var(--color-primary);
font-family: var(--font-primary);
border-bottom: 2px solid var(--color-gray);
padding-bottom: 0.4rem;
margin-top: 1.5rem;
margin-bottom: 1rem;
}
.block-heading-3 {
font-size: 16px;
font-weight: 600;
color: var(--color-secondary);
font-family: var(--font-secondary);
margin-top: 1rem;
margin-bottom: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ── Paragraph Block ─────────────────────────────────────────────────────── */
.block-paragraph {
font-size: 15px;
line-height: 1.75;
color: #2d3748;
font-family: var(--font-secondary);
margin-bottom: 1rem;
}
/* ── Image Block ─────────────────────────────────────────────────────────── */
.block-image {
margin: 1.25rem 0;
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow-md);
}
.block-image img {
width: 100%;
display: block;
object-fit: cover;
max-height: 320px;
}
.block-image figcaption {
background: #f0f3f6;
color: var(--color-dark-gray);
font-size: 12px;
font-family: var(--font-secondary);
padding: 0.5rem 0.75rem;
font-style: italic;
border-top: 1px solid var(--color-gray);
}
/* ── Quote Block ─────────────────────────────────────────────────────────── */
/* Uses existing .quote-block, .quote-text, .quote-attribution from components.css */
/* ── Gallery Block ───────────────────────────────────────────────────────── */
.block-gallery {
margin: 1.25rem 0;
}
.gallery-scroll {
display: flex;
gap: 0.75rem;
overflow-x: auto;
padding-bottom: 0.5rem;
scrollbar-width: thin;
scrollbar-color: var(--color-gray) transparent;
}
.gallery-scroll::-webkit-scrollbar {
height: 4px;
}
.gallery-scroll::-webkit-scrollbar-thumb {
background: var(--color-gray);
border-radius: 4px;
}
.gallery-item {
flex: 0 0 200px;
border-radius: var(--radius-sm);
overflow: hidden;
box-shadow: var(--shadow-sm);
position: relative;
}
.gallery-item img {
width: 100%;
height: 140px;
object-fit: cover;
display: block;
}
.gallery-item-caption {
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 11px;
font-family: var(--font-secondary);
padding: 0.3rem 0.5rem;
position: absolute;
bottom: 0;
left: 0;
right: 0;
}
/* ── 4-Image Grid Block ──────────────────────────────────────────────────── */
.block-grid4 {
margin: 1.25rem 0;
}
.grid4-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
.grid4-item {
position: relative;
border-radius: var(--radius-sm);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.grid4-item img {
width: 100%;
height: 120px;
object-fit: cover;
display: block;
transition: transform 0.2s ease;
}
.grid4-item:hover img {
transform: scale(1.04);
}
.grid4-caption {
background: rgba(0, 0, 0, 0.65);
color: #fff;
font-size: 10px;
font-family: var(--font-secondary);
padding: 0.25rem 0.4rem;
position: absolute;
bottom: 0;
left: 0;
right: 0;
}
/* ── Statistics Block ────────────────────────────────────────────────────── */
.block-statistics {
margin: 1.25rem 0;
}
.block-stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
/* ── Map Button Block ────────────────────────────────────────────────────── */
.block-map-button-wrap {
margin: 0.75rem 0;
}
/* ── Section Content Container ───────────────────────────────────────────── */
.section-content-container {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}

653
builder.html Normal file
View File

@@ -0,0 +1,653 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Section Builder — Project Friction</title>
<link rel="stylesheet" href="main.css">
<link rel="stylesheet" href="components.css">
<link rel="stylesheet" href="blocks.css">
<style>
/* ── Builder Layout ─────────────────────────────────────────────── */
body {
overflow: auto;
height: auto;
background: #f0f2f5;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.builder-header {
background: linear-gradient(135deg, #2c3e50, #34495e);
color: #fff;
padding: 0 1.5rem;
height: 54px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 200;
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
}
.builder-brand {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 15px;
font-weight: 600;
}
.builder-brand .logo {
width: 32px;
height: 32px;
background: #c0392b;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
}
.builder-header-nav {
display: flex;
align-items: center;
gap: 0.5rem;
}
.builder-header-nav a,
.builder-header-nav button {
background: rgba(255,255,255,0.15);
color: #fff;
border: none;
padding: 0.4rem 0.9rem;
border-radius: 20px;
font-size: 13px;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.3rem;
transition: background 0.15s;
}
.builder-header-nav a:hover,
.builder-header-nav button:hover {
background: rgba(255,255,255,0.28);
}
.builder-header-nav .btn-save-header {
background: #27ae60;
}
.builder-header-nav .btn-save-header:hover {
background: #219a52;
}
/* ── Main layout ─────────────────────────────────────────────────── */
.builder-layout {
display: flex;
height: calc(100vh - 54px);
}
/* ── Sidebar ─────────────────────────────────────────────────────── */
.builder-sidebar {
width: 220px;
background: #fff;
border-right: 1px solid #dde3ea;
display: flex;
flex-direction: column;
overflow-y: auto;
flex-shrink: 0;
}
.palette-section {
padding: 1rem;
border-bottom: 1px solid #eef0f3;
}
.palette-title {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.8px;
color: #8a96a3;
margin-bottom: 0.6rem;
}
.section-tab {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.55rem 0.75rem;
background: #f8f9fb;
border: 1px solid #e2e8f0;
border-radius: 6px;
margin-bottom: 0.35rem;
cursor: pointer;
font-size: 13px;
text-align: left;
transition: all 0.15s;
border-left: 3px solid var(--tab-color, #ccc);
}
.section-tab:hover {
background: #f0f3f7;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
}
.section-tab.active {
background: #fff;
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
border-left-width: 4px;
font-weight: 600;
}
.tab-icon { font-size: 16px; }
/* ── Block Palette ───────────────────────────────────────────────── */
.palette-blocks {
display: flex;
flex-direction: column;
gap: 0.35rem;
transition: box-shadow 0.3s;
}
.palette-blocks.palette-highlight {
box-shadow: 0 0 0 3px #3498db;
border-radius: 6px;
padding: 4px;
}
.palette-block {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.65rem;
background: #f8f9fb;
border: 1px solid #e2e8f0;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
text-align: left;
transition: all 0.15s;
width: 100%;
}
.palette-block:hover {
background: #e8f4fd;
border-color: #3498db;
color: #1a5276;
}
/* ── Sidebar footer actions ──────────────────────────────────────── */
.sidebar-actions {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-top: auto;
}
.sidebar-actions button {
width: 100%;
padding: 0.5rem;
border: 1px solid #dde3ea;
border-radius: 6px;
background: #f8f9fb;
cursor: pointer;
font-size: 13px;
transition: background 0.15s;
}
.sidebar-actions button:hover { background: #e8ecf0; }
.sidebar-actions .btn-reset {
color: #c0392b;
border-color: #f5b7b1;
}
/* ── Canvas Area ─────────────────────────────────────────────────── */
.builder-canvas-area {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
background: #f0f2f5;
}
.canvas-header {
background: #fff;
border-bottom: 1px solid #dde3ea;
padding: 0.9rem 1.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
position: sticky;
top: 0;
z-index: 50;
}
.canvas-header h2 {
font-size: 18px;
font-weight: 600;
color: #2c3e50;
margin: 0;
}
.canvas-badge {
padding: 0.2rem 0.6rem;
border-radius: 12px;
font-size: 11px;
color: #fff;
font-weight: 600;
}
#builderCanvas {
padding: 1.5rem;
max-width: 720px;
margin: 0 auto;
width: 100%;
}
.canvas-empty {
text-align: center;
color: #8a96a3;
padding: 3rem 1rem;
border: 2px dashed #dde3ea;
border-radius: 10px;
background: #fff;
font-size: 15px;
line-height: 1.8;
}
/* ── Canvas Block Card ───────────────────────────────────────────── */
.canvas-block {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
margin-bottom: 0.5rem;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
transition: box-shadow 0.15s;
}
.canvas-block:hover {
box-shadow: 0 3px 10px rgba(0,0,0,0.1);
}
.canvas-block-controls {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.75rem;
background: #f8f9fb;
border-bottom: 1px solid #eef0f3;
font-size: 12px;
}
.block-type-badge {
background: #e2e8f0;
color: #4a5568;
padding: 0.15rem 0.5rem;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.block-actions {
display: flex;
gap: 0.3rem;
}
.block-actions button {
padding: 0.25rem 0.5rem;
border: 1px solid #dde3ea;
border-radius: 4px;
background: #fff;
cursor: pointer;
font-size: 12px;
transition: all 0.15s;
}
.block-actions button:hover:not(:disabled) { background: #f0f3f7; }
.block-actions button:disabled { opacity: 0.35; cursor: default; }
.block-actions .btn-edit { color: #2980b9; border-color: #aed6f1; }
.block-actions .btn-edit:hover { background: #eaf4fb; }
.block-actions .btn-delete { color: #c0392b; border-color: #f5b7b1; }
.block-actions .btn-delete:hover { background: #fdf2f0; }
.canvas-block-preview {
padding: 1rem 1.25rem;
pointer-events: none;
max-height: 280px;
overflow: hidden;
position: relative;
}
.canvas-block-preview::after {
content: '';
position: absolute;
bottom: 0; left: 0; right: 0;
height: 30px;
background: linear-gradient(to bottom, transparent, rgba(255,255,255,0.8));
}
/* ── Insert Strip ────────────────────────────────────────────────── */
.canvas-insert {
text-align: center;
padding: 0.15rem 0;
opacity: 0;
transition: opacity 0.15s;
}
.canvas-block:hover + .canvas-block .canvas-insert,
.canvas-block:hover .canvas-insert,
.canvas-insert-bottom {
opacity: 1;
}
.canvas-insert-bottom { opacity: 1; margin-top: 0.5rem; }
.insert-btn {
background: none;
border: 1px dashed #3498db;
border-radius: 20px;
color: #3498db;
font-size: 12px;
padding: 0.2rem 1rem;
cursor: pointer;
transition: all 0.15s;
}
.insert-btn:hover {
background: #eaf4fb;
}
/* ── Modal ───────────────────────────────────────────────────────── */
.builder-modal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.builder-modal.hidden { display: none; }
.modal-overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.45);
}
.modal-dialog {
position: relative;
background: #fff;
border-radius: 10px;
box-shadow: 0 12px 40px rgba(0,0,0,0.25);
width: 560px;
max-width: 95vw;
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid #eef0f3;
background: #f8f9fb;
}
.modal-header h3 {
font-size: 16px;
font-weight: 600;
color: #2c3e50;
}
#modalClose {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #8a96a3;
line-height: 1;
padding: 0 0.2rem;
}
#modalClose:hover { color: #2c3e50; }
.modal-body {
padding: 1.25rem;
overflow-y: auto;
flex: 1;
}
.modal-footer {
padding: 0.75rem 1.25rem;
border-top: 1px solid #eef0f3;
display: flex;
gap: 0.5rem;
justify-content: flex-end;
background: #f8f9fb;
}
/* ── Form Styles ─────────────────────────────────────────────────── */
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
font-size: 12px;
font-weight: 600;
color: #4a5568;
margin-bottom: 0.3rem;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.form-group input[type="text"],
.form-group input[type="number"],
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.55rem 0.75rem;
border: 1px solid #dde3ea;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
background: #fff;
transition: border-color 0.15s;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 2px rgba(52,152,219,0.15);
}
.form-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
}
/* Gallery / Stats item rows */
.item-row {
display: flex;
align-items: flex-start;
gap: 0.4rem;
margin-bottom: 0.5rem;
padding: 0.5rem;
background: #f8f9fb;
border: 1px solid #e2e8f0;
border-radius: 6px;
}
.item-row-fields { flex: 1; display: flex; flex-direction: column; gap: 0.3rem; }
.item-row input[type="text"] {
padding: 0.35rem 0.55rem;
border: 1px solid #dde3ea;
border-radius: 4px;
font-size: 13px;
width: 100%;
}
.btn-remove-item {
background: none;
border: 1px solid #f5b7b1;
border-radius: 4px;
color: #c0392b;
cursor: pointer;
padding: 0.25rem 0.4rem;
font-size: 13px;
align-self: flex-start;
}
.btn-remove-item:hover { background: #fdf2f0; }
.add-item-btn {
margin-top: 0.5rem;
background: none;
border: 1px dashed #3498db;
border-radius: 20px;
color: #3498db;
font-size: 12px;
padding: 0.3rem 1rem;
cursor: pointer;
}
.add-item-btn:hover { background: #eaf4fb; }
/* ── Buttons ─────────────────────────────────────────────────────── */
.btn { padding: 0.5rem 1.1rem; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-family: inherit; }
.btn-primary { background: #3498db; color: #fff; }
.btn-primary:hover { background: #2980b9; }
.btn-secondary { background: #e2e8f0; color: #2c3e50; }
.btn-secondary:hover { background: #cdd3da; }
/* ── Toast ───────────────────────────────────────────────────────── */
.builder-toast {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
background: #2c3e50;
color: #fff;
padding: 0.7rem 1.25rem;
border-radius: 8px;
font-size: 14px;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
opacity: 0;
transform: translateY(8px);
transition: all 0.25s;
z-index: 2000;
}
.builder-toast.show {
opacity: 1;
transform: translateY(0);
}
/* ── Preview block overrides (keep things legible at small size) ── */
.canvas-block-preview .block-heading-1 { font-size: 18px; }
.canvas-block-preview .block-heading-2 { font-size: 15px; }
.canvas-block-preview .block-paragraph { font-size: 13px; margin: 0; }
.canvas-block-preview .quote-text { font-size: 13px; }
.canvas-block-preview .block-stats-grid { grid-template-columns: repeat(4, 1fr); }
.canvas-block-preview .gallery-item img { height: 80px; }
.canvas-block-preview .grid4-item img { height: 70px; }
</style>
</head>
<body>
<!-- Top Bar -->
<header class="builder-header">
<div class="builder-brand">
<div class="logo">PF</div>
<span>Section Builder</span>
</div>
<nav class="builder-header-nav">
<a href="index.html">👁 View Site</a>
<button id="exportBtn">⬇ Export JSON</button>
<button id="importBtn">⬆ Import JSON</button>
<input type="file" id="importFile" accept=".json" style="display:none">
<button id="saveBtn" class="btn-save-header">💾 Save Section</button>
</nav>
</header>
<div class="builder-layout">
<!-- ── Sidebar ───────────────────────────────────────────────────── -->
<aside class="builder-sidebar">
<!-- Section Tabs -->
<div class="palette-section">
<div class="palette-title">Sections</div>
<div id="sectionTabs"></div>
</div>
<!-- Block Palette -->
<div class="palette-section">
<div class="palette-title">Add Block</div>
<div class="palette-blocks">
<button class="palette-block" data-type="heading"> H Heading</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="quote"> " Quote</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="statistics"> # Statistics</button>
<button class="palette-block" data-type="map-button"> 📍 Map Button</button>
</div>
</div>
<!-- Actions -->
<div class="sidebar-actions">
<button id="resetBtn" class="btn-reset">↩ Reset to Default</button>
</div>
</aside>
<!-- ── Canvas ────────────────────────────────────────────────────── -->
<main class="builder-canvas-area">
<div class="canvas-header">
<h2 id="canvasSectionTitle">Loading…</h2>
<span class="canvas-badge" id="canvasBadge"></span>
</div>
<div id="builderCanvas"></div>
</main>
</div>
<!-- ── Edit Modal ────────────────────────────────────────────────────── -->
<div id="builderModal" class="builder-modal hidden">
<div class="modal-overlay"></div>
<div class="modal-dialog">
<div class="modal-header">
<h3 id="modalTitle">Edit Block</h3>
<button id="modalClose" title="Close">&#x2715;</button>
</div>
<div class="modal-body" id="modalBody"></div>
<div class="modal-footer">
<button id="modalSave" class="btn btn-primary">Save Block</button>
<button id="modalCancel" class="btn btn-secondary">Cancel</button>
</div>
</div>
</div>
<script src="js/block-renderer.js"></script>
<script src="js/builder.js"></script>
</body>
</html>

536
components.css Normal file
View File

@@ -0,0 +1,536 @@
/* Project Friction - Components CSS */
/* Reusable UI components for the museum exhibit */
/* Navigation Cards */
.nav-card {
background: var(--color-white);
border: 2px solid #e9ecef;
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
margin-bottom: var(--spacing-lg);
cursor: pointer;
transition: all var(--transition-normal);
position: relative;
overflow: hidden;
}
.nav-card::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 5px;
background: var(--theme-color);
transition: width var(--transition-normal);
}
.nav-card:hover {
transform: translateX(5px);
box-shadow: var(--shadow-lg);
border-color: var(--theme-color);
}
.nav-card:hover::before {
width: 8px;
}
.nav-card[data-section="context"] { --theme-color: var(--color-context); }
.nav-card[data-section="rcaf"] { --theme-color: var(--color-rcaf); }
.nav-card[data-section="rcn"] { --theme-color: var(--color-rcn); }
.nav-card[data-section="army"] { --theme-color: var(--color-army); }
.nav-card[data-section="legacy"] { --theme-color: var(--color-legacy); }
.nav-card-header {
display: flex;
align-items: center;
gap: var(--spacing-lg);
margin-bottom: var(--spacing-md);
}
.nav-card-icon {
font-size: 28px;
}
.nav-card-title {
flex: 1;
}
.nav-card-title h3 {
font-size: 16px;
margin-bottom: var(--spacing-xs);
color: var(--color-black);
font-family: var(--font-primary);
}
.nav-card-title span {
font-size: 12px;
color: var(--color-dark-gray);
font-family: var(--font-secondary);
}
.nav-card-preview {
font-size: 13px;
line-height: 1.6;
color: var(--color-dark-gray);
margin-bottom: var(--spacing-md);
font-family: var(--font-secondary);
}
.nav-card-links {
display: flex;
gap: var(--spacing-lg);
font-size: 12px;
color: var(--color-rcaf);
font-family: var(--font-secondary);
}
.nav-card-link {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
/* Interactive Map Buttons */
.map-button {
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-md) var(--spacing-lg);
background: linear-gradient(135deg, var(--color-rcaf) 0%, #2980b9 100%);
color: var(--color-white);
border: none;
border-radius: 25px;
font-size: 14px;
cursor: pointer;
transition: all var(--transition-normal);
margin: var(--spacing-md) var(--spacing-md) var(--spacing-md) 0;
box-shadow: 0 2px 5px rgba(52, 152, 219, 0.3);
font-family: var(--font-secondary);
}
.map-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(52, 152, 219, 0.4);
}
.map-button.active {
background: linear-gradient(135deg, var(--color-accent) 0%, #c0392b 100%);
box-shadow: 0 2px 5px rgba(192, 57, 43, 0.3);
}
.map-button-icon {
font-size: 16px;
}
/* Fact Boxes */
.fact-box {
background: linear-gradient(to right, #fef5e7, #fdf2e9);
border-left: 5px solid #f39c12;
padding: var(--spacing-xl);
margin: var(--spacing-xl) 0;
border-radius: var(--radius-sm);
box-shadow: var(--shadow-sm);
}
.fact-box h3 {
font-size: 16px;
color: #d68910;
margin-bottom: var(--spacing-md);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
font-family: var(--font-secondary);
}
.fact-box ul {
list-style: none;
}
.fact-box li {
padding: 6px 0;
color: #7f6000;
font-size: 14px;
display: flex;
align-items: flex-start;
font-family: var(--font-secondary);
}
.fact-box li::before {
content: "▸";
margin-right: var(--spacing-md);
color: #f39c12;
font-weight: bold;
}
/* Quote Blocks */
.quote-block {
background: var(--color-light-gray);
border-left: 4px solid var(--color-secondary);
padding: var(--spacing-xl);
margin: var(--spacing-xxl) 0;
font-style: italic;
position: relative;
border-radius: var(--radius-sm);
}
.quote-block::before {
content: """;
font-size: 60px;
color: #cbd5e0;
position: absolute;
top: -10px;
left: 10px;
font-family: var(--font-primary);
}
.quote-text {
font-size: 17px;
line-height: 1.6;
color: var(--color-secondary);
margin-bottom: var(--spacing-md);
padding-left: 30px;
font-family: var(--font-primary);
}
.quote-attribution {
text-align: right;
font-size: 14px;
color: var(--color-dark-gray);
font-style: normal;
font-family: var(--font-secondary);
}
/* Timeline Cards */
.timeline-card {
background: var(--color-white);
border: 1px solid var(--color-gray);
border-radius: var(--radius-md);
padding: var(--spacing-xl);
margin: var(--spacing-xl) 0;
position: relative;
transition: all var(--transition-normal);
}
.timeline-card:hover {
box-shadow: var(--shadow-lg);
transform: translateX(5px);
}
.timeline-date {
position: absolute;
left: -15px;
top: var(--spacing-xl);
background: var(--color-accent);
color: var(--color-white);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: bold;
font-family: var(--font-secondary);
}
.timeline-content {
margin-left: var(--spacing-xl);
}
.timeline-content h4 {
font-size: 18px;
color: var(--color-primary);
margin-bottom: var(--spacing-md);
font-family: var(--font-primary);
}
.timeline-content p {
font-size: 14px;
line-height: 1.6;
color: var(--color-dark-gray);
font-family: var(--font-secondary);
}
/* Media Gallery */
.media-section {
margin: var(--spacing-xxl) 0;
padding: var(--spacing-xl);
background: var(--color-light-gray);
border-radius: var(--radius-md);
}
.media-header {
display: flex;
align-items: center;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.media-header h3 {
font-size: 18px;
color: var(--color-primary);
font-family: var(--font-primary);
}
.media-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-lg);
}
.media-item {
position: relative;
aspect-ratio: 4/3;
background: #ddd;
border-radius: var(--radius-sm);
overflow: hidden;
cursor: pointer;
transition: transform var(--transition-normal);
}
.media-item:hover {
transform: scale(1.05);
box-shadow: var(--shadow-lg);
}
.media-caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
color: var(--color-white);
padding: var(--spacing-md);
font-size: 12px;
font-family: var(--font-secondary);
}
/* Document Section */
.document-section {
background: var(--color-white);
border: 2px solid #ecf0f1;
border-radius: var(--radius-md);
padding: var(--spacing-xl);
margin: var(--spacing-xl) 0;
}
.document-header {
display: flex;
align-items: center;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-md);
border-bottom: 2px solid #ecf0f1;
}
.document-header h3 {
font-size: 16px;
color: var(--color-primary);
font-family: var(--font-primary);
}
.document-list {
list-style: none;
}
.document-list li {
padding: var(--spacing-md) 0;
border-bottom: 1px solid #f5f5f5;
}
.document-link {
display: flex;
align-items: center;
gap: var(--spacing-md);
color: var(--color-rcaf);
text-decoration: none;
transition: all var(--transition-fast);
font-family: var(--font-secondary);
font-size: 14px;
}
.document-link:hover {
color: #2980b9;
padding-left: var(--spacing-md);
}
/* Article Index */
.article-index {
padding: var(--spacing-xl);
background: var(--color-white);
border-radius: var(--radius-md);
border: 1px solid var(--color-gray);
margin: var(--spacing-xl) 0;
}
.article-index h3 {
font-size: 16px;
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-md);
border-bottom: 2px solid var(--color-rcaf);
color: var(--color-primary);
font-family: var(--font-primary);
}
.article-list {
list-style: none;
}
.article-list li {
padding: var(--spacing-sm) 0;
border-bottom: 1px solid #f1f3f5;
}
.article-list a {
color: var(--color-rcaf);
text-decoration: none;
font-size: 14px;
display: flex;
align-items: center;
gap: var(--spacing-sm);
transition: all var(--transition-fast);
font-family: var(--font-secondary);
}
.article-list a:hover {
padding-left: var(--spacing-md);
color: #0056b3;
}
/* Info Box */
.info-box {
background: linear-gradient(to right, var(--color-light-gray), var(--color-white));
border-left: 4px solid var(--color-rcaf);
padding: var(--spacing-lg);
margin: var(--spacing-xl) 0;
border-radius: var(--radius-sm);
}
.info-box h4 {
font-size: 14px;
margin-bottom: var(--spacing-md);
color: var(--color-black);
font-family: var(--font-secondary);
font-weight: 600;
}
.info-box ul {
list-style: none;
}
.info-box li {
font-size: 13px;
padding: 4px 0;
color: #495057;
font-family: var(--font-secondary);
}
/* Story Map Banner */
.story-map-banner {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: var(--color-white);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
margin: var(--spacing-xl) 0;
cursor: pointer;
transition: transform var(--transition-normal);
}
.story-map-banner:hover {
transform: scale(1.02);
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
}
.story-map-banner h4 {
font-size: 18px;
margin-bottom: var(--spacing-sm);
display: flex;
align-items: center;
gap: var(--spacing-md);
font-family: var(--font-primary);
}
.story-map-banner p {
font-size: 14px;
opacity: 0.9;
line-height: 1.6;
font-family: var(--font-secondary);
}
/* Section Headers */
.chapter-header {
margin: var(--spacing-xxl) 0 var(--spacing-xl) 0;
padding-bottom: var(--spacing-md);
border-bottom: 2px solid var(--color-context);
}
.chapter-header h2 {
font-size: 24px;
color: var(--color-primary);
font-family: var(--font-primary);
font-weight: normal;
}
.chapter-date {
font-size: 14px;
color: var(--color-dark-gray);
margin-top: var(--spacing-sm);
font-style: italic;
font-family: var(--font-secondary);
}
/* Buttons */
.btn {
padding: var(--spacing-md) var(--spacing-lg);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 14px;
font-family: var(--font-secondary);
transition: all var(--transition-fast);
display: inline-flex;
align-items: center;
gap: var(--spacing-sm);
}
.btn-primary {
background: var(--color-rcaf);
color: var(--color-white);
}
.btn-primary:hover {
background: #2980b9;
transform: translateY(-2px);
}
.btn-secondary {
background: var(--color-gray);
color: var(--color-black);
}
.btn-secondary:hover {
background: #adb5bd;
}
/* Close button */
.close-btn,
.close-info {
position: absolute;
top: var(--spacing-md);
right: var(--spacing-md);
width: 30px;
height: 30px;
border: none;
background: rgba(0,0,0,0.1);
border-radius: var(--radius-full);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: var(--color-dark-gray);
transition: all var(--transition-fast);
}
.close-btn:hover,
.close-info:hover {
background: rgba(0,0,0,0.2);
color: var(--color-black);
}

200
copilot_instructions.md Normal file
View File

@@ -0,0 +1,200 @@
# Project Friction - Digital Gulf War Exhibit
## Copilot Development Instructions
### PROJECT SCOPE & CONTEXT
**Project Name**: Project Friction - Canada's Role in the Gulf War Digital Exhibit
**Client**: Canadian Research and Mapping Association (CRMA) / Veterans Affairs Canada
**Purpose**: Interactive digital museum exhibit for online and touchscreen kiosk deployment
**Timeline**: Phase 2 development (Feb-Jun 2026) - Content Creation & Frontend Development
**Core Mission**:
Create a comprehensive, educational digital exhibit showcasing Canada's military contribution to the 1991 Gulf War through interactive maps, historical narratives, and multimedia content. The exhibit must serve both online users and museum visitors using touchscreen kiosks.
### TECHNICAL REQUIREMENTS
**Primary Technologies**:
- HTML5 for structure
- CSS3 for styling (museum-quality visual design)
- Vanilla JavaScript for interactivity
- Leaflet.js for interactive mapping
- No frameworks required - keep dependencies minimal for kiosk compatibility
**Target Platforms**:
- Desktop browsers (museum kiosks)
- Modern tablets and mobile devices
- Must work offline after initial load
**Performance Standards**:
- Load time under 3 seconds
- Touch-friendly interface (minimum 44px touch targets)
- WCAG 2.1 AA accessibility compliance
- Responsive design for multiple screen sizes
### FILE STRUCTURE TO CREATE
```
project-friction/
├── index.html # Main navigation hub
├── css/
│ ├── main.css # Global styles and layout
│ ├── components.css # Reusable UI components
│ ├── sections.css # Section-specific styles
│ └── responsive.css # Media queries and responsive design
├── js/
│ ├── app.js # Main application logic
│ ├── navigation.js # Section switching and breadcrumbs
│ ├── map-controller.js # Map initialization and controls
│ ├── timeline.js # Timeline animation and controls
│ ├── data-layers.js # Map data and layer management
│ └── interactions.js # UI interactions and animations
├── data/
│ ├── map-data.json # Geographic data for all sections
│ ├── timeline-events.json # Timeline event data
│ ├── articles.json # Article content and metadata
│ └── media-gallery.json # Image and media references
├── sections/
│ ├── context.html # Historical Context section
│ ├── rcaf.html # RCAF Desert Cats section
│ ├── rcn.html # RCN Task Group section
│ ├── army.html # Canadian Army section
│ └── legacy.html # Post-War Legacy section
└── assets/
├── images/ # Historical photos and media
├── icons/ # UI icons and map markers
└── fonts/ # Custom typography files
```
### DEVELOPMENT APPROACH
**Phase 1: Core Structure**
1. Create main HTML layout with sidebar navigation
2. Implement CSS grid/flexbox layout system
3. Build basic JavaScript navigation between sections
4. Initialize Leaflet map with basic controls
**Phase 2: Interactive Features**
1. Timeline control implementation
2. Map layer switching system
3. Interactive buttons for map filters
4. Content loading and display system
**Phase 3: Content Integration**
1. Load historical narrative content
2. Implement map data layers
3. Add media galleries and fact boxes
4. Polish animations and transitions
### SPECIFIC IMPLEMENTATION GUIDELINES
**CSS Architecture**:
- Use CSS Custom Properties for theming
- BEM methodology for class naming
- Mobile-first responsive design
- Print stylesheet for offline reading
**JavaScript Patterns**:
- Module pattern for code organization
- Event delegation for touch interactions
- Async/await for data loading
- Error handling for offline scenarios
**Map Integration**:
- Leaflet.js with custom marker styles
- GeoJSON data format for geographic features
- Layer groups for different data types
- Custom popup templates
**Accessibility Requirements**:
- Semantic HTML structure
- ARIA labels for interactive elements
- Keyboard navigation support
- Screen reader compatible content
- High contrast mode support
### CONTENT SPECIFICATIONS
**Historical Context Section** (already developed):
- 2,500+ word narrative in 6 chapters
- Interactive map buttons for key events
- Timeline integration
- Fact boxes and quote blocks
- Document reference sections
**Remaining Sections** (to be developed):
- RCAF: CF-18 operations, pilot stories, mission details
- RCN: Naval operations, ship modifications, MIF operations
- Army: Field hospital operations, security details, humanitarian missions
- Legacy: Veteran health, commemorations, lessons learned
**Map Data Requirements**:
- Force positions and movements
- Event locations with timestamps
- Base locations and infrastructure
- Supply routes and operational areas
- Political boundaries and geographic context
### QUALITY STANDARDS
**Content Quality**:
- Museum-grade historical accuracy
- Canadian perspective throughout
- Primary source citations
- Appropriate reading level (Grade 8-10)
**Visual Design**:
- Professional museum aesthetic
- Consistent color coding by section
- Clear typography hierarchy
- Intuitive navigation patterns
**User Experience**:
- Maximum 3-click depth to any content
- Clear breadcrumb navigation
- Immediate visual feedback on interactions
- Graceful degradation for older devices
### TESTING REQUIREMENTS
**Device Testing**:
- Desktop browsers (Chrome, Firefox, Safari, Edge)
- Tablet devices (iPad, Android tablets)
- Touch screen kiosks (Windows-based museum hardware)
**Content Validation**:
- Historical accuracy review by subject matter experts
- Canadian military veteran feedback
- Museum educator usability testing
### DEPLOYMENT CONSIDERATIONS
**Online Deployment**:
- Static site hosting (Netlify/Vercel compatible)
- CDN integration for media assets
- Progressive web app features
**Kiosk Deployment**:
- Offline functionality
- Reset mechanism for public use
- Screensaver integration
- Maintenance mode capabilities
---
## IMPORTANT CONSTRAINTS
1. **Stick to defined scope**: 5 thematic sections covering Canada's Gulf War role
2. **No feature creep**: Request approval for any additions beyond specified content
3. **Museum standards**: All content must meet professional exhibition quality
4. **Accessibility first**: Design for all users, including those with disabilities
5. **Historical accuracy**: No fictional elements or speculative content
6. **Canadian focus**: Maintain Canadian perspective while showing international context
## APPROVAL REQUIRED FOR:
- Additional sections beyond the 5 defined areas
- New interactive features not specified in requirements
- Integration of external APIs or services
- Significant changes to visual design approach
- Addition of audio/video content beyond static images
This project represents a significant digital heritage initiative. Maintain focus on educational value, historical accuracy, and professional presentation standards throughout development.

113
data/sections-content.json Normal file
View File

@@ -0,0 +1,113 @@
{
"context": {
"blocks": [
{ "id": "c1", "type": "heading", "level": 1, "text": "Historical Context: The Road to War" },
{ "id": "c2", "type": "paragraph", "text": "On August 2, 1990, Iraqi forces under President Saddam Hussein crossed the border into neighbouring Kuwait, overwhelming its small military in a matter of hours. The invasion shocked the international community and triggered an unprecedented diplomatic and military response. Within days, the United Nations Security Council passed Resolution 660, condemning the invasion and demanding an immediate, unconditional withdrawal of Iraqi forces." },
{ "id": "c3", "type": "statistics", "stats": [
{ "number": "34", "label": "Coalition Nations" },
{ "number": "12", "label": "UN Resolutions" },
{ "number": "43", "label": "Days Air Campaign" },
{ "number": "100", "label": "Hours Ground War" }
]},
{ "id": "c4", "type": "map-button", "label": "Show Iraqi Invasion Routes", "lat": 29.8, "lng": 47.9, "zoom": 8 },
{ "id": "c5", "type": "heading", "level": 2, "text": "Canada's Diplomatic Response" },
{ "id": "c6", "type": "paragraph", "text": "Canada was among the first nations to condemn Iraq's aggression and commit forces to the coalition. Prime Minister Brian Mulroney announced on August 10, 1990 that Canada would deploy naval vessels to the Persian Gulf as part of the multinational force. This decision reflected Canada's longstanding commitment to collective security and the rule of international law." },
{ "id": "c7", "type": "quote", "text": "Canada will not stand idly by while a small nation is crushed by its neighbour in clear violation of international law.", "author": "Prime Minister Brian Mulroney, August 1990" },
{ "id": "c8", "type": "map-button", "label": "Show Coalition Force Positions", "lat": 27.0, "lng": 50.0, "zoom": 6 },
{ "id": "c9", "type": "heading", "level": 2, "text": "Operation Desert Shield" },
{ "id": "c10", "type": "paragraph", "text": "Operation Desert Shield — the build-up phase from August 1990 to January 1991 — saw the massing of over 700,000 coalition troops in Saudi Arabia and the surrounding region. Canada contributed naval, air, and later ground forces, making it one of the most significant Canadian military deployments since the Korean War." },
{ "id": "c11", "type": "image", "src": "https://placehold.co/800x450/2c3e50/ffffff?text=Coalition+Forces+Assembling", "alt": "Coalition forces assembling in Saudi Arabia", "caption": "Coalition forces assembling in Saudi Arabia, late 1990. Canada contributed 4,500 personnel across naval, air, and ground components." }
]
},
"rcaf": {
"blocks": [
{ "id": "r1", "type": "heading", "level": 1, "text": "RCAF Desert Cats: 409 Squadron" },
{ "id": "r2", "type": "paragraph", "text": "Canada's air contribution to the Gulf War was led by 409 Tactical Fighter Squadron. Known as the 'Nighthawks,' the squadron's CF-18 Hornets were deployed to Doha, Qatar, where they formed the core of Canada's Gulf War air contingent — the Desert Cats. From their base at Canada Dry 1, pilots flew Combat Air Patrol and, later, offensive strike missions deep into Iraq." },
{ "id": "r3", "type": "statistics", "stats": [
{ "number": "26", "label": "CF-18 Hornets" },
{ "number": "2,700+", "label": "Sorties Flown" },
{ "number": "5", "label": "Months Deployed" },
{ "number": "0", "label": "Aircraft Lost" }
]},
{ "id": "r4", "type": "map-button", "label": "Show Doha Air Base (Canada Dry 1)", "lat": 25.27, "lng": 51.61, "zoom": 10 },
{ "id": "r5", "type": "heading", "level": 2, "text": "From Defence to Offence" },
{ "id": "r6", "type": "paragraph", "text": "Initially tasked with Combat Air Patrol (CAP) missions protecting the naval fleet, the Desert Cats transitioned to offensive strike operations after Operation Desert Storm began on January 17, 1991. CF-18 pilots flew interdiction missions targeting Iraqi airfields, radar sites, and command infrastructure — a significant shift that required rapid retraining and aircraft modification." },
{ "id": "r7", "type": "quote", "text": "We went from protecting the fleet to striking deep into Iraq. The transition was fast, but the training paid off. The Desert Cats were ready.", "author": "Pilot, 409 Squadron" },
{ "id": "r8", "type": "heading", "level": 2, "text": "The TNC-45 Engagement" },
{ "id": "r9", "type": "paragraph", "text": "On January 30, 1991, two CF-18s from 409 Squadron engaged and sank an Iraqi Zhuk-class patrol vessel, the TNC-45, in the northern Persian Gulf. It was the first time Canadian aircraft had destroyed an enemy vessel since the Second World War — a historic moment for the RCAF and a demonstration of the Desert Cats' combat effectiveness." },
{ "id": "r10", "type": "map-button", "label": "Show TNC-45 Engagement Site", "lat": 29.14, "lng": 48.13, "zoom": 9 },
{ "id": "r11", "type": "grid4", "images": [
{ "src": "https://placehold.co/400x300/3498db/ffffff?text=CF-18+Hornet", "alt": "CF-18 Hornet", "caption": "CF-18 Hornet in Desert Cats livery" },
{ "src": "https://placehold.co/400x300/2980b9/ffffff?text=Doha+Air+Base", "alt": "Doha Air Base", "caption": "Canada Dry 1, Doha, Qatar" },
{ "src": "https://placehold.co/400x300/1a5276/ffffff?text=Desert+Cats+Patch", "alt": "Desert Cats patch", "caption": "409 Squadron Desert Cats patch" },
{ "src": "https://placehold.co/400x300/154360/ffffff?text=Mission+Briefing", "alt": "Mission briefing", "caption": "Pre-mission briefing, 1991" }
]}
]
},
"rcn": {
"blocks": [
{ "id": "n1", "type": "heading", "level": 1, "text": "RCN Task Group: Naval Blockade Operations" },
{ "id": "n2", "type": "paragraph", "text": "Canada was among the first nations to respond to the Gulf Crisis with a naval contribution. Three Royal Canadian Navy vessels — HMCS Athabaskan, HMCS Terra Nova, and HMCS Protecteur — were dispatched to the Persian Gulf as part of Operation Friction. Together they formed Canadian Task Group 302.3, operating under the Maritime Interception Force." },
{ "id": "n3", "type": "statistics", "stats": [
{ "number": "3", "label": "RCN Vessels" },
{ "number": "1,000+", "label": "Sailors Deployed" },
{ "number": "160+", "label": "Ships Challenged" },
{ "number": "8", "label": "Months at Sea" }
]},
{ "id": "n4", "type": "map-button", "label": "Show Naval Operations Area", "lat": 27.0, "lng": 50.5, "zoom": 7 },
{ "id": "n5", "type": "heading", "level": 2, "text": "HMCS Terra Nova: A Remarkable Transformation" },
{ "id": "n6", "type": "paragraph", "text": "One of the most significant operational achievements was the rapid conversion of HMCS Terra Nova into a guided-missile destroyer. Canadian naval technicians worked around the clock to retrofit the vessel with modern anti-aircraft missile systems, significantly upgrading her capability for the high-threat Persian Gulf environment. The conversion was completed in record time." },
{ "id": "n7", "type": "image", "src": "https://placehold.co/800x450/2c3e50/ffffff?text=HMCS+Terra+Nova", "alt": "HMCS Terra Nova in the Persian Gulf", "caption": "HMCS Terra Nova operating in the Persian Gulf, 199091. The vessel underwent rapid modifications to enhance her missile defence capability." },
{ "id": "n8", "type": "quote", "text": "The men and women of the task group performed extraordinary work under pressure. What they achieved in those weeks of preparation was remarkable.", "author": "Commodore Ken Summers, CTG 302.3 Commander" },
{ "id": "n9", "type": "heading", "level": 2, "text": "Maritime Interception Force" },
{ "id": "n10", "type": "paragraph", "text": "Canada's naval task group formed a key part of the Maritime Interception Force (MIF), enforcing the UN-mandated blockade of Iraq. Canadian vessels challenged hundreds of merchant ships, and the task group was recognised for its professionalism and effectiveness in this complex multinational operation at sea." },
{ "id": "n11", "type": "map-button", "label": "Show HMCS Task Group Position", "lat": 26.23, "lng": 50.59, "zoom": 9 }
]
},
"army": {
"blocks": [
{ "id": "a1", "type": "heading", "level": 1, "text": "Canadian Army: Medical & Security Forces" },
{ "id": "a2", "type": "paragraph", "text": "Canada's army contribution to the Gulf War centred on two primary missions: the deployment of 1 Canadian Field Hospital to Saudi Arabia, and the provision of security forces to protect coalition installations. Together, these elements brought Canada's ground commitment to over 1,200 personnel operating in one of the most hostile environments in the world." },
{ "id": "a3", "type": "statistics", "stats": [
{ "number": "530+", "label": "Hospital Personnel" },
{ "number": "600", "label": "Bed Capacity" },
{ "number": "500+", "label": "Security Forces" },
{ "number": "0", "label": "Canadian Fatalities" }
]},
{ "id": "a4", "type": "map-button", "label": "Show 1 Canadian Field Hospital", "lat": 28.43, "lng": 46.09, "zoom": 9 },
{ "id": "a5", "type": "heading", "level": 2, "text": "1 Canadian Field Hospital" },
{ "id": "a6", "type": "paragraph", "text": "Established at Al-Qaysumah, Saudi Arabia, 1 Canadian Field Hospital (1 CFH) was one of the most capable coalition medical facilities in the theatre of operations. With a planned capacity of 600 beds, it included surgical, intensive care, and recovery wards staffed by some of Canada's finest medical personnel. The swift coalition victory meant the hospital treated fewer battlefield casualties than anticipated, but the hospital remained on standby throughout the conflict." },
{ "id": "a7", "type": "quote", "text": "We prepared for the worst. The training was intense, the equipment was first-class, and the people were exceptional. We were ready for whatever came our way.", "author": "Colonel John Abt, Commanding Officer, 1 Canadian Field Hospital" },
{ "id": "a8", "type": "image", "src": "https://placehold.co/800x450/27ae60/ffffff?text=1+Canadian+Field+Hospital", "alt": "1 Canadian Field Hospital, Al-Qaysumah", "caption": "1 Canadian Field Hospital at Al-Qaysumah, Saudi Arabia. The 530-person unit was fully operational and prepared for mass casualty scenarios." },
{ "id": "a9", "type": "heading", "level": 2, "text": "Security Operations in Bahrain" },
{ "id": "a10", "type": "paragraph", "text": "Members of the Royal Canadian Regiment and other army units provided security for coalition installations in Bahrain and Saudi Arabia. These operations, while less visible than combat missions, were essential to the smooth functioning of the broader coalition effort. Canadian soldiers maintained a professional presence and provided force protection in a tense and unpredictable environment." },
{ "id": "a11", "type": "map-button", "label": "Show Bahrain Security Operations", "lat": 26.07, "lng": 50.56, "zoom": 10 }
]
},
"legacy": {
"blocks": [
{ "id": "l1", "type": "heading", "level": 1, "text": "After the War: Veterans, Legacy & Long-Term Impact" },
{ "id": "l2", "type": "paragraph", "text": "Operation Desert Storm ended on February 28, 1991 — just 100 hours after the ground offensive began. The swift coalition victory liberated Kuwait and expelled Iraqi forces, but for thousands of Canadian veterans, the war's end was only the beginning of a longer struggle. Gulf War Syndrome, commemorations, peacekeeping deployments, and ongoing veteran support shaped the post-war legacy of Canada's participation." },
{ "id": "l3", "type": "statistics", "stats": [
{ "number": "4,449", "label": "Medals Awarded" },
{ "number": "4,500+", "label": "Veterans" },
{ "number": "700+", "label": "Gulf War Illness Claims" },
{ "number": "19912003", "label": "UNIKOM Service" }
]},
{ "id": "l4", "type": "heading", "level": 2, "text": "Gulf War Syndrome" },
{ "id": "l5", "type": "paragraph", "text": "In the years following the war, thousands of veterans across the coalition reported a range of unexplained illnesses — chronic fatigue, muscle pain, cognitive difficulties, and other debilitating symptoms. This cluster of conditions became known as Gulf War Syndrome. Canadian veterans fought for official recognition of their illnesses and improved healthcare support, a battle that continued for many years after they returned home." },
{ "id": "l6", "type": "quote", "text": "We served with honour and came home changed. The government needed to understand that the war didn't end when we crossed the ocean and came home.", "author": "Gulf War Veteran, Royal Canadian Regiment" },
{ "id": "l7", "type": "map-button", "label": "Show Veterans Affairs Canada", "lat": 45.42, "lng": -75.70, "zoom": 10 },
{ "id": "l8", "type": "heading", "level": 2, "text": "Peacekeeping & UNIKOM" },
{ "id": "l9", "type": "paragraph", "text": "Following the ceasefire, Canada contributed personnel to the UN Iraq-Kuwait Observation Mission (UNIKOM), established to monitor the demilitarised zone along the Iraq-Kuwait border. Canadian peacekeepers served with UNIKOM from 1991 until the mission's end in 2003, reflecting Canada's ongoing commitment to regional stability and the international rule of law." },
{ "id": "l10", "type": "map-button", "label": "Show UNIKOM Peacekeeping Zone", "lat": 30.05, "lng": 47.80, "zoom": 9 },
{ "id": "l11", "type": "gallery", "images": [
{ "src": "https://placehold.co/600x400/c0392b/ffffff?text=Medal+Parade", "alt": "Medal parade", "caption": "Veterans receiving Gulf War Service Medals" },
{ "src": "https://placehold.co/600x400/922b21/ffffff?text=Homecoming", "alt": "Homecoming ceremony", "caption": "Canadian troops returning home, 1991" },
{ "src": "https://placehold.co/600x400/76261d/ffffff?text=Commemorations", "alt": "Commemoration ceremony", "caption": "Annual Gulf War commemoration" },
{ "src": "https://placehold.co/600x400/4a1515/ffffff?text=UNIKOM+Service", "alt": "UNIKOM peacekeeping", "caption": "Canadian peacekeepers with UNIKOM, 1992" }
]}
]
}
}

112
index.html Normal file
View File

@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Project Friction - Canada's Gulf War Digital Exhibit</title>
<link rel="stylesheet" href="main.css">
<link rel="stylesheet" href="components.css">
<link rel="stylesheet" href="blocks.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
</head>
<body>
<div class="app-container">
<!-- Left Sidebar -->
<div class="sidebar" id="sidebar">
<!-- Toggle Button -->
<div class="sidebar-toggle" id="sidebarToggle">
<span id="toggle-icon"></span>
</div>
<!-- Header -->
<div class="sidebar-header">
<div class="logo-section">
<div class="logo">PF</div>
<div class="title">
<h1>Project Friction</h1>
<p>Canada's Role in the Gulf War 1990-1991</p>
</div>
</div>
</div>
<!-- Back Navigation -->
<div class="back-button hidden" id="backButton">
<span class="back-arrow"></span>
<span class="back-text">Back to Main Navigation</span>
</div>
<!-- Main Navigation View -->
<div class="main-navigation" id="mainNavigation">
<div class="nav-welcome">
<h2>Welcome to Project Friction</h2>
<p>Explore Canada's military contribution to the Gulf War through interactive maps, detailed articles, and veteran stories. Select a thematic area below to begin.</p>
</div>
<div class="quick-stats" id="quickStats">
<!-- Stats loaded via JavaScript -->
</div>
<div class="nav-areas">
<div class="nav-areas-title">Explore Thematic Areas</div>
<div id="navigationCards">
<!-- Navigation cards loaded via JavaScript -->
</div>
</div>
</div>
<!-- Section Content Container -->
<div class="section-content-container hidden" id="sectionContainer">
<!-- Section content loaded dynamically -->
</div>
<!-- Footer -->
<div class="sidebar-footer">
© 2026 Canadian Research and Mapping Association<br>
<a href="#about">About</a><a href="#credits">Credits</a><a href="#contact">Contact</a>
&nbsp;&nbsp; <a href="builder.html" style="color:#27ae60">⚙ Builder</a>
</div>
</div>
<!-- Map Container -->
<div class="map-container">
<div id="map"></div>
<!-- Map Controls -->
<div class="map-controls" id="mapControls">
<!-- Map control buttons -->
</div>
<!-- Map Info Panel -->
<div class="map-info-panel hidden" id="infoPanel">
<div class="map-info-title" id="infoTitle"></div>
<div class="map-info-content" id="infoContent"></div>
<button class="close-info" id="closeInfo">×</button>
</div>
<!-- Map Legend -->
<div class="map-legend" id="mapLegend">
<!-- Legend loaded via JavaScript -->
</div>
<!-- Timeline Control -->
<div class="timeline-container" id="timelineContainer">
<!-- Timeline loaded via JavaScript -->
</div>
</div>
</div>
<!-- Loading Screen -->
<div class="loading-screen" id="loadingScreen">
<div class="loading-content">
<div class="loading-spinner"></div>
<h3>Loading Project Friction</h3>
<p>Preparing the interactive exhibit...</p>
</div>
</div>
<!-- Scripts -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="js/block-renderer.js"></script>
<script src="app.js"></script>
</body>
</html>

95
js/block-renderer.js Normal file
View File

@@ -0,0 +1,95 @@
/**
* Block Renderer - Project Friction
* Converts a block array into HTML strings.
* Used by both the main exhibit (app.js) and the builder preview.
*/
class BlockRenderer {
static render(blocks) {
if (!Array.isArray(blocks)) return '';
return blocks.map(function(block) { return BlockRenderer.renderBlock(block); }).join('\n');
}
static renderBlock(block) {
if (!block || !block.type) return '';
switch (block.type) {
case 'heading': return BlockRenderer.renderHeading(block);
case 'paragraph': return BlockRenderer.renderParagraph(block);
case 'image': return BlockRenderer.renderImage(block);
case 'quote': return BlockRenderer.renderQuote(block);
case 'gallery': return BlockRenderer.renderGallery(block);
case 'grid4': return BlockRenderer.renderGrid4(block);
case 'statistics': return BlockRenderer.renderStatistics(block);
case 'map-button': return BlockRenderer.renderMapButton(block);
default: return '';
}
}
static renderHeading(block) {
var level = Math.min(Math.max(parseInt(block.level) || 2, 1), 6);
return '<h' + level + ' class="block block-heading block-heading-' + level + '">' + (block.text || '') + '</h' + level + '>';
}
static renderParagraph(block) {
return '<p class="block block-paragraph">' + (block.text || '') + '</p>';
}
static renderImage(block) {
return '<figure class="block block-image">'
+ '<img src="' + (block.src || '') + '" alt="' + (block.alt || '') + '" loading="lazy">'
+ (block.caption ? '<figcaption>' + block.caption + '</figcaption>' : '')
+ '</figure>';
}
static renderQuote(block) {
return '<blockquote class="block block-quote quote-block">'
+ '<p class="quote-text">' + (block.text || '') + '</p>'
+ (block.author ? '<cite class="quote-attribution">&mdash; ' + block.author + '</cite>' : '')
+ '</blockquote>';
}
static renderGallery(block) {
var images = block.images || [];
var items = 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">' + items + '</div></div>';
}
static renderGrid4(block) {
var images = (block.images || []).slice(0, 4);
var items = images.map(function(img) {
return '<div class="grid4-item">'
+ '<img src="' + (img.src || '') + '" alt="' + (img.alt || '') + '" loading="lazy">'
+ (img.caption ? '<div class="grid4-caption">' + img.caption + '</div>' : '')
+ '</div>';
}).join('');
return '<div class="block block-grid4"><div class="grid4-grid">' + items + '</div></div>';
}
static renderStatistics(block) {
var stats = block.stats || [];
var items = stats.map(function(s) {
return '<div class="stat-card">'
+ '<div class="stat-number">' + (s.number || '') + '</div>'
+ '<div class="stat-label">' + (s.label || '') + '</div>'
+ '</div>';
}).join('');
return '<div class="block block-statistics"><div class="block-stats-grid">' + items + '</div></div>';
}
static renderMapButton(block) {
return '<div class="block block-map-button-wrap">'
+ '<button class="map-button block-map-btn"'
+ ' data-lat="' + (block.lat || 0) + '"'
+ ' data-lng="' + (block.lng || 0) + '"'
+ ' data-zoom="' + (block.zoom || 6) + '">'
+ '<span class="map-button-icon">&#x1F5FA;</span> '
+ (block.label || 'View on Map')
+ '</button></div>';
}
}

528
js/builder.js Normal file
View File

@@ -0,0 +1,528 @@
/**
* Section Builder - Project Friction
* Block-based page builder for the 5 exhibit sections.
* Saves to localStorage; export/import as JSON.
*/
class SectionBuilder {
constructor() {
this.sections = {
context: { title: 'Historical Context', icon: '🌍', color: '#d4a574', blocks: [] },
rcaf: { title: 'RCAF Desert Cats', icon: '✈️', color: '#3498db', blocks: [] },
rcn: { title: 'RCN Task Group', icon: '⚓', color: '#2c3e50', blocks: [] },
army: { title: 'Canadian Army', icon: '🏥', color: '#27ae60', blocks: [] },
legacy: { title: 'After the War', icon: '🍁', color: '#c0392b', blocks: [] }
};
this.currentSection = 'context';
this.editingIndex = null;
this.defaultContent = null;
this.init();
}
async init() {
try {
var res = await fetch('data/sections-content.json');
if (res.ok) this.defaultContent = await res.json();
} catch (e) { console.warn('Could not load default content'); }
this.loadAllFromStorage();
this.renderSectionTabs();
this.bindGlobalEvents();
this.loadSection('context');
}
// ── Storage ────────────────────────────────────────────────────────────
loadAllFromStorage() {
var self = this;
Object.keys(this.sections).forEach(function(id) {
var saved = localStorage.getItem('pf-section-' + id);
if (saved) {
try { self.sections[id].blocks = JSON.parse(saved); } catch(e) {}
} else if (self.defaultContent && self.defaultContent[id]) {
self.sections[id].blocks = JSON.parse(JSON.stringify(self.defaultContent[id].blocks));
}
});
}
saveSection(id) {
localStorage.setItem('pf-section-' + id, JSON.stringify(this.sections[id].blocks));
this.showToast(this.sections[id].title + ' saved!');
}
// ── Section Loading ────────────────────────────────────────────────────
loadSection(id) {
this.currentSection = id;
var sec = this.sections[id];
document.querySelectorAll('.section-tab').forEach(function(t) {
t.classList.toggle('active', t.dataset.section === id);
});
document.getElementById('canvasSectionTitle').textContent = sec.icon + ' ' + sec.title;
var badge = document.getElementById('canvasBadge');
badge.textContent = sec.blocks.length + ' block' + (sec.blocks.length !== 1 ? 's' : '');
badge.style.background = sec.color;
this.renderCanvas();
}
// ── Canvas Rendering ───────────────────────────────────────────────────
renderCanvas() {
var canvas = document.getElementById('builderCanvas');
var blocks = this.sections[this.currentSection].blocks;
var self = this;
if (blocks.length === 0) {
canvas.innerHTML = '<div class="canvas-empty">'
+ '<p>No blocks yet.</p>'
+ '<p>Click a block type in the sidebar to add one.</p>'
+ '</div>';
return;
}
var html = blocks.map(function(block, index) {
return self.renderCanvasBlock(block, index, blocks.length);
}).join('');
html += '<div class="canvas-insert canvas-insert-bottom">'
+ '<button class="insert-btn" data-index="' + blocks.length + '">+ Add Block</button>'
+ '</div>';
canvas.innerHTML = html;
this.bindCanvasEvents();
}
renderCanvasBlock(block, index, total) {
var preview = BlockRenderer.renderBlock(block);
var isFirst = index === 0;
var isLast = index === total - 1;
var typeLabel = this.blockTypeLabel(block.type);
return '<div class="canvas-block" data-index="' + index + '">'
+ '<div class="canvas-block-controls">'
+ '<span class="block-type-badge">' + typeLabel + '</span>'
+ '<div class="block-actions">'
+ '<button data-action="up" data-index="' + index + '" title="Move Up" ' + (isFirst ? 'disabled' : '') + '>&#x2191;</button>'
+ '<button data-action="down" data-index="' + index + '" title="Move Down" ' + (isLast ? 'disabled' : '') + '>&#x2193;</button>'
+ '<button data-action="edit" data-index="' + index + '" class="btn-edit" title="Edit">&#x270F; Edit</button>'
+ '<button data-action="delete" data-index="' + index + '" class="btn-delete" title="Delete">&#x1F5D1;</button>'
+ '</div>'
+ '</div>'
+ '<div class="canvas-block-preview">' + preview + '</div>'
+ '<div class="canvas-insert">'
+ '<button class="insert-btn" data-index="' + (index + 1) + '">+ Insert Block</button>'
+ '</div>'
+ '</div>';
}
bindCanvasEvents() {
var self = this;
document.querySelectorAll('[data-action]').forEach(function(btn) {
btn.addEventListener('click', function() {
var action = btn.dataset.action;
var idx = parseInt(btn.dataset.index);
if (action === 'edit') self.openEditModal(idx);
if (action === 'delete') self.deleteBlock(idx);
if (action === 'up') self.moveBlock(idx, -1);
if (action === 'down') self.moveBlock(idx, 1);
});
});
document.querySelectorAll('.insert-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
self.pendingInsertIndex = parseInt(btn.dataset.index);
self.highlightPalette();
});
});
}
highlightPalette() {
var palette = document.querySelector('.palette-blocks');
palette.classList.add('palette-highlight');
setTimeout(function() { palette.classList.remove('palette-highlight'); }, 1200);
palette.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// ── Block CRUD ─────────────────────────────────────────────────────────
addBlock(type) {
var block = this.createDefaultBlock(type);
var blocks = this.sections[this.currentSection].blocks;
var atIdx = (this.pendingInsertIndex !== undefined) ? this.pendingInsertIndex : blocks.length;
this.pendingInsertIndex = undefined;
if (atIdx >= 0 && atIdx <= blocks.length) {
blocks.splice(atIdx, 0, block);
} else {
blocks.push(block);
}
this.renderCanvas();
var newIdx = blocks.indexOf(block);
this.openEditModal(newIdx);
}
createDefaultBlock(type) {
var id = 'b' + Date.now();
switch (type) {
case 'heading': return { id: id, type: type, level: 2, text: 'New Heading' };
case 'paragraph': return { id: id, type: type, text: 'Enter paragraph text here.' };
case 'image': return { id: id, type: type, src: '', alt: '', caption: '' };
case 'quote': return { id: id, type: type, text: 'Quote text here.', author: 'Source' };
case 'gallery': return { id: id, type: type, images: [{ src: '', alt: '', caption: '' }] };
case 'grid4': return { id: id, type: type, images: [
{ src: '', alt: '', caption: '' }, { src: '', alt: '', caption: '' },
{ src: '', alt: '', caption: '' }, { src: '', alt: '', caption: '' }
]};
case 'statistics': return { id: id, type: type, stats: [{ number: '0', label: 'Label' }, { number: '0', label: 'Label' }] };
case 'map-button': return { id: id, type: type, label: 'View on Map', lat: 29.3759, lng: 47.9774, zoom: 6 };
default: return { id: id, type: type };
}
}
deleteBlock(index) {
if (!confirm('Delete this block?')) return;
this.sections[this.currentSection].blocks.splice(index, 1);
this.renderCanvas();
}
moveBlock(index, direction) {
var blocks = this.sections[this.currentSection].blocks;
var newIndex = index + direction;
if (newIndex < 0 || newIndex >= blocks.length) return;
var tmp = blocks[index]; blocks[index] = blocks[newIndex]; blocks[newIndex] = tmp;
this.renderCanvas();
// Scroll moved block into view
var el = document.querySelector('[data-index="' + newIndex + '"]');
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// ── Modal Editor ───────────────────────────────────────────────────────
openEditModal(index) {
this.editingIndex = index;
var block = this.sections[this.currentSection].blocks[index];
document.getElementById('modalTitle').textContent = 'Edit: ' + this.blockTypeLabel(block.type);
document.getElementById('modalBody').innerHTML = this.buildModalForm(block);
document.getElementById('builderModal').classList.remove('hidden');
this.bindDynamicFormEvents(document.getElementById('modalBody'));
var first = document.getElementById('modalBody').querySelector('input, textarea, select');
if (first) first.focus();
}
buildModalForm(block) {
switch (block.type) {
case 'heading':
return this.formGroup('Level',
'<select name="level">'
+ '<option value="1"' + (block.level == 1 ? ' selected' : '') + '>H1 — Page Title</option>'
+ '<option value="2"' + (block.level == 2 ? ' selected' : '') + '>H2 — Section Title</option>'
+ '<option value="3"' + (block.level == 3 ? ' selected' : '') + '>H3 — Sub-heading</option>'
+ '</select>')
+ this.formGroup('Text', '<input type="text" name="text" value="' + this.esc(block.text || '') + '">');
case 'paragraph':
return this.formGroup('Text', '<textarea name="text" rows="6">' + this.esc(block.text || '') + '</textarea>');
case 'image':
return this.formGroup('Image URL', '<input type="text" name="src" value="' + this.esc(block.src || '') + '" placeholder="https://... or assets/images/...">')
+ this.formGroup('Alt Text (accessibility)', '<input type="text" name="alt" value="' + this.esc(block.alt || '') + '">')
+ this.formGroup('Caption (optional)', '<input type="text" name="caption" value="' + this.esc(block.caption || '') + '">');
case 'quote':
return this.formGroup('Quote Text', '<textarea name="text" rows="4">' + this.esc(block.text || '') + '</textarea>')
+ this.formGroup('Attribution (optional)', '<input type="text" name="author" value="' + this.esc(block.author || '') + '" placeholder="Name, Title, Date">');
case 'gallery':
return this.formGroup('Gallery Images',
'<div id="galleryItems">'
+ (block.images || []).map(function(img, i) { return SectionBuilder.imageRow(i, img, true); }).join('')
+ '</div>'
+ '<button type="button" class="add-item-btn btn-add-img">+ Add Image</button>');
case 'grid4':
return this.formGroup('4-Image Grid',
'<div id="grid4Items">'
+ [0,1,2,3].map(function(i) { return SectionBuilder.imageRow(i, (block.images || [])[i] || {}, false); }).join('')
+ '</div>');
case 'statistics':
return this.formGroup('Statistics',
'<div id="statsItems">'
+ (block.stats || []).map(function(s, i) { return SectionBuilder.statRow(i, s); }).join('')
+ '</div>'
+ '<button type="button" class="add-item-btn btn-add-stat">+ Add Statistic</button>');
case 'map-button':
return this.formGroup('Button Label', '<input type="text" name="label" value="' + this.esc(block.label || '') + '" placeholder="e.g. Show Kuwait Invasion Routes">')
+ '<div class="form-row">'
+ this.formGroup('Latitude', '<input type="number" name="lat" value="' + (block.lat || 0) + '" step="0.0001">')
+ this.formGroup('Longitude', '<input type="number" name="lng" value="' + (block.lng || 0) + '" step="0.0001">')
+ this.formGroup('Zoom (118)', '<input type="number" name="zoom" value="' + (block.zoom || 6) + '" min="1" max="18">')
+ '</div>';
default:
return '<p>Unknown block type.</p>';
}
}
static imageRow(index, img, removable) {
return '<div class="item-row" data-index="' + index + '">'
+ '<div class="item-row-fields">'
+ '<input type="text" data-field="src" data-index="' + index + '" value="' + (img.src || '') + '" placeholder="Image URL">'
+ '<input type="text" data-field="alt" data-index="' + index + '" value="' + (img.alt || '') + '" placeholder="Alt text">'
+ '<input type="text" data-field="caption" data-index="' + index + '" value="' + (img.caption || '') + '" placeholder="Caption (optional)">'
+ '</div>'
+ (removable ? '<button type="button" class="btn-remove-item">&#x2715;</button>' : '')
+ '</div>';
}
static statRow(index, stat) {
return '<div class="item-row" data-index="' + index + '">'
+ '<input type="text" data-field="number" data-index="' + index + '" value="' + (stat.number || '') + '" placeholder="Number (e.g. 4,500+)">'
+ '<input type="text" data-field="label" data-index="' + index + '" value="' + (stat.label || '') + '" placeholder="Label (e.g. Personnel)">'
+ '<button type="button" class="btn-remove-item">&#x2715;</button>'
+ '</div>';
}
bindDynamicFormEvents(container) {
var self = this;
var addImgBtn = container.querySelector('.btn-add-img');
if (addImgBtn) {
addImgBtn.addEventListener('click', function() {
var items = container.querySelector('#galleryItems');
var newIdx = items.querySelectorAll('.item-row').length;
items.insertAdjacentHTML('beforeend', SectionBuilder.imageRow(newIdx, {}, true));
self.bindRemoveBtns(container);
});
}
var addStatBtn = container.querySelector('.btn-add-stat');
if (addStatBtn) {
addStatBtn.addEventListener('click', function() {
var items = container.querySelector('#statsItems');
var newIdx = items.querySelectorAll('.item-row').length;
items.insertAdjacentHTML('beforeend', SectionBuilder.statRow(newIdx, {}));
self.bindRemoveBtns(container);
});
}
this.bindRemoveBtns(container);
}
bindRemoveBtns(container) {
container.querySelectorAll('.btn-remove-item').forEach(function(btn) {
btn.onclick = function() {
btn.closest('.item-row').remove();
container.querySelectorAll('.item-row').forEach(function(row, i) {
row.dataset.index = i;
row.querySelectorAll('[data-index]').forEach(function(el) { el.dataset.index = i; });
});
};
});
}
saveModalBlock() {
var block = this.sections[this.currentSection].blocks[this.editingIndex];
var body = document.getElementById('modalBody');
switch (block.type) {
case 'heading':
block.level = parseInt(body.querySelector('[name="level"]').value);
block.text = body.querySelector('[name="text"]').value;
break;
case 'paragraph':
block.text = body.querySelector('[name="text"]').value;
break;
case 'image':
block.src = body.querySelector('[name="src"]').value;
block.alt = body.querySelector('[name="alt"]').value;
block.caption = body.querySelector('[name="caption"]').value;
break;
case 'quote':
block.text = body.querySelector('[name="text"]').value;
block.author = body.querySelector('[name="author"]').value;
break;
case 'gallery':
block.images = this.readItemRows(body.querySelector('#galleryItems'), ['src','alt','caption']);
break;
case 'grid4':
block.images = this.readItemRows(body.querySelector('#grid4Items'), ['src','alt','caption']);
break;
case 'statistics':
block.stats = this.readItemRows(body.querySelector('#statsItems'), ['number','label']);
break;
case 'map-button':
block.label = body.querySelector('[name="label"]').value;
block.lat = parseFloat(body.querySelector('[name="lat"]').value);
block.lng = parseFloat(body.querySelector('[name="lng"]').value);
block.zoom = parseInt(body.querySelector('[name="zoom"]').value);
break;
}
this.closeModal();
this.renderCanvas();
}
readItemRows(container, fields) {
if (!container) return [];
return Array.from(container.querySelectorAll('.item-row')).map(function(row) {
var obj = {};
fields.forEach(function(field) {
var el = row.querySelector('[data-field="' + field + '"]');
obj[field] = el ? el.value : '';
});
return obj;
});
}
closeModal() {
document.getElementById('builderModal').classList.add('hidden');
this.editingIndex = null;
}
// ── UI Helpers ─────────────────────────────────────────────────────────
renderSectionTabs() {
var self = this;
var container = document.getElementById('sectionTabs');
container.innerHTML = Object.entries(this.sections).map(function(entry) {
var id = entry[0], sec = entry[1];
return '<button class="section-tab" data-section="' + id + '" style="--tab-color:' + sec.color + '">'
+ '<span class="tab-icon">' + sec.icon + '</span>'
+ '<span>' + sec.title + '</span>'
+ '</button>';
}).join('');
container.querySelectorAll('.section-tab').forEach(function(btn) {
btn.addEventListener('click', function() { self.loadSection(btn.dataset.section); });
});
}
bindGlobalEvents() {
var self = this;
document.getElementById('saveBtn').addEventListener('click', function() {
if (self.currentSection) self.saveSection(self.currentSection);
});
document.getElementById('resetBtn').addEventListener('click', function() {
if (!self.currentSection) return;
if (!confirm('Reset to default content? Saved changes will be lost.')) return;
localStorage.removeItem('pf-section-' + self.currentSection);
if (self.defaultContent && self.defaultContent[self.currentSection]) {
self.sections[self.currentSection].blocks = JSON.parse(JSON.stringify(self.defaultContent[self.currentSection].blocks));
} else {
self.sections[self.currentSection].blocks = [];
}
self.renderCanvas();
self.showToast('Section reset to default.');
});
document.getElementById('exportBtn').addEventListener('click', function() { self.exportJSON(); });
document.getElementById('importBtn').addEventListener('click', function() {
document.getElementById('importFile').click();
});
document.getElementById('importFile').addEventListener('change', function(e) {
var file = e.target.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function(evt) {
try {
var data = JSON.parse(evt.target.result);
Object.keys(self.sections).forEach(function(id) {
if (data[id] && data[id].blocks) {
self.sections[id].blocks = data[id].blocks;
localStorage.setItem('pf-section-' + id, JSON.stringify(data[id].blocks));
}
});
self.loadSection(self.currentSection);
self.showToast('Content imported!');
} catch (err) {
alert('Invalid JSON file: ' + err.message);
}
};
reader.readAsText(file);
e.target.value = '';
});
document.querySelectorAll('.palette-block').forEach(function(btn) {
btn.addEventListener('click', function() {
if (!self.currentSection) return;
self.addBlock(btn.dataset.type);
});
});
document.getElementById('modalSave').addEventListener('click', function() { self.saveModalBlock(); });
document.getElementById('modalCancel').addEventListener('click', function() { self.closeModal(); });
document.getElementById('modalClose').addEventListener('click', function() { self.closeModal(); });
document.querySelector('.modal-overlay').addEventListener('click', function() { self.closeModal(); });
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') self.closeModal();
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
if (self.currentSection) self.saveSection(self.currentSection);
}
});
}
exportJSON() {
var output = {};
Object.keys(this.sections).forEach(function(id) {
output[id] = { blocks: this.sections[id].blocks };
}, this);
var blob = new Blob([JSON.stringify(output, null, 2)], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'sections-content.json';
a.click();
URL.revokeObjectURL(url);
}
showToast(message) {
var toast = document.createElement('div');
toast.className = 'builder-toast';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(function() { toast.classList.add('show'); }, 10);
setTimeout(function() {
toast.classList.remove('show');
setTimeout(function() { toast.remove(); }, 300);
}, 2500);
}
blockTypeLabel(type) {
var labels = {
heading: 'H Heading', paragraph: '¶ Paragraph', image: '🖼 Image',
quote: '" Quote', gallery: '⊞ Gallery', grid4: '▦ Grid 4',
statistics: '# Statistics', 'map-button': '📍 Map Button'
};
return labels[type] || type;
}
formGroup(label, inputHtml) {
return '<div class="form-group"><label>' + label + '</label>' + inputHtml + '</div>';
}
esc(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
}
document.addEventListener('DOMContentLoaded', function() {
window.builder = new SectionBuilder();
});

404
main.css Normal file
View File

@@ -0,0 +1,404 @@
/* Project Friction - Main CSS */
/* Museum-quality digital exhibit styling */
:root {
/* Color Palette */
--color-primary: #2c3e50;
--color-secondary: #34495e;
--color-accent: #c0392b;
--color-context: #d4a574;
--color-rcaf: #3498db;
--color-rcn: #2c3e50;
--color-army: #27ae60;
--color-legacy: #c0392b;
/* Neutral Colors */
--color-white: #ffffff;
--color-light-gray: #f8f9fa;
--color-gray: #dee2e6;
--color-dark-gray: #6c757d;
--color-black: #212529;
/* Typography */
--font-primary: 'Georgia', 'Times New Roman', serif;
--font-secondary: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-xxl: 3rem;
/* Transitions */
--transition-fast: 0.15s ease;
--transition-normal: 0.3s ease;
--transition-slow: 0.5s ease;
/* Shadows */
--shadow-sm: 0 2px 5px rgba(0,0,0,0.1);
--shadow-md: 0 4px 10px rgba(0,0,0,0.1);
--shadow-lg: 0 8px 20px rgba(0,0,0,0.15);
/* Border Radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-full: 50%;
}
/* Reset and Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
font-family: var(--font-primary);
background: var(--color-light-gray);
color: var(--color-black);
overflow: hidden;
height: 100vh;
line-height: 1.6;
}
/* Layout Structure */
.app-container {
display: flex;
height: 100vh;
position: relative;
}
/* Sidebar Styling */
.sidebar {
width: 420px;
background: var(--color-white);
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
z-index: 100;
transition: transform var(--transition-normal);
position: relative;
}
.sidebar.collapsed {
transform: translateX(-370px);
}
.sidebar-toggle {
position: absolute;
right: -30px;
top: 20px;
width: 30px;
height: 60px;
background: var(--color-white);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
box-shadow: var(--shadow-md);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 101;
transition: background-color var(--transition-fast);
}
.sidebar-toggle:hover {
background: var(--color-light-gray);
}
/* Header Styling */
.sidebar-header {
background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-secondary) 100%);
color: var(--color-white);
padding: var(--spacing-xl);
border-bottom: 3px solid var(--color-accent);
}
.logo-section {
display: flex;
align-items: center;
gap: var(--spacing-lg);
}
.logo {
width: 45px;
height: 45px;
background: var(--color-accent);
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-white);
font-weight: 600;
font-size: 18px;
font-family: var(--font-secondary);
}
.title h1 {
font-size: 22px;
font-weight: 600;
margin-bottom: var(--spacing-xs);
}
.title p {
font-size: 13px;
opacity: 0.8;
font-style: italic;
}
/* Navigation States */
.back-button {
padding: var(--spacing-md) var(--spacing-xl);
background: var(--color-light-gray);
border-bottom: 1px solid var(--color-gray);
display: flex;
align-items: center;
gap: var(--spacing-md);
cursor: pointer;
transition: background-color var(--transition-fast);
font-family: var(--font-secondary);
}
.back-button:hover {
background: #e9ecef;
}
.back-button.hidden {
display: none;
}
.back-button.show {
display: flex;
}
/* Main Navigation */
.main-navigation {
display: block;
flex: 1;
overflow-y: auto;
}
.main-navigation.hidden {
display: none;
}
.nav-welcome {
padding: var(--spacing-xl);
background: var(--color-light-gray);
border-bottom: 1px solid var(--color-gray);
}
.nav-welcome h2 {
font-size: 18px;
color: var(--color-black);
margin-bottom: var(--spacing-md);
font-family: var(--font-primary);
}
.nav-welcome p {
font-size: 14px;
line-height: 1.6;
color: var(--color-dark-gray);
font-family: var(--font-secondary);
}
/* Quick Stats */
.quick-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-md);
padding: var(--spacing-xl);
background: var(--color-white);
}
.stat-card {
text-align: center;
padding: var(--spacing-lg);
background: var(--color-light-gray);
border-radius: var(--radius-md);
border: 1px solid #e9ecef;
transition: transform var(--transition-fast);
}
.stat-card:hover {
transform: translateY(-2px);
}
.stat-number {
font-size: 22px;
font-weight: 600;
color: var(--color-rcaf);
margin-bottom: var(--spacing-xs);
font-family: var(--font-secondary);
}
.stat-label {
font-size: 11px;
color: var(--color-dark-gray);
text-transform: uppercase;
letter-spacing: 0.5px;
font-family: var(--font-secondary);
}
/* Navigation Areas */
.nav-areas {
padding: var(--spacing-xl);
}
.nav-areas-title {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-dark-gray);
margin-bottom: var(--spacing-lg);
font-weight: 600;
font-family: var(--font-secondary);
}
/* Map Container */
.map-container {
flex: 1;
position: relative;
background: var(--color-secondary);
}
#map {
width: 100%;
height: 100%;
}
/* Footer */
.sidebar-footer {
padding: var(--spacing-lg) var(--spacing-xl);
background: var(--color-light-gray);
border-top: 1px solid var(--color-gray);
font-size: 12px;
color: var(--color-dark-gray);
text-align: center;
font-family: var(--font-secondary);
}
.sidebar-footer a {
color: var(--color-rcaf);
text-decoration: none;
transition: color var(--transition-fast);
}
.sidebar-footer a:hover {
color: var(--color-primary);
}
/* Utility Classes */
.hidden {
display: none !important;
}
.show {
display: block !important;
}
.text-center {
text-align: center;
}
.font-secondary {
font-family: var(--font-secondary);
}
.font-primary {
font-family: var(--font-primary);
}
/* Loading Screen */
.loading-screen {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--color-white);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
transition: opacity var(--transition-slow);
}
.loading-screen.fade-out {
opacity: 0;
pointer-events: none;
}
.loading-content {
text-align: center;
color: var(--color-primary);
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid var(--color-gray);
border-top: 4px solid var(--color-accent);
border-radius: var(--radius-full);
animation: spin 1s linear infinite;
margin: 0 auto var(--spacing-lg);
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-content h3 {
font-size: 20px;
margin-bottom: var(--spacing-sm);
font-family: var(--font-primary);
}
.loading-content p {
font-size: 14px;
color: var(--color-dark-gray);
font-family: var(--font-secondary);
}
/* Focus and Accessibility */
*:focus {
outline: 2px solid var(--color-rcaf);
outline-offset: 2px;
}
button:focus,
[role="button"]:focus {
outline: 2px solid var(--color-rcaf);
outline-offset: 2px;
}
/* Print Styles */
@media print {
.sidebar-toggle,
.map-container,
.back-button {
display: none !important;
}
.sidebar {
width: 100%;
transform: none;
box-shadow: none;
}
.app-container {
display: block;
}
body {
overflow: visible;
height: auto;
}
}

164
map-data.json Normal file
View File

@@ -0,0 +1,164 @@
{
"context": {
"markers": [
{
"lat": 29.3759,
"lng": 47.9774,
"title": "Kuwait City",
"description": "Capital of Kuwait, occupied August 2-February 26, 1991",
"type": "events",
"date": "1990-08-02",
"category": "invasion"
},
{
"lat": 33.3152,
"lng": 44.3661,
"title": "Baghdad",
"description": "Iraqi capital and command center",
"type": "iraqi",
"category": "command"
},
{
"lat": 30.5852,
"lng": 47.7817,
"title": "Basra",
"description": "Southern Iraq staging area",
"type": "iraqi",
"category": "military"
}
],
"routes": [
{
"name": "Northern Invasion Route",
"coordinates": [[30.0051, 47.7034], [29.3759, 47.9774]],
"type": "invasion",
"date": "1990-08-02"
},
{
"name": "Central Invasion Route",
"coordinates": [[29.9234, 47.8456], [29.3759, 47.9774]],
"type": "invasion",
"date": "1990-08-02"
},
{
"name": "Southern Invasion Route",
"coordinates": [[29.6678, 48.0234], [29.3759, 47.9774]],
"type": "invasion",
"date": "1990-08-02"
}
]
},
"rcaf": {
"markers": [
{
"lat": 25.2731,
"lng": 51.6080,
"title": "Canada Dry 1 - Doha",
"description": "Main CF-18 operating base, 409 Squadron",
"type": "canadian",
"category": "airbase"
},
{
"lat": 26.0667,
"lng": 50.5577,
"title": "CAP Patrol Area",
"description": "Combat Air Patrol protection zone",
"type": "canadian",
"category": "operations"
},
{
"lat": 29.1449,
"lng": 48.1251,
"title": "TNC-45 Engagement",
"description": "Iraqi patrol boat engagement, January 30, 1991",
"type": "events",
"date": "1991-01-30",
"category": "combat"
}
]
},
"rcn": {
"markers": [
{
"lat": 26.2285,
"lng": 50.5860,
"title": "HMCS Terra Nova",
"description": "Canadian guided-missile destroyer",
"type": "canadian",
"category": "naval"
},
{
"lat": 27.5000,
"lng": 50.0000,
"title": "MIF Operations Area",
"description": "Maritime Interception Force patrol zone",
"type": "coalition",
"category": "naval"
},
{
"lat": 26.0667,
"lng": 50.5577,
"title": "Bahrain Naval Base",
"description": "Coalition naval headquarters",
"type": "coalition",
"category": "base"
}
]
},
"army": {
"markers": [
{
"lat": 28.4312,
"lng": 46.0897,
"title": "1 Canadian Field Hospital",
"description": "Al-Qaysumah, Saudi Arabia - 530 personnel",
"type": "canadian",
"category": "medical"
},
{
"lat": 26.0667,
"lng": 50.5577,
"title": "Base Security - Bahrain",
"description": "RCR security operations",
"type": "canadian",
"category": "security"
},
{
"lat": 37.0000,
"lng": 43.0000,
"title": "Operation Provide Comfort",
"description": "Kurdish refugee relief operations",
"type": "canadian",
"category": "humanitarian"
}
]
},
"legacy": {
"markers": [
{
"lat": 29.3759,
"lng": 47.9774,
"title": "Environmental Cleanup Zone",
"description": "Post-war environmental restoration",
"type": "events",
"category": "cleanup"
},
{
"lat": 30.3753,
"lng": 47.7903,
"title": "UNIKOM Peacekeeping",
"description": "Iraq-Kuwait observation mission",
"type": "coalition",
"category": "peacekeeping"
},
{
"lat": 45.4215,
"lng": -75.6972,
"title": "Veterans Affairs Canada",
"description": "Ottawa - Veteran support services",
"type": "canadian",
"category": "veterans"
}
]
}
}