- Added 3 categories to unredacted.json: UFO/UAP (US), Covert Operations, Government - 127 real declassified PDFs (4.8GB) on VPS including: - UK MOD UFO files (112 files, 4.3GB) - Project Condign (250MB) - Pentagon UAP Report (2021) - CIA/NSA UFO documents - MKUltra Senate Hearing - Stargate Project docs - Operation Northwoods/Paperclip - Pentagon Papers (62MB) - CIA Torture Report (44MB) - JFK/Iran-Contra docs - New CRIME SCENE section: HTML, CSS, JS, JSON, API routes - Added to navigation and depot landing page
1023 lines
43 KiB
JavaScript
1023 lines
43 KiB
JavaScript
/* ═══════════════════════════════════════════════════════
|
||
CRIME SCENE — Criminal Case Archive
|
||
SPA Engine with hash routing & PDF.js viewer
|
||
═══════════════════════════════════════════════════════ */
|
||
|
||
(function () {
|
||
'use strict';
|
||
|
||
const ROOT = document.getElementById('crimesceneRoot');
|
||
const PDF_BASE = '/crimescene/docs';
|
||
let DATA = null;
|
||
|
||
// ─── Data Loading ────────────────────────────────────
|
||
async function loadData() {
|
||
try {
|
||
const r = await fetch('/api/crimescene');
|
||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||
DATA = await r.json();
|
||
} catch (e) {
|
||
console.error('CRIMESCENE: data load failed', e);
|
||
ROOT.innerHTML = `<div class="prop-empty">
|
||
<div class="prop-empty-icon">⚠️</div>
|
||
<div class="prop-empty-title">CASE DATABASE OFFLINE</div>
|
||
<div class="prop-empty-text">Failed to load criminal case 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">CRIMINAL CASE 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: 'CRIME SCENE' }]);
|
||
|
||
// ─── Global Search Bar ────────────────────────────────
|
||
html += `<div class="ur-search-container">
|
||
<div class="ur-search-bar">
|
||
<span class="ur-search-icon">⌕</span>
|
||
<input type="text" id="csSearchInput" class="ur-search-input" placeholder="SEARCH CRIMINAL ARCHIVES..." autocomplete="off" spellcheck="false">
|
||
<span class="ur-search-clear" id="csSearchClear" style="display:none;">✕</span>
|
||
</div>
|
||
<div class="ur-search-status" id="csSearchStatus"></div>
|
||
<div class="ur-search-results" id="csSearchResults"></div>
|
||
</div>`;
|
||
|
||
html += '<div id="csCategorySection">';
|
||
html += '<div class="prop-section-label">SELECT CATEGORY</div>';
|
||
|
||
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>';
|
||
html += '</div>'; // close #csCategorySection
|
||
ROOT.innerHTML = html;
|
||
|
||
// ─── Bind search events ────────────────────────────────
|
||
initSearchListeners();
|
||
}
|
||
|
||
// ─── 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">CASE FILE // ${esc(cat.name)} // ACCESS GRANTED</div>`;
|
||
|
||
html += breadcrumb([
|
||
{ label: 'CRIME SCENE', 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">CASE FILE // ${esc(col.name).toUpperCase()} // ACCESS GRANTED</div>`;
|
||
|
||
html += breadcrumb([
|
||
{ label: 'CRIME SCENE', 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">CASE FILE // ${esc(doc.title).toUpperCase()}</div>`;
|
||
|
||
html += breadcrumb([
|
||
{ label: 'CRIME SCENE', 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>
|
||
<button class="prop-viewer-btn" id="pdfSearchToggle" title="Search (Ctrl+F)">🔍 FIND</button>
|
||
<a class="prop-viewer-download" href="${pdfUrl}" target="_blank" download>⬇ DOWNLOAD</a>
|
||
</div>
|
||
</div>
|
||
<div class="prop-search-bar" id="pdfSearchBar">
|
||
<div class="prop-search-inner">
|
||
<input type="text" class="prop-search-input" id="pdfSearchInput" placeholder="SEARCH DOCUMENT..." autocomplete="off" spellcheck="false">
|
||
<button class="prop-search-btn" id="pdfSearchCaseBtn" title="Case sensitive">Aa</button>
|
||
<span class="prop-search-info" id="pdfSearchInfo"></span>
|
||
<button class="prop-search-btn" id="pdfSearchPrev" title="Previous match (Shift+Enter)">▲</button>
|
||
<button class="prop-search-btn" id="pdfSearchNext" title="Next match (Enter)">▼</button>
|
||
<button class="prop-search-btn prop-search-close" id="pdfSearchClose" title="Close search (Esc)">✕</button>
|
||
</div>
|
||
</div>
|
||
<div class="prop-viewer-canvas-wrap" id="pdfCanvasWrap">
|
||
<div class="prop-viewer-loading" id="pdfLoading">RETRIEVING CASE FILE...</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;
|
||
let pdfAllText = [];
|
||
let pdfSearchActive = false;
|
||
let pdfSearchQuery = '';
|
||
let pdfSearchCaseSensitive = false;
|
||
let pdfSearchMatches = [];
|
||
let pdfSearchCurrentIdx = -1;
|
||
|
||
|
||
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 page container with canvas
|
||
const pageContainer = document.createElement('div');
|
||
pageContainer.id = 'pdfPageContainer';
|
||
pageContainer.className = 'prop-page-container';
|
||
const canvas = document.createElement('canvas');
|
||
canvas.id = 'pdfCanvas';
|
||
pageContainer.appendChild(canvas);
|
||
canvasWrap.appendChild(pageContainer);
|
||
|
||
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();
|
||
});
|
||
|
||
// Extract all page text for search
|
||
extractAllText();
|
||
|
||
// Initialize search controls
|
||
initSearchControls();
|
||
} 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;
|
||
|
||
// Update page container size to match viewport
|
||
const pageContainer = document.getElementById('pdfPageContainer');
|
||
if (pageContainer) {
|
||
pageContainer.style.width = viewport.width + 'px';
|
||
pageContainer.style.height = viewport.height + 'px';
|
||
}
|
||
|
||
await page.render({ canvasContext: ctx, viewport: viewport }).promise;
|
||
|
||
// Render text layer for search and text selection
|
||
await renderPageTextLayer(page, viewport);
|
||
|
||
// Highlight search matches on this page
|
||
highlightCurrentPageMatches();
|
||
|
||
pdfRendering = false;
|
||
updatePageInfo();
|
||
|
||
if (pdfPending !== null) {
|
||
renderPdfPage();
|
||
pdfPending = null;
|
||
}
|
||
}
|
||
|
||
function queueRender() {
|
||
if (pdfRendering) {
|
||
pdfPending = pdfPage;
|
||
} else {
|
||
renderPdfPage();
|
||
}
|
||
}
|
||
|
||
// ─── Text Layer Rendering ────────────────────────────
|
||
async function renderPageTextLayer(page, viewport) {
|
||
const existing = document.getElementById('pdfTextLayer');
|
||
if (existing) existing.remove();
|
||
|
||
const pageContainer = document.getElementById('pdfPageContainer');
|
||
if (!pageContainer) return;
|
||
|
||
const textContent = await page.getTextContent();
|
||
const textLayerDiv = document.createElement('div');
|
||
textLayerDiv.id = 'pdfTextLayer';
|
||
textLayerDiv.className = 'prop-text-layer';
|
||
textLayerDiv.style.width = viewport.width + 'px';
|
||
textLayerDiv.style.height = viewport.height + 'px';
|
||
|
||
// Try pdf.js built-in renderTextLayer, fallback to manual
|
||
if (typeof pdfjsLib.renderTextLayer === 'function') {
|
||
try {
|
||
const task = pdfjsLib.renderTextLayer({
|
||
textContentSource: textContent,
|
||
container: textLayerDiv,
|
||
viewport: viewport,
|
||
});
|
||
await task.promise;
|
||
} catch (err) {
|
||
console.warn('renderTextLayer failed, using fallback:', err);
|
||
buildTextLayerFallback(textContent, viewport, textLayerDiv);
|
||
}
|
||
} else {
|
||
buildTextLayerFallback(textContent, viewport, textLayerDiv);
|
||
}
|
||
|
||
// Tag each span with its item index for search highlighting
|
||
let itemIdx = 0;
|
||
for (const child of textLayerDiv.childNodes) {
|
||
if (child.nodeName === 'SPAN') {
|
||
child.dataset.itemIdx = itemIdx;
|
||
itemIdx++;
|
||
}
|
||
}
|
||
|
||
pageContainer.appendChild(textLayerDiv);
|
||
}
|
||
|
||
function buildTextLayerFallback(textContent, viewport, container) {
|
||
textContent.items.forEach((item, idx) => {
|
||
if (!item.str) return;
|
||
const span = document.createElement('span');
|
||
span.textContent = item.str;
|
||
const tx = item.transform;
|
||
const fontHeight = Math.sqrt(tx[2] * tx[2] + tx[3] * tx[3]);
|
||
const s = viewport.scale;
|
||
span.style.fontSize = (fontHeight * s) + 'px';
|
||
span.style.fontFamily = 'sans-serif';
|
||
span.style.left = (tx[4] * s) + 'px';
|
||
span.style.top = (viewport.height - tx[5] * s - fontHeight * s) + 'px';
|
||
span.style.position = 'absolute';
|
||
span.style.color = 'transparent';
|
||
span.style.whiteSpace = 'pre';
|
||
container.appendChild(span);
|
||
});
|
||
}
|
||
|
||
// ─── Text Extraction for Search ──────────────────────
|
||
async function extractAllText() {
|
||
pdfAllText = [];
|
||
if (!pdfDoc) return;
|
||
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
||
const page = await pdfDoc.getPage(i);
|
||
const tc = await page.getTextContent();
|
||
let fullText = '';
|
||
const itemOffsets = [];
|
||
tc.items.forEach((item, idx) => {
|
||
const start = fullText.length;
|
||
fullText += item.str;
|
||
itemOffsets.push({ start: start, end: fullText.length, itemIdx: idx });
|
||
if (item.hasEOL) fullText += '\n';
|
||
});
|
||
pdfAllText.push({ pageNum: i, text: fullText, items: tc.items, itemOffsets: itemOffsets });
|
||
}
|
||
console.log('CRIMESCENE: Extracted text from ' + pdfAllText.length + ' pages');
|
||
}
|
||
|
||
// ─── Search Controls Init ────────────────────────────
|
||
function initSearchControls() {
|
||
const searchInput = document.getElementById('pdfSearchInput');
|
||
const searchPrev = document.getElementById('pdfSearchPrev');
|
||
const searchNext = document.getElementById('pdfSearchNext');
|
||
const searchClose = document.getElementById('pdfSearchClose');
|
||
const searchToggle = document.getElementById('pdfSearchToggle');
|
||
const searchCaseBtn = document.getElementById('pdfSearchCaseBtn');
|
||
if (!searchInput) return;
|
||
|
||
searchToggle.addEventListener('click', () => toggleSearchBar());
|
||
searchClose.addEventListener('click', () => closeSearchBar());
|
||
|
||
let searchTimeout = null;
|
||
searchInput.addEventListener('input', () => {
|
||
clearTimeout(searchTimeout);
|
||
searchTimeout = setTimeout(() => {
|
||
pdfSearchQuery = searchInput.value;
|
||
performSearch();
|
||
}, 200);
|
||
});
|
||
|
||
searchInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
if (e.shiftKey) prevMatch(); else nextMatch();
|
||
}
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
closeSearchBar();
|
||
}
|
||
});
|
||
|
||
searchPrev.addEventListener('click', () => prevMatch());
|
||
searchNext.addEventListener('click', () => nextMatch());
|
||
|
||
searchCaseBtn.addEventListener('click', () => {
|
||
pdfSearchCaseSensitive = !pdfSearchCaseSensitive;
|
||
searchCaseBtn.classList.toggle('active', pdfSearchCaseSensitive);
|
||
performSearch();
|
||
});
|
||
|
||
// Ctrl+F shortcut — only when PDF viewer is present
|
||
document.addEventListener('keydown', (e) => {
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
||
if (document.getElementById('pdfViewerContainer')) {
|
||
e.preventDefault();
|
||
toggleSearchBar(true);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function toggleSearchBar(forceOpen) {
|
||
const searchBar = document.getElementById('pdfSearchBar');
|
||
const searchInput = document.getElementById('pdfSearchInput');
|
||
if (!searchBar) return;
|
||
if (forceOpen || !pdfSearchActive) {
|
||
searchBar.classList.add('active');
|
||
pdfSearchActive = true;
|
||
setTimeout(() => searchInput && searchInput.focus(), 50);
|
||
} else {
|
||
closeSearchBar();
|
||
}
|
||
}
|
||
|
||
function closeSearchBar() {
|
||
const searchBar = document.getElementById('pdfSearchBar');
|
||
if (!searchBar) return;
|
||
searchBar.classList.remove('active');
|
||
pdfSearchActive = false;
|
||
pdfSearchQuery = '';
|
||
pdfSearchMatches = [];
|
||
pdfSearchCurrentIdx = -1;
|
||
const searchInput = document.getElementById('pdfSearchInput');
|
||
if (searchInput) searchInput.value = '';
|
||
updateSearchInfo();
|
||
clearHighlights();
|
||
}
|
||
|
||
function performSearch() {
|
||
pdfSearchMatches = [];
|
||
pdfSearchCurrentIdx = -1;
|
||
const query = pdfSearchQuery.trim();
|
||
if (!query || query.length === 0) {
|
||
updateSearchInfo();
|
||
clearHighlights();
|
||
return;
|
||
}
|
||
const cs = pdfSearchCaseSensitive;
|
||
const q = cs ? query : query.toLowerCase();
|
||
pdfAllText.forEach(pageData => {
|
||
const text = cs ? pageData.text : pageData.text.toLowerCase();
|
||
let pos = 0;
|
||
while ((pos = text.indexOf(q, pos)) !== -1) {
|
||
const matchStart = pos;
|
||
const matchEnd = pos + q.length;
|
||
const affectedItems = pageData.itemOffsets.filter(
|
||
io => io.end > matchStart && io.start < matchEnd
|
||
);
|
||
pdfSearchMatches.push({
|
||
page: pageData.pageNum,
|
||
charStart: matchStart,
|
||
charEnd: matchEnd,
|
||
itemIndices: affectedItems.map(io => io.itemIdx)
|
||
});
|
||
pos += 1;
|
||
}
|
||
});
|
||
pdfSearchCurrentIdx = pdfSearchMatches.length > 0 ? 0 : -1;
|
||
updateSearchInfo();
|
||
if (pdfSearchMatches.length > 0) {
|
||
goToMatch(0);
|
||
} else {
|
||
clearHighlights();
|
||
}
|
||
}
|
||
|
||
function goToMatch(idx) {
|
||
if (idx < 0 || idx >= pdfSearchMatches.length) return;
|
||
pdfSearchCurrentIdx = idx;
|
||
const match = pdfSearchMatches[idx];
|
||
if (match.page !== pdfPage) {
|
||
pdfPage = match.page;
|
||
queueRender();
|
||
} else {
|
||
highlightCurrentPageMatches();
|
||
}
|
||
updateSearchInfo();
|
||
}
|
||
|
||
function nextMatch() {
|
||
if (pdfSearchMatches.length === 0) return;
|
||
goToMatch((pdfSearchCurrentIdx + 1) % pdfSearchMatches.length);
|
||
}
|
||
|
||
function prevMatch() {
|
||
if (pdfSearchMatches.length === 0) return;
|
||
goToMatch((pdfSearchCurrentIdx - 1 + pdfSearchMatches.length) % pdfSearchMatches.length);
|
||
}
|
||
|
||
function highlightCurrentPageMatches() {
|
||
clearHighlights();
|
||
const textLayer = document.getElementById('pdfTextLayer');
|
||
if (!textLayer) return;
|
||
const pageMatches = pdfSearchMatches.filter(m => m.page === pdfPage);
|
||
if (pageMatches.length === 0) return;
|
||
|
||
pageMatches.forEach(match => {
|
||
const globalIdx = pdfSearchMatches.indexOf(match);
|
||
const isActive = globalIdx === pdfSearchCurrentIdx;
|
||
match.itemIndices.forEach(itemIdx => {
|
||
const span = textLayer.querySelector('span[data-item-idx="' + itemIdx + '"]');
|
||
if (span) {
|
||
span.classList.add('prop-search-hl');
|
||
if (isActive) {
|
||
span.classList.add('prop-search-hl-active');
|
||
const canvasWrap = document.getElementById('pdfCanvasWrap');
|
||
if (canvasWrap) {
|
||
const sr = span.getBoundingClientRect();
|
||
const wr = canvasWrap.getBoundingClientRect();
|
||
const offset = sr.top - wr.top - wr.height / 3;
|
||
canvasWrap.scrollBy({ top: offset, behavior: 'smooth' });
|
||
}
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function clearHighlights() {
|
||
const tl = document.getElementById('pdfTextLayer');
|
||
if (!tl) return;
|
||
tl.querySelectorAll('.prop-search-hl').forEach(el => {
|
||
el.classList.remove('prop-search-hl', 'prop-search-hl-active');
|
||
});
|
||
}
|
||
|
||
function updateSearchInfo() {
|
||
const info = document.getElementById('pdfSearchInfo');
|
||
if (!info) return;
|
||
if (pdfSearchMatches.length === 0) {
|
||
if (pdfSearchQuery && pdfSearchQuery.trim().length > 0) {
|
||
info.textContent = 'NO MATCHES';
|
||
info.className = 'prop-search-info no-match';
|
||
} else {
|
||
info.textContent = '';
|
||
info.className = 'prop-search-info';
|
||
}
|
||
} else {
|
||
info.textContent = (pdfSearchCurrentIdx + 1) + ' OF ' + pdfSearchMatches.length;
|
||
info.className = 'prop-search-info';
|
||
}
|
||
}
|
||
|
||
|
||
// ─── Global Archive Search ─────────────────────────────
|
||
let searchDebounceTimer = null;
|
||
|
||
function initSearchListeners() {
|
||
const input = document.getElementById('csSearchInput');
|
||
const clearBtn = document.getElementById('csSearchClear');
|
||
if (!input) return;
|
||
|
||
input.addEventListener('input', () => {
|
||
clearTimeout(searchDebounceTimer);
|
||
const val = input.value.trim();
|
||
clearBtn.style.display = val.length > 0 ? 'inline-block' : 'none';
|
||
searchDebounceTimer = setTimeout(() => {
|
||
globalArchiveSearch(val);
|
||
}, 300);
|
||
});
|
||
|
||
clearBtn.addEventListener('click', () => {
|
||
input.value = '';
|
||
clearBtn.style.display = 'none';
|
||
globalArchiveSearch('');
|
||
input.focus();
|
||
});
|
||
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
input.value = '';
|
||
clearBtn.style.display = 'none';
|
||
globalArchiveSearch('');
|
||
}
|
||
});
|
||
}
|
||
|
||
function globalArchiveSearch(query) {
|
||
const resultsEl = document.getElementById('csSearchResults');
|
||
const statusEl = document.getElementById('csSearchStatus');
|
||
const catSection = document.getElementById('csCategorySection');
|
||
if (!resultsEl || !statusEl) return;
|
||
|
||
if (!query || query.length === 0) {
|
||
resultsEl.innerHTML = '';
|
||
statusEl.innerHTML = '';
|
||
if (catSection) catSection.style.display = '';
|
||
return;
|
||
}
|
||
|
||
const q = query.toLowerCase();
|
||
const results = [];
|
||
|
||
if (!DATA || !DATA.categories) return;
|
||
|
||
DATA.categories.forEach(cat => {
|
||
(cat.countries || []).forEach(cn => {
|
||
(cn.collections || []).forEach(col => {
|
||
(col.documents || []).forEach(doc => {
|
||
const fields = [
|
||
doc.title || '',
|
||
doc.description || '',
|
||
col.name || '',
|
||
cat.name || '',
|
||
cn.name || '',
|
||
doc.year ? String(doc.year) : '',
|
||
doc.classification || ''
|
||
];
|
||
const combined = fields.join(' ').toLowerCase();
|
||
if (combined.includes(q)) {
|
||
results.push({
|
||
doc,
|
||
catName: cat.name,
|
||
catId: cat.id,
|
||
catIcon: cat.icon || '📁',
|
||
countryName: cn.name,
|
||
countryCode: cn.code,
|
||
countryFlag: cn.flag || '',
|
||
colName: col.name,
|
||
colId: col.id
|
||
});
|
||
}
|
||
});
|
||
|
||
// Also match collection-level (even if no docs match)
|
||
const colFields = [col.name || '', col.description || '', cat.name || '', cn.name || ''].join(' ').toLowerCase();
|
||
if (colFields.includes(q) && !results.find(r => r.colId === col.id && r.catId === cat.id && r.countryCode === cn.code && !r.doc.id)) {
|
||
// Check if we already added docs from this collection
|
||
const hasDocResults = results.some(r => r.colId === col.id && r.catId === cat.id && r.countryCode === cn.code);
|
||
if (!hasDocResults) {
|
||
results.push({
|
||
doc: { title: col.name, description: col.description || 'Collection in ' + cat.name, id: '__col__' },
|
||
catName: cat.name,
|
||
catId: cat.id,
|
||
catIcon: cat.icon || '📁',
|
||
countryName: cn.name,
|
||
countryCode: cn.code,
|
||
countryFlag: cn.flag || '',
|
||
colName: col.name,
|
||
colId: col.id,
|
||
isCollection: true
|
||
});
|
||
}
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
// Hide categories when searching
|
||
if (catSection) catSection.style.display = 'none';
|
||
|
||
if (results.length === 0) {
|
||
statusEl.innerHTML = '<span class="ur-search-none">NO MATCHING CASE FILES // REFINE SEARCH PARAMETERS</span>';
|
||
resultsEl.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
statusEl.innerHTML = `<span class="ur-search-count">FOUND ${results.length} MATCHING CASE FILE${results.length === 1 ? '' : 'S'}</span>`;
|
||
|
||
let html = '<div class="ur-results-grid">';
|
||
results.forEach(r => {
|
||
const title = highlightMatch(esc(r.doc.title || 'Untitled'), q);
|
||
const desc = r.doc.description ? highlightMatch(esc(truncateText(r.doc.description, 160)), q) : '';
|
||
const hash = r.isCollection
|
||
? `country/${r.catId}/${r.countryCode}`
|
||
: `doc/${r.catId}/${r.countryCode}/${r.colId}/${r.doc.id}`;
|
||
|
||
html += `<div class="ur-result-card" onclick="location.hash='${hash}'">
|
||
<div class="ur-result-header">
|
||
<span class="ur-result-icon">${r.catIcon}</span>
|
||
<span class="ur-result-title">${title}</span>
|
||
</div>
|
||
<div class="ur-result-meta">
|
||
<span class="ur-result-badge">${esc(r.catName)}</span>
|
||
<span class="ur-result-badge ur-result-badge-country">${r.countryFlag} ${esc(r.countryName)}</span>
|
||
<span class="ur-result-badge ur-result-badge-col">${esc(r.colName)}</span>
|
||
</div>
|
||
${desc ? `<div class="ur-result-desc">${desc}</div>` : ''}
|
||
</div>`;
|
||
});
|
||
html += '</div>';
|
||
resultsEl.innerHTML = html;
|
||
}
|
||
|
||
function highlightMatch(text, query) {
|
||
if (!query) return text;
|
||
const regex = new RegExp('(' + query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
|
||
return text.replace(regex, '<mark class="ur-highlight">$1</mark>');
|
||
}
|
||
|
||
function truncateText(text, maxLen) {
|
||
if (text.length <= maxLen) return text;
|
||
return text.substring(0, maxLen).replace(/\s+\S*$/, '') + '...';
|
||
}
|
||
|
||
// ─── 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 case file could not be located in the criminal archive.</div>
|
||
</div>
|
||
<div style="text-align:center;margin-top:1rem">
|
||
<a class="prop-back-btn" onclick="location.hash=''">◂ RETURN TO CASE 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;
|
||
pdfAllText = [];
|
||
pdfSearchActive = false;
|
||
pdfSearchQuery = '';
|
||
pdfSearchMatches = [];
|
||
pdfSearchCurrentIdx = -1;
|
||
|
||
|
||
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 CRIME SCENE ARCHIVE...</div>';
|
||
await loadData();
|
||
if (DATA) {
|
||
window.addEventListener('hashchange', route);
|
||
route();
|
||
}
|
||
}
|
||
|
||
// Wait for DOM
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
})();
|