feat: add propaganda declassified document archive with PDF viewer, hierarchical navigation, and API endpoints

This commit is contained in:
jae 2026-04-15 16:16:22 +00:00
parent 0f4ce1a5f7
commit 1d6b3fe4b4
5 changed files with 1561 additions and 17 deletions

View file

@ -1525,5 +1525,76 @@ def api_govdomains_stats():
'types_list': sorted(type_counts.keys())
})
# ─── Propaganda: Declassified Document Archive ────────
_propaganda_cache = None
_propaganda_mtime = 0
def _load_propaganda():
global _propaganda_cache, _propaganda_mtime
p = DATA_DIR / 'propaganda.json'
if not p.exists():
return {'categories': []}
mt = p.stat().st_mtime
if _propaganda_cache is None or mt != _propaganda_mtime:
with open(p, encoding='utf-8') as f:
_propaganda_cache = json.load(f)
_propaganda_mtime = mt
return _propaganda_cache
@app.route('/api/propaganda')
def get_propaganda():
return jsonify(_load_propaganda())
@app.route('/api/propaganda/categories')
def get_propaganda_categories():
db = _load_propaganda()
cats = []
for c in db.get('categories', []):
n_countries = len(c.get('countries', []))
n_docs = 0
n_collections = 0
for cn in c.get('countries', []):
for col in cn.get('collections', []):
n_collections += 1
n_docs += len(col.get('documents', []))
cats.append({
'id': c['id'], 'name': c['name'], 'description': c.get('description', ''),
'icon': c.get('icon', ''), 'countries': n_countries,
'collections': n_collections, 'documents': n_docs
})
return jsonify({'categories': cats})
@app.route('/api/propaganda/category/<cat_id>')
def get_propaganda_category(cat_id):
db = _load_propaganda()
for c in db.get('categories', []):
if c['id'] == cat_id:
return jsonify(c)
abort(404, f'Category {cat_id} not found')
@app.route('/api/propaganda/upload', methods=['POST'])
@require_auth
def upload_propaganda_doc():
"""Upload a PDF to the propaganda archive. Future admin use."""
try:
if 'file' not in request.files:
return jsonify({'error': 'No file uploaded'}), 400
f = request.files['file']
cat_id = request.form.get('category', '')
col_id = request.form.get('collection', '')
if not cat_id or not col_id:
return jsonify({'error': 'category and collection required'}), 400
if not f.filename.lower().endswith('.pdf'):
return jsonify({'error': 'Only PDF files accepted'}), 400
# Save file — this saves locally; on VPS would save to /propaganda/docs/
save_dir = Path('/var/www/jaeswift-homepage/propaganda/docs') / cat_id / col_id
save_dir.mkdir(parents=True, exist_ok=True)
dest = save_dir / f.filename
f.save(str(dest))
return jsonify({'ok': True, 'path': str(dest), 'filename': f.filename})
except Exception as e:
return jsonify({'error': str(e)}), 500
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=False)

197
api/data/propaganda.json Normal file
View file

