530 lines
22 KiB
JavaScript
530 lines
22 KiB
JavaScript
/* ═══════════════════════════════════════════════════════
|
||
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();
|
||
}
|
||
})();
|