refactor learning hub renderers

This commit is contained in:
Daniel 2026-05-08 15:47:10 +02:00
parent 6d13765fc4
commit 7858484cb0
6 changed files with 352 additions and 140 deletions

View file

@ -10,7 +10,16 @@ import {
} from './learningHub/tiptapEditor.js';
import { esc, sanitizeHtml } from './learningHub/sanitize.js';
import { deleteJson, getJson, sendJson } from './learningHub/api.js';
import {
renderCmsCategoryList,
renderCmsContentEmpty,
renderCmsContentRow,
renderOptionRowShell,
renderQuestionBlockShell
} from './learningHub/cmsRenderer.js';
import { renderCategoryPills, renderEmptySearchMessage, renderFeed, renderSearchHeader } from './learningHub/feedRenderer.js';
import { renderQuizQuestions, renderQuizResultExplanations } from './learningHub/quizRenderer.js';
import { buildViewerMeta, renderPresentationCard, renderProgressList } from './learningHub/viewerRenderer.js';
var loaded = false;
var cmsLoaded = false;
@ -126,6 +135,10 @@ import { renderCategoryPills, renderEmptySearchMessage, renderFeed, renderSearch
var delCat = e.target.closest('.lh-cms-del-cat');
if (delCat) { deleteCategory(delCat.dataset.id); return; }
// CMS sidebar category filter
var cmsCat = e.target.closest('.lh-cms-cat-filter');
if (cmsCat && cmsCat.dataset.id) { filterCmsContentByCategory(cmsCat.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) { destroyQuestionBlockEditors(qb); qb.remove(); } return; }
@ -278,14 +291,7 @@ import { renderCategoryPills, renderEmptySearchMessage, renderFeed, renderSearch
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(' | ');
}
if (metaEl) metaEl.textContent = buildViewerMeta(item);
var presViewer = document.getElementById('lh-presentation-viewer');
var bodyCard = bodyEl ? bodyEl.closest('.card') : null;
@ -293,17 +299,8 @@ import { renderCategoryPills, renderEmptySearchMessage, renderFeed, renderSearch
// 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>';
presViewer.innerHTML = renderPresentationCard(item);
// Auto-load and open slides
document.getElementById('btn-lh-view-slides').addEventListener('click', function() {
@ -341,15 +338,7 @@ import { renderCategoryPills, renderEmptySearchMessage, renderFeed, renderSearch
// 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;
document.getElementById('lh-progress-list').innerHTML = renderProgressList(item.progress);
} else if (progressSection) {
progressSection.classList.add('hidden');
}
@ -361,32 +350,7 @@ import { renderCategoryPills, renderEmptySearchMessage, renderFeed, renderSearch
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('');
container.innerHTML = renderQuizQuestions(questions);
}
function submitQuiz() {
@ -432,24 +396,7 @@ import { renderCategoryPills, renderEmptySearchMessage, renderFeed, renderSearch
}
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('');
explEl.innerHTML = renderQuizResultExplanations(data.results);
}
// Highlight correct/incorrect in quiz
@ -882,7 +829,7 @@ import { renderCategoryPills, renderEmptySearchMessage, renderFeed, renderSearch
opt.textContent = categoryName;
sel.appendChild(opt);
sel.value = String(data.id);
loadCmsCategories();
loadCmsCategories(String(data.id));
loadCategories();
})
.catch(function(err) {
@ -943,43 +890,46 @@ import { renderCategoryPills, renderEmptySearchMessage, renderFeed, renderSearch
});
}
function loadCmsCategories() {
function loadCmsCategories(editorCategoryId, filterCategoryId) {
getJson('/api/admin/learning/categories')
.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('');
}
container.innerHTML = renderCmsCategoryList(data.categories);
// Update category dropdowns (editor + filter)
var sel = document.getElementById('lh-cms-edit-category');
if (sel) {
var selectedEditorId = editorCategoryId !== undefined ? editorCategoryId : sel.value;
sel.innerHTML = '<option value="">Uncategorized</option>';
data.categories.forEach(function(c) {
sel.innerHTML += '<option value="' + c.id + '">' + esc(c.name) + '</option>';
});
if (selectedEditorId && hasSelectValue(sel, selectedEditorId)) sel.value = selectedEditorId;
}
var filterSel = document.getElementById('cms-filter-category');
if (filterSel) {
var selectedFilterId = filterCategoryId !== undefined ? filterCategoryId : filterSel.value;
filterSel.innerHTML = '<option value="all">All Categories</option>';
data.categories.forEach(function(c) {
filterSel.innerHTML += '<option value="' + c.id + '">' + esc(c.name) + '</option>';
});
if (selectedFilterId && hasSelectValue(filterSel, selectedFilterId)) filterSel.value = selectedFilterId;
}
setActiveCmsCat((document.getElementById('cms-filter-category') || {}).value || 'all');
});
}
function hasSelectValue(sel, value) {
value = String(value);
for (var i = 0; i < sel.options.length; i++) {
if (sel.options[i].value === value) return true;
}
return false;
}
function addCategory() {
var input = document.getElementById('lh-cms-cat-name');
if (!input || !input.value.trim()) { showToast('Enter category name', 'error'); return; }
@ -1032,27 +982,11 @@ import { renderCategoryPills, renderEmptySearchMessage, renderFeed, renderSearch
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>';
container.innerHTML = renderCmsContentEmpty();
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('');
container.innerHTML = items.map(renderCmsContentRow).join('');
}
// CMS search & filter
@ -1060,9 +994,26 @@ import { renderCategoryPills, renderEmptySearchMessage, renderFeed, renderSearch
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();
if (e.target.id === 'cms-filter-status' || e.target.id === 'cms-filter-category') {
if (e.target.id === 'cms-filter-category') setActiveCmsCat(e.target.value || 'all');
filterCmsContent();
}
});
function filterCmsContentByCategory(categoryId) {
var filterSel = document.getElementById('cms-filter-category');
if (filterSel) filterSel.value = String(categoryId);
setActiveCmsCat(categoryId);
filterCmsContent();
}
function setActiveCmsCat(categoryId) {
categoryId = String(categoryId || 'all');
document.querySelectorAll('.lh-cms-cat-filter').forEach(function(item) {
item.classList.toggle('active', categoryId !== 'all' && item.dataset.id === categoryId);
});
}
function filterCmsContent() {
var items = window._cmsContentData || [];
var search = (document.getElementById('cms-search') || {}).value || '';
@ -1314,6 +1265,7 @@ import { renderCategoryPills, renderEmptySearchMessage, renderFeed, renderSearch
showToast('Content saved', 'success');
closeEditor();
loadCmsContent();
loadCmsCategories();
loadCmsStats();
loaded = false; // force refresh user feed
});
@ -1386,6 +1338,7 @@ import { renderCategoryPills, renderEmptySearchMessage, renderFeed, renderSearch
showToast('Content deleted', 'info');
closeEditor();
loadCmsContent();
loadCmsCategories();
loadCmsStats();
loaded = false;
} else showToast(data.error || 'Failed', 'error');
@ -1430,6 +1383,7 @@ import { renderCategoryPills, renderEmptySearchMessage, renderFeed, renderSearch
}
function openSlideModal(slides, css) {
closeSlideModal();
_previewSlides = slides || [];
_previewIdx = 0;
@ -1437,7 +1391,7 @@ import { renderCategoryPills, renderEmptySearchMessage, renderFeed, renderSearch
var cssEl = document.getElementById('slide-preview-css');
var dotsEl = document.getElementById('slide-preview-dots');
if (!modal) return;
if (!modal || !_previewSlides.length) { showToast('No slides to display', 'error'); return; }
// Inject Marp CSS once (scoped to preview frame)
if (cssEl) cssEl.textContent = css || '';
@ -1499,6 +1453,14 @@ import { renderCategoryPills, renderEmptySearchMessage, renderFeed, renderSearch
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
@ -1543,29 +1505,7 @@ import { renderCategoryPills, renderEmptySearchMessage, renderFeed, renderSearch
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>';
block.innerHTML = renderQuestionBlockShell(existingQ, qNum);
container.appendChild(block);
@ -1589,16 +1529,7 @@ import { renderCategoryPills, renderEmptySearchMessage, renderFeed, renderSearch
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>';
row.innerHTML = renderOptionRowShell(opt);
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 || '' : '';

View file

@ -0,0 +1,109 @@
import { esc } from './sanitize.js';
export function renderCmsCategoryList(categories) {
if (!categories || categories.length === 0) {
return '<div style="font-size:12px;color:var(--g400);">No categories yet</div>';
}
return categories.map(function(c) {
return '<div class="cms-cat-item lh-cms-cat-filter" data-id="' + c.id + '">' +
'<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('');
}
export function renderCmsContentEmpty() {
return '<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>';
}
export function renderCmsContentRow(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>';
}
export function renderQuestionBlockShell(existingQ, qNum) {
var qBadgeStyle = 'background:linear-gradient(135deg,var(--blue),var(--purple));color:white;' +
'font-size:12px;font-weight:700;padding:3px 10px;border-radius:6px;';
var qTextStyle = '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;';
var qExplStyle = '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;';
return '<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="' + qBadgeStyle + '">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"' + selectedQuestionType(existingQ, 'mcq') + '>Single Choice</option>' +
'<option value="multi"' + selectedQuestionType(existingQ, 'multi') + '>Multiple Select</option>' +
'<option value="true_false"' + selectedQuestionType(existingQ, 'true_false') + '>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="' + qTextStyle + '"></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="' + qExplStyle + '"></textarea>' +
'</div>';
}
export function renderOptionRowShell(opt) {
var optTextStyle = 'width:100%;font-size:13px;padding:6px 10px;border:1.5px solid var(--g300);' +
'border-radius:6px;box-sizing:border-box;';
var optExplStyle = 'flex:1;font-size:12px;padding:6px 10px;border:1px solid var(--g300);' +
'border-radius:6px;color:var(--g600);';
return '<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="' + optTextStyle + '">' +
'<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="' + optExplStyle + '">' +
'<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>';
}
function selectedQuestionType(existingQ, type) {
return existingQ && existingQ.question_type === type ? ' selected' : '';
}

View file

@ -11,7 +11,10 @@ export function renderCategoryPills(categories) {
export function renderFeed(items, feedEl) {
if (!feedEl) return;
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>';
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;
}
@ -37,8 +40,14 @@ export function renderEmptySearchMessage() {
}
function renderFeedItem(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 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() : '';

View file

@ -0,0 +1,63 @@
import { esc, sanitizeHtml } from './sanitize.js';
export function renderQuizQuestions(questions) {
return (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 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">' + renderQuizOptions(q.options, inputType, nameAttr) + '</div>' +
'</div>';
}).join('');
}
export function renderQuizResultExplanations(results) {
return (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('');
}
function renderQuizOptions(options, inputType, nameAttr) {
return (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('');
}

View file

@ -0,0 +1,42 @@
import { esc } from './sanitize.js';
export function buildViewerMeta(item) {
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());
return parts.join(' | ');
}
export function renderPresentationCard(item) {
var slideCount = (item.body || '').split(/\n---\n/).length;
var slideButtonStyle = 'padding:11px 28px;font-size:14px;border-radius:10px;border:none;' +
'cursor:pointer;background:var(--blue);color:white;font-weight:600;';
return '<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="' + slideButtonStyle + '">' +
'<i class="fas fa-play-circle"></i> View Slides' +
'</button>' +
'</div>';
}
export function renderProgressList(progress) {
return (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();
var color = pct >= 70 ? 'var(--green)' : 'var(--amber)';
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:' + color + ';">' +
p.score + '/' + p.total + ' (' + pct + '%)' +
'</span>' +
'</div>';
}).join('');
}

View file

@ -9,9 +9,12 @@ function read(relativePath) {
const source = read('public/js/learningHub.js');
const apiModule = read('public/js/learningHub/api.js');
const cmsRendererModule = read('public/js/learningHub/cmsRenderer.js');
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 viewerRendererModule = read('public/js/learningHub/viewerRenderer.js');
test('Learning Hub destroys embedded Tiptap editors before removing CMS rows', () => {
assert.match(source, /from '\.\/learningHub\/tiptapEditor\.js'/);
@ -57,3 +60,58 @@ test('Learning Hub feed renderers are isolated from API and editor flows', () =>
assert.doesNotMatch(feedRendererModule, /fetch\(|getJson|sendJson|deleteJson|ai-generate|FormData|window\.Tiptap/);
assert.doesNotMatch(source, /function renderFeed\(items, feedEl\)/);
});
test('Learning Hub CMS renderers are isolated from API and editor flows', () => {
assert.match(source, /from '\.\/learningHub\/cmsRenderer\.js'/);
assert.match(cmsRendererModule, /export function renderCmsCategoryList\(categories\)/);
assert.match(cmsRendererModule, /export function renderCmsContentRow\(item\)/);
assert.match(cmsRendererModule, /export function renderQuestionBlockShell\(existingQ, qNum\)/);
assert.match(cmsRendererModule, /export function renderOptionRowShell\(opt\)/);
assert.match(cmsRendererModule, /lh-cms-cat-filter/);
assert.match(cmsRendererModule, /selectedQuestionType\(existingQ, type\)/);
assert.doesNotMatch(cmsRendererModule, /fetch\(|getJson|sendJson|deleteJson|ai-generate|FormData|window\.Tiptap/);
});
test('Learning Hub preserves AI-created category selection before save', () => {
assert.match(source, /function loadCmsCategories\(editorCategoryId, filterCategoryId\)/);
assert.match(source, /var selectedEditorId = editorCategoryId !== undefined \? editorCategoryId : sel\.value/);
assert.match(source, /if \(selectedEditorId && hasSelectValue\(sel, selectedEditorId\)\) sel\.value = selectedEditorId/);
assert.match(source, /loadCmsCategories\(String\(data\.id\)\)/);
});
test('Learning Hub CMS sidebar categories filter content list', () => {
assert.match(source, /filterCmsContentByCategory\(cmsCat\.dataset\.id\)/);
assert.match(source, /function filterCmsContentByCategory\(categoryId\)/);
assert.match(source, /function setActiveCmsCat\(categoryId\)/);
assert.match(source, /item\.classList\.toggle\('active'/);
});
test('Learning Hub refreshes CMS category counts after content changes', () => {
assert.match(source, /saveQuestions\(contentId, questions, 0, function\(\) \{[\s\S]*?loadCmsContent\(\);[\s\S]*?loadCmsCategories\(\);[\s\S]*?loadCmsStats\(\);/);
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 quiz renderers are isolated from API and submit flow', () => {
assert.match(source, /from '\.\/learningHub\/quizRenderer\.js'/);
assert.match(quizRendererModule, /export function renderQuizQuestions\(questions\)/);
assert.match(quizRendererModule, /export function renderQuizResultExplanations\(results\)/);
assert.match(quizRendererModule, /sanitizeHtml\(q\.question_text\)/);
assert.match(quizRendererModule, /sanitizeHtml\(r\.generalExplanation\)/);
assert.doesNotMatch(quizRendererModule, /fetch\(|getJson|sendJson|deleteJson|submitQuiz|showLoading|currentContent/);
});
test('Learning Hub viewer renderers are isolated from API and slide loading', () => {
assert.match(source, /from '\.\/learningHub\/viewerRenderer\.js'/);
assert.match(viewerRendererModule, /export function buildViewerMeta\(item\)/);
assert.match(viewerRendererModule, /export function renderPresentationCard\(item\)/);
assert.match(viewerRendererModule, /export function renderProgressList\(progress\)/);
assert.match(viewerRendererModule, /btn-lh-view-slides/);
assert.doesNotMatch(viewerRendererModule, /fetch\(|getJson|sendJson|deleteJson|openSlidesFromSlug|showViewer/);
});