@ -0,0 +1,197 @@
{
"categories": [
{
"id": "ufo-uap",
"name": "UFO / UAP",
"description": "Unidentified aerial phenomena declassified files",
"icon": "👽",
"countries": [
{
"code": "UK",
"name": "United Kingdom",
"flag": "🇬🇧",
"collections": [
{
"id": "rendlesham-forest",
"name": "Rendlesham Forest Incident",
"year": "1980",
"description": "Britain's most famous UFO incident. USAF personnel reported unexplained lights near RAF Woodbridge.",
"source": "UK National Archives",
"documents": [
{
"id": "rendlesham-memo",
"title": "Lt Col Halt's Memorandum",
"filename": "rendlesham-halt-memo.pdf",
"pages": 2,
"date_released": "2001",
"description": "Official memo from Deputy Base Commander Charles Halt describing the incident."
}
]
},
{
"id": "uk-mod-ufo-files",
"name": "UK MOD UFO Files",
"year": "2009-2013",
"description": "Complete release of Ministry of Defence UFO investigation files.",
"source": "UK National Archives",
"documents": []
}
]
},
{
"code": "USA",
"name": "United States",
"flag": "🇺🇸",
"collections": [
{
"id": "project-blue-book",
"name": "Project Blue Book",
"year": "1952-1969",
"description": "USAF systematic study of UFOs. 12,618 sightings investigated.",
"source": "National Archives",
"documents": []
},
{
"id": "uap-task-force",
"name": "UAP Task Force Reports",
"year": "2021-2024",
"description": "Modern Pentagon UAP investigation reports released to Congress.",
"source": "DoD / ODNI",
"documents": []
}
]
},
{
"code": "RUS",
"name": "Russia",
"flag": "🇷🇺",
"collections": [
{
"id": "soviet-ufo-files",
"name": "Soviet Military UFO Files",
"year": "1978-1990",
"description": "Declassified Soviet military reports on unidentified aerial phenomena collected during the Cold War era.",
"source": "Russian State Archives",
"documents": []
}
]
}
]
},
{
"id": "intelligence",
"name": "INTELLIGENCE",
"description": "Declassified intelligence agency operations and programs",
"icon": "🕵️",
"countries": [
{
"code": "USA",
"name": "United States",
"flag": "🇺🇸",
"collections": [
{
"id": "mkultra",
"name": "Project MKUltra",
"year": "1953-1973",
"description": "CIA mind control program. Most documents destroyed in 1973, surviving files declassified in 1977.",
"source": "CIA Reading Room",
"documents": []
},
{
"id": "cointelpro",
"name": "COINTELPRO",
"year": "1956-1971",
"description": "FBI counterintelligence program targeting domestic political organisations.",
"source": "FBI Vault",
"documents": []
}
]
},
{
"code": "UK",
"name": "United Kingdom",
"flag": "🇬🇧",
"collections": [
{
"id": "mi5-cold-war",
"name": "MI5 Cold War Files",
"year": "1945-1991",
"description": "Declassified Security Service files from the Cold War period including surveillance and counter-espionage operations.",
"source": "UK National Archives",
"documents": []
}
]
}
]
},
{
"id": "military-ops",
"name": "MILITARY OPERATIONS",
"description": "Declassified military operations and strategic planning",
"icon": "⚔️",
"countries": [
{
"code": "USA",
"name": "United States",
"flag": "🇺🇸",
"collections": [
{
"id": "operation-northwoods",
"name": "Operation Northwoods",
"year": "1962",
"description": "Proposed false flag operations against American citizens to justify military intervention in Cuba. Rejected by JFK.",
"source": "National Security Archive",
"documents": []
}
]
}
]
},
{
"id": "government",
"name": "GOVERNMENT",
"description": "Released government documents, investigations, and correspondence",
"icon": "🏛️",
"countries": [
{
"code": "USA",
"name": "United States",
"flag": "🇺🇸",
"collections": [
{
"id": "jfk-files",
"name": "JFK Assassination Files",
"year": "1963-2023",
"description": "Released files related to the assassination of President John F. Kennedy.",
"source": "National Archives",
"documents": []
}
]
}
]
},
{
"id": "science-tech",
"name": "SCIENCE & TECHNOLOGY",
"description": "Declassified research programs and technical studies",
"icon": "🔬",
"countries": [
{
"code": "USA",
"name": "United States",
"flag": "🇺🇸",
"collections": [
{
"id": "project-stargate",
"name": "Project Stargate",
"year": "1978-1995",
"description": "CIA/DIA remote viewing program investigating psychic phenomena for intelligence applications.",
"source": "CIA Reading Room",
"documents": []
}
]
}
]
}
]
}

726
css/propaganda.css Normal file
View file

