extract learning hub slide controller

This commit is contained in:
Daniel 2026-05-08 16:04:21 +02:00
parent 7858484cb0
commit 41598e0fb6
4 changed files with 195 additions and 156 deletions

View file

@ -19,6 +19,7 @@ import {
} from './learningHub/cmsRenderer.js';
import { renderCategoryPills, renderEmptySearchMessage, renderFeed, renderSearchHeader } from './learningHub/feedRenderer.js';
import { renderQuizQuestions, renderQuizResultExplanations } from './learningHub/quizRenderer.js';
import { createSlideController } from './learningHub/slideController.js';
import { buildViewerMeta, renderPresentationCard, renderProgressList } from './learningHub/viewerRenderer.js';
var loaded = false;
@ -26,6 +27,13 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l
var currentContent = null;
var currentView = 'feed'; // 'feed' | 'category' | 'viewer'
var _bodyEditor = null; // Tiptap Editor instance for body
var slides = createSlideController({
getMarkdown: getMarpMarkdown,
getTitle: getPresentationTitle,
showBusy: function(message) { showBusy(message); },
hideBusy: function() { hideBusy(); },
showToast: function(message, type) { showToast(message, type); }
});
// ── Load when tab activated ────────────────────────────────
document.addEventListener('tabChanged', function(e) {
@ -41,11 +49,11 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l
// ── Event delegation ───────────────────────────────────────
document.addEventListener('click', function(e) {
// Slide preview modal controls (checked first — modal is outside CMS)
if (e.target.closest('#slide-preview-close') || e.target.id === 'slide-preview-overlay') { closeSlideModal(); return; }
if (e.target.closest('#slide-nav-prev')) { slideStep(-1); return; }
if (e.target.closest('#slide-nav-next')) { slideStep(1); return; }
if (e.target.closest('#slide-preview-close') || e.target.id === 'slide-preview-overlay') { slides.closeSlideModal(); return; }
if (e.target.closest('#slide-nav-prev')) { slides.slideStep(-1); return; }
if (e.target.closest('#slide-nav-next')) { slides.slideStep(1); return; }
var dot = e.target.closest('.slide-dot');
if (dot) { renderSlide(parseInt(dot.dataset.idx)); return; }
if (dot) { slides.renderSlide(parseInt(dot.dataset.idx)); return; }
// Feed item click
@ -95,8 +103,8 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l
if (e.target.closest('#btn-lh-add-question')) { addQuestionBlock(); return; }
// Presentation
if (e.target.closest('#btn-lh-preview-slides')) { previewSlides(); return; }
if (e.target.closest('#btn-lh-download-pptx')) { downloadPptx(); return; }
if (e.target.closest('#btn-lh-preview-slides')) { slides.previewCurrentSlides(); return; }
if (e.target.closest('#btn-lh-download-pptx')) { slides.downloadPptx(); return; }
// AI panel
if (e.target.closest('#btn-lh-ai-open')) { openAiPanel(); return; }
@ -304,10 +312,10 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l
// Auto-load and open slides
document.getElementById('btn-lh-view-slides').addEventListener('click', function() {
openSlidesFromSlug(item.slug);
slides.openSlidesFromSlug(item.slug);
});
// Open automatically on load
openSlidesFromSlug(item.slug);
slides.openSlidesFromSlug(item.slug);
}
// Quiz for presentations (optional)
if (quizSection) {
@ -1351,149 +1359,9 @@ import { buildViewerMeta, renderPresentationCard, renderProgressList } from './l
return ta ? ta.value.trim() : '';
}
// ── Slide preview modal ──────────────────────────────────────
var _previewSlides = [];
var _previewIdx = 0;
// Open slide modal for a published presentation in the user-facing viewer
function openSlidesFromSlug(slug) {
showBusy('Loading slides...');
getJson('/api/learning/content/' + encodeURIComponent(slug) + '/slides')
.then(function(data) {
hideBusy();
if (!data.success) { showToast(data.error || 'Could not load slides', 'error'); return; }
openSlideModal(data.slides, data.css);
})
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
}
function previewSlides() {
var md = getMarpMarkdown();
if (!md) { showToast('No slide content yet. Use AI Generate first.', 'info'); return; }
showBusy('Rendering slides...');
sendJson('/api/admin/learning/preview-slides', 'POST', { markdown: md })
.then(function(data) {
hideBusy();
if (!data.success) { showToast(data.error || 'Preview failed', 'error'); return; }
openSlideModal(data.slides, data.css);
})
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
}
function openSlideModal(slides, css) {
closeSlideModal();
_previewSlides = slides || [];
_previewIdx = 0;
var modal = document.getElementById('slide-preview-modal');
var cssEl = document.getElementById('slide-preview-css');
var dotsEl = document.getElementById('slide-preview-dots');
if (!modal || !_previewSlides.length) { showToast('No slides to display', 'error'); return; }
// Inject Marp CSS once (scoped to preview frame)
if (cssEl) cssEl.textContent = css || '';
// Build dot indicators
if (dotsEl) {
dotsEl.innerHTML = _previewSlides.map(function(_, i) {
return '<button class="slide-dot" data-idx="' + i + '" aria-label="Go to slide ' + (i+1) + '"></button>';
}).join('');
}
modal.classList.remove('hidden');
document.body.style.overflow = 'hidden';
renderSlide(0);
// Keyboard navigation
document._slideKeyHandler = function(e) {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') slideStep(1);
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') slideStep(-1);
else if (e.key === 'Escape') closeSlideModal();
};
document.addEventListener('keydown', document._slideKeyHandler);
// Touch/swipe
var touchStartX = 0;
modal._touchStart = function(e) { touchStartX = e.touches[0].clientX; };
modal._touchEnd = function(e) {
var dx = e.changedTouches[0].clientX - touchStartX;
if (Math.abs(dx) > 40) slideStep(dx < 0 ? 1 : -1);
};
modal.addEventListener('touchstart', modal._touchStart);
modal.addEventListener('touchend', modal._touchEnd);
}
function renderSlide(idx) {
var content = document.getElementById('slide-preview-content');
var counter = document.getElementById('slide-preview-counter');
var dots = document.querySelectorAll('.slide-dot');
var prev = document.getElementById('slide-nav-prev');
var next = document.getElementById('slide-nav-next');
if (!content || !_previewSlides.length) return;
_previewIdx = Math.max(0, Math.min(idx, _previewSlides.length - 1));
content.innerHTML = _previewSlides[_previewIdx];
if (counter) counter.textContent = (_previewIdx + 1) + ' / ' + _previewSlides.length;
if (prev) prev.style.opacity = _previewIdx === 0 ? '0.3' : '1';
if (next) next.style.opacity = _previewIdx === _previewSlides.length - 1 ? '0.3' : '1';
dots.forEach(function(d, i) { d.classList.toggle('active', i === _previewIdx); });
}
function slideStep(dir) { renderSlide(_previewIdx + dir); }
function closeSlideModal() {
var modal = document.getElementById('slide-preview-modal');
if (modal) modal.classList.add('hidden');
document.body.style.overflow = '';
if (document._slideKeyHandler) {
document.removeEventListener('keydown', document._slideKeyHandler);
document._slideKeyHandler = null;
}
if (modal && modal._touchStart) {
modal.removeEventListener('touchstart', modal._touchStart);
modal._touchStart = null;
}
if (modal && modal._touchEnd) {
modal.removeEventListener('touchend', modal._touchEnd);
modal._touchEnd = null;
}
}
// slide modal nav is wired via the main document click delegation below
function downloadPptx() {
var md = getMarpMarkdown();
if (!md) { showToast('No slide content yet', 'error'); return; }
var title = document.getElementById('lh-cms-edit-title').value || 'presentation';
var btn = document.getElementById('btn-lh-download-pptx');
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Building...'; }
fetch('/api/admin/learning/generate-pptx', {
method: 'POST', headers: getAuthHeaders(),
body: JSON.stringify({ markdown: md, title: title })
})
.then(function(r) {
if (!r.ok) return r.json().then(function(e) { throw new Error(e.error); });
return r.blob();
})
.then(function(blob) {
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = title.replace(/\s+/g, '-').toLowerCase() + '.pptx';
a.click();
URL.revokeObjectURL(url);
showToast('PPTX downloaded!', 'success');
})
.catch(function(err) { showToast(err.message, 'error'); })
.finally(function() {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-file-powerpoint"></i> Download PPTX'; }
});
function getPresentationTitle() {
var titleEl = document.getElementById('lh-cms-edit-title');
return titleEl ? titleEl.value : 'presentation';
}
function addQuestionBlock(existingQ) {

View file

@ -10,6 +10,17 @@ export function sendJson(url, method, payload) {
}).then(function(r) { return r.json(); });
}
export function sendJsonBlob(url, method, payload) {
return fetch(url, {
method: method,
headers: getAuthHeaders(),
body: JSON.stringify(payload || {})
}).then(function(r) {
if (!r.ok) return r.json().then(function(e) { throw new Error(e.error || 'Request failed'); });
return r.blob();
});
}
export function deleteJson(url) {
return fetch(url, { method: 'DELETE', headers: getAuthHeaders() }).then(function(r) { return r.json(); });
}

View file

@ -0,0 +1,149 @@
import { getJson, sendJson, sendJsonBlob } from './api.js';
export function createSlideController(deps) {
var previewSlides = [];
var previewIdx = 0;
function openSlidesFromSlug(slug) {
deps.showBusy('Loading slides...');
getJson('/api/learning/content/' + encodeURIComponent(slug) + '/slides')
.then(function(data) {
deps.hideBusy();
if (!data.success) { deps.showToast(data.error || 'Could not load slides', 'error'); return; }
openSlideModal(data.slides, data.css);
})
.catch(function(err) { deps.hideBusy(); deps.showToast(err.message, 'error'); });
}
function previewCurrentSlides() {
var md = deps.getMarkdown();
if (!md) { deps.showToast('No slide content yet. Use AI Generate first.', 'info'); return; }
deps.showBusy('Rendering slides...');
sendJson('/api/admin/learning/preview-slides', 'POST', { markdown: md })
.then(function(data) {
deps.hideBusy();
if (!data.success) { deps.showToast(data.error || 'Preview failed', 'error'); return; }
openSlideModal(data.slides, data.css);
})
.catch(function(err) { deps.hideBusy(); deps.showToast(err.message, 'error'); });
}
function openSlideModal(slides, css) {
closeSlideModal();
previewSlides = slides || [];
previewIdx = 0;
var modal = document.getElementById('slide-preview-modal');
var cssEl = document.getElementById('slide-preview-css');
var dotsEl = document.getElementById('slide-preview-dots');
if (!modal || !previewSlides.length) { deps.showToast('No slides to display', 'error'); return; }
if (cssEl) cssEl.textContent = css || '';
if (dotsEl) dotsEl.innerHTML = renderDots(previewSlides.length);
modal.classList.remove('hidden');
document.body.style.overflow = 'hidden';
renderSlide(0);
wireModalInput(modal);
}
function renderSlide(idx) {
var content = document.getElementById('slide-preview-content');
var counter = document.getElementById('slide-preview-counter');
var dots = document.querySelectorAll('.slide-dot');
var prev = document.getElementById('slide-nav-prev');
var next = document.getElementById('slide-nav-next');
if (!content || !previewSlides.length) return;
previewIdx = Math.max(0, Math.min(idx, previewSlides.length - 1));
content.innerHTML = previewSlides[previewIdx];
if (counter) counter.textContent = (previewIdx + 1) + ' / ' + previewSlides.length;
if (prev) prev.style.opacity = previewIdx === 0 ? '0.3' : '1';
if (next) next.style.opacity = previewIdx === previewSlides.length - 1 ? '0.3' : '1';
dots.forEach(function(d, i) { d.classList.toggle('active', i === previewIdx); });
}
function slideStep(dir) { renderSlide(previewIdx + dir); }
function closeSlideModal() {
var modal = document.getElementById('slide-preview-modal');
if (modal) modal.classList.add('hidden');
document.body.style.overflow = '';
if (document._slideKeyHandler) {
document.removeEventListener('keydown', document._slideKeyHandler);
document._slideKeyHandler = null;
}
if (modal && modal._touchStart) {
modal.removeEventListener('touchstart', modal._touchStart);
modal._touchStart = null;
}
if (modal && modal._touchEnd) {
modal.removeEventListener('touchend', modal._touchEnd);
modal._touchEnd = null;
}
}
function downloadPptx() {
var md = deps.getMarkdown();
if (!md) { deps.showToast('No slide content yet', 'error'); return; }
var title = deps.getTitle() || 'presentation';
var btn = document.getElementById('btn-lh-download-pptx');
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Building...'; }
sendJsonBlob('/api/admin/learning/generate-pptx', 'POST', { markdown: md, title: title })
.then(function(blob) {
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = makePptxFilename(title);
a.click();
URL.revokeObjectURL(url);
deps.showToast('PPTX downloaded!', 'success');
})
.catch(function(err) { deps.showToast(err.message, 'error'); })
.finally(function() {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-file-powerpoint"></i> Download PPTX'; }
});
}
function wireModalInput(modal) {
document._slideKeyHandler = function(e) {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') slideStep(1);
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') slideStep(-1);
else if (e.key === 'Escape') closeSlideModal();
};
document.addEventListener('keydown', document._slideKeyHandler);
var touchStartX = 0;
modal._touchStart = function(e) { touchStartX = e.touches[0].clientX; };
modal._touchEnd = function(e) {
var dx = e.changedTouches[0].clientX - touchStartX;
if (Math.abs(dx) > 40) slideStep(dx < 0 ? 1 : -1);
};
modal.addEventListener('touchstart', modal._touchStart);
modal.addEventListener('touchend', modal._touchEnd);
}
return {
closeSlideModal: closeSlideModal,
downloadPptx: downloadPptx,
openSlidesFromSlug: openSlidesFromSlug,
previewCurrentSlides: previewCurrentSlides,
renderSlide: renderSlide,
slideStep: slideStep
};
}
export function makePptxFilename(title) {
return String(title || 'presentation').replace(/\s+/g, '-').toLowerCase() + '.pptx';
}
function renderDots(count) {
var html = '';
for (var i = 0; i < count; i++) {
html += '<button class="slide-dot" data-idx="' + i + '" aria-label="Go to slide ' + (i + 1) + '"></button>';
}
return html;
}

View file

@ -14,6 +14,7 @@ const editorModule = read('public/js/learningHub/tiptapEditor.js');
const feedRendererModule = read('public/js/learningHub/feedRenderer.js');
const quizRendererModule = read('public/js/learningHub/quizRenderer.js');
const sanitizeModule = read('public/js/learningHub/sanitize.js');
const slideControllerModule = read('public/js/learningHub/slideController.js');
const viewerRendererModule = read('public/js/learningHub/viewerRenderer.js');
test('Learning Hub destroys embedded Tiptap editors before removing CMS rows', () => {
@ -41,6 +42,7 @@ test('Learning Hub API helpers centralize auth JSON fetches', () => {
assert.match(apiModule, /export function getJson\(url\)/);
assert.match(apiModule, /getAuthHeaders\(\)/);
assert.match(apiModule, /export function sendJson\(url, method, payload\)/);
assert.match(apiModule, /export function sendJsonBlob\(url, method, payload\)/);
assert.match(apiModule, /export function deleteJson\(url\)/);
});
@ -91,11 +93,20 @@ test('Learning Hub refreshes CMS category counts after content changes', () => {
assert.match(source, /showToast\('Content deleted', 'info'\);[\s\S]*?loadCmsContent\(\);[\s\S]*?loadCmsCategories\(\);[\s\S]*?loadCmsStats\(\);/);
});
test('Learning Hub slide modal cleans up reusable input handlers', () => {
assert.match(source, /function openSlideModal\(slides, css\) \{\s*closeSlideModal\(\);/);
assert.match(source, /if \(!modal \|\| !_previewSlides\.length\) \{ showToast\('No slides to display', 'error'\); return; \}/);
assert.match(source, /modal\.removeEventListener\('touchstart', modal\._touchStart\)/);
assert.match(source, /modal\.removeEventListener\('touchend', modal\._touchEnd\)/);
test('Learning Hub slide controller owns preview and PPTX flow', () => {
assert.match(source, /from '\.\/learningHub\/slideController\.js'/);
assert.match(source, /var slides = createSlideController\(/);
assert.match(source, /slides\.openSlidesFromSlug\(item\.slug\)/);
assert.match(source, /slides\.previewCurrentSlides\(\)/);
assert.match(source, /slides\.downloadPptx\(\)/);
assert.match(slideControllerModule, /export function createSlideController\(deps\)/);
assert.match(slideControllerModule, /function openSlideModal\(slides, css\) \{\s*closeSlideModal\(\);/);
assert.match(slideControllerModule, /if \(!modal \|\| !previewSlides\.length\) \{ deps\.showToast\('No slides to display', 'error'\); return; \}/);
assert.match(slideControllerModule, /modal\.removeEventListener\('touchstart', modal\._touchStart\)/);
assert.match(slideControllerModule, /modal\.removeEventListener\('touchend', modal\._touchEnd\)/);
assert.match(slideControllerModule, /sendJsonBlob\('\/api\/admin\/learning\/generate-pptx'/);
assert.doesNotMatch(slideControllerModule, /window\.Tiptap|ai-generate|FormData/);
assert.doesNotMatch(source, /function openSlideModal\(slides, css\)/);
});
test('Learning Hub quiz renderers are isolated from API and submit flow', () => {