From 41598e0fb65156d0dfe032a23d6f36b3950a28ed Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 8 May 2026 16:04:21 +0200 Subject: [PATCH] extract learning hub slide controller --- public/js/learningHub.js | 170 +++-------------------- public/js/learningHub/api.js | 11 ++ public/js/learningHub/slideController.js | 149 ++++++++++++++++++++ test/learning-hub-editor.test.js | 21 ++- 4 files changed, 195 insertions(+), 156 deletions(-) create mode 100644 public/js/learningHub/slideController.js diff --git a/public/js/learningHub.js b/public/js/learningHub.js index 9726848..7170c7a 100644 --- a/public/js/learningHub.js +++ b/public/js/learningHub.js @@ -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 ''; - }).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 = ' 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 = ' Download PPTX'; } - }); + function getPresentationTitle() { + var titleEl = document.getElementById('lh-cms-edit-title'); + return titleEl ? titleEl.value : 'presentation'; } function addQuestionBlock(existingQ) { diff --git a/public/js/learningHub/api.js b/public/js/learningHub/api.js index a14f6ea..ead0cfa 100644 --- a/public/js/learningHub/api.js +++ b/public/js/learningHub/api.js @@ -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(); }); } diff --git a/public/js/learningHub/slideController.js b/public/js/learningHub/slideController.js new file mode 100644 index 0000000..9523bb2 --- /dev/null +++ b/public/js/learningHub/slideController.js @@ -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 = ' 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 = ' 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 += ''; + } + return html; +} diff --git a/test/learning-hub-editor.test.js b/test/learning-hub-editor.test.js index 4bd2776..3937ba9 100644 --- a/test/learning-hub-editor.test.js +++ b/test/learning-hub-editor.test.js @@ -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', () => {