@ -0,0 +1,726 @@
/*
PROPAGANDA Declassified Document Archive
*/
/* ─── Stats Bar ──────────────────────────────────────── */
.prop-stats-bar {
display: flex;
gap: 2rem;
padding: 1rem 1.5rem;
background: rgba(16, 16, 16, 0.8);
border: 1px solid var(--border);
border-left: 3px solid var(--mil-red);
margin-bottom: 1.5rem;
font-family: var(--font-mono);
font-size: 0.85rem;
letter-spacing: 1px;
flex-wrap: wrap;
}
.prop-stat {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.prop-stat-label {
color: var(--text-muted);
font-size: 0.75rem;
text-transform: uppercase;
}
.prop-stat-value {
color: var(--status-green);
font-weight: 700;
font-size: 1rem;
}
.prop-stat-value.amber { color: var(--status-amber); }
.prop-stat-value.red { color: var(--mil-red); }
/* ─── Breadcrumb Trail ───────────────────────────────── */
.prop-breadcrumb {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: rgba(16, 16, 16, 0.6);
border: 1px solid var(--border);
border-left: 3px solid var(--status-green);
margin-bottom: 1.5rem;
font-family: var(--font-mono);
font-size: 0.8rem;
letter-spacing: 1px;
flex-wrap: wrap;
}
.prop-breadcrumb a {
color: var(--status-green);
text-decoration: none;
cursor: pointer;
transition: color 0.2s;
}
.prop-breadcrumb a:hover {
color: #fff;
text-decoration: underline;
}
.prop-breadcrumb .sep {
color: var(--text-muted);
opacity: 0.5;
}
.prop-breadcrumb .current {
color: var(--text-primary);
font-weight: 700;
}
/* ─── Category Grid ──────────────────────────────────── */
.prop-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.prop-card {
background: rgba(16, 16, 16, 0.6);
border: 1px solid var(--border);
padding: 1.25rem;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.prop-card::before {
content: '';
position: absolute;
top: 0; left: 0;
width: 3px; height: 100%;
background: var(--mil-red);
opacity: 0;
transition: opacity 0.3s ease;
}
.prop-card:hover {
border-color: var(--text-muted);
background: rgba(20, 20, 20, 0.9);
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
.prop-card:hover::before { opacity: 1; }
.prop-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.6rem;
}
.prop-card-icon {
font-size: 2rem;
opacity: 0.6;
line-height: 1;
}
.prop-card-name {
font-family: var(--font-display);
font-size: 1rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 1px;
}
.prop-card-desc {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-muted);
line-height: 1.5;
margin-bottom: 0.75rem;
}
.prop-card-meta {
display: flex;
justify-content: space-between;
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-muted);
letter-spacing: 1px;
border-top: 1px solid var(--border);
padding-top: 0.5rem;
}
.prop-card-count {
color: var(--status-green);
}
/* ─── Country Selector ───────────────────────────────── */
.prop-country-grid {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
margin-bottom: 1.5rem;
}
.prop-country-btn {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.65rem 1.25rem;
background: rgba(16, 16, 16, 0.8);
border: 1px solid var(--border);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 0.85rem;
letter-spacing: 1px;
cursor: pointer;
transition: all 0.3s ease;
text-transform: uppercase;
position: relative;
}
.prop-country-btn::before {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 2px;
background: var(--mil-red);
transform: scaleX(0);
transition: transform 0.3s ease;
}
.prop-country-btn:hover {
border-color: var(--text-muted);
color: #fff;
background: rgba(20, 20, 20, 0.9);
}
.prop-country-btn:hover::before {
transform: scaleX(1);
}
.prop-country-btn.active {
border-color: var(--mil-red);
color: #fff;
background: rgba(139, 0, 0, 0.15);
box-shadow: 0 0 15px rgba(139, 0, 0, 0.1);
}
.prop-country-btn.active::before {
transform: scaleX(1);
}
.prop-country-flag {
font-size: 1.2rem;
line-height: 1;
}
.prop-country-label {
font-weight: 700;
}
.prop-country-count {
color: var(--text-muted);
font-size: 0.7rem;
margin-left: 0.25rem;
}
/* ─── Collection Cards ───────────────────────────────── */
.prop-collection-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.prop-collection-card {
background: rgba(16, 16, 16, 0.6);
border: 1px solid var(--border);
padding: 1.25rem;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
}
.prop-collection-card::before {
content: '';
position: absolute;
top: 0; left: 0;
width: 100%; height: 2px;
background: linear-gradient(90deg, var(--mil-red), transparent);
opacity: 0;
transition: opacity 0.3s ease;
}
.prop-collection-card:hover {
border-color: var(--text-muted);
background: rgba(20, 20, 20, 0.9);
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
.prop-collection-card:hover::before { opacity: 1; }
.prop-collection-name {
font-family: var(--font-display);
font-size: 0.95rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 1px;
margin-bottom: 0.25rem;
}
.prop-collection-year {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--mil-red);
letter-spacing: 2px;
margin-bottom: 0.5rem;
}
.prop-collection-desc {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-muted);
line-height: 1.5;
margin-bottom: 0.75rem;
}
.prop-collection-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--text-muted);
letter-spacing: 1px;
border-top: 1px solid var(--border);
padding-top: 0.5rem;
}
.prop-collection-source {
opacity: 0.7;
}
.prop-collection-docs {
color: var(--status-green);
font-weight: 700;
}
/* ─── Collection Detail Header ───────────────────────── */
.prop-detail-header {
background: rgba(16, 16, 16, 0.8);
border: 1px solid var(--border);
border-left: 3px solid var(--mil-red);
padding: 1.25rem 1.5rem;
margin-bottom: 1.5rem;
}
.prop-detail-title {
font-family: var(--font-display);
font-size: 1.2rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 2px;
margin-bottom: 0.25rem;
}
.prop-detail-year {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--mil-red);
letter-spacing: 2px;
margin-bottom: 0.5rem;
}
.prop-detail-desc {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 0.5rem;
}
.prop-detail-source {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--text-muted);
letter-spacing: 1px;
}
.prop-detail-source span {
color: var(--status-green);
}
/* ─── Document List ──────────────────────────────────── */
.prop-doc-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 2rem;
}
.prop-doc-item {
display: flex;
align-items: center;
gap: 1rem;
background: rgba(16, 16, 16, 0.6);
border: 1px solid var(--border);
padding: 1rem 1.25rem;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
}
.prop-doc-item::before {
content: '';
position: absolute;
left: 0; top: 0;
width: 3px; height: 100%;
background: var(--status-green);
opacity: 0;
transition: opacity 0.3s ease;
}
.prop-doc-item:hover {
border-color: var(--status-green);
background: rgba(20, 20, 20, 0.9);
transform: translateX(4px);
}
.prop-doc-item:hover::before { opacity: 1; }
.prop-doc-icon {
font-size: 1.8rem;
opacity: 0.5;
flex-shrink: 0;
}
.prop-doc-info {
flex: 1;
min-width: 0;
}
.prop-doc-title {
font-family: var(--font-mono);
font-size: 0.85rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 1px;
margin-bottom: 0.25rem;
}
.prop-doc-desc {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--text-muted);
line-height: 1.4;
}
.prop-doc-meta {
display: flex;
gap: 1rem;
font-family: var(--font-mono);
font-size: 0.65rem;
color: var(--text-muted);
letter-spacing: 1px;
margin-top: 0.35rem;
}
.prop-doc-meta span {
opacity: 0.7;
}
.prop-doc-badge {
flex-shrink: 0;
padding: 0.3rem 0.6rem;
background: rgba(139, 0, 0, 0.2);
border: 1px solid rgba(139, 0, 0, 0.4);
font-family: var(--font-mono);
font-size: 0.65rem;
color: var(--mil-red);
letter-spacing: 2px;
text-transform: uppercase;
}
/* ─── Empty State ────────────────────────────────────── */
.prop-empty {
text-align: center;
padding: 3rem 2rem;
background: rgba(16, 16, 16, 0.4);
border: 1px dashed var(--border);
}
.prop-empty-icon {
font-size: 2.5rem;
opacity: 0.3;
margin-bottom: 1rem;
}
.prop-empty-title {
font-family: var(--font-display);
font-size: 0.9rem;
color: var(--text-muted);
letter-spacing: 2px;
margin-bottom: 0.5rem;
}
.prop-empty-text {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--text-muted);
opacity: 0.6;
}
/* ─── PDF Viewer ─────────────────────────────────────── */
.prop-viewer-container {
background: rgba(10, 10, 10, 0.95);
border: 1px solid var(--border);
margin-bottom: 2rem;
position: relative;
}
.prop-viewer-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 1rem;
background: rgba(16, 16, 16, 0.95);
border-bottom: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 0.75rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.prop-viewer-title {
color: var(--text-primary);
letter-spacing: 1px;
font-weight: 700;
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.prop-viewer-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.prop-viewer-btn {
padding: 0.35rem 0.65rem;
background: rgba(30, 30, 30, 0.8);
border: 1px solid var(--border);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s;
letter-spacing: 1px;
}
.prop-viewer-btn:hover {
border-color: var(--status-green);
color: #fff;
background: rgba(0, 204, 51, 0.1);
}
.prop-viewer-btn.active {
border-color: var(--status-green);
color: var(--status-green);
}
.prop-viewer-page-info {
color: var(--text-muted);
letter-spacing: 1px;
min-width: 80px;
text-align: center;
}
.prop-viewer-canvas-wrap {
display: flex;
justify-content: center;
align-items: flex-start;
padding: 1rem;
min-height: 500px;
max-height: 80vh;
overflow: auto;
background: rgba(5, 5, 5, 0.9);
}
.prop-viewer-canvas-wrap canvas {
max-width: 100%;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.6);
}
.prop-viewer-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
font-family: var(--font-mono);
font-size: 0.85rem;
color: var(--text-muted);
letter-spacing: 2px;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
.prop-viewer-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: 1rem;
padding: 2rem;
text-align: center;
}
.prop-viewer-error-icon {
font-size: 2rem;
opacity: 0.4;
}
.prop-viewer-error-text {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--mil-red);
letter-spacing: 1px;
}
.prop-viewer-download {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgba(139, 0, 0, 0.2);
border: 1px solid rgba(139, 0, 0, 0.4);
color: var(--mil-red);
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 1px;
text-decoration: none;
cursor: pointer;
transition: all 0.2s;
}
.prop-viewer-download:hover {
background: rgba(139, 0, 0, 0.35);
color: #fff;
}
/* ─── Classification Banner ──────────────────────────── */
.prop-classification {
text-align: center;
padding: 0.4rem;
font-family: var(--font-mono);
font-size: 0.65rem;
letter-spacing: 3px;
color: var(--mil-red);
border: 1px solid rgba(139, 0, 0, 0.3);
background: rgba(139, 0, 0, 0.05);
margin-bottom: 1.5rem;
}
/* ─── Back Button ────────────────────────────────────── */
.prop-back-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgba(16, 16, 16, 0.8);
border: 1px solid var(--border);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 1px;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 1rem;
text-decoration: none;
}
.prop-back-btn:hover {
border-color: var(--status-green);
color: #fff;
}
/* ─── Loading ────────────────────────────────────────── */
.prop-loading {
text-align: center;
padding: 3rem;
font-family: var(--font-mono);
font-size: 0.85rem;
color: var(--text-muted);
letter-spacing: 2px;
animation: pulse 1.5s ease-in-out infinite;
}
/* ─── Section Label ──────────────────────────────────── */
.prop-section-label {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--text-muted);
letter-spacing: 3px;
text-transform: uppercase;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
/* ─── Responsive ─────────────────────────────────────── */
@media (max-width: 768px) {
.prop-grid {
grid-template-columns: 1fr;
}
.prop-collection-grid {
grid-template-columns: 1fr;
}
.prop-stats-bar {
gap: 1rem;
}
.prop-country-grid {
gap: 0.5rem;
}
.prop-country-btn {
padding: 0.5rem 0.85rem;
font-size: 0.8rem;
}
.prop-doc-item {
flex-direction: column;
align-items: flex-start;
}
.prop-doc-badge {
align-self: flex-start;
}
.prop-viewer-toolbar {
flex-direction: column;
align-items: flex-start;
}
.prop-viewer-canvas-wrap {
min-height: 300px;
max-height: 60vh;
}
.prop-breadcrumb {
font-size: 0.7rem;
}
}
@media (min-width: 1400px) {
.prop-grid {
grid-template-columns: repeat(3, 1fr);
}
}

