- Navigation: 6 top-level items (BASE, TRANSMISSIONS, ARMOURY, INTEL, SAFEHOUSE, COMMS) with dropdown children - nav.js: renders nested dropdown submenus, mobile tap-to-toggle support - Theme: tactical green (#00ff41) accent, deep black (#0a0a0a) bg, amber (#c9a227) secondary - 176 colour replacements across 4 CSS + 3 JS files - Mobile: responsive dropdowns with slide animation - Updated navigation.json with full nested structure
349 lines
13 KiB
JavaScript
349 lines
13 KiB
JavaScript
/* ===================================================
|
|
JAESWIFT BLOG — Individual Post Loader
|
|
Markdown rendering, operator stats, navigation
|
|
=================================================== */
|
|
(function () {
|
|
'use strict';
|
|
|
|
const API = window.location.hostname === 'localhost'
|
|
? 'http://localhost:5000'
|
|
: '/api';
|
|
|
|
// ─── Simple Markdown Parser ───
|
|
function parseMarkdown(md) {
|
|
let html = md;
|
|
|
|
// Code blocks (```lang ... ```)
|
|
html = html.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) => {
|
|
const cls = lang ? ` class="language-${lang}"` : '';
|
|
return `<pre><code${cls}>${escapeHtml(code.trim())}</code></pre>`;
|
|
});
|
|
|
|
// Inline code
|
|
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
|
|
|
|
// Headers
|
|
html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
|
|
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
|
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
|
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
|
|
|
// Bold & Italic
|
|
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
|
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
|
|
|
// Links
|
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
|
|
// Unordered lists
|
|
html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
|
|
html = html.replace(/(<li>.*<\/li>\n?)+/g, (match) => `<ul>${match}</ul>`);
|
|
|
|
// Horizontal rule
|
|
html = html.replace(/^---$/gm, '<hr>');
|
|
|
|
// Paragraphs (double newline)
|
|
html = html.replace(/\n\n(?!<)/g, '</p><p>');
|
|
html = '<p>' + html + '</p>';
|
|
|
|
// Clean up empty paragraphs and fix nesting
|
|
html = html.replace(/<p><(h[1-4]|pre|ul|hr|blockquote)/g, '<$1');
|
|
html = html.replace(/<\/(h[1-4]|pre|ul|hr|blockquote)><\/p>/g, '</$1>');
|
|
html = html.replace(/<p><\/p>/g, '');
|
|
html = html.replace(/<p>\s*<\/p>/g, '');
|
|
|
|
return html;
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
// ─── Stat Pips ───
|
|
function buildPips(val, max) {
|
|
max = max || 5;
|
|
let html = '<div class="stat-pips">';
|
|
for (let i = 0; i < max; i++) {
|
|
let cls = 'pip';
|
|
if (i < val) {
|
|
cls += ' filled';
|
|
if (val <= 2) cls += ' danger';
|
|
else if (val <= 3) cls += ' warn';
|
|
}
|
|
html += '<div class="' + cls + '"></div>';
|
|
}
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
// ─── Clock ───
|
|
function initClock() {
|
|
const el = document.getElementById('navClock');
|
|
if (!el) return;
|
|
const tick = () => {
|
|
const d = new Date();
|
|
el.textContent = d.toLocaleTimeString('en-GB', { hour12: false }) + ' UTC' + (d.getTimezoneOffset() <= 0 ? '+' : '') + (-d.getTimezoneOffset() / 60);
|
|
};
|
|
tick();
|
|
setInterval(tick, 1000);
|
|
}
|
|
|
|
// ─── Navbar ───
|
|
function initNavbar() {
|
|
const toggle = document.getElementById('navToggle');
|
|
const menu = document.getElementById('navMenu');
|
|
if (toggle && menu) {
|
|
toggle.addEventListener('click', () => menu.classList.toggle('active'));
|
|
}
|
|
window.addEventListener('scroll', () => {
|
|
const nb = document.getElementById('navbar');
|
|
if (nb) nb.classList.toggle('scrolled', window.scrollY > 50);
|
|
}, { passive: true });
|
|
}
|
|
|
|
// ─── Mood Icons Map ───
|
|
const moodIcons = {
|
|
'focused': '◎', 'creative': '✦', 'productive': '⚡',
|
|
'tired': '◡', 'wired': '⚡', 'chaotic': '✸',
|
|
'locked-in': '◉', 'zen': '☯'
|
|
};
|
|
const moodColors = {
|
|
'focused': '#00ff41', 'creative': '#a855f7', 'productive': '#22d3ee',
|
|
'tired': '#6b7280', 'wired': '#f59e0b', 'chaotic': '#ff2d2d',
|
|
'locked-in': '#00ff88', 'zen': '#818cf8'
|
|
};
|
|
|
|
// ─── Render Operator Stats ───
|
|
function renderStats(post) {
|
|
const el = document.getElementById('postStats');
|
|
if (!el) return;
|
|
|
|
const moodStr = (post.mood || 'focused').toLowerCase();
|
|
const moodIcon = moodIcons[moodStr] || '◈';
|
|
const moodColor = moodColors[moodStr] || '#00ff41';
|
|
const hr = post.heart_rate || post.bpm || 72;
|
|
|
|
const coffee = '☕'.repeat(Math.min(post.coffee || 0, 10)) +
|
|
'<span style="opacity:0.2">' + '☕'.repeat(Math.max(0, 5 - (post.coffee || 0))) + '</span>';
|
|
|
|
el.innerHTML = `
|
|
<div class="stat-row mood-row">
|
|
<span class="stat-label">MOOD</span>
|
|
<span class="stat-mood-badge" style="color:${moodColor};border-color:${moodColor}">${moodIcon} ${moodStr.toUpperCase()}</span>
|
|
</div>
|
|
<div class="stat-row">
|
|
<span class="stat-label">ENERGY</span>
|
|
${buildPips(post.energy)}
|
|
</div>
|
|
<div class="stat-row">
|
|
<span class="stat-label">MOTIVATION</span>
|
|
${buildPips(post.motivation)}
|
|
</div>
|
|
<div class="stat-row">
|
|
<span class="stat-label">FOCUS</span>
|
|
${buildPips(post.focus)}
|
|
</div>
|
|
<div class="stat-row">
|
|
<span class="stat-label">DIFFICULTY</span>
|
|
${buildPips(post.difficulty)}
|
|
</div>
|
|
<div class="stat-divider"></div>
|
|
<div class="stat-big-row">
|
|
<div class="stat-bpm-display">
|
|
<span class="stat-bpm-value" id="bpmValue">${hr}</span>
|
|
<span class="stat-bpm-unit">BPM</span>
|
|
<div class="heartbeat-line" id="heartbeatLine"></div>
|
|
</div>
|
|
</div>
|
|
<div class="stat-coffee-row">
|
|
<span class="stat-label">CAFFEINE</span>
|
|
<span class="stat-coffee">${coffee}</span>
|
|
</div>
|
|
`;
|
|
|
|
// Animate heartbeat
|
|
animateHeartbeat(hr);
|
|
}
|
|
|
|
// ─── Heartbeat Animation ───
|
|
function animateHeartbeat(bpm) {
|
|
const el = document.getElementById('bpmValue');
|
|
if (!el) return;
|
|
const interval = 60000 / bpm;
|
|
|
|
setInterval(() => {
|
|
el.classList.add('pulse');
|
|
setTimeout(() => el.classList.remove('pulse'), 200);
|
|
// Flicker the value slightly
|
|
const jitter = Math.floor(Math.random() * 5) - 2;
|
|
el.textContent = bpm + jitter;
|
|
}, interval);
|
|
}
|
|
|
|
// ─── Render Metadata Panel ───
|
|
function renderMetaPanel(post) {
|
|
const el = document.getElementById('postMetaPanel');
|
|
if (!el) return;
|
|
|
|
el.innerHTML = `
|
|
<div class="meta-row">
|
|
<span class="meta-key">DATE</span>
|
|
<span class="meta-val">${post.date}</span>
|
|
</div>
|
|
<div class="meta-row">
|
|
<span class="meta-key">TIME</span>
|
|
<span class="meta-val">${post.time_written || 'UNKNOWN'}</span>
|
|
</div>
|
|
<div class="meta-row">
|
|
<span class="meta-key">WORDS</span>
|
|
<span class="meta-val">${post.word_count || '---'}</span>
|
|
</div>
|
|
<div class="meta-row">
|
|
<span class="meta-key">THREAT</span>
|
|
<span class="meta-val threat-${(post.threat_level || 'low').toLowerCase()}">${post.threat_level || 'LOW'}</span>
|
|
</div>
|
|
<div class="meta-row">
|
|
<span class="meta-key">READ TIME</span>
|
|
<span class="meta-val">~${Math.max(1, Math.ceil((post.word_count || 300) / 250))} MIN</span>
|
|
</div>
|
|
<div class="meta-row">
|
|
<span class="meta-key">SLUG</span>
|
|
<span class="meta-val mono">${post.slug}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ─── Render Navigation ───
|
|
function renderNav(allPosts, currentSlug) {
|
|
const el = document.getElementById('postNav');
|
|
if (!el) return;
|
|
|
|
// Sort by date descending
|
|
const sorted = [...allPosts].sort((a, b) => new Date(b.date) - new Date(a.date));
|
|
const idx = sorted.findIndex(p => p.slug === currentSlug);
|
|
|
|
let navHtml = '';
|
|
|
|
if (idx > 0) {
|
|
const prev = sorted[idx - 1];
|
|
navHtml += `<a href="/blog/post/${prev.slug}" class="post-nav-link nav-newer">
|
|
<span class="nav-dir">← NEWER</span>
|
|
<span class="nav-post-title">${prev.title}</span>
|
|
</a>`;
|
|
}
|
|
|
|
if (idx < sorted.length - 1) {
|
|
const next = sorted[idx + 1];
|
|
navHtml += `<a href="/blog/post/${next.slug}" class="post-nav-link nav-older">
|
|
<span class="nav-dir">OLDER →</span>
|
|
<span class="nav-post-title">${next.title}</span>
|
|
</a>`;
|
|
}
|
|
|
|
navHtml += `<a href="/blog" class="post-nav-link nav-all">
|
|
<span class="nav-dir">⟐ ALL TRANSMISSIONS</span>
|
|
</a>`;
|
|
|
|
el.innerHTML = navHtml;
|
|
}
|
|
|
|
// ─── Load Post ───
|
|
async function loadPost() {
|
|
// Support both /blog/post/slug and ?slug=x formats
|
|
let slug = null;
|
|
const pathMatch = window.location.pathname.match(/\/blog\/post\/([^\/]+)/);
|
|
if (pathMatch) {
|
|
slug = decodeURIComponent(pathMatch[1]);
|
|
} else {
|
|
const params = new URLSearchParams(window.location.search);
|
|
slug = params.get('slug');
|
|
}
|
|
|
|
if (!slug) {
|
|
document.getElementById('postContent').innerHTML =
|
|
'<div class="post-error">NO TRANSMISSION ID SPECIFIED</div>';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Fetch the specific post
|
|
const res = await fetch(API + '/posts/' + encodeURIComponent(slug));
|
|
if (!res.ok) throw new Error('Post not found');
|
|
const post = await res.json();
|
|
|
|
// Update page title
|
|
document.title = 'JAESWIFT // ' + post.title.toUpperCase();
|
|
|
|
// Update header
|
|
document.getElementById('postTitle').textContent = post.title;
|
|
document.getElementById('postDate').textContent = post.date;
|
|
document.getElementById('postTime').textContent = post.time_written || '';
|
|
|
|
const threatEl = document.getElementById('postThreat');
|
|
threatEl.textContent = post.threat_level || 'LOW';
|
|
threatEl.className = 'post-threat threat-' + (post.threat_level || 'LOW').toLowerCase();
|
|
|
|
// Tags
|
|
const tagsEl = document.getElementById('postTags');
|
|
tagsEl.innerHTML = (post.tags || []).map(t =>
|
|
'<span class="post-tag">' + t + '</span>'
|
|
).join('');
|
|
|
|
// Render content
|
|
const contentEl = document.getElementById('postContent');
|
|
contentEl.innerHTML = '<div class="post-rendered">' + parseMarkdown(post.content || '') + '</div>';
|
|
|
|
// Animate content in
|
|
contentEl.style.opacity = '0';
|
|
contentEl.style.transform = 'translateY(20px)';
|
|
contentEl.style.transition = 'all 0.6s ease';
|
|
requestAnimationFrame(() => {
|
|
contentEl.style.opacity = '1';
|
|
contentEl.style.transform = 'translateY(0)';
|
|
});
|
|
|
|
// Sidebar panels
|
|
renderStats(post);
|
|
renderMetaPanel(post);
|
|
|
|
// Load all posts for navigation
|
|
try {
|
|
const allRes = await fetch(API + '/posts');
|
|
const allPosts = await allRes.json();
|
|
renderNav(allPosts, slug);
|
|
} catch (e) {
|
|
console.warn('Could not load post navigation');
|
|
}
|
|
|
|
} catch (err) {
|
|
// Fallback: try static JSON
|
|
try {
|
|
const res2 = await fetch('api/data/posts.json');
|
|
const posts = await res2.json();
|
|
const post = posts.find(p => p.slug === slug);
|
|
if (!post) throw new Error('Not found');
|
|
|
|
document.title = 'JAESWIFT // ' + post.title.toUpperCase();
|
|
document.getElementById('postTitle').textContent = post.title;
|
|
document.getElementById('postDate').textContent = post.date;
|
|
document.getElementById('postTime').textContent = post.time_written || '';
|
|
document.getElementById('postContent').innerHTML =
|
|
'<div class="post-rendered">' + parseMarkdown(post.content || '') + '</div>';
|
|
renderStats(post);
|
|
renderMetaPanel(post);
|
|
renderNav(posts, slug);
|
|
} catch (e2) {
|
|
document.getElementById('postContent').innerHTML =
|
|
'<div class="post-error">TRANSMISSION NOT FOUND — SIGNAL LOST</div>';
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Init ───
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
initClock();
|
|
initNavbar();
|
|
loadPost();
|
|
});
|
|
})();
|