- New dedicated Content Manager tab (WordPress-like layout) with: - Stats dashboard (published, drafts, categories, quizzes, attempts) - Sidebar with category tree and filter controls (status, category) - Table-style content list with search - Full-page editor with title bar, meta fields, body editor, quiz builder - Content Manager visible only for admin and moderator roles in sidebar - Removed CMS from admin panel (admin panel is admin-only again) - Email templates: fully responsive with max-width, proper mobile breakpoints, table-based layout for Outlook/Gmail compatibility, no overflow - Security: escape all user data in emails with escHtml(), escape APP_URL in verify-email HTML, replace raw err.message with generic errors in auth - Security: email body from admin settings now HTML-escaped before rendering
839 lines
36 KiB
JavaScript
839 lines
36 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'
|
|
|
|
// ── Load when tab activated ────────────────────────────────
|
|
document.addEventListener('tabChanged', function(e) {
|
|
if (e.detail && e.detail.tab === 'learning') {
|
|
if (!loaded) { loadFeed(); loadCategories(); loaded = true; }
|
|
}
|
|
// Load CMS when Content Manager tab is opened
|
|
if (e.detail && e.detail.tab === 'cms') {
|
|
if (!cmsLoaded) { loadCms(); cmsLoaded = true; }
|
|
}
|
|
});
|
|
|
|
// ── Event delegation ───────────────────────────────────────
|
|
document.addEventListener('click', function(e) {
|
|
// 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-add-cat')) { addCategory(); return; }
|
|
if (e.target.closest('#btn-lh-new-content')) { openEditor(null); 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')) { deleteContent(); return; }
|
|
if (e.target.closest('#btn-lh-add-question')) { addQuestionBlock(); 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; }
|
|
});
|
|
|
|
// Search
|
|
var searchTimeout = null;
|
|
var searchEl = document.getElementById('lh-search');
|
|
if (searchEl) {
|
|
searchEl.addEventListener('input', function() {
|
|
clearTimeout(searchTimeout);
|
|
var q = searchEl.value.trim();
|
|
if (!q) { loadFeed(); return; }
|
|
searchTimeout = setTimeout(function() { searchContent(q); }, 300);
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// 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();
|
|
|
|
fetch('/api/learning/search?q=' + encodeURIComponent(q), { headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data.success) return;
|
|
renderFeed(data.content, feedEl);
|
|
});
|
|
}
|
|
|
|
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' : 'fa-file-alt';
|
|
var typeColor = item.content_type === 'quiz' ? 'var(--amber)' : item.content_type === 'pearl' ? 'var(--purple, #8b5cf6)' : '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 searchWrap = document.getElementById('lh-search');
|
|
if (feedEl) feedEl.classList.remove('hidden');
|
|
if (viewerEl) viewerEl.classList.add('hidden');
|
|
if (catsEl) catsEl.classList.remove('hidden');
|
|
if (searchWrap) searchWrap.closest('.card').classList.remove('hidden');
|
|
}
|
|
|
|
function showViewer(item) {
|
|
currentView = 'viewer';
|
|
var feedEl = document.getElementById('lh-feed');
|
|
var viewerEl = document.getElementById('lh-viewer');
|
|
var catsEl = document.getElementById('lh-categories');
|
|
var searchWrap = document.getElementById('lh-search');
|
|
if (feedEl) feedEl.classList.add('hidden');
|
|
if (viewerEl) viewerEl.classList.remove('hidden');
|
|
if (catsEl) catsEl.classList.add('hidden');
|
|
if (searchWrap) searchWrap.closest('.card').classList.add('hidden');
|
|
|
|
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(' | ');
|
|
}
|
|
if (bodyEl) {
|
|
// Render body as HTML (admin creates it)
|
|
bodyEl.innerHTML = sanitizeHtml(item.body || '');
|
|
}
|
|
|
|
// Quiz
|
|
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 typeLabel = q.question_type === 'true_false' ? 'True / False' : 'Multiple Choice';
|
|
var optionsHtml = (q.options || []).map(function(opt) {
|
|
return '<label class="lh-quiz-option">' +
|
|
'<input type="radio" name="lh-q-' + q.id + '" value="' + opt.id + '">' +
|
|
'<span>' + esc(opt.option_text) + '</span>' +
|
|
'</label>';
|
|
}).join('');
|
|
|
|
return '<div class="lh-quiz-q" data-qid="' + q.id + '">' +
|
|
'<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">' + esc(q.question_text) + '</p>' +
|
|
'<div class="lh-quiz-options">' + optionsHtml + '</div>' +
|
|
'</div>';
|
|
}).join('');
|
|
}
|
|
|
|
function submitQuiz() {
|
|
if (!currentContent || !currentContent.questions) return;
|
|
|
|
var answers = [];
|
|
currentContent.questions.forEach(function(q) {
|
|
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> ' + esc(r.generalExplanation) + '</div>';
|
|
}
|
|
|
|
return '<div class="lh-result-item">' +
|
|
'<div class="lh-result-header">' + icon + ' <strong>Q' + (idx + 1) + ':</strong> ' + esc(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;
|
|
qDiv.querySelectorAll('.lh-quiz-option').forEach(function(label) {
|
|
var input = label.querySelector('input');
|
|
if (!input) return;
|
|
var optId = parseInt(input.value);
|
|
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 loadCms() {
|
|
loadCmsCategories();
|
|
loadCmsContent();
|
|
loadCmsStats();
|
|
}
|
|
|
|
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 (!confirm('Delete this category? Content will become uncategorized.')) return;
|
|
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' : '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);
|
|
}
|
|
|
|
function openEditor(contentId) {
|
|
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-body').value = '';
|
|
document.getElementById('lh-cms-edit-category').value = '';
|
|
document.getElementById('lh-cms-edit-type').value = 'article';
|
|
document.getElementById('lh-cms-edit-published').value = 'false';
|
|
document.getElementById('lh-cms-questions').innerHTML = '';
|
|
document.getElementById('btn-lh-delete-content').classList.add('hidden');
|
|
|
|
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-body').value = c.body || '';
|
|
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');
|
|
|
|
// 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 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');
|
|
}
|
|
|
|
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 body = document.getElementById('lh-cms-edit-body').value;
|
|
var category_id = document.getElementById('lh-cms-edit-category').value || null;
|
|
var content_type = document.getElementById('lh-cms-edit-type').value;
|
|
var published = document.getElementById('lh-cms-edit-published').value === 'true';
|
|
|
|
if (!title) { showToast('Title required', 'error'); return; }
|
|
|
|
// Gather questions
|
|
var questions = [];
|
|
document.querySelectorAll('#lh-cms-questions .lh-question-block').forEach(function(qBlock) {
|
|
var qText = qBlock.querySelector('.lh-q-text').value.trim();
|
|
if (!qText) return;
|
|
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 = row.querySelector('.lh-opt-text').value.trim();
|
|
if (!optText) return;
|
|
options.push({
|
|
option_text: optText,
|
|
is_correct: row.querySelector('.lh-opt-correct').checked,
|
|
explanation: row.querySelector('.lh-opt-expl').value.trim()
|
|
});
|
|
});
|
|
|
|
questions.push({
|
|
id: qId,
|
|
question_text: qText,
|
|
question_type: qType,
|
|
explanation: qExpl,
|
|
options: options
|
|
});
|
|
});
|
|
|
|
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 deleteContent() {
|
|
var id = document.getElementById('lh-cms-edit-id').value;
|
|
if (!id) return;
|
|
if (!confirm('Delete this content and all its questions? This cannot be undone.')) return;
|
|
|
|
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');
|
|
});
|
|
}
|
|
|
|
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;
|
|
|
|
block.innerHTML =
|
|
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">' +
|
|
'<span style="font-size:12px;font-weight:600;color:var(--g600);">Question</span>' +
|
|
'<button class="btn-sm btn-ghost lh-rm-question" style="padding:2px 6px;font-size:11px;color:var(--red);"><i class="fas fa-trash"></i> Remove</button>' +
|
|
'</div>' +
|
|
'<input type="text" class="lh-q-text" placeholder="Question text..." value="' + esc(existingQ ? existingQ.question_text : '') + '" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;margin-bottom:6px;">' +
|
|
'<div style="display:flex;gap:8px;margin-bottom:6px;">' +
|
|
'<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' : '') + '>Multiple Choice</option>' +
|
|
'<option value="true_false"' + (existingQ && existingQ.question_type === 'true_false' ? ' selected' : '') + '>True / False</option>' +
|
|
'</select>' +
|
|
'<input type="text" class="lh-q-explanation" placeholder="General explanation (shown after answering)" value="' + esc(existingQ ? existingQ.explanation || '' : '') + '" style="flex:1;font-size:12px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">' +
|
|
'</div>' +
|
|
'<div class="lh-options-list"></div>' +
|
|
'<button class="btn-sm btn-ghost lh-add-option" style="margin-top:4px;font-size:12px;"><i class="fas fa-plus"></i> Add Option</button>';
|
|
|
|
container.appendChild(block);
|
|
|
|
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;" title="Mark as correct">' +
|
|
'<input type="checkbox" class="lh-opt-correct"' + (opt && opt.is_correct ? ' checked' : '') + '>' +
|
|
'<i class="fas fa-check" style="font-size:11px;color:var(--green);"></i>' +
|
|
'</label>' +
|
|
'<input type="text" class="lh-opt-text" placeholder="Option text" value="' + esc(opt ? opt.option_text : '') + '" style="flex:1;font-size:12px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">' +
|
|
'<input type="text" class="lh-opt-expl" placeholder="Why wrong (optional)" value="' + esc(opt ? opt.explanation || '' : '') + '" style="flex:1;font-size:12px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">' +
|
|
'<button class="btn-sm btn-ghost lh-rm-option" style="padding:2px 6px;font-size:11px;color:var(--red);"><i class="fas fa-times"></i></button>';
|
|
container.appendChild(row);
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────
|
|
function esc(str) {
|
|
if (!str) return '';
|
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function sanitizeHtml(html) {
|
|
// Allowlist-based sanitizer: only safe tags and no event handlers
|
|
var 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'];
|
|
var ALLOWED_ATTRS = { 'a': ['href'], 'td': ['colspan','rowspan'], 'th': ['colspan','rowspan'] };
|
|
|
|
var div = document.createElement('div');
|
|
div.innerHTML = html;
|
|
|
|
function clean(node) {
|
|
var children = Array.prototype.slice.call(node.childNodes);
|
|
for (var i = 0; i < children.length; i++) {
|
|
var child = children[i];
|
|
if (child.nodeType === 3) continue; // text nodes OK
|
|
if (child.nodeType !== 1) { child.remove(); continue; } // remove comments etc
|
|
|
|
var tag = child.tagName.toLowerCase();
|
|
if (ALLOWED_TAGS.indexOf(tag) === -1) {
|
|
// Replace with text content
|
|
var text = document.createTextNode(child.textContent);
|
|
node.replaceChild(text, child);
|
|
continue;
|
|
}
|
|
|
|
// Remove all attributes except explicitly allowed ones
|
|
var allowed = ALLOWED_ATTRS[tag] || [];
|
|
var attrs = Array.prototype.slice.call(child.attributes);
|
|
for (var j = 0; j < attrs.length; j++) {
|
|
if (allowed.indexOf(attrs[j].name) === -1) {
|
|
child.removeAttribute(attrs[j].name);
|
|
}
|
|
}
|
|
|
|
// For <a> tags, validate href is not javascript:
|
|
if (tag === 'a') {
|
|
var href = (child.getAttribute('href') || '').trim().toLowerCase();
|
|
if (href.indexOf('javascript:') === 0 || href.indexOf('data:') === 0 || href.indexOf('vbscript:') === 0) {
|
|
child.setAttribute('href', '#');
|
|
}
|
|
child.setAttribute('rel', 'noopener noreferrer');
|
|
child.setAttribute('target', '_blank');
|
|
}
|
|
|
|
clean(child);
|
|
}
|
|
}
|
|
|
|
clean(div);
|
|
return div.innerHTML;
|
|
}
|
|
|
|
})();
|