View file

@ -3,49 +3,69 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JAESWIFT // PROPAGANDA</title>
<title>JAESWIFT // PROPAGANDA — Declassified Document Archive</title>
<meta name="description" content="Declassified document archive. Browse categorised intelligence files, military reports, and government documents with built-in PDF viewer.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=JetBrains+Mono:wght@300;400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/section.css">
<link rel="stylesheet" href="/css/propaganda.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
</head>
<body>
<div class="scanline-overlay"></div>
<div class="grid-bg"></div>
<nav class="nav-main" id="navbar">
<div class="nav-container">
<a href="/" class="nav-logo"><span class="logo-bracket">[</span> JAE <span class="logo-bracket">]</span></a>
<button class="nav-toggle" id="navToggle" aria-label="Menu"><span></span><span></span><span></span></button>
<a href="/" class="nav-logo">
<span class="logo-bracket">[</span> JAE <span class="logo-bracket">]</span>
</a>
<button class="nav-toggle" id="navToggle" aria-label="Menu">
<span></span><span></span><span></span>
</button>
<ul class="nav-menu" id="navMenu"></ul>
<div class="nav-status"><span class="nav-clock" id="navClock"></span></div>
<div class="nav-status">
<span class="nav-clock" id="navClock"></span>
</div>
</div>
</nav>
<div class="breadcrumb">
<a href="/">HOME</a><span class="separator">/</span>
<a href="/depot">DEPOT</a><span class="separator">/</span>
<a href="/">HOME</a>
<span class="separator">/</span>
<a href="/depot">DEPOT</a>
<span class="separator">/</span>
<span class="current">PROPAGANDA</span>
</div>
<section class="section-header" style="padding-top: calc(var(--nav-height) + 1.5rem);">
<div class="section-header-label">DEPOT // INFORMATION WARFARE</div>
<div class="section-header-label">DEPOT // DECLASSIFIED ARCHIVE</div>
<h1 class="section-header-title">PROPAGANDA</h1>
<p class="section-header-sub">&gt; Documents, briefings, and intelligence reports. Information is power.</p>
<p class="section-header-sub">&gt; Declassified documents, intelligence briefings, and government files. Browse the archive.</p>
</section>
<section class="subpage-content">
<div class="subpage-placeholder">
<div class="placeholder-icon"></div>
<div class="placeholder-status">UNDER CONSTRUCTION</div>
<div class="placeholder-text">This section is being prepared. Content deployment imminent.</div>
<div class="placeholder-classification">CLASSIFICATION: PENDING // STATUS: STANDBY</div>
<section class="subpage-content" style="max-width: clamp(1200px, 90vw, 2400px); margin: 0 auto; padding: 0 2rem 3rem;">
<div id="propagandaRoot">
<div class="prop-loading">INITIALISING PROPAGANDA ARCHIVE...</div>
</div>
</section>
<footer class="footer">
<div class="footer-container">
<div class="footer-left"><span class="footer-logo">[JAE]</span><span class="footer-copy">&copy; 2026 JAESWIFT.XYZ</span></div>
<div class="footer-right"><span class="footer-signal">SIGNAL ████<span class="signal-flicker"></span></span></div>
<div class="footer-left">
<span class="footer-logo">[JAE]</span>
<span class="footer-copy">&copy; 2026 JAESWIFT.XYZ</span>
</div>
<div class="footer-right">
<span class="footer-signal">SIGNAL ████<span class="signal-flicker"></span></span>
</div>
</div>
</footer>
<script src="/js/wallet-connect.js"></script>
<script src="/js/nav.js"></script>
<script src="/js/clock.js"></script>
<script src="/js/propaganda.js"></script>
</body>
</html>
</html>

530
js/propaganda.js Normal file
View file

@ -0,0 +1,530 @@
/*
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();
}
})();