- Created /source-code.html — sovereign hosting landing page explaining why we left GitHub, how to access the code, and the sovereignty model - Navbar: GitHub link → Source Code link (desktop + mobile) - Footer: GitHub link → Source Code link - Docs sidebar: GitHub section → Source Code section with sovereign repo - Implementer page: all repository links point to /source-code.html, clone instructions updated, CI/CD code example genericised - FAQ: GitHub Discussions button → Contact Us with email icon - FAQ content: all 4 locales (en/de/fr/mi) rewritten to remove GitHub Actions YAML, GitHub URLs, and GitHub-specific patterns - faq.js fallback content: same changes as locale files - agent-lightning integration page: updated to source-code.html - Project model: example URL changed from GitHub to Codeberg - All locale files updated: navbar.github → navbar.source_code, footer GitHub → source_code, FAQ button text updated in 4 languages Zero GitHub references remain in any HTML, JS, or JSON file (only github-dark.min.css theme name in highlight.js CDN reference). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
427 lines
12 KiB
JavaScript
427 lines
12 KiB
JavaScript
/**
|
|
* Blog Presentation Mode
|
|
* Renders blog posts as full-viewport slides with presenter notes
|
|
* Zero dependencies — pure JS
|
|
*/
|
|
|
|
/* eslint-disable no-unused-vars */
|
|
|
|
/**
|
|
* Check if presentation mode is requested and initialize
|
|
* Called from blog-post.js after post data is loaded
|
|
*/
|
|
function initPresentationMode(post) {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
if (urlParams.get('mode') !== 'presentation') return false;
|
|
|
|
const slides = buildSlides(post);
|
|
if (slides.length === 0) return false;
|
|
|
|
renderPresentation(slides);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Build slides from post data
|
|
* Option A: curated slides from post.presentation
|
|
* Option B: auto-extract from HTML content using <h2> breaks
|
|
*/
|
|
function buildSlides(post) {
|
|
const slides = [];
|
|
|
|
// Title slide is always first
|
|
const authorName = post.author_name || (post.author && post.author.name) || 'Tractatus Team';
|
|
const publishedDate = post.published_at
|
|
? new Date(post.published_at).toLocaleDateString('en-US', {
|
|
year: 'numeric', month: 'long', day: 'numeric'
|
|
})
|
|
: '';
|
|
|
|
slides.push({
|
|
type: 'title',
|
|
heading: post.title,
|
|
subtitle: post.excerpt || '',
|
|
meta: [authorName, publishedDate].filter(Boolean).join(' — '),
|
|
notes: ''
|
|
});
|
|
|
|
// Option A: curated presentation data
|
|
if (post.presentation && post.presentation.enabled &&
|
|
post.presentation.slides && post.presentation.slides.length > 0) {
|
|
for (const slide of post.presentation.slides) {
|
|
slides.push({
|
|
type: 'content',
|
|
heading: slide.heading,
|
|
bullets: slide.bullets || [],
|
|
notes: slide.notes || ''
|
|
});
|
|
}
|
|
return slides;
|
|
}
|
|
|
|
// Option B: auto-extract from content HTML
|
|
const contentHtml = post.content_html || post.content || '';
|
|
if (!contentHtml) return slides;
|
|
|
|
// Parse the HTML to extract sections by <h2>
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(`<div>${contentHtml}</div>`, 'text/html');
|
|
const wrapper = doc.body.firstChild;
|
|
|
|
let currentHeading = null;
|
|
let currentElements = [];
|
|
|
|
const flushSection = () => {
|
|
if (!currentHeading) return;
|
|
|
|
// Extract bullet points from paragraphs
|
|
const bullets = [];
|
|
for (const el of currentElements) {
|
|
if (el.tagName === 'UL' || el.tagName === 'OL') {
|
|
const items = el.querySelectorAll('li');
|
|
for (const item of items) {
|
|
const text = item.textContent.trim();
|
|
if (text) bullets.push(text);
|
|
}
|
|
} else if (el.tagName === 'P') {
|
|
const text = el.textContent.trim();
|
|
if (text) {
|
|
const firstSentence = text.match(/^[^.!?]+[.!?]/);
|
|
bullets.push(firstSentence ? firstSentence[0] : text.substring(0, 120));
|
|
}
|
|
} else if (el.tagName === 'BLOCKQUOTE') {
|
|
const text = el.textContent.trim();
|
|
if (text) bullets.push(text);
|
|
}
|
|
}
|
|
|
|
// Limit to 6 bullets per slide for readability
|
|
const slideBullets = bullets.slice(0, 6);
|
|
|
|
// Use full section text as notes
|
|
const allText = currentElements
|
|
.map(el => el.textContent.trim())
|
|
.filter(Boolean)
|
|
.join(' ');
|
|
const notes = allText.length > 200
|
|
? `${allText.substring(0, 500)}...`
|
|
: allText;
|
|
|
|
slides.push({
|
|
type: 'content',
|
|
heading: currentHeading,
|
|
bullets: slideBullets,
|
|
notes
|
|
});
|
|
};
|
|
|
|
for (const node of wrapper.childNodes) {
|
|
if (node.nodeType !== 1) continue; // skip text nodes
|
|
|
|
if (node.tagName === 'H2') {
|
|
flushSection();
|
|
currentHeading = node.textContent.trim();
|
|
currentElements = [];
|
|
} else if (currentHeading) {
|
|
currentElements.push(node);
|
|
}
|
|
}
|
|
flushSection();
|
|
|
|
return slides;
|
|
}
|
|
|
|
/**
|
|
* Escape HTML for safe insertion
|
|
*/
|
|
function escapeForPresentation(text) {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
/**
|
|
* Render the presentation UI
|
|
*/
|
|
function renderPresentation(slides) {
|
|
// Hide the normal article view
|
|
document.body.classList.add('presentation-active');
|
|
|
|
// Create presentation container
|
|
const container = document.createElement('div');
|
|
container.className = 'presentation-mode';
|
|
container.id = 'presentation-container';
|
|
|
|
// Progress bar
|
|
const progress = document.createElement('div');
|
|
progress.className = 'slide-progress';
|
|
progress.id = 'slide-progress';
|
|
container.appendChild(progress);
|
|
|
|
// Slides container
|
|
const slidesEl = document.createElement('div');
|
|
slidesEl.className = 'presentation-slides';
|
|
|
|
for (let i = 0; i < slides.length; i++) {
|
|
const slide = slides[i];
|
|
const slideEl = document.createElement('div');
|
|
slideEl.className = `presentation-slide${slide.type === 'title' ? ' slide-title' : ''}`;
|
|
slideEl.dataset.index = i;
|
|
if (i === 0) slideEl.classList.add('active');
|
|
|
|
if (slide.type === 'title') {
|
|
const subtitleHtml = slide.subtitle
|
|
? `<p class="slide-subtitle">${escapeForPresentation(slide.subtitle)}</p>`
|
|
: '';
|
|
const metaHtml = slide.meta
|
|
? `<p class="slide-meta">${escapeForPresentation(slide.meta)}</p>`
|
|
: '';
|
|
slideEl.innerHTML =
|
|
`<h1>${escapeForPresentation(slide.heading)}</h1>${subtitleHtml}${metaHtml}`;
|
|
} else {
|
|
let bulletsHtml = '';
|
|
if (slide.bullets && slide.bullets.length > 0) {
|
|
const items = slide.bullets
|
|
.map(b => `<li>${escapeForPresentation(b)}</li>`)
|
|
.join('');
|
|
bulletsHtml = `<ul class="slide-bullets">${items}</ul>`;
|
|
}
|
|
slideEl.innerHTML =
|
|
`<h2 class="slide-heading">${escapeForPresentation(slide.heading)}</h2>${bulletsHtml}`;
|
|
}
|
|
|
|
slidesEl.appendChild(slideEl);
|
|
}
|
|
|
|
container.appendChild(slidesEl);
|
|
|
|
// Notes panel
|
|
const notesPanel = document.createElement('div');
|
|
notesPanel.className = 'presentation-notes';
|
|
notesPanel.id = 'presentation-notes';
|
|
notesPanel.innerHTML =
|
|
'<div class="presentation-notes-inner">' +
|
|
'<div class="presentation-notes-label">Presenter Notes</div>' +
|
|
'<div class="presentation-notes-text" id="presentation-notes-text"></div>' +
|
|
'</div>';
|
|
container.appendChild(notesPanel);
|
|
|
|
// Navigation bar
|
|
const nav = document.createElement('div');
|
|
nav.className = 'presentation-nav';
|
|
nav.innerHTML =
|
|
'<div style="display:flex;gap:0.5rem;align-items:center;">' +
|
|
'<button id="pres-exit" title="Exit presentation (Esc)">Exit</button>' +
|
|
'<button id="pres-notes" title="Toggle notes (N)">Notes</button>' +
|
|
'</div>' +
|
|
`<span class="slide-counter" id="slide-counter">1 / ${slides.length}</span>` +
|
|
'<div style="display:flex;gap:0.5rem;">' +
|
|
'<button id="pres-prev" title="Previous slide" disabled>← Prev</button>' +
|
|
'<button id="pres-next" title="Next slide">Next →</button>' +
|
|
'</div>';
|
|
container.appendChild(nav);
|
|
|
|
// Keyboard hints overlay
|
|
const hints = document.createElement('div');
|
|
hints.className = 'presentation-hints';
|
|
hints.id = 'presentation-hints';
|
|
hints.innerHTML =
|
|
'<h3>Keyboard Shortcuts</h3>' +
|
|
'<dl>' +
|
|
'<dt>← / →</dt><dd>Previous / Next slide</dd>' +
|
|
'<dt>N</dt><dd>Toggle presenter notes</dd>' +
|
|
'<dt>H / ?</dt><dd>Show this help</dd>' +
|
|
'<dt>Esc</dt><dd>Exit presentation</dd>' +
|
|
'<dt>F</dt><dd>Toggle fullscreen</dd>' +
|
|
'</dl>';
|
|
container.appendChild(hints);
|
|
|
|
document.body.appendChild(container);
|
|
|
|
// Start presentation controller
|
|
startController(slides, container);
|
|
}
|
|
|
|
/**
|
|
* Presentation controller — manages slide state and navigation
|
|
*/
|
|
function startController(slides, container) {
|
|
let currentIndex = 0;
|
|
let notesOpen = false;
|
|
let touchStartX = 0;
|
|
let touchStartY = 0;
|
|
|
|
const updateUI = () => {
|
|
document.getElementById('slide-counter').textContent =
|
|
`${currentIndex + 1} / ${slides.length}`;
|
|
|
|
const pct = ((currentIndex + 1) / slides.length) * 100;
|
|
document.getElementById('slide-progress').style.width = `${pct}%`;
|
|
|
|
document.getElementById('pres-prev').disabled = (currentIndex === 0);
|
|
document.getElementById('pres-next').disabled = (currentIndex === slides.length - 1);
|
|
};
|
|
|
|
const updateNotes = () => {
|
|
const notes = slides[currentIndex].notes || '';
|
|
document.getElementById('presentation-notes-text').textContent = notes;
|
|
};
|
|
|
|
const goTo = index => {
|
|
if (index < 0 || index >= slides.length) return;
|
|
|
|
const slideEls = container.querySelectorAll('.presentation-slide');
|
|
const currentEl = slideEls[currentIndex];
|
|
const nextEl = slideEls[index];
|
|
|
|
// Direction-aware animation
|
|
currentEl.classList.remove('active');
|
|
currentEl.classList.add('exiting-left');
|
|
nextEl.style.transform = index > currentIndex ? 'translateX(40px)' : 'translateX(-40px)';
|
|
|
|
// Trigger reflow then animate in
|
|
void nextEl.offsetWidth; // eslint-disable-line no-void
|
|
nextEl.classList.add('active');
|
|
nextEl.style.transform = '';
|
|
|
|
setTimeout(() => {
|
|
currentEl.classList.remove('exiting-left');
|
|
}, 400);
|
|
|
|
currentIndex = index;
|
|
updateUI();
|
|
updateNotes();
|
|
};
|
|
|
|
const next = () => goTo(currentIndex + 1);
|
|
const prev = () => goTo(currentIndex - 1);
|
|
|
|
const exit = () => {
|
|
document.removeEventListener('keydown', onKeyDown);
|
|
container.removeEventListener('touchstart', onTouchStart);
|
|
container.removeEventListener('touchend', onTouchEnd);
|
|
document.body.style.overflow = '';
|
|
|
|
const el = document.getElementById('presentation-container');
|
|
if (el) el.remove();
|
|
|
|
document.body.classList.remove('presentation-active');
|
|
|
|
// Remove mode=presentation from URL without reload
|
|
const url = new URL(window.location);
|
|
url.searchParams.delete('mode');
|
|
window.history.replaceState({}, '', url.toString());
|
|
};
|
|
|
|
const toggleNotes = () => {
|
|
notesOpen = !notesOpen;
|
|
const panel = document.getElementById('presentation-notes');
|
|
if (notesOpen) {
|
|
panel.classList.add('open');
|
|
} else {
|
|
panel.classList.remove('open');
|
|
}
|
|
};
|
|
|
|
const toggleFullscreen = () => {
|
|
if (!document.fullscreenElement) {
|
|
container.requestFullscreen().catch(() => {});
|
|
} else {
|
|
document.exitFullscreen().catch(() => {});
|
|
}
|
|
};
|
|
|
|
const showHints = () => {
|
|
const hintsEl = document.getElementById('presentation-hints');
|
|
hintsEl.classList.add('visible');
|
|
setTimeout(() => {
|
|
hintsEl.classList.remove('visible');
|
|
}, 3000);
|
|
};
|
|
|
|
const onKeyDown = e => {
|
|
switch (e.key) {
|
|
case 'ArrowRight':
|
|
case 'ArrowDown':
|
|
case ' ':
|
|
e.preventDefault();
|
|
next();
|
|
break;
|
|
case 'ArrowLeft':
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
prev();
|
|
break;
|
|
case 'Escape':
|
|
e.preventDefault();
|
|
exit();
|
|
break;
|
|
case 'n':
|
|
case 'N':
|
|
e.preventDefault();
|
|
toggleNotes();
|
|
break;
|
|
case 'f':
|
|
case 'F':
|
|
e.preventDefault();
|
|
toggleFullscreen();
|
|
break;
|
|
case 'h':
|
|
case 'H':
|
|
case '?':
|
|
e.preventDefault();
|
|
showHints();
|
|
break;
|
|
case 'Home':
|
|
e.preventDefault();
|
|
goTo(0);
|
|
break;
|
|
case 'End':
|
|
e.preventDefault();
|
|
goTo(slides.length - 1);
|
|
break;
|
|
}
|
|
};
|
|
|
|
const onTouchStart = e => {
|
|
touchStartX = e.changedTouches[0].screenX;
|
|
touchStartY = e.changedTouches[0].screenY;
|
|
};
|
|
|
|
const onTouchEnd = e => {
|
|
const dx = e.changedTouches[0].screenX - touchStartX;
|
|
const dy = e.changedTouches[0].screenY - touchStartY;
|
|
|
|
// Only trigger if horizontal swipe is dominant and > 50px
|
|
if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 50) {
|
|
if (dx < 0) {
|
|
next();
|
|
} else {
|
|
prev();
|
|
}
|
|
}
|
|
};
|
|
|
|
// Bind events
|
|
document.addEventListener('keydown', onKeyDown);
|
|
container.addEventListener('touchstart', onTouchStart, { passive: true });
|
|
container.addEventListener('touchend', onTouchEnd, { passive: true });
|
|
|
|
document.getElementById('pres-prev').addEventListener('click', prev);
|
|
document.getElementById('pres-next').addEventListener('click', next);
|
|
document.getElementById('pres-exit').addEventListener('click', exit);
|
|
document.getElementById('pres-notes').addEventListener('click', toggleNotes);
|
|
|
|
// Click on slide area to advance
|
|
container.querySelector('.presentation-slides').addEventListener('click', e => {
|
|
if (e.target.closest('.presentation-nav') || e.target.closest('.presentation-notes')) return;
|
|
next();
|
|
});
|
|
|
|
// Initial state
|
|
updateUI();
|
|
updateNotes();
|
|
showHints();
|
|
document.body.style.overflow = 'hidden';
|
|
}
|