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', () => {