pediatric-ai-scribe-v3/public/js/learningHub.js
Daniel Onyejesi 936ad7e8a0 Add AI content generation to Learning Hub CMS
Backend (src/routes/learningAI.js):
- POST /api/admin/learning/ai-generate
  * Accepts: topic text, uploaded file (PDF/TXT/MD/HTML), or Nextcloud WebDAV path
  * Extracts text from PDFs via pdf-parse; plain text read directly
  * Fetches WebDAV files using stored Nextcloud credentials
  * Prompts AI to return structured JSON: title, subject, body (HTML), questions[]
  * Strips code fences, falls back to regex JSON extraction if needed
- POST /api/admin/learning/ai-refine — refine existing body with instructions
- GET  /api/admin/learning/webdav-browse — PROPFIND-based Nextcloud file browser
- POST /api/admin/learning/webdav-path — save user's default browse path

Frontend:
- AI Generate button in CMS editor header (gradient purple→blue)
- Panel with 3 source tabs: By Topic / Upload File / Nextcloud
- Drag-and-drop file dropzone with DataTransfer API support
- WebDAV file browser: navigate folders, select files, breadcrumb path
- Options: question count, content type (article/quiz/pearl), model selector, instructions
- Refine Current Body: prompt-driven AI rewrite of existing content
- Generated content auto-fills title, subject, type, Tiptap body, quiz questions

Settings:
- Nextcloud section: "Learning Hub Default Browse Path" field (shown when connected)
- Saves to webdav_learning_path column via /api/admin/learning/webdav-path

DB: ALTER TABLE users ADD COLUMN IF NOT EXISTS webdav_learning_path TEXT
2026-03-24 00:17:28 -04:00

1414 lines
63 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; }
}
// Load CMS when Content Manager tab is opened
if (e.detail && e.detail.tab === 'cms') {
if (!cmsLoaded) { loadCms(); cmsLoaded = true; wireCmsFileInput(); }
}
});
// ── 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, '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-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; }
// 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')) { runAiRefineBody(); 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; }
});
// 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 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 wireCmsFileInput() {
var fileInput = document.getElementById('lh-ai-file');
var fileLabel = document.getElementById('lh-ai-file-label');
var dropzone = document.getElementById('lh-ai-dropzone');
if (fileInput) {
fileInput.addEventListener('change', function() {
if (fileInput.files[0] && fileLabel) fileLabel.textContent = fileInput.files[0].name;
});
}
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[0] && fileInput && fileLabel) {
// Transfer to file input via DataTransfer
try {
var dt = new DataTransfer();
dt.items.add(files[0]);
fileInput.files = dt.files;
} catch(ex) {}
fileLabel.textContent = files[0].name;
}
});
}
}
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');
// Populate model selector
populateAiModelSelect();
// 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 || '/';
}
});
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
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 selDiv = document.getElementById('lh-ai-webdav-selected');
var selName = document.getElementById('lh-ai-webdav-selected-name');
if (selDiv) selDiv.classList.remove('hidden');
if (selName) selName.textContent = name;
}
function deselectWebdavFile() {
_aiWebdavSelectedPath = '';
_aiWebdavSelectedName = '';
var selDiv = document.getElementById('lh-ai-webdav-selected');
if (selDiv) selDiv.classList.add('hidden');
}
// ── 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 questionCount = document.getElementById('lh-ai-q-count') ? document.getElementById('lh-ai-q-count').value : 5;
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 formData = new FormData();
formData.append('model', model);
formData.append('questionCount', questionCount);
formData.append('contentType', contentType);
if (refinement) formData.append('refinement', refinement);
if (tabName === 'topic') {
var topic = document.getElementById('lh-ai-topic') ? document.getElementById('lh-ai-topic').value.trim() : '';
if (!topic) { showToast('Enter a topic', 'error'); return; }
formData.append('topic', topic);
} else if (tabName === 'upload') {
var fileInput = document.getElementById('lh-ai-file');
if (!fileInput || !fileInput.files[0]) { showToast('Select a file to upload', 'error'); return; }
formData.append('file', fileInput.files[0]);
} else if (tabName === 'webdav') {
if (!_aiWebdavSelectedPath) { showToast('Select a file from Nextcloud', 'error'); return; }
formData.append('webdavPath', _aiWebdavSelectedPath);
}
var btn = document.getElementById('btn-lh-ai-generate');
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating...'; }
showLoading('AI is generating content…');
// For FormData, do NOT set Content-Type (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) {
hideLoading();
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; }
applyAiContent(data.content, contentType);
closeAiPanel();
showToast('Content generated! Review and save.', 'success');
})
.catch(function(err) {
hideLoading();
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) {
// Fill title
var titleEl = document.getElementById('lh-cms-edit-title');
if (titleEl && content.title) titleEl.value = content.title;
// Fill subject
var subjectEl = document.getElementById('lh-cms-edit-subject');
if (subjectEl && content.subject) subjectEl.value = content.subject;
// Set content type
var typeEl = document.getElementById('lh-cms-edit-type');
if (typeEl && contentType) typeEl.value = contentType;
// 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 runAiRefineBody() {
var instructions = prompt('How should the body be refined?\n(e.g., "Make it more concise", "Add a clinical case", "Focus on management")');
if (!instructions || !instructions.trim()) 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();
showLoading('Refining content…');
fetch('/api/admin/learning/ai-refine', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ content: currentBody, instructions: instructions.trim(), model: model })
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideLoading();
if (!data.success) { showToast(data.error || 'Refine failed', 'error'); return; }
if (_bodyEditor) _bodyEditor.commands.setContent(data.refined);
showToast('Body refined!', 'success');
})
.catch(function(err) { hideLoading(); 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 (!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);
}
// ── 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');
initBodyEditor();
if (_bodyEditor) _bodyEditor.commands.clearContent(true);
// 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 || '';
if (_bodyEditor) _bodyEditor.commands.setContent(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 = _bodyEditor ? getTpHTML(_bodyEditor) : '';
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 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 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;
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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'], 'li': ['class'], 'ol': ['class'], 'ul': ['class'], 'span': ['class'], 'p': ['class'] };
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;
}
})();