extract learning hub slide controller
This commit is contained in:
parent
7858484cb0
commit
41598e0fb6
4 changed files with 195 additions and 156 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(); });
|
||||
}
|
||||
|
|
|
|||
149
public/js/learningHub/slideController.js
Normal file
149
public/js/learningHub/slideController.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue