- App-layer AES-256-GCM crypto helper (src/utils/crypto.js) - Nextcloud tokens encrypted at rest; transparent migration on next use - Audio backups encrypted at rest (version byte 0x01 envelope); legacy rows still decrypt as-is until overwritten - argon2id password hashing via src/utils/passwords.js with bcrypt fallback; bcrypt hashes rehashed to argon2id on next successful login. argon2 package is optional — server keeps running with bcrypt only until npm install adds the native dep - PHI redactor for audit log details (src/utils/redact.js) — strips SSN, phone, email, DoB, long IDs; caps at 500 chars; detects note bodies - DOMPurify (cdnjs, SRI-pinned) replaces custom regex sanitizer in Learning Hub content rendering - SRI integrity hashes added for Font Awesome CSS and Chart.js - Magic-byte file-type verification on document uploads (src/utils/fileType.js) - Generic 500 error responses via src/utils/errors.js applied to nextcloud and audioBackups; full detail still logged server-side - DATA_ENCRYPTION_KEY env documented in .env.example Deploy: requires rebuild of the container image to pick up the new files and `npm install` (adds argon2). Existing users keep working because bcrypt stays available and crypto helpers pass through plaintext when the key is not yet set in dev.
1812 lines
82 KiB
JavaScript
1812 lines
82 KiB
JavaScript
// ============================================================
|
|
// LEARNING HUB — User content feed, viewer, quizzes + Admin CMS
|
|
// ============================================================
|
|
|
|
(function() {
|
|
|
|
var loaded = false;
|
|
var cmsLoaded = false;
|
|
var currentContent = null;
|
|
var currentView = 'feed'; // 'feed' | 'category' | 'viewer'
|
|
var _bodyEditor = null; // Tiptap Editor instance for body
|
|
|
|
// ── Load when tab activated ────────────────────────────────
|
|
document.addEventListener('tabChanged', function(e) {
|
|
if (e.detail && e.detail.tab === 'learning') {
|
|
if (!loaded) { loadFeed(); loadCategories(); loaded = true; wireSearch(); }
|
|
}
|
|
// Load CMS when Content Manager tab is opened
|
|
if (e.detail && e.detail.tab === 'cms') {
|
|
if (!cmsLoaded) { loadCms(); cmsLoaded = true; wireCmsFileInput(); wireCmsTypeSelect(); }
|
|
}
|
|
});
|
|
|
|
// ── 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; }
|
|
var dot = e.target.closest('.slide-dot');
|
|
if (dot) { renderSlide(parseInt(dot.dataset.idx)); return; }
|
|
|
|
|
|
// Feed item click
|
|
var feedItem = e.target.closest('.lh-feed-item');
|
|
if (feedItem && feedItem.dataset.slug) {
|
|
loadContent(feedItem.dataset.slug);
|
|
return;
|
|
}
|
|
|
|
// Category pill click
|
|
var catPill = e.target.closest('.lh-cat-pill');
|
|
if (catPill && catPill.dataset.slug) {
|
|
if (catPill.dataset.slug === 'all') {
|
|
loadFeed();
|
|
setActiveCatPill('all');
|
|
} else {
|
|
loadCategoryContent(catPill.dataset.slug);
|
|
setActiveCatPill(catPill.dataset.slug);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Back button
|
|
if (e.target.closest('#lh-back')) {
|
|
showFeed();
|
|
return;
|
|
}
|
|
|
|
// Submit quiz
|
|
if (e.target.closest('#lh-submit-quiz')) {
|
|
submitQuiz();
|
|
return;
|
|
}
|
|
|
|
// ── CMS events ─────────────────────────────
|
|
if (e.target.closest('#btn-lh-refresh-content')) { loadCmsContent(); loadCmsStats(); return; }
|
|
if (e.target.closest('#btn-lh-add-cat')) { addCategory(); return; }
|
|
if (e.target.closest('#btn-lh-new-content')) { openEditor(null, 'article'); return; }
|
|
if (e.target.closest('#btn-lh-new-quiz')) { openEditor(null, 'quiz'); return; }
|
|
if (e.target.closest('#btn-lh-new-pearl')) { openEditor(null, 'pearl'); return; }
|
|
if (e.target.closest('#btn-lh-new-presentation')) { openEditor(null, 'presentation'); return; }
|
|
if (e.target.closest('#btn-lh-close-editor')) { closeEditor(); return; }
|
|
if (e.target.closest('#btn-lh-save-content')) { saveContent(); return; }
|
|
if (e.target.closest('#btn-lh-delete-content')) { showDeleteConfirm(); return; }
|
|
if (e.target.closest('#btn-lh-delete-yes')) { confirmDelete(); return; }
|
|
if (e.target.closest('#btn-lh-delete-cancel')) { hideDeleteConfirm(); return; }
|
|
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; }
|
|
|
|
// AI panel
|
|
if (e.target.closest('#btn-lh-ai-open')) { openAiPanel(); return; }
|
|
if (e.target.closest('#btn-lh-ai-close')) { closeAiPanel(); return; }
|
|
if (e.target.closest('#btn-lh-ai-generate')) { runAiGenerate(); return; }
|
|
if (e.target.closest('#btn-lh-ai-refine-body')) { toggleRefineBar(); return; }
|
|
if (e.target.closest('#btn-lh-refine-submit')) { submitRefineBody(); return; }
|
|
if (e.target.closest('#btn-lh-refine-cancel')) { toggleRefineBar(false); return; }
|
|
if (e.target.closest('#btn-lh-webdav-refresh')) { browseWebdav(_aiWebdavCurrentPath); return; }
|
|
if (e.target.closest('#btn-lh-webdav-deselect')) { deselectWebdavFile(); return; }
|
|
|
|
// AI tabs
|
|
var aiTab = e.target.closest('.lh-ai-tab');
|
|
if (aiTab && aiTab.dataset.aitab) { switchAiTab(aiTab.dataset.aitab); return; }
|
|
|
|
// WebDAV list items
|
|
var wdItem = e.target.closest('.lh-webdav-item');
|
|
if (wdItem) {
|
|
if (wdItem.dataset.isdir === '1') {
|
|
browseWebdav(wdItem.dataset.path);
|
|
} else {
|
|
selectWebdavFile(wdItem.dataset.path, wdItem.dataset.name);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Rich text toggle for question or option
|
|
var rtBtn = e.target.closest('.lh-richtext-btn');
|
|
if (rtBtn) { toggleRichText(rtBtn); return; }
|
|
|
|
// CMS content item edit
|
|
var cmsItem = e.target.closest('.lh-cms-content-item');
|
|
if (cmsItem && cmsItem.dataset.id) { openEditor(cmsItem.dataset.id); return; }
|
|
|
|
// CMS delete category
|
|
var delCat = e.target.closest('.lh-cms-del-cat');
|
|
if (delCat) { deleteCategory(delCat.dataset.id); return; }
|
|
|
|
// Remove question block
|
|
var rmQ = e.target.closest('.lh-rm-question');
|
|
if (rmQ) { var qb = rmQ.closest('.lh-question-block'); if (qb) qb.remove(); return; }
|
|
|
|
// Add option to question
|
|
var addOpt = e.target.closest('.lh-add-option');
|
|
if (addOpt) {
|
|
var qBlock = addOpt.closest('.lh-question-block');
|
|
if (qBlock) addOptionRow(qBlock.querySelector('.lh-options-list'), null);
|
|
return;
|
|
}
|
|
|
|
// Remove option
|
|
var rmOpt = e.target.closest('.lh-rm-option');
|
|
if (rmOpt) { var row = rmOpt.closest('.lh-option-row'); if (row) row.remove(); return; }
|
|
});
|
|
|
|
// wireSearch() called after component is in DOM
|
|
var _searchTimeout = null;
|
|
function wireSearch() {
|
|
var searchEl = document.getElementById('lh-search');
|
|
if (!searchEl || searchEl._wired) return;
|
|
searchEl._wired = true;
|
|
searchEl.addEventListener('input', function() {
|
|
clearTimeout(_searchTimeout);
|
|
var q = searchEl.value.trim();
|
|
if (!q) { loadFeed(); return; }
|
|
_searchTimeout = setTimeout(function() { searchContent(q); }, 350);
|
|
});
|
|
searchEl.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape') { searchEl.value = ''; loadFeed(); }
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// USER-FACING: FEED, CATEGORIES, CONTENT, QUIZZES
|
|
// ============================================================
|
|
|
|
function loadFeed() {
|
|
var feedEl = document.getElementById('lh-feed');
|
|
if (!feedEl) return;
|
|
feedEl.innerHTML = '<p style="text-align:center;color:var(--g400);padding:24px;">Loading...</p>';
|
|
showFeed();
|
|
|
|
fetch('/api/learning/feed', { headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data.success) { feedEl.innerHTML = '<p style="text-align:center;color:var(--red);padding:24px;">Failed to load</p>'; return; }
|
|
renderFeed(data.content, feedEl);
|
|
})
|
|
.catch(function() { feedEl.innerHTML = '<p style="text-align:center;color:var(--red);padding:24px;">Connection error</p>'; });
|
|
}
|
|
|
|
function loadCategories() {
|
|
fetch('/api/learning/categories', { headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data.success) return;
|
|
var bar = document.getElementById('lh-categories');
|
|
if (!bar) return;
|
|
var html = '<button class="lh-cat-pill active" data-slug="all">All</button>';
|
|
data.categories.forEach(function(c) {
|
|
html += '<button class="lh-cat-pill" data-slug="' + esc(c.slug) + '">' + esc(c.name) + '</button>';
|
|
});
|
|
bar.innerHTML = html;
|
|
});
|
|
}
|
|
|
|
function loadCategoryContent(slug) {
|
|
var feedEl = document.getElementById('lh-feed');
|
|
if (!feedEl) return;
|
|
feedEl.innerHTML = '<p style="text-align:center;color:var(--g400);padding:24px;">Loading...</p>';
|
|
showFeed();
|
|
|
|
fetch('/api/learning/category/' + encodeURIComponent(slug), { headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data.success) { feedEl.innerHTML = '<p style="text-align:center;color:var(--red);padding:24px;">Category not found</p>'; return; }
|
|
renderFeed(data.content, feedEl);
|
|
});
|
|
}
|
|
|
|
function searchContent(q) {
|
|
var feedEl = document.getElementById('lh-feed');
|
|
if (!feedEl) return;
|
|
showFeed();
|
|
feedEl.innerHTML =
|
|
'<div style="padding:8px 0 12px;font-size:13px;color:var(--g500);">' +
|
|
'<i class="fas fa-search"></i> Searching for <strong>"' + esc(q) + '"</strong>…' +
|
|
'</div>';
|
|
|
|
fetch('/api/learning/search?q=' + encodeURIComponent(q), { headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data.success) { feedEl.innerHTML = '<p style="color:var(--red);padding:12px;">Search failed</p>'; return; }
|
|
var header = '<div style="padding:8px 0 12px;display:flex;align-items:center;justify-content:space-between;">' +
|
|
'<span style="font-size:13px;color:var(--g600);">' +
|
|
(data.content.length > 0
|
|
? '<strong>' + data.content.length + '</strong> result' + (data.content.length !== 1 ? 's' : '') + ' for <strong>"' + esc(q) + '"</strong>'
|
|
: 'No results for <strong>"' + esc(q) + '"</strong>') +
|
|
'</span>' +
|
|
'<button id="lh-search-clear" style="background:none;border:none;font-size:12px;color:var(--blue);cursor:pointer;padding:0;">Clear search</button>' +
|
|
'</div>';
|
|
|
|
if (data.content.length === 0) {
|
|
feedEl.innerHTML = header +
|
|
'<div style="text-align:center;padding:40px;color:var(--g400);">' +
|
|
'<i class="fas fa-search" style="font-size:32px;margin-bottom:12px;display:block;opacity:0.4;"></i>' +
|
|
'Try different keywords or browse by category' +
|
|
'</div>';
|
|
} else {
|
|
var tempEl = document.createElement('div');
|
|
renderFeed(data.content, tempEl);
|
|
feedEl.innerHTML = header + tempEl.innerHTML;
|
|
}
|
|
|
|
var clearBtn = document.getElementById('lh-search-clear');
|
|
if (clearBtn) {
|
|
clearBtn.addEventListener('click', function() {
|
|
var s = document.getElementById('lh-search');
|
|
if (s) s.value = '';
|
|
loadFeed();
|
|
});
|
|
}
|
|
})
|
|
.catch(function() { feedEl.innerHTML = '<p style="color:var(--red);padding:12px;">Search error</p>'; });
|
|
}
|
|
|
|
function renderFeed(items, feedEl) {
|
|
if (!items || items.length === 0) {
|
|
feedEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--g400);"><i class="fas fa-book-open" style="font-size:32px;margin-bottom:12px;display:block;"></i>No content yet. Check back soon!</div>';
|
|
return;
|
|
}
|
|
|
|
feedEl.innerHTML = items.map(function(item) {
|
|
var typeIcon = item.content_type === 'quiz' ? 'fa-clipboard-question' : item.content_type === 'pearl' ? 'fa-gem' : item.content_type === 'presentation' ? 'fa-presentation-screen' : 'fa-file-alt';
|
|
var typeColor = item.content_type === 'quiz' ? 'var(--amber)' : item.content_type === 'pearl' ? 'var(--purple, #8b5cf6)' : item.content_type === 'presentation' ? '#065f46' : 'var(--blue)';
|
|
var quizBadge = item.question_count > 0 ? '<span class="lh-badge lh-badge-quiz"><i class="fas fa-clipboard-question"></i> ' + item.question_count + ' Q</span>' : '';
|
|
var catBadge = item.category_name ? '<span class="lh-badge">' + esc(item.category_name) + '</span>' : '<span class="lh-badge" style="opacity:0.5;">Uncategorized</span>';
|
|
var date = item.created_at ? new Date(item.created_at).toLocaleDateString() : '';
|
|
|
|
return '<div class="lh-feed-item card" data-slug="' + esc(item.slug) + '">' +
|
|
'<div class="lh-feed-item-header">' +
|
|
'<i class="fas ' + typeIcon + '" style="color:' + typeColor + ';font-size:16px;"></i>' +
|
|
'<div style="flex:1;min-width:0;">' +
|
|
'<div class="lh-feed-title">' + esc(item.title) + '</div>' +
|
|
'<div class="lh-feed-meta">' +
|
|
(item.subject ? '<span>' + esc(item.subject) + '</span>' : '') +
|
|
(item.author_name ? '<span>by ' + esc(item.author_name) + '</span>' : '') +
|
|
'<span>' + date + '</span>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<div class="lh-feed-badges">' + catBadge + quizBadge + '</div>' +
|
|
'</div>' +
|
|
'</div>';
|
|
}).join('');
|
|
}
|
|
|
|
function loadContent(slug) {
|
|
fetch('/api/learning/content/' + encodeURIComponent(slug), { headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data.success) { showToast(data.error || 'Content not found', 'error'); return; }
|
|
currentContent = data.content;
|
|
showViewer(data.content);
|
|
})
|
|
.catch(function() { showToast('Failed to load content', 'error'); });
|
|
}
|
|
|
|
function showFeed() {
|
|
currentView = 'feed';
|
|
var feedEl = document.getElementById('lh-feed');
|
|
var viewerEl = document.getElementById('lh-viewer');
|
|
var catsEl = document.getElementById('lh-categories');
|
|
var searchEl = document.getElementById('lh-search');
|
|
// Use style.display — classList.add('hidden') is overridden by .lh-feed{display:flex}
|
|
// and .lh-category-bar{display:flex} which are defined later in CSS
|
|
if (feedEl) feedEl.style.display = 'flex';
|
|
if (viewerEl) viewerEl.style.display = 'none';
|
|
if (catsEl) catsEl.style.display = 'flex';
|
|
if (searchEl) { var sc = searchEl.closest('.card'); if (sc) sc.style.display = ''; }
|
|
}
|
|
|
|
function showViewer(item) {
|
|
currentView = 'viewer';
|
|
var feedEl = document.getElementById('lh-feed');
|
|
var viewerEl = document.getElementById('lh-viewer');
|
|
var catsEl = document.getElementById('lh-categories');
|
|
var searchEl = document.getElementById('lh-search');
|
|
if (feedEl) feedEl.style.display = 'none';
|
|
if (viewerEl) viewerEl.style.display = 'block';
|
|
if (catsEl) catsEl.style.display = 'none';
|
|
if (searchEl) { var sc = searchEl.closest('.card'); if (sc) sc.style.display = 'none'; }
|
|
|
|
var titleEl = document.getElementById('lh-viewer-title');
|
|
var metaEl = document.getElementById('lh-viewer-meta');
|
|
var bodyEl = document.getElementById('lh-viewer-body');
|
|
var quizSection = document.getElementById('lh-quiz-section');
|
|
var resultsSection = document.getElementById('lh-quiz-results');
|
|
var progressSection = document.getElementById('lh-progress-section');
|
|
|
|
if (titleEl) titleEl.textContent = item.title;
|
|
if (metaEl) {
|
|
var parts = [];
|
|
if (item.category_name) parts.push(item.category_name);
|
|
if (item.subject) parts.push(item.subject);
|
|
if (item.author_name) parts.push('by ' + item.author_name);
|
|
if (item.created_at) parts.push(new Date(item.created_at).toLocaleDateString());
|
|
metaEl.textContent = parts.join(' | ');
|
|
}
|
|
var presViewer = document.getElementById('lh-presentation-viewer');
|
|
var bodyCard = bodyEl ? bodyEl.closest('.card') : null;
|
|
|
|
if (item.content_type === 'presentation') {
|
|
// Hide body card, show presentation card with View Slides button
|
|
if (bodyCard) bodyCard.style.display = 'none';
|
|
if (presViewer) {
|
|
var slideCount = (item.body || '').split(/\n---\n/).length;
|
|
presViewer.className = ''; // remove hidden
|
|
presViewer.innerHTML =
|
|
'<div class="card" style="text-align:center;padding:32px 24px;">' +
|
|
'<div style="font-size:42px;margin-bottom:12px;">📊</div>' +
|
|
'<h3 style="margin:0 0 4px;color:var(--g800);">' + esc(item.title) + '</h3>' +
|
|
'<p style="font-size:13px;color:var(--g500);margin:0 0 20px;">' + slideCount + ' slides' + (item.subject ? ' · ' + esc(item.subject) : '') + '</p>' +
|
|
'<button id="btn-lh-view-slides" class="btn-primary" style="padding:11px 28px;font-size:14px;border-radius:10px;border:none;cursor:pointer;background:var(--blue);color:white;font-weight:600;">' +
|
|
'<i class="fas fa-play-circle"></i> View Slides' +
|
|
'</button>' +
|
|
'</div>';
|
|
|
|
// Auto-load and open slides
|
|
document.getElementById('btn-lh-view-slides').addEventListener('click', function() {
|
|
openSlidesFromSlug(item.slug);
|
|
});
|
|
// Open automatically on load
|
|
openSlidesFromSlug(item.slug);
|
|
}
|
|
// Quiz for presentations (optional)
|
|
if (quizSection) {
|
|
if (item.questions && item.questions.length > 0) {
|
|
quizSection.classList.remove('hidden');
|
|
renderQuiz(item.questions);
|
|
} else {
|
|
quizSection.classList.add('hidden');
|
|
}
|
|
}
|
|
} else {
|
|
// Normal content — show body card, hide presentation card
|
|
if (bodyCard) bodyCard.style.display = '';
|
|
if (presViewer) presViewer.className = 'hidden';
|
|
if (bodyEl) bodyEl.innerHTML = sanitizeHtml(item.body || '');
|
|
|
|
if (quizSection) {
|
|
if (item.questions && item.questions.length > 0) {
|
|
quizSection.classList.remove('hidden');
|
|
renderQuiz(item.questions);
|
|
} else {
|
|
quizSection.classList.add('hidden');
|
|
}
|
|
}
|
|
}
|
|
if (resultsSection) resultsSection.classList.add('hidden');
|
|
|
|
// Progress
|
|
if (progressSection && item.progress && item.progress.length > 0) {
|
|
progressSection.classList.remove('hidden');
|
|
var progHtml = item.progress.map(function(p) {
|
|
var pct = p.total > 0 ? Math.round((p.score / p.total) * 100) : 0;
|
|
var date = new Date(p.completed_at).toLocaleDateString();
|
|
return '<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid var(--g100);">' +
|
|
'<span>' + date + '</span>' +
|
|
'<span style="font-weight:600;color:' + (pct >= 70 ? 'var(--green)' : 'var(--amber)') + ';">' + p.score + '/' + p.total + ' (' + pct + '%)</span>' +
|
|
'</div>';
|
|
}).join('');
|
|
document.getElementById('lh-progress-list').innerHTML = progHtml;
|
|
} else if (progressSection) {
|
|
progressSection.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
function renderQuiz(questions) {
|
|
var container = document.getElementById('lh-quiz-questions');
|
|
var countEl = document.getElementById('lh-quiz-count');
|
|
if (!container) return;
|
|
if (countEl) countEl.textContent = questions.length + ' question' + (questions.length !== 1 ? 's' : '');
|
|
|
|
container.innerHTML = questions.map(function(q, idx) {
|
|
var isMulti = q.question_type === 'multi';
|
|
var typeLabel = q.question_type === 'true_false' ? 'True / False'
|
|
: isMulti ? 'Multiple Select' : 'Single Choice';
|
|
var inputType = isMulti ? 'checkbox' : 'radio';
|
|
var nameAttr = isMulti ? 'lh-qm-' + q.id : 'lh-q-' + q.id;
|
|
|
|
var optionsHtml = (q.options || []).map(function(opt) {
|
|
return '<label class="lh-quiz-option">' +
|
|
'<input type="' + inputType + '" name="' + nameAttr + '" value="' + opt.id + '">' +
|
|
'<span>' + sanitizeHtml(opt.option_text) + '</span>' +
|
|
'</label>';
|
|
}).join('');
|
|
|
|
var hint = isMulti ? '<div class="lh-quiz-multi-hint">Select all that apply</div>' : '';
|
|
|
|
return '<div class="lh-quiz-q" data-qid="' + q.id + '" data-qtype="' + q.question_type + '">' +
|
|
'<div class="lh-quiz-q-header">' +
|
|
'<span class="lh-quiz-q-num">Q' + (idx + 1) + '</span>' +
|
|
'<span class="lh-quiz-q-type">' + typeLabel + '</span>' +
|
|
'</div>' +
|
|
'<p class="lh-quiz-q-text">' + sanitizeHtml(q.question_text) + '</p>' +
|
|
hint +
|
|
'<div class="lh-quiz-options">' + optionsHtml + '</div>' +
|
|
'</div>';
|
|
}).join('');
|
|
}
|
|
|
|
function submitQuiz() {
|
|
if (!currentContent || !currentContent.questions) return;
|
|
|
|
var answers = [];
|
|
currentContent.questions.forEach(function(q) {
|
|
if (q.question_type === 'multi') {
|
|
var checkedEls = document.querySelectorAll('input[name="lh-qm-' + q.id + '"]:checked');
|
|
var optionIds = [];
|
|
checkedEls.forEach(function(el) { optionIds.push(parseInt(el.value)); });
|
|
answers.push({ questionId: q.id, optionIds: optionIds });
|
|
} else {
|
|
var selected = document.querySelector('input[name="lh-q-' + q.id + '"]:checked');
|
|
answers.push({ questionId: q.id, optionId: selected ? parseInt(selected.value) : null });
|
|
}
|
|
});
|
|
|
|
showLoading('Submitting quiz...');
|
|
|
|
fetch('/api/learning/submit-quiz', {
|
|
method: 'POST',
|
|
headers: getAuthHeaders(),
|
|
body: JSON.stringify({ contentId: currentContent.id, answers: answers })
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
hideLoading();
|
|
if (!data.success) { showToast(data.error || 'Submit failed', 'error'); return; }
|
|
showQuizResults(data);
|
|
})
|
|
.catch(function() { hideLoading(); showToast('Submit failed', 'error'); });
|
|
}
|
|
|
|
function showQuizResults(data) {
|
|
var resultsEl = document.getElementById('lh-quiz-results');
|
|
var scoreEl = document.getElementById('lh-quiz-score');
|
|
var explEl = document.getElementById('lh-quiz-explanations');
|
|
if (!resultsEl) return;
|
|
|
|
resultsEl.classList.remove('hidden');
|
|
var pct = data.percentage;
|
|
var color = pct >= 80 ? 'var(--green)' : pct >= 50 ? 'var(--amber)' : 'var(--red)';
|
|
if (scoreEl) {
|
|
scoreEl.textContent = data.score + '/' + data.total + ' (' + pct + '%)';
|
|
scoreEl.style.background = color;
|
|
scoreEl.style.color = 'white';
|
|
}
|
|
|
|
if (explEl) {
|
|
explEl.innerHTML = data.results.map(function(r, idx) {
|
|
var icon = r.isCorrect ? '<i class="fas fa-check-circle" style="color:var(--green);"></i>' : '<i class="fas fa-times-circle" style="color:var(--red);"></i>';
|
|
var explHtml = '';
|
|
if (!r.isCorrect && r.selectedExplanation) {
|
|
explHtml += '<div class="lh-expl lh-expl-wrong"><strong>Why incorrect:</strong> ' + esc(r.selectedExplanation) + '</div>';
|
|
}
|
|
if (!r.isCorrect) {
|
|
explHtml += '<div class="lh-expl lh-expl-correct"><strong>Correct answer:</strong> ' + esc(r.correctOptionText) + '</div>';
|
|
}
|
|
if (r.generalExplanation) {
|
|
explHtml += '<div class="lh-expl lh-expl-general"><strong>Explanation:</strong> ' + sanitizeHtml(r.generalExplanation) + '</div>';
|
|
}
|
|
|
|
return '<div class="lh-result-item">' +
|
|
'<div class="lh-result-header">' + icon + ' <strong>Q' + (idx + 1) + ':</strong> ' + sanitizeHtml(r.questionText) + '</div>' +
|
|
explHtml +
|
|
'</div>';
|
|
}).join('');
|
|
}
|
|
|
|
// Highlight correct/incorrect in quiz
|
|
data.results.forEach(function(r) {
|
|
var qDiv = document.querySelector('.lh-quiz-q[data-qid="' + r.questionId + '"]');
|
|
if (!qDiv) return;
|
|
var isMulti = r.questionType === 'multi';
|
|
qDiv.querySelectorAll('.lh-quiz-option').forEach(function(label) {
|
|
var input = label.querySelector('input');
|
|
if (!input) return;
|
|
var optId = parseInt(input.value);
|
|
if (isMulti) {
|
|
var correctIds = r.correctOptionIds || [];
|
|
var selectedIds = r.selectedOptionIds || [];
|
|
if (correctIds.indexOf(optId) !== -1) label.classList.add('lh-opt-correct');
|
|
if (selectedIds.indexOf(optId) !== -1 && correctIds.indexOf(optId) === -1) label.classList.add('lh-opt-wrong');
|
|
} else {
|
|
if (optId === r.correctOptionId) label.classList.add('lh-opt-correct');
|
|
if (optId === r.selectedOptionId && !r.isCorrect) label.classList.add('lh-opt-wrong');
|
|
}
|
|
input.disabled = true;
|
|
});
|
|
});
|
|
|
|
// Disable submit button
|
|
var submitBtn = document.getElementById('lh-submit-quiz');
|
|
if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = '<i class="fas fa-check"></i> Submitted'; }
|
|
|
|
resultsEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}
|
|
|
|
function setActiveCatPill(slug) {
|
|
document.querySelectorAll('.lh-cat-pill').forEach(function(p) {
|
|
p.classList.toggle('active', p.dataset.slug === slug);
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// ADMIN CMS
|
|
// ============================================================
|
|
|
|
function wireCmsTypeSelect() {
|
|
var typeEl = document.getElementById('lh-cms-edit-type');
|
|
if (typeEl) {
|
|
typeEl.addEventListener('change', function() { toggleEditorMode(typeEl.value); });
|
|
}
|
|
}
|
|
|
|
function wireCmsFileInput() {
|
|
var fileInput = document.getElementById('lh-ai-file');
|
|
var fileLabel = document.getElementById('lh-ai-file-label');
|
|
var filesList = document.getElementById('lh-ai-files-list');
|
|
var dropzone = document.getElementById('lh-ai-dropzone');
|
|
|
|
function updateFilesList() {
|
|
if (!fileInput || !fileLabel || !filesList) return;
|
|
var files = fileInput.files;
|
|
if (files.length === 0) {
|
|
fileLabel.textContent = 'Drop files here or click to browse';
|
|
filesList.style.display = 'none';
|
|
filesList.innerHTML = '';
|
|
} else if (files.length === 1) {
|
|
fileLabel.textContent = files[0].name;
|
|
filesList.style.display = 'none';
|
|
filesList.innerHTML = '';
|
|
} else {
|
|
fileLabel.textContent = files.length + ' files selected';
|
|
filesList.style.display = 'block';
|
|
filesList.innerHTML = '<ul style="list-style:none;padding:0;margin:0;font-size:0.9em;">' +
|
|
Array.from(files).map(function(f) {
|
|
return '<li style="padding:2px 0;"><i class="fas fa-file-pdf" style="color:var(--blue);margin-right:4px;"></i>' + f.name + '</li>';
|
|
}).join('') + '</ul>';
|
|
}
|
|
}
|
|
|
|
if (fileInput) {
|
|
fileInput.addEventListener('change', updateFilesList);
|
|
}
|
|
if (dropzone) {
|
|
dropzone.addEventListener('dragover', function(e) { e.preventDefault(); dropzone.style.borderColor = 'var(--blue)'; });
|
|
dropzone.addEventListener('dragleave', function() { dropzone.style.borderColor = ''; });
|
|
dropzone.addEventListener('drop', function(e) {
|
|
e.preventDefault();
|
|
dropzone.style.borderColor = '';
|
|
var files = e.dataTransfer.files;
|
|
if (files.length > 0 && fileInput) {
|
|
// Transfer to file input via DataTransfer
|
|
try {
|
|
var dt = new DataTransfer();
|
|
for (var i = 0; i < files.length && i < 10; i++) {
|
|
dt.items.add(files[i]);
|
|
}
|
|
fileInput.files = dt.files;
|
|
updateFilesList();
|
|
} catch(ex) {}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function loadCms() {
|
|
loadCmsCategories();
|
|
loadCmsContent();
|
|
loadCmsStats();
|
|
}
|
|
|
|
// ============================================================
|
|
// AI GENERATE PANEL
|
|
// ============================================================
|
|
|
|
var _aiWebdavCurrentPath = '/';
|
|
var _aiWebdavSelectedPath = '';
|
|
var _aiWebdavSelectedName = '';
|
|
|
|
function openAiPanel() {
|
|
var panel = document.getElementById('lh-ai-panel');
|
|
if (!panel) return;
|
|
panel.classList.remove('hidden');
|
|
populateAiModelSelect();
|
|
|
|
// Sync content type selector with the editor's current type
|
|
var editorType = (document.getElementById('lh-cms-edit-type') || {}).value || 'article';
|
|
var ctype = document.getElementById('lh-ai-ctype');
|
|
if (ctype) ctype.value = editorType;
|
|
updateAiOptions(editorType);
|
|
|
|
// Hide WebDAV tab if Nextcloud not configured
|
|
fetch('/api/auth/me', { headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(d) {
|
|
var tab = document.getElementById('lh-ai-tab-webdav');
|
|
if (tab) tab.style.display = (d.user && d.user.nextcloud_url) ? '' : 'none';
|
|
if (d.user && d.user.webdav_learning_path) _aiWebdavCurrentPath = d.user.webdav_learning_path || '/';
|
|
});
|
|
|
|
// Wire ctype change once (use a flag so it doesn't stack on re-opens)
|
|
var ctypeEl = document.getElementById('lh-ai-ctype');
|
|
var toggleEl = document.getElementById('lh-ai-q-toggle');
|
|
if (ctypeEl && !ctypeEl._wired) {
|
|
ctypeEl.addEventListener('change', function() { updateAiOptions(ctypeEl.value); });
|
|
ctypeEl._wired = true;
|
|
}
|
|
if (toggleEl && !toggleEl._wired) {
|
|
toggleEl.addEventListener('change', function() { updateAiOptions((document.getElementById('lh-ai-ctype') || {}).value); });
|
|
toggleEl._wired = true;
|
|
}
|
|
|
|
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
}
|
|
|
|
function updateAiOptions(type) {
|
|
var wordsField = document.getElementById('lh-ai-words-field');
|
|
var slidesField = document.getElementById('lh-ai-slides-field');
|
|
var quizRow = document.getElementById('lh-ai-quiz-row');
|
|
var cardTitle = document.getElementById('lh-ai-quiz-card-title');
|
|
var toggleLabel = document.getElementById('lh-ai-q-toggle-label');
|
|
var toggleCb = document.getElementById('lh-ai-q-toggle');
|
|
var countWrap = document.getElementById('lh-ai-q-count-wrap');
|
|
|
|
// Use style.display directly — classList.toggle('hidden') doesn't work because
|
|
// .lh-ai-opt-field { display:flex } is defined later in CSS and overrides .hidden
|
|
if (wordsField) wordsField.style.display = (type === 'quiz' || type === 'presentation') ? 'none' : 'flex';
|
|
if (slidesField) slidesField.style.display = (type === 'presentation') ? 'flex' : 'none';
|
|
|
|
if (type === 'quiz') {
|
|
// Quiz: questions are the whole point — show the card prominently, no toggle needed
|
|
if (quizRow) quizRow.style.display = 'block';
|
|
if (cardTitle) cardTitle.textContent = 'Quiz Questions';
|
|
if (toggleLabel) toggleLabel.style.display = 'none';
|
|
if (toggleCb) { toggleCb.checked = true; toggleCb.disabled = true; }
|
|
if (countWrap) countWrap.style.display = 'flex';
|
|
} else if (type === 'presentation') {
|
|
// Presentation: optional questions (same as article)
|
|
if (quizRow) quizRow.style.display = 'block';
|
|
if (cardTitle) cardTitle.textContent = 'Quiz Questions (optional)';
|
|
if (toggleLabel) toggleLabel.style.display = 'flex';
|
|
if (toggleCb) toggleCb.disabled = false;
|
|
var checkedP = toggleCb ? toggleCb.checked : false;
|
|
if (countWrap) countWrap.style.display = checkedP ? 'flex' : 'none';
|
|
} else {
|
|
// Article / pearl: optional questions via toggle
|
|
if (quizRow) quizRow.style.display = 'block';
|
|
if (cardTitle) cardTitle.textContent = 'Quiz Questions (optional)';
|
|
if (toggleLabel) toggleLabel.style.display = 'flex';
|
|
if (toggleCb) toggleCb.disabled = false;
|
|
var checked = toggleCb ? toggleCb.checked : false;
|
|
if (countWrap) countWrap.style.display = checked ? 'flex' : 'none';
|
|
}
|
|
}
|
|
|
|
function closeAiPanel() {
|
|
var panel = document.getElementById('lh-ai-panel');
|
|
if (panel) panel.classList.add('hidden');
|
|
}
|
|
|
|
function populateAiModelSelect() {
|
|
var sel = document.getElementById('lh-ai-model');
|
|
if (!sel || sel.options.length > 1) return;
|
|
// Copy options from global model select
|
|
var globalSel = document.getElementById('global-model-select');
|
|
if (!globalSel) return;
|
|
sel.innerHTML = globalSel.innerHTML;
|
|
sel.value = globalSel.value;
|
|
}
|
|
|
|
function switchAiTab(tabName) {
|
|
document.querySelectorAll('.lh-ai-tab').forEach(function(t) { t.classList.toggle('active', t.dataset.aitab === tabName); });
|
|
document.querySelectorAll('.lh-ai-tabpanel').forEach(function(p) { p.classList.add('hidden'); });
|
|
var tp = document.getElementById('lh-ai-tp-' + tabName);
|
|
if (tp) tp.classList.remove('hidden');
|
|
if (tabName === 'webdav' && !document.querySelector('#lh-ai-webdav-list .lh-webdav-item')) {
|
|
browseWebdav(_aiWebdavCurrentPath);
|
|
}
|
|
}
|
|
|
|
// ── WebDAV browser ──────────────────────────────────────────
|
|
function browseWebdav(path) {
|
|
_aiWebdavCurrentPath = path || '/';
|
|
var list = document.getElementById('lh-ai-webdav-list');
|
|
var pathLabel = document.getElementById('lh-ai-webdav-path-label');
|
|
if (list) list.innerHTML = '<div style="padding:12px;text-align:center;color:var(--g400);font-size:13px;"><i class="fas fa-spinner fa-spin"></i> Loading...</div>';
|
|
if (pathLabel) pathLabel.textContent = _aiWebdavCurrentPath;
|
|
|
|
fetch('/api/admin/learning/webdav-browse?path=' + encodeURIComponent(_aiWebdavCurrentPath), { headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data.success) { if (list) list.innerHTML = '<div style="padding:12px;color:var(--red);font-size:13px;">' + esc(data.error) + '</div>'; return; }
|
|
if (pathLabel) pathLabel.textContent = data.path;
|
|
renderWebdavList(data);
|
|
})
|
|
.catch(function(err) { if (list) list.innerHTML = '<div style="padding:12px;color:var(--red);font-size:13px;">' + esc(err.message) + '</div>'; });
|
|
}
|
|
|
|
function renderWebdavList(data) {
|
|
var list = document.getElementById('lh-ai-webdav-list');
|
|
if (!list) return;
|
|
|
|
var html = '';
|
|
|
|
// Parent nav
|
|
if (data.path !== '/' && data.path !== data.parentPath) {
|
|
html += '<div class="lh-webdav-item lh-webdav-dir" data-path="' + esc(data.parentPath) + '" data-isdir="1">' +
|
|
'<i class="fas fa-arrow-left" style="color:var(--g400);"></i> <span>..</span></div>';
|
|
}
|
|
|
|
if (data.items.length === 0 && html === '') {
|
|
html = '<div style="padding:12px;text-align:center;color:var(--g400);font-size:13px;">Empty folder</div>';
|
|
}
|
|
|
|
data.items.forEach(function(item) {
|
|
var icon = item.isDir ? 'fa-folder' : getFileIcon(item.contentType, item.name);
|
|
var iconColor = item.isDir ? 'var(--amber)' : 'var(--blue)';
|
|
var sizeStr = (!item.isDir && item.size) ? ' <span style="color:var(--g400);font-size:11px;">(' + formatBytes(item.size) + ')</span>' : '';
|
|
html += '<div class="lh-webdav-item' + (item.isDir ? ' lh-webdav-dir' : ' lh-webdav-file') + '"' +
|
|
' data-path="' + esc(item.path) + '" data-name="' + esc(item.name) + '" data-isdir="' + (item.isDir ? '1' : '0') + '">' +
|
|
'<i class="fas ' + icon + '" style="color:' + iconColor + ';width:16px;flex-shrink:0;"></i>' +
|
|
'<span class="lh-webdav-name">' + esc(item.name) + '</span>' + sizeStr +
|
|
'</div>';
|
|
});
|
|
list.innerHTML = html;
|
|
}
|
|
|
|
function getFileIcon(mime, name) {
|
|
if (!name) name = '';
|
|
var ext = name.split('.').pop().toLowerCase();
|
|
if (mime && mime.includes('pdf') || ext === 'pdf') return 'fa-file-pdf';
|
|
if (['doc','docx'].includes(ext)) return 'fa-file-word';
|
|
if (['txt','md'].includes(ext)) return 'fa-file-lines';
|
|
if (['html','htm'].includes(ext)) return 'fa-file-code';
|
|
return 'fa-file';
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1024*1024) return (bytes/1024).toFixed(0) + ' KB';
|
|
return (bytes/1024/1024).toFixed(1) + ' MB';
|
|
}
|
|
|
|
function selectWebdavFile(path, name) {
|
|
_aiWebdavSelectedPath = path;
|
|
_aiWebdavSelectedName = name;
|
|
var browser = document.getElementById('lh-ai-webdav-browser');
|
|
var selDiv = document.getElementById('lh-ai-webdav-selected');
|
|
var selName = document.getElementById('lh-ai-webdav-selected-name');
|
|
// Hide browser, show selected indicator (use style.display — class="hidden" is overridden by inline styles)
|
|
if (browser) browser.style.display = 'none';
|
|
if (selDiv) selDiv.style.display = 'flex';
|
|
if (selName) selName.textContent = name;
|
|
}
|
|
|
|
function deselectWebdavFile() {
|
|
_aiWebdavSelectedPath = '';
|
|
_aiWebdavSelectedName = '';
|
|
var browser = document.getElementById('lh-ai-webdav-browser');
|
|
var selDiv = document.getElementById('lh-ai-webdav-selected');
|
|
if (selDiv) selDiv.style.display = 'none';
|
|
if (browser) browser.style.display = '';
|
|
}
|
|
|
|
// ── Generate ────────────────────────────────────────────────
|
|
function runAiGenerate() {
|
|
var activeTab = document.querySelector('.lh-ai-tab.active');
|
|
var tabName = activeTab ? activeTab.dataset.aitab : 'topic';
|
|
var model = document.getElementById('lh-ai-model') ? document.getElementById('lh-ai-model').value : getSelectedModel();
|
|
var contentType = document.getElementById('lh-ai-ctype') ? document.getElementById('lh-ai-ctype').value : 'article';
|
|
var refinement = document.getElementById('lh-ai-refinement') ? document.getElementById('lh-ai-refinement').value.trim() : '';
|
|
var toggleCb = document.getElementById('lh-ai-q-toggle');
|
|
var generateQs = contentType === 'quiz' || (toggleCb && toggleCb.checked);
|
|
var questionCount = generateQs ? (document.getElementById('lh-ai-q-count') ? document.getElementById('lh-ai-q-count').value : 5) : 0;
|
|
var wordCount = document.getElementById('lh-ai-word-count') ? document.getElementById('lh-ai-word-count').value : '';
|
|
var slideCount = document.getElementById('lh-ai-slide-count') ? document.getElementById('lh-ai-slide-count').value : '';
|
|
|
|
var formData = new FormData();
|
|
formData.append('model', model);
|
|
formData.append('questionCount', questionCount);
|
|
formData.append('contentType', contentType);
|
|
if (refinement) formData.append('refinement', refinement);
|
|
if (wordCount) formData.append('wordCount', wordCount);
|
|
if (slideCount) formData.append('slideCount', slideCount);
|
|
|
|
if (tabName === 'topic') {
|
|
var topic = document.getElementById('lh-ai-topic') ? document.getElementById('lh-ai-topic').value.trim() : '';
|
|
if (!topic) { showToast('Describe what you want AI to create', 'error'); return; }
|
|
formData.append('topic', topic);
|
|
} else if (tabName === 'upload') {
|
|
var fileInput = document.getElementById('lh-ai-file');
|
|
if (!fileInput || fileInput.files.length === 0) { showToast('Select at least one file to upload', 'error'); return; }
|
|
for (var i = 0; i < fileInput.files.length; i++) {
|
|
formData.append('files', fileInput.files[i]);
|
|
}
|
|
var uploadCtx = document.getElementById('lh-ai-upload-context');
|
|
if (uploadCtx && uploadCtx.value.trim()) formData.append('topic', uploadCtx.value.trim());
|
|
} else if (tabName === 'webdav') {
|
|
if (!_aiWebdavSelectedPath) { showToast('Select a file from Nextcloud', 'error'); return; }
|
|
formData.append('webdavPath', _aiWebdavSelectedPath);
|
|
var webdavCtx = document.getElementById('lh-ai-webdav-context');
|
|
if (webdavCtx && webdavCtx.value.trim()) formData.append('topic', webdavCtx.value.trim());
|
|
}
|
|
|
|
var btn = document.getElementById('btn-lh-ai-generate');
|
|
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating...'; }
|
|
showBusy('AI is generating content...');
|
|
|
|
// FormData: no Content-Type header (browser sets it with boundary)
|
|
var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '';
|
|
fetch('/api/admin/learning/ai-generate', {
|
|
method: 'POST',
|
|
headers: { 'Authorization': 'Bearer ' + token },
|
|
body: formData
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
hideBusy();
|
|
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-wand-magic-sparkles"></i> Generate Content'; }
|
|
if (!data.success) { showToast(data.error || 'Generation failed', 'error'); return; }
|
|
// Presentation returns marpMarkdown directly; others return content{}
|
|
var payload = data.contentType === 'presentation' ? data : data.content;
|
|
applyAiContent(payload, contentType);
|
|
closeAiPanel();
|
|
showToast('Content generated! Review and save.', 'success');
|
|
})
|
|
.catch(function(err) {
|
|
hideBusy();
|
|
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-wand-magic-sparkles"></i> Generate Content'; }
|
|
showToast(err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function applyAiContent(content, contentType) {
|
|
// Set content type first so toggleEditorMode works correctly
|
|
var typeEl = document.getElementById('lh-cms-edit-type');
|
|
if (typeEl && contentType) { typeEl.value = contentType; toggleEditorMode(contentType); }
|
|
|
|
// Presentation: fill Marp textarea
|
|
if (contentType === 'presentation' && content.marpMarkdown) {
|
|
var marpTa = document.getElementById('lh-marp-editor');
|
|
if (marpTa) marpTa.value = content.marpMarkdown;
|
|
// Extract title from first # heading
|
|
var titleMatch = content.marpMarkdown.match(/^#\s+(.+)$/m);
|
|
var titleEl = document.getElementById('lh-cms-edit-title');
|
|
if (titleEl && titleMatch) titleEl.value = titleMatch[1];
|
|
// Fill questions if any were generated alongside slides
|
|
var qContainerP = document.getElementById('lh-cms-questions');
|
|
if (qContainerP) {
|
|
qContainerP.innerHTML = '';
|
|
if (content.questions && Array.isArray(content.questions)) {
|
|
content.questions.forEach(function(q) { addQuestionBlock(q); });
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Fill title
|
|
var titleEl2 = document.getElementById('lh-cms-edit-title');
|
|
if (titleEl2 && content.title) titleEl2.value = content.title;
|
|
|
|
// Fill subject
|
|
var subjectEl = document.getElementById('lh-cms-edit-subject');
|
|
if (subjectEl && content.subject) subjectEl.value = content.subject;
|
|
|
|
// Fill body via Tiptap
|
|
if (_bodyEditor && content.body) _bodyEditor.commands.setContent(content.body);
|
|
|
|
// Replace questions
|
|
var qContainer = document.getElementById('lh-cms-questions');
|
|
if (qContainer) {
|
|
qContainer.innerHTML = '';
|
|
if (content.questions && Array.isArray(content.questions)) {
|
|
content.questions.forEach(function(q) { addQuestionBlock(q); });
|
|
}
|
|
}
|
|
}
|
|
|
|
function toggleRefineBar(forceShow) {
|
|
var bar = document.getElementById('lh-ai-refine-bar');
|
|
var input = document.getElementById('lh-ai-refine-input');
|
|
if (!bar) return;
|
|
var show = forceShow !== undefined ? forceShow : bar.style.display === 'none';
|
|
bar.style.display = show ? 'flex' : 'none';
|
|
if (show && input) { input.value = ''; input.focus(); }
|
|
}
|
|
|
|
function submitRefineBody() {
|
|
var input = document.getElementById('lh-ai-refine-input');
|
|
var instructions = input ? input.value.trim() : '';
|
|
if (!instructions) { showToast('Enter instructions for refinement', 'error'); return; }
|
|
|
|
var currentBody = _bodyEditor ? getTpHTML(_bodyEditor) : '';
|
|
if (!currentBody) { showToast('No body content to refine', 'error'); return; }
|
|
|
|
var model = document.getElementById('lh-ai-model') ? document.getElementById('lh-ai-model').value : getSelectedModel();
|
|
toggleRefineBar(false);
|
|
showBusy('Refining content...');
|
|
|
|
fetch('/api/admin/learning/ai-refine', {
|
|
method: 'POST',
|
|
headers: getAuthHeaders(),
|
|
body: JSON.stringify({ content: currentBody, instructions: instructions, model: model })
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
hideBusy();
|
|
if (!data.success) { showToast(data.error || 'Refine failed', 'error'); return; }
|
|
if (_bodyEditor) _bodyEditor.commands.setContent(data.refined);
|
|
showToast('Body refined!', 'success');
|
|
})
|
|
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
|
|
}
|
|
|
|
function loadCmsStats() {
|
|
fetch('/api/admin/learning/stats', { headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data.success) return;
|
|
var s = data.stats;
|
|
var set = function(id, val) { var el = document.getElementById(id); if (el) el.textContent = val; };
|
|
set('cms-stat-published', s.publishedContent);
|
|
set('cms-stat-drafts', s.totalContent - s.publishedContent);
|
|
set('cms-stat-categories', s.totalCategories);
|
|
set('cms-stat-quizzes', s.totalQuizzes);
|
|
set('cms-stat-attempts', s.totalAttempts);
|
|
});
|
|
}
|
|
|
|
function loadCmsCategories() {
|
|
fetch('/api/admin/learning/categories', { headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data.success) return;
|
|
var container = document.getElementById('lh-cms-categories');
|
|
if (!container) return;
|
|
|
|
if (data.categories.length === 0) {
|
|
container.innerHTML = '<div style="font-size:12px;color:var(--g400);">No categories yet</div>';
|
|
} else {
|
|
container.innerHTML = data.categories.map(function(c) {
|
|
return '<div class="cms-cat-item">' +
|
|
'<span class="cms-cat-name"><i class="fas fa-folder" style="color:var(--amber);margin-right:6px;font-size:11px;"></i>' + esc(c.name) + '</span>' +
|
|
'<span class="cms-cat-count">' + c.content_count + '</span>' +
|
|
'<button class="lh-cms-del-cat" data-id="' + c.id + '" style="border:none;background:none;cursor:pointer;color:var(--g400);font-size:11px;padding:2px;" title="Delete"><i class="fas fa-times"></i></button>' +
|
|
'</div>';
|
|
}).join('');
|
|
}
|
|
|
|
// Update category dropdowns (editor + filter)
|
|
var sel = document.getElementById('lh-cms-edit-category');
|
|
if (sel) {
|
|
sel.innerHTML = '<option value="">Uncategorized</option>';
|
|
data.categories.forEach(function(c) {
|
|
sel.innerHTML += '<option value="' + c.id + '">' + esc(c.name) + '</option>';
|
|
});
|
|
}
|
|
var filterSel = document.getElementById('cms-filter-category');
|
|
if (filterSel) {
|
|
filterSel.innerHTML = '<option value="all">All Categories</option>';
|
|
data.categories.forEach(function(c) {
|
|
filterSel.innerHTML += '<option value="' + c.id + '">' + esc(c.name) + '</option>';
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function addCategory() {
|
|
var input = document.getElementById('lh-cms-cat-name');
|
|
if (!input || !input.value.trim()) { showToast('Enter category name', 'error'); return; }
|
|
|
|
fetch('/api/admin/learning/categories', {
|
|
method: 'POST',
|
|
headers: getAuthHeaders(),
|
|
body: JSON.stringify({ name: input.value.trim() })
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (data.success) {
|
|
input.value = '';
|
|
loadCmsCategories();
|
|
loadCategories(); // refresh user-facing pills too
|
|
showToast('Category created', 'success');
|
|
} else showToast(data.error || 'Failed', 'error');
|
|
})
|
|
.catch(function() { showToast('Request failed', 'error'); });
|
|
}
|
|
|
|
function deleteCategory(id) {
|
|
if (!window.__confirmDeleteCat) {
|
|
window.__confirmDeleteCat = id;
|
|
showToast('Click delete again to confirm removing this category', 'info');
|
|
setTimeout(function() { window.__confirmDeleteCat = null; }, 4000);
|
|
return;
|
|
}
|
|
window.__confirmDeleteCat = null;
|
|
fetch('/api/admin/learning/categories/' + id, { method: 'DELETE', headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (data.success) { loadCmsCategories(); loadCategories(); showToast('Category deleted', 'info'); }
|
|
else showToast(data.error || 'Failed', 'error');
|
|
});
|
|
}
|
|
|
|
function loadCmsContent() {
|
|
var container = document.getElementById('lh-cms-content-list');
|
|
if (!container) return;
|
|
container.innerHTML = '<p style="text-align:center;color:var(--g400);padding:12px;">Loading...</p>';
|
|
|
|
fetch('/api/admin/learning/content', { headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data.success) return;
|
|
if (data.content.length === 0) {
|
|
container.innerHTML = '<p style="text-align:center;color:var(--g400);padding:12px;">No content yet. Click "New Content" to create.</p>';
|
|
return;
|
|
}
|
|
window._cmsContentData = data.content;
|
|
renderCmsContentList(data.content);
|
|
});
|
|
}
|
|
|
|
function renderCmsContentList(items) {
|
|
var container = document.getElementById('lh-cms-content-list');
|
|
if (!container) return;
|
|
if (!items || items.length === 0) {
|
|
container.innerHTML = '<div style="text-align:center;padding:40px;color:var(--g400);"><i class="fas fa-file-circle-plus" style="font-size:24px;display:block;margin-bottom:8px;"></i>No content yet. Click "New Content" to get started.</div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = items.map(function(item) {
|
|
var statusBadge = item.published
|
|
? '<span class="cms-badge cms-badge-pub">Published</span>'
|
|
: '<span class="cms-badge cms-badge-draft">Draft</span>';
|
|
var typeLabel = item.content_type === 'quiz' ? 'Quiz' : item.content_type === 'pearl' ? 'Pearl' : item.content_type === 'presentation' ? 'Slides' : 'Article';
|
|
var qBadge = item.question_count > 0 ? ' <span style="color:var(--amber);">' + item.question_count + 'Q</span>' : '';
|
|
var date = item.updated_at ? new Date(item.updated_at).toLocaleDateString() : '';
|
|
|
|
return '<div class="cms-table-row lh-cms-content-item" data-id="' + item.id + '">' +
|
|
'<span class="cms-col-title">' + esc(item.title) + qBadge + '<br><span class="cms-title-sub">' + esc(item.subject || '') + '</span></span>' +
|
|
'<span class="cms-col-cat">' + esc(item.category_name || 'Uncategorized') + '</span>' +
|
|
'<span class="cms-col-type">' + typeLabel + '</span>' +
|
|
'<span class="cms-col-status">' + statusBadge + '</span>' +
|
|
'<span class="cms-col-date">' + date + '</span>' +
|
|
'<span class="cms-col-actions"><i class="fas fa-pen" style="color:var(--g400);"></i></span>' +
|
|
'</div>';
|
|
}).join('');
|
|
}
|
|
|
|
// CMS search & filter
|
|
document.addEventListener('input', function(e) {
|
|
if (e.target.id === 'cms-search') filterCmsContent();
|
|
});
|
|
document.addEventListener('change', function(e) {
|
|
if (e.target.id === 'cms-filter-status' || e.target.id === 'cms-filter-category') filterCmsContent();
|
|
});
|
|
|
|
function filterCmsContent() {
|
|
var items = window._cmsContentData || [];
|
|
var search = (document.getElementById('cms-search') || {}).value || '';
|
|
var statusFilter = (document.getElementById('cms-filter-status') || {}).value || 'all';
|
|
var catFilter = (document.getElementById('cms-filter-category') || {}).value || 'all';
|
|
|
|
search = search.toLowerCase().trim();
|
|
var filtered = items.filter(function(item) {
|
|
if (search && item.title.toLowerCase().indexOf(search) === -1 && (item.subject || '').toLowerCase().indexOf(search) === -1) return false;
|
|
if (statusFilter === 'published' && !item.published) return false;
|
|
if (statusFilter === 'draft' && item.published) return false;
|
|
if (catFilter !== 'all' && String(item.category_id) !== catFilter) return false;
|
|
return true;
|
|
});
|
|
renderCmsContentList(filtered);
|
|
}
|
|
|
|
// ── Tiptap editor helpers ──────────────────────────────────
|
|
var T = window.Tiptap || {};
|
|
|
|
function buildTpToolbar(mini, isOption) {
|
|
var btns;
|
|
if (isOption) {
|
|
btns = '<button type="button" class="tp-btn" data-cmd="bold" title="Bold"><i class="fas fa-bold"></i></button>' +
|
|
'<button type="button" class="tp-btn" data-cmd="italic" title="Italic"><i class="fas fa-italic"></i></button>' +
|
|
'<button type="button" class="tp-btn" data-cmd="link" title="Link"><i class="fas fa-link"></i></button>';
|
|
} else if (mini) {
|
|
btns = '<button type="button" class="tp-btn" data-cmd="bold" title="Bold"><i class="fas fa-bold"></i></button>' +
|
|
'<button type="button" class="tp-btn" data-cmd="italic" title="Italic"><i class="fas fa-italic"></i></button>' +
|
|
'<button type="button" class="tp-btn" data-cmd="bulletList" title="List"><i class="fas fa-list-ul"></i></button>' +
|
|
'<button type="button" class="tp-btn" data-cmd="link" title="Link"><i class="fas fa-link"></i></button>';
|
|
} else {
|
|
btns = '<button type="button" class="tp-btn" data-cmd="bold" title="Bold"><i class="fas fa-bold"></i></button>' +
|
|
'<button type="button" class="tp-btn" data-cmd="italic" title="Italic"><i class="fas fa-italic"></i></button>' +
|
|
'<button type="button" class="tp-btn" data-cmd="underline" title="Underline"><i class="fas fa-underline"></i></button>' +
|
|
'<button type="button" class="tp-btn" data-cmd="strike" title="Strike"><i class="fas fa-strikethrough"></i></button>' +
|
|
'<span class="tp-sep"></span>' +
|
|
'<button type="button" class="tp-btn" data-cmd="h2" title="Heading 2">H2</button>' +
|
|
'<button type="button" class="tp-btn" data-cmd="h3" title="Heading 3">H3</button>' +
|
|
'<span class="tp-sep"></span>' +
|
|
'<button type="button" class="tp-btn" data-cmd="bulletList" title="Bullet List"><i class="fas fa-list-ul"></i></button>' +
|
|
'<button type="button" class="tp-btn" data-cmd="orderedList" title="Numbered List"><i class="fas fa-list-ol"></i></button>' +
|
|
'<button type="button" class="tp-btn" data-cmd="blockquote" title="Blockquote"><i class="fas fa-quote-left"></i></button>' +
|
|
'<button type="button" class="tp-btn" data-cmd="codeBlock" title="Code"><i class="fas fa-code"></i></button>' +
|
|
'<span class="tp-sep"></span>' +
|
|
'<button type="button" class="tp-btn" data-cmd="link" title="Link"><i class="fas fa-link"></i></button>' +
|
|
'<span class="tp-sep"></span>' +
|
|
'<button type="button" class="tp-btn" data-cmd="clear" title="Clear formatting"><i class="fas fa-remove-format"></i></button>';
|
|
}
|
|
return '<div class="tp-toolbar' + (mini ? ' tp-toolbar-mini' : '') + '">' + btns + '</div>' +
|
|
'<div class="tp-link-bar" style="display:none;">' +
|
|
'<input type="url" class="tp-link-input" placeholder="https://">' +
|
|
'<button type="button" class="tp-link-apply">Apply</button>' +
|
|
'<button type="button" class="tp-link-remove">Remove</button>' +
|
|
'<button type="button" class="tp-link-cancel">✕</button>' +
|
|
'</div>';
|
|
}
|
|
|
|
function wireTpToolbar(wrap, editor) {
|
|
var linkBar = wrap.querySelector('.tp-link-bar');
|
|
var linkInput = wrap.querySelector('.tp-link-input');
|
|
|
|
wrap.querySelector('.tp-toolbar').addEventListener('mousedown', function(e) {
|
|
var btn = e.target.closest('.tp-btn[data-cmd]');
|
|
if (!btn) return;
|
|
e.preventDefault();
|
|
var cmd = btn.dataset.cmd;
|
|
switch (cmd) {
|
|
case 'bold': editor.chain().focus().toggleBold().run(); break;
|
|
case 'italic': editor.chain().focus().toggleItalic().run(); break;
|
|
case 'underline': editor.chain().focus().toggleUnderline().run(); break;
|
|
case 'strike': editor.chain().focus().toggleStrike().run(); break;
|
|
case 'h2': editor.chain().focus().toggleHeading({ level: 2 }).run(); break;
|
|
case 'h3': editor.chain().focus().toggleHeading({ level: 3 }).run(); break;
|
|
case 'bulletList': editor.chain().focus().toggleBulletList().run(); break;
|
|
case 'orderedList': editor.chain().focus().toggleOrderedList().run(); break;
|
|
case 'blockquote': editor.chain().focus().toggleBlockquote().run(); break;
|
|
case 'codeBlock': editor.chain().focus().toggleCodeBlock().run(); break;
|
|
case 'clear': editor.chain().focus().unsetAllMarks().clearNodes().run(); break;
|
|
case 'link':
|
|
if (linkBar.style.display === 'none') {
|
|
linkInput.value = editor.getAttributes('link').href || '';
|
|
linkBar.style.display = 'flex';
|
|
setTimeout(function() { linkInput.focus(); }, 0);
|
|
} else {
|
|
linkBar.style.display = 'none';
|
|
}
|
|
break;
|
|
}
|
|
updateTpState(wrap, editor);
|
|
});
|
|
|
|
wrap.querySelector('.tp-link-apply').addEventListener('mousedown', function(e) {
|
|
e.preventDefault();
|
|
var url = linkInput.value.trim();
|
|
if (url) editor.chain().focus().setLink({ href: url }).run();
|
|
linkBar.style.display = 'none';
|
|
});
|
|
wrap.querySelector('.tp-link-remove').addEventListener('mousedown', function(e) {
|
|
e.preventDefault();
|
|
editor.chain().focus().unsetLink().run();
|
|
linkBar.style.display = 'none';
|
|
});
|
|
wrap.querySelector('.tp-link-cancel').addEventListener('mousedown', function(e) {
|
|
e.preventDefault();
|
|
linkBar.style.display = 'none';
|
|
});
|
|
linkInput.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter') { e.preventDefault(); wrap.querySelector('.tp-link-apply').dispatchEvent(new MouseEvent('mousedown')); }
|
|
if (e.key === 'Escape') linkBar.style.display = 'none';
|
|
});
|
|
}
|
|
|
|
function updateTpState(wrap, editor) {
|
|
wrap.querySelectorAll('.tp-btn[data-cmd]').forEach(function(btn) {
|
|
var a = false;
|
|
switch (btn.dataset.cmd) {
|
|
case 'bold': a = editor.isActive('bold'); break;
|
|
case 'italic': a = editor.isActive('italic'); break;
|
|
case 'underline': a = editor.isActive('underline'); break;
|
|
case 'strike': a = editor.isActive('strike'); break;
|
|
case 'h2': a = editor.isActive('heading', { level: 2 }); break;
|
|
case 'h3': a = editor.isActive('heading', { level: 3 }); break;
|
|
case 'bulletList': a = editor.isActive('bulletList'); break;
|
|
case 'orderedList': a = editor.isActive('orderedList'); break;
|
|
case 'blockquote': a = editor.isActive('blockquote'); break;
|
|
case 'codeBlock': a = editor.isActive('codeBlock'); break;
|
|
case 'link': a = editor.isActive('link'); break;
|
|
}
|
|
btn.classList.toggle('active', a);
|
|
});
|
|
}
|
|
|
|
function makeTpEditor(wrap, existingHtml, mini, isOption) {
|
|
wrap.innerHTML = buildTpToolbar(mini, isOption) + '<div class="tp-content"></div>';
|
|
var ed = new T.Editor({
|
|
element: wrap.querySelector('.tp-content'),
|
|
extensions: [
|
|
T.StarterKit,
|
|
T.Link.configure({ openOnClick: false, autolink: true }),
|
|
T.Underline
|
|
],
|
|
content: existingHtml || '',
|
|
onUpdate: function() { updateTpState(wrap, ed); },
|
|
onSelectionUpdate: function() { updateTpState(wrap, ed); }
|
|
});
|
|
wireTpToolbar(wrap, ed);
|
|
return ed;
|
|
}
|
|
|
|
function getTpHTML(editor) {
|
|
if (!editor) return '';
|
|
var html = editor.getHTML();
|
|
return (html === '<p></p>' || html === '') ? '' : html;
|
|
}
|
|
|
|
// ── Body editor ────────────────────────────────────────────
|
|
function initBodyEditor() {
|
|
var wrap = document.getElementById('lh-body-editor');
|
|
if (!wrap || _bodyEditor) return;
|
|
_bodyEditor = makeTpEditor(wrap, '', false, false);
|
|
}
|
|
|
|
// ── Rich text toggle for questions / options ───────────────
|
|
function toggleRichText(btn) {
|
|
var isActive = btn.classList.contains('active');
|
|
var target = btn.dataset.target;
|
|
var block = btn.closest(target === 'question' ? '.lh-question-block' : '.lh-option-row');
|
|
if (!block) return;
|
|
|
|
if (target === 'question') {
|
|
var ta = block.querySelector('.lh-q-text');
|
|
var wrap = block.querySelector('.lh-q-richtext-wrap');
|
|
|
|
if (!isActive) {
|
|
var val = ta ? ta.value : '';
|
|
btn.classList.add('active');
|
|
btn.textContent = 'Plain Text';
|
|
if (ta) ta.style.display = 'none';
|
|
if (!wrap) {
|
|
wrap = document.createElement('div');
|
|
wrap.className = 'lh-q-richtext-wrap';
|
|
ta.parentNode.insertBefore(wrap, ta.nextSibling);
|
|
}
|
|
wrap.style.display = '';
|
|
if (!block._questionEditor) block._questionEditor = makeTpEditor(wrap, val, true, false);
|
|
} else {
|
|
btn.classList.remove('active');
|
|
btn.textContent = 'Rich Text';
|
|
if (block._questionEditor) {
|
|
if (ta) { ta.value = getTpHTML(block._questionEditor); ta.style.display = ''; }
|
|
}
|
|
if (wrap) wrap.style.display = 'none';
|
|
}
|
|
|
|
} else {
|
|
var row = block;
|
|
var optInput = row.querySelector('.lh-opt-text');
|
|
var optWrap = row.querySelector('.lh-opt-richtext-wrap');
|
|
|
|
if (!isActive) {
|
|
var curVal = optInput ? optInput.value : '';
|
|
btn.classList.add('active');
|
|
btn.textContent = 'Plain';
|
|
if (optInput) optInput.style.display = 'none';
|
|
if (!optWrap) {
|
|
optWrap = document.createElement('div');
|
|
optWrap.className = 'lh-opt-richtext-wrap';
|
|
optInput.parentNode.insertBefore(optWrap, optInput.nextSibling);
|
|
}
|
|
optWrap.style.display = '';
|
|
if (!row._optEditor) row._optEditor = makeTpEditor(optWrap, curVal, true, true);
|
|
} else {
|
|
btn.classList.remove('active');
|
|
btn.textContent = 'Rich';
|
|
if (row._optEditor) {
|
|
if (optInput) { optInput.value = getTpHTML(row._optEditor); optInput.style.display = ''; }
|
|
}
|
|
if (optWrap) optWrap.style.display = 'none';
|
|
}
|
|
}
|
|
}
|
|
|
|
function getQText(block) {
|
|
var wrap = block.querySelector('.lh-q-richtext-wrap');
|
|
if (block._questionEditor && wrap && wrap.style.display !== 'none') return getTpHTML(block._questionEditor);
|
|
var ta = block.querySelector('.lh-q-text');
|
|
return ta ? ta.value.trim() : '';
|
|
}
|
|
|
|
function getOptText(row) {
|
|
var wrap = row.querySelector('.lh-opt-richtext-wrap');
|
|
if (row._optEditor && wrap && wrap.style.display !== 'none') return getTpHTML(row._optEditor);
|
|
var inp = row.querySelector('.lh-opt-text');
|
|
return inp ? inp.value.trim() : '';
|
|
}
|
|
|
|
function openEditor(contentId, contentType) {
|
|
var editor = document.getElementById('lh-cms-editor');
|
|
var listView = document.getElementById('cms-list-view');
|
|
if (!editor) return;
|
|
editor.classList.remove('hidden');
|
|
if (listView) listView.classList.add('hidden');
|
|
|
|
// Reset
|
|
document.getElementById('lh-cms-edit-id').value = '';
|
|
document.getElementById('lh-cms-edit-title').value = '';
|
|
document.getElementById('lh-cms-edit-subject').value = '';
|
|
document.getElementById('lh-cms-edit-category').value = '';
|
|
document.getElementById('lh-cms-edit-type').value = contentType || 'article';
|
|
document.getElementById('lh-cms-edit-published').value = 'false';
|
|
document.getElementById('lh-cms-questions').innerHTML = '';
|
|
document.getElementById('btn-lh-delete-content').classList.add('hidden');
|
|
hideDeleteConfirm();
|
|
toggleEditorMode(contentType || 'article');
|
|
initBodyEditor();
|
|
if (_bodyEditor) _bodyEditor.commands.clearContent(true);
|
|
var marpTa = document.getElementById('lh-marp-editor');
|
|
if (marpTa) marpTa.value = '';
|
|
|
|
// For quiz type, add a starter question
|
|
if (contentType === 'quiz' && !contentId) {
|
|
addQuestionBlock();
|
|
}
|
|
|
|
if (!contentId) return;
|
|
|
|
// Load existing content
|
|
fetch('/api/admin/learning/content/' + contentId, { headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data.success) { showToast('Failed to load', 'error'); return; }
|
|
var c = data.content;
|
|
document.getElementById('lh-cms-edit-id').value = c.id;
|
|
document.getElementById('lh-cms-edit-title').value = c.title;
|
|
document.getElementById('lh-cms-edit-subject').value = c.subject || '';
|
|
document.getElementById('lh-cms-edit-category').value = c.category_id || '';
|
|
document.getElementById('lh-cms-edit-type').value = c.content_type || 'article';
|
|
document.getElementById('lh-cms-edit-published').value = c.published ? 'true' : 'false';
|
|
document.getElementById('btn-lh-delete-content').classList.remove('hidden');
|
|
toggleEditorMode(c.content_type || 'article');
|
|
|
|
if (c.content_type === 'presentation') {
|
|
var marpEl = document.getElementById('lh-marp-editor');
|
|
if (marpEl) marpEl.value = c.body || '';
|
|
} else {
|
|
if (_bodyEditor) _bodyEditor.commands.setContent(c.body || '');
|
|
}
|
|
|
|
// Load questions
|
|
var qContainer = document.getElementById('lh-cms-questions');
|
|
qContainer.innerHTML = '';
|
|
if (c.questions && c.questions.length > 0) {
|
|
c.questions.forEach(function(q) { addQuestionBlock(q); });
|
|
}
|
|
|
|
editor.scrollIntoView({ behavior: 'smooth' });
|
|
});
|
|
}
|
|
|
|
function toggleEditorMode(contentType) {
|
|
var bodySection = document.getElementById('lh-body-section');
|
|
var marpSection = document.getElementById('lh-marp-section');
|
|
var isPresentation = contentType === 'presentation';
|
|
if (bodySection) bodySection.classList.toggle('hidden', isPresentation);
|
|
if (marpSection) marpSection.classList.toggle('hidden', !isPresentation);
|
|
}
|
|
|
|
function closeEditor() {
|
|
var editor = document.getElementById('lh-cms-editor');
|
|
var listView = document.getElementById('cms-list-view');
|
|
if (editor) editor.classList.add('hidden');
|
|
if (listView) listView.classList.remove('hidden');
|
|
hideDeleteConfirm();
|
|
}
|
|
|
|
function saveContent() {
|
|
var id = document.getElementById('lh-cms-edit-id').value;
|
|
var title = document.getElementById('lh-cms-edit-title').value.trim();
|
|
var subject = document.getElementById('lh-cms-edit-subject').value.trim();
|
|
var content_type = document.getElementById('lh-cms-edit-type').value;
|
|
var body = content_type === 'presentation'
|
|
? (document.getElementById('lh-marp-editor') ? document.getElementById('lh-marp-editor').value : '')
|
|
: (_bodyEditor ? getTpHTML(_bodyEditor) : '');
|
|
var category_id = document.getElementById('lh-cms-edit-category').value || null;
|
|
var published = document.getElementById('lh-cms-edit-published').value === 'true';
|
|
|
|
if (!title) { showToast('Title required', 'error'); return; }
|
|
|
|
// Gather questions with validation
|
|
var questions = [];
|
|
var validationError = false;
|
|
var qBlocks = document.querySelectorAll('#lh-cms-questions .lh-question-block');
|
|
for (var qi = 0; qi < qBlocks.length; qi++) {
|
|
var qBlock = qBlocks[qi];
|
|
var qText = getQText(qBlock);
|
|
if (!qText) continue;
|
|
var qType = qBlock.querySelector('.lh-q-type').value;
|
|
var qExpl = qBlock.querySelector('.lh-q-explanation').value.trim();
|
|
var qId = qBlock.dataset.questionId || null;
|
|
|
|
var options = [];
|
|
qBlock.querySelectorAll('.lh-option-row').forEach(function(row) {
|
|
var optText = getOptText(row);
|
|
if (!optText) return;
|
|
options.push({
|
|
option_text: optText,
|
|
is_correct: row.querySelector('.lh-opt-correct').checked,
|
|
explanation: row.querySelector('.lh-opt-expl').value.trim()
|
|
});
|
|
});
|
|
|
|
// Validate: must have a correct answer marked
|
|
var hasCorrect = options.some(function(o) { return o.is_correct; });
|
|
if (options.length > 0 && !hasCorrect) {
|
|
showToast('Question "' + qText.substring(0, 50) + '..." needs a correct answer marked', 'error');
|
|
qBlock.style.outline = '2px solid var(--red)';
|
|
qBlock.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
validationError = true;
|
|
break;
|
|
}
|
|
qBlock.style.outline = '';
|
|
|
|
questions.push({
|
|
id: qId,
|
|
question_text: qText,
|
|
question_type: qType,
|
|
explanation: qExpl,
|
|
options: options
|
|
});
|
|
}
|
|
if (validationError) return;
|
|
|
|
var payload = { title: title, body: body, category_id: category_id, subject: subject, content_type: content_type, published: published };
|
|
|
|
var method = id ? 'PUT' : 'POST';
|
|
var url = id ? '/api/admin/learning/content/' + id : '/api/admin/learning/content';
|
|
|
|
showLoading('Saving...');
|
|
|
|
fetch(url, { method: method, headers: getAuthHeaders(), body: JSON.stringify(payload) })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data.success) { hideLoading(); showToast(data.error || 'Save failed', 'error'); return; }
|
|
var contentId = id || data.id;
|
|
|
|
// Save questions sequentially
|
|
saveQuestions(contentId, questions, 0, function() {
|
|
hideLoading();
|
|
showToast('Content saved', 'success');
|
|
closeEditor();
|
|
loadCmsContent();
|
|
loadCmsStats();
|
|
loaded = false; // force refresh user feed
|
|
});
|
|
})
|
|
.catch(function() { hideLoading(); showToast('Save failed', 'error'); });
|
|
}
|
|
|
|
function saveQuestions(contentId, questions, index, callback) {
|
|
if (index >= questions.length) { callback(); return; }
|
|
|
|
var q = questions[index];
|
|
var qId = q.id;
|
|
var method, url;
|
|
|
|
if (qId) {
|
|
method = 'PUT';
|
|
url = '/api/admin/learning/questions/' + qId;
|
|
} else {
|
|
method = 'POST';
|
|
url = '/api/admin/learning/content/' + contentId + '/questions';
|
|
}
|
|
|
|
fetch(url, {
|
|
method: method,
|
|
headers: getAuthHeaders(),
|
|
body: JSON.stringify({
|
|
question_text: q.question_text,
|
|
question_type: q.question_type,
|
|
explanation: q.explanation,
|
|
options: q.options
|
|
})
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function() {
|
|
saveQuestions(contentId, questions, index + 1, callback);
|
|
})
|
|
.catch(function() {
|
|
saveQuestions(contentId, questions, index + 1, callback);
|
|
});
|
|
}
|
|
|
|
function showDeleteConfirm() {
|
|
var id = document.getElementById('lh-cms-edit-id').value;
|
|
if (!id) return;
|
|
var title = (document.getElementById('lh-cms-edit-title').value || 'this content').substring(0, 60);
|
|
var ctype = (document.getElementById('lh-cms-edit-type') || {}).value || 'article';
|
|
var bar = document.getElementById('lh-delete-confirm-bar');
|
|
var msgEl = document.getElementById('lh-delete-confirm-msg');
|
|
if (!bar) return;
|
|
|
|
// Build wording based on content type
|
|
var typeLabel = { article: 'article', quiz: 'quiz', pearl: 'clinical pearl', presentation: 'presentation' }[ctype] || 'content';
|
|
var consequence = '';
|
|
if (ctype === 'quiz') consequence = ' All quiz questions will be permanently removed.';
|
|
else if (ctype === 'article' || ctype === 'pearl') consequence = ' Any quiz questions attached to it will also be removed.';
|
|
// Presentation has no questions → no extra wording
|
|
|
|
if (msgEl) msgEl.innerHTML = 'Delete the ' + typeLabel + ' <strong>"' + esc(title) + '"</strong>?' + consequence + ' This cannot be undone.';
|
|
bar.classList.remove('hidden');
|
|
bar.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
}
|
|
|
|
function hideDeleteConfirm() {
|
|
var bar = document.getElementById('lh-delete-confirm-bar');
|
|
if (bar) bar.classList.add('hidden');
|
|
}
|
|
|
|
function confirmDelete() {
|
|
var id = document.getElementById('lh-cms-edit-id').value;
|
|
if (!id) return;
|
|
hideDeleteConfirm();
|
|
fetch('/api/admin/learning/content/' + id, { method: 'DELETE', headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (data.success) {
|
|
showToast('Content deleted', 'info');
|
|
closeEditor();
|
|
loadCmsContent();
|
|
loadCmsStats();
|
|
loaded = false;
|
|
} else showToast(data.error || 'Failed', 'error');
|
|
});
|
|
}
|
|
|
|
// ── Presentation / Marp ──────────────────────────────────────
|
|
function getMarpMarkdown() {
|
|
var ta = document.getElementById('lh-marp-editor');
|
|
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...');
|
|
fetch('/api/learning/content/' + encodeURIComponent(slug) + '/slides', { headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.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...');
|
|
|
|
fetch('/api/admin/learning/preview-slides', {
|
|
method: 'POST', headers: getAuthHeaders(),
|
|
body: JSON.stringify({ markdown: md })
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.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) {
|
|
_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) 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;
|
|
}
|
|
}
|
|
|
|
// 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 addQuestionBlock(existingQ) {
|
|
var container = document.getElementById('lh-cms-questions');
|
|
if (!container) return;
|
|
|
|
var block = document.createElement('div');
|
|
block.className = 'lh-question-block';
|
|
if (existingQ && existingQ.id) block.dataset.questionId = existingQ.id;
|
|
|
|
var qNum = container.querySelectorAll('.lh-question-block').length + 1;
|
|
block.innerHTML =
|
|
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid var(--g200);">' +
|
|
'<div style="display:flex;align-items:center;gap:8px;">' +
|
|
'<span style="background:linear-gradient(135deg,var(--blue),var(--purple));color:white;font-size:12px;font-weight:700;padding:3px 10px;border-radius:6px;">Q' + qNum + '</span>' +
|
|
'<select class="lh-q-type" style="font-size:12px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">' +
|
|
'<option value="mcq"' + (existingQ && existingQ.question_type === 'mcq' ? ' selected' : '') + '>Single Choice</option>' +
|
|
'<option value="multi"' + (existingQ && existingQ.question_type === 'multi' ? ' selected' : '') + '>Multiple Select</option>' +
|
|
'<option value="true_false"' + (existingQ && existingQ.question_type === 'true_false' ? ' selected' : '') + '>True / False</option>' +
|
|
'</select>' +
|
|
'<button type="button" class="lh-richtext-btn" data-target="question">Rich Text</button>' +
|
|
'</div>' +
|
|
'<button class="btn-sm btn-ghost lh-rm-question" style="padding:2px 8px;font-size:12px;color:var(--red);"><i class="fas fa-trash"></i></button>' +
|
|
'</div>' +
|
|
'<textarea class="lh-q-text" placeholder="Enter question text..." rows="3" style="width:100%;font-size:14px;font-weight:500;padding:10px;border:1.5px solid var(--g300);border-radius:8px;box-sizing:border-box;margin-bottom:8px;resize:vertical;font-family:inherit;"></textarea>' +
|
|
'<div style="margin-bottom:8px;">' +
|
|
'<label style="font-size:11px;font-weight:600;color:var(--g500);text-transform:uppercase;letter-spacing:0.3px;display:block;margin-bottom:4px;">Answer Options <span style="color:var(--green);">(check the correct answer)</span></label>' +
|
|
'</div>' +
|
|
'<div class="lh-options-list"></div>' +
|
|
'<button class="btn-sm btn-ghost lh-add-option" style="margin-top:6px;font-size:12px;"><i class="fas fa-plus"></i> Add Option</button>' +
|
|
'<div style="margin-top:8px;">' +
|
|
'<label style="font-size:11px;font-weight:600;color:var(--g500);display:block;margin-bottom:3px;">Explanation (shown after answering)</label>' +
|
|
'<textarea class="lh-q-explanation" placeholder="Explain the correct answer..." rows="2" style="width:100%;font-size:12px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;resize:vertical;font-family:inherit;"></textarea>' +
|
|
'</div>';
|
|
|
|
container.appendChild(block);
|
|
|
|
// Set textarea values after insertion (can't use value="" attr on textarea)
|
|
block.querySelector('.lh-q-text').value = existingQ ? existingQ.question_text || '' : '';
|
|
block.querySelector('.lh-q-explanation').value = existingQ ? existingQ.explanation || '' : '';
|
|
|
|
var optList = block.querySelector('.lh-options-list');
|
|
if (existingQ && existingQ.options && existingQ.options.length > 0) {
|
|
existingQ.options.forEach(function(opt) { addOptionRow(optList, opt); });
|
|
} else if (existingQ && existingQ.question_type === 'true_false') {
|
|
addOptionRow(optList, { option_text: 'True', is_correct: false, explanation: '' });
|
|
addOptionRow(optList, { option_text: 'False', is_correct: false, explanation: '' });
|
|
} else {
|
|
// Add 4 blank MCQ options
|
|
for (var i = 0; i < 4; i++) addOptionRow(optList, null);
|
|
}
|
|
}
|
|
|
|
function addOptionRow(container, opt) {
|
|
if (!container) return;
|
|
var row = document.createElement('div');
|
|
row.className = 'lh-option-row';
|
|
row.innerHTML =
|
|
'<label style="display:flex;align-items:center;gap:4px;cursor:pointer;min-width:28px;padding-top:6px;" title="Mark as correct answer">' +
|
|
'<input type="checkbox" class="lh-opt-correct"' + (opt && opt.is_correct ? ' checked' : '') + ' style="accent-color:var(--green);width:16px;height:16px;">' +
|
|
'</label>' +
|
|
'<div style="flex:2;display:flex;flex-direction:column;gap:3px;">' +
|
|
'<input type="text" class="lh-opt-text" placeholder="Option text" style="width:100%;font-size:13px;padding:6px 10px;border:1.5px solid var(--g300);border-radius:6px;box-sizing:border-box;">' +
|
|
'<button type="button" class="lh-richtext-btn" data-target="option" style="align-self:flex-start;">Rich</button>' +
|
|
'</div>' +
|
|
'<input type="text" class="lh-opt-expl" placeholder="Explanation if wrong" value="' + esc(opt ? opt.explanation || '' : '') + '" style="flex:1;font-size:12px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;color:var(--g600);">' +
|
|
'<button class="btn-sm btn-ghost lh-rm-option" style="padding:2px 6px;font-size:12px;color:var(--g400);margin-top:4px;"><i class="fas fa-times"></i></button>';
|
|
container.appendChild(row);
|
|
// Set option text value after insertion (can't use value="" on nested input via innerHTML)
|
|
row.querySelector('.lh-opt-text').value = opt ? opt.option_text || '' : '';
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────
|
|
function esc(str) {
|
|
if (!str) return '';
|
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function sanitizeHtml(html) {
|
|
// DOMPurify is loaded from cdnjs via <script> in index.html. If it somehow
|
|
// fails to load, refuse to render unsafe content rather than falling back
|
|
// to a regex sanitizer (historical bypass risk).
|
|
if (!window.DOMPurify || typeof window.DOMPurify.sanitize !== 'function') {
|
|
console.warn('[learningHub] DOMPurify unavailable — rendering as plain text.');
|
|
var d = document.createElement('div');
|
|
d.textContent = String(html == null ? '' : html);
|
|
return d.innerHTML;
|
|
}
|
|
return window.DOMPurify.sanitize(html, {
|
|
ALLOWED_TAGS: ['p','br','b','strong','i','em','u','s','h1','h2','h3','h4','h5','h6',
|
|
'ul','ol','li','a','blockquote','code','pre','table','thead','tbody','tr','th','td',
|
|
'hr','div','span','sub','sup','dl','dt','dd'],
|
|
ALLOWED_ATTR: ['href','colspan','rowspan','class','target','rel'],
|
|
ADD_ATTR: ['target'],
|
|
FORBID_ATTR: ['style','onerror','onload','onclick','onmouseover'],
|
|
ALLOW_DATA_ATTR: false
|
|
});
|
|
}
|
|
|
|
})();
|