jaeswift-website/js/propaganda.js

530 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/* ═══════════════════════════════════════════════════════
PROPAGANDA — Declassified Document Archive
SPA Engine with hash routing & PDF.js viewer
═══════════════════════════════════════════════════════ */
(function () {
'use strict';
const ROOT = document.getElementById('propagandaRoot');
const PDF_BASE = '/propaganda/docs';
let DATA = null;
// ─── Data Loading ────────────────────────────────────
async function loadData() {
try {
const r = await fetch('/api/propaganda');
if (!r.ok) throw new Error(`HTTP ${r.status}`);
DATA = await r.json();
} catch (e) {
console.error('PROPAGANDA: data load failed', e);
ROOT.innerHTML = `<div class="prop-empty">
<div class="prop-empty-icon">⚠️</div>
<div class="prop-empty-title">DATA FEED OFFLINE</div>
<div class="prop-empty-text">Failed to load archive index. ${e.message}</div>
</div>`;
}
}
// ─── Helpers ──────────────────────────────────────────
function countDocuments(obj) {
if (!obj) return 0;
if (Array.isArray(obj.documents)) return obj.documents.length;
if (Array.isArray(obj.collections)) {
return obj.collections.reduce((s, c) => s + (c.documents ? c.documents.length : 0), 0);
}
if (Array.isArray(obj.countries)) {
return obj.countries.reduce((s, cn) => s + countDocuments(cn), 0);
}
return 0;
}
function countCollections(obj) {
if (!obj) return 0;
if (Array.isArray(obj.collections)) return obj.collections.length;
if (Array.isArray(obj.countries)) {
return obj.countries.reduce((s, cn) => s + (cn.collections ? cn.collections.length : 0), 0);
}
return 0;
}
function countCountries(cat) {
return cat.countries ? cat.countries.length : 0;
}
function totalStats() {
if (!DATA || !DATA.categories) return { cats: 0, countries: 0, collections: 0, docs: 0 };
let countries = new Set();
let collections = 0;
let docs = 0;
DATA.categories.forEach(cat => {
(cat.countries || []).forEach(cn => {
countries.add(cn.code);
(cn.collections || []).forEach(col => {
collections++;
docs += (col.documents || []).length;
});
});
});
return { cats: DATA.categories.length, countries: countries.size, collections, docs };
}
function findCategory(id) {
return DATA.categories.find(c => c.id === id);
}
function findCountry(cat, code) {
return (cat.countries || []).find(c => c.code === code);
}
function findCollection(country, colId) {
return (country.collections || []).find(c => c.id === colId);
}
function findDocument(collection, docId) {
return (collection.documents || []).find(d => d.id === docId);
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
// ─── Breadcrumb Builder ───────────────────────────────
function breadcrumb(parts) {
// parts: [{label, hash?}, ...]
let html = '<div class="prop-breadcrumb">';
parts.forEach((p, i) => {
if (i > 0) html += '<span class="sep">/</span>';
if (p.hash) {
html += `<a onclick="location.hash='${p.hash}'">${esc(p.label)}</a>`;
} else {
html += `<span class="current">${esc(p.label)}</span>`;
}
});
html += '</div>';
return html;
}
// ─── View: Categories (Home) ──────────────────────────
function renderCategories() {
const stats = totalStats();
let html = '';
html += `<div class="prop-classification">DECLASSIFIED // DOCUMENT ARCHIVE // ACCESS GRANTED</div>`;
html += `<div class="prop-stats-bar">
<div class="prop-stat"><span class="prop-stat-label">CATEGORIES</span><span class="prop-stat-value">${stats.cats}</span></div>
<div class="prop-stat"><span class="prop-stat-label">COUNTRIES</span><span class="prop-stat-value amber">${stats.countries}</span></div>
<div class="prop-stat"><span class="prop-stat-label">COLLECTIONS</span><span class="prop-stat-value">${stats.collections}</span></div>
<div class="prop-stat"><span class="prop-stat-label">DOCUMENTS</span><span class="prop-stat-value red">${stats.docs}</span></div>
</div>`;
html += breadcrumb([{ label: 'PROPAGANDA' }]);
html += '<div class="prop-section-label">SELECT CATEGORY</div>';
html += '<div class="prop-grid">';
DATA.categories.forEach(cat => {
const nCountries = countCountries(cat);
const nDocs = countDocuments(cat);
const nCol = countCollections(cat);
html += `<div class="prop-card" onclick="location.hash='category/${cat.id}'">
<div class="prop-card-header">
<div class="prop-card-name">${esc(cat.name)}</div>
<div class="prop-card-icon">${cat.icon || '📁'}</div>
</div>
<div class="prop-card-desc">${esc(cat.description)}</div>
<div class="prop-card-meta">
<span>${nCountries} COUNTR${nCountries === 1 ? 'Y' : 'IES'} · ${nCol} COLLECTION${nCol === 1 ? '' : 'S'}</span>
<span class="prop-card-count">${nDocs} DOC${nDocs === 1 ? '' : 'S'}</span>
</div>
</div>`;
});
html += '</div>';
ROOT.innerHTML = html;
}
// ─── View: Category (Country Selector + Collections) ──
function renderCategory(catId, activeCountry) {
const cat = findCategory(catId);
if (!cat) return renderNotFound('Category not found');
let html = '';
html += `<div class="prop-classification">DECLASSIFIED // ${esc(cat.name)} // ACCESS GRANTED</div>`;
html += breadcrumb([
{ label: 'PROPAGANDA', hash: '' },
{ label: cat.name }
]);
// Country selector
if (cat.countries && cat.countries.length > 0) {
html += '<div class="prop-section-label">SELECT COUNTRY</div>';
html += '<div class="prop-country-grid">';
cat.countries.forEach(cn => {
const nCol = cn.collections ? cn.collections.length : 0;
const isActive = activeCountry === cn.code;
html += `<div class="prop-country-btn${isActive ? ' active' : ''}" onclick="location.hash='country/${catId}/${cn.code}'">
<span class="prop-country-flag">${cn.flag || ''}</span>
<span class="prop-country-label">${esc(cn.name)}</span>
<span class="prop-country-count">[${nCol}]</span>
</div>`;
});
html += '</div>';
// If a country is selected, show its collections
if (activeCountry) {
const country = findCountry(cat, activeCountry);
if (country) {
html += renderCountryCollections(cat, country);
}
} else {
// Show all collections from all countries
html += '<div class="prop-section-label">ALL COLLECTIONS</div>';
let hasCols = false;
cat.countries.forEach(cn => {
if (cn.collections && cn.collections.length > 0) {
hasCols = true;
}
});
if (hasCols) {
html += '<div class="prop-collection-grid">';
cat.countries.forEach(cn => {
(cn.collections || []).forEach(col => {
html += renderCollectionCard(catId, cn.code, col, cn.flag);
});
});
html += '</div>';
} else {
html += renderEmpty('NO COLLECTIONS YET', 'Collections will appear here once documents are added to this category.');
}
}
} else {
html += renderEmpty('NO COUNTRIES REGISTERED', 'No country data available for this category yet.');
}
ROOT.innerHTML = html;
}
function renderCountryCollections(cat, country) {
let html = '';
html += `<div class="prop-section-label">${country.flag || ''} ${esc(country.name)} — COLLECTIONS</div>`;
if (country.collections && country.collections.length > 0) {
html += '<div class="prop-collection-grid">';
country.collections.forEach(col => {
html += renderCollectionCard(cat.id, country.code, col);
});
html += '</div>';
} else {
html += renderEmpty('NO COLLECTIONS', 'No document collections available for this country yet.');
}
return html;
}
function renderCollectionCard(catId, countryCode, col, flag) {
const nDocs = col.documents ? col.documents.length : 0;
return `<div class="prop-collection-card" onclick="location.hash='collection/${catId}/${countryCode}/${col.id}'">
<div class="prop-collection-name">${flag ? flag + ' ' : ''}${esc(col.name)}</div>
<div class="prop-collection-year">${esc(col.year)}</div>
<div class="prop-collection-desc">${esc(col.description)}</div>
<div class="prop-collection-footer">
<span class="prop-collection-source">SRC: ${esc(col.source)}</span>
<span class="prop-collection-docs">${nDocs} DOCUMENT${nDocs === 1 ? '' : 'S'}</span>
</div>
</div>`;
}
// ─── View: Collection (Documents) ─────────────────────
function renderCollection(catId, countryCode, colId) {
const cat = findCategory(catId);
if (!cat) return renderNotFound('Category not found');
const country = findCountry(cat, countryCode);
if (!country) return renderNotFound('Country not found');
const col = findCollection(country, colId);
if (!col) return renderNotFound('Collection not found');
let html = '';
html += `<div class="prop-classification">DECLASSIFIED // ${esc(col.name).toUpperCase()} // ACCESS GRANTED</div>`;
html += breadcrumb([
{ label: 'PROPAGANDA', hash: '' },
{ label: cat.name, hash: `category/${catId}` },
{ label: `${country.flag || ''} ${country.name}`, hash: `country/${catId}/${countryCode}` },
{ label: col.name }
]);
// Detail header
html += `<div class="prop-detail-header">
<div class="prop-detail-title">${esc(col.name)}</div>
<div class="prop-detail-year">${esc(col.year)}</div>
<div class="prop-detail-desc">${esc(col.description)}</div>
<div class="prop-detail-source">SOURCE: <span>${esc(col.source)}</span></div>
</div>`;
// Documents
if (col.documents && col.documents.length > 0) {
html += `<div class="prop-section-label">DOCUMENTS [${col.documents.length}]</div>`;
html += '<div class="prop-doc-list">';
col.documents.forEach(doc => {
html += `<div class="prop-doc-item" onclick="location.hash='doc/${catId}/${countryCode}/${colId}/${doc.id}'">
<div class="prop-doc-icon">📄</div>
<div class="prop-doc-info">
<div class="prop-doc-title">${esc(doc.title)}</div>
<div class="prop-doc-desc">${esc(doc.description)}</div>
<div class="prop-doc-meta">
<span>PAGES: ${doc.pages || '?'}</span>
<span>RELEASED: ${esc(doc.date_released || 'UNKNOWN')}</span>
<span>${esc(doc.filename)}</span>
</div>
</div>
<div class="prop-doc-badge">VIEW</div>
</div>`;
});
html += '</div>';
} else {
html += renderEmpty('NO DOCUMENTS UPLOADED YET', 'This collection has been catalogued but documents have not yet been uploaded to the archive.');
}
ROOT.innerHTML = html;
}
// ─── View: Document (PDF Viewer) ──────────────────────
function renderDocument(catId, countryCode, colId, docId) {
const cat = findCategory(catId);
if (!cat) return renderNotFound('Category not found');
const country = findCountry(cat, countryCode);
if (!country) return renderNotFound('Country not found');
const col = findCollection(country, colId);
if (!col) return renderNotFound('Collection not found');
const doc = findDocument(col, docId);
if (!doc) return renderNotFound('Document not found');
const pdfUrl = `${PDF_BASE}/${catId}/${colId}/${doc.filename}`;
let html = '';
html += `<div class="prop-classification">DECLASSIFIED // ${esc(doc.title).toUpperCase()}</div>`;
html += breadcrumb([
{ label: 'PROPAGANDA', hash: '' },
{ label: cat.name, hash: `category/${catId}` },
{ label: `${country.flag || ''} ${country.name}`, hash: `country/${catId}/${countryCode}` },
{ label: col.name, hash: `collection/${catId}/${countryCode}/${colId}` },
{ label: doc.title }
]);
// Viewer
html += `<div class="prop-viewer-container" id="pdfViewerContainer">
<div class="prop-viewer-toolbar">
<div class="prop-viewer-title">${esc(doc.title)}</div>
<div class="prop-viewer-controls">
<button class="prop-viewer-btn" id="pdfPrev" title="Previous page">◂ PREV</button>
<span class="prop-viewer-page-info" id="pdfPageInfo">— / —</span>
<button class="prop-viewer-btn" id="pdfNext" title="Next page">NEXT ▸</button>
<button class="prop-viewer-btn" id="pdfZoomOut" title="Zoom out"></button>
<button class="prop-viewer-btn" id="pdfZoomIn" title="Zoom in">+</button>
<button class="prop-viewer-btn" id="pdfFit" title="Fit width">FIT</button>
<a class="prop-viewer-download" href="${pdfUrl}" target="_blank" download>⬇ DOWNLOAD</a>
</div>
</div>
<div class="prop-viewer-canvas-wrap" id="pdfCanvasWrap">
<div class="prop-viewer-loading" id="pdfLoading">DECRYPTING DOCUMENT...</div>
</div>
</div>`;
// Doc info
html += `<div class="prop-detail-header">
<div class="prop-detail-title">${esc(doc.title)}</div>
<div class="prop-detail-desc">${esc(doc.description)}</div>
<div class="prop-doc-meta" style="margin-top:0.5rem">
<span>PAGES: ${doc.pages || '?'}</span>
<span>RELEASED: ${esc(doc.date_released || 'UNKNOWN')}</span>
<span>FILE: ${esc(doc.filename)}</span>
</div>
</div>`;
ROOT.innerHTML = html;
// Init PDF viewer
initPdfViewer(pdfUrl);
}
// ─── PDF.js Viewer ───────────────────────────────────
let pdfDoc = null;
let pdfPage = 1;
let pdfScale = 1.5;
let pdfRendering = false;
let pdfPending = null;
async function initPdfViewer(url) {
const canvasWrap = document.getElementById('pdfCanvasWrap');
const loadingEl = document.getElementById('pdfLoading');
const pageInfo = document.getElementById('pdfPageInfo');
// Check if pdf.js is loaded
if (typeof pdfjsLib === 'undefined') {
canvasWrap.innerHTML = `<div class="prop-viewer-error">
<div class="prop-viewer-error-icon">⚠️</div>
<div class="prop-viewer-error-text">PDF.js library not loaded</div>
<a class="prop-viewer-download" href="${url}" target="_blank" download>⬇ DOWNLOAD PDF DIRECTLY</a>
</div>`;
return;
}
try {
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
const loadingTask = pdfjsLib.getDocument(url);
pdfDoc = await loadingTask.promise;
pdfPage = 1;
loadingEl.remove();
// Create canvas
const canvas = document.createElement('canvas');
canvas.id = 'pdfCanvas';
canvasWrap.appendChild(canvas);
updatePageInfo();
renderPdfPage();
// Controls
document.getElementById('pdfPrev').addEventListener('click', () => {
if (pdfPage <= 1) return;
pdfPage--;
queueRender();
});
document.getElementById('pdfNext').addEventListener('click', () => {
if (pdfPage >= pdfDoc.numPages) return;
pdfPage++;
queueRender();
});
document.getElementById('pdfZoomIn').addEventListener('click', () => {
pdfScale = Math.min(pdfScale + 0.25, 4);
queueRender();
});
document.getElementById('pdfZoomOut').addEventListener('click', () => {
pdfScale = Math.max(pdfScale - 0.25, 0.5);
queueRender();
});
document.getElementById('pdfFit').addEventListener('click', () => {
pdfScale = 1.5;
queueRender();
});
} catch (e) {
console.error('PDF load failed:', e);
canvasWrap.innerHTML = `<div class="prop-viewer-error">
<div class="prop-viewer-error-icon">⚠️</div>
<div class="prop-viewer-error-text">DOCUMENT LOAD FAILED — ${esc(e.message || 'File may not exist yet')}</div>
<a class="prop-viewer-download" href="${url}" target="_blank" download>⬇ ATTEMPT DIRECT DOWNLOAD</a>
</div>`;
}
}
function updatePageInfo() {
const el = document.getElementById('pdfPageInfo');
if (el && pdfDoc) {
el.textContent = `${pdfPage} / ${pdfDoc.numPages}`;
}
}
async function renderPdfPage() {
if (!pdfDoc) return;
pdfRendering = true;
const page = await pdfDoc.getPage(pdfPage);
const viewport = page.getViewport({ scale: pdfScale });
const canvas = document.getElementById('pdfCanvas');
if (!canvas) { pdfRendering = false; return; }
const ctx = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
await page.render({ canvasContext: ctx, viewport: viewport }).promise;
pdfRendering = false;
updatePageInfo();
if (pdfPending !== null) {
renderPdfPage();
pdfPending = null;
}
}
function queueRender() {
if (pdfRendering) {
pdfPending = pdfPage;
} else {
renderPdfPage();
}
}
// ─── Not Found / Empty ────────────────────────────────
function renderNotFound(msg) {
ROOT.innerHTML = `<div class="prop-empty">
<div class="prop-empty-icon">⚠️</div>
<div class="prop-empty-title">${esc(msg)}</div>
<div class="prop-empty-text">The requested resource could not be located in the archive.</div>
</div>
<div style="text-align:center;margin-top:1rem">
<a class="prop-back-btn" onclick="location.hash=''">◂ RETURN TO ARCHIVE INDEX</a>
</div>`;
}
function renderEmpty(title, text) {
return `<div class="prop-empty">
<div class="prop-empty-icon">◎</div>
<div class="prop-empty-title">${esc(title)}</div>
<div class="prop-empty-text">${esc(text)}</div>
</div>`;
}
// ─── Router ───────────────────────────────────────────
function route() {
if (!DATA) return;
// Reset PDF state
pdfDoc = null;
pdfPage = 1;
pdfRendering = false;
pdfPending = null;
const hash = (location.hash || '').replace(/^#\/?/, '');
const parts = hash.split('/').filter(Boolean);
if (parts.length === 0 || hash === '') {
renderCategories();
} else if (parts[0] === 'category' && parts[1]) {
renderCategory(parts[1], null);
} else if (parts[0] === 'country' && parts[1] && parts[2]) {
renderCategory(parts[1], parts[2]);
} else if (parts[0] === 'collection' && parts.length >= 4) {
renderCollection(parts[1], parts[2], parts[3]);
} else if (parts[0] === 'doc' && parts.length >= 5) {
renderDocument(parts[1], parts[2], parts[3], parts[4]);
} else {
renderNotFound('INVALID ROUTE');
}
// Scroll to top
window.scrollTo({ top: 0, behavior: 'smooth' });
}
// ─── Init ─────────────────────────────────────────────
async function init() {
ROOT.innerHTML = '<div class="prop-loading">INITIALISING PROPAGANDA ARCHIVE...</div>';
await loadData();
if (DATA) {
window.addEventListener('hashchange', route);
route();
}
}
// Wait for DOM
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();