';
}).join('');
}
function loadContent(slug) {
fetch('/api/learning/content/' + encodeURIComponent(slug), { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) { showToast(data.error || 'Content not found', 'error'); return; }
currentContent = data.content;
showViewer(data.content);
})
.catch(function() { showToast('Failed to load content', 'error'); });
}
function showFeed() {
currentView = 'feed';
var feedEl = document.getElementById('lh-feed');
var viewerEl = document.getElementById('lh-viewer');
var catsEl = document.getElementById('lh-categories');
var searchEl = document.getElementById('lh-search');
// Use style.display — classList.add('hidden') is overridden by .lh-feed{display:flex}
// and .lh-category-bar{display:flex} which are defined later in CSS
if (feedEl) feedEl.style.display = 'flex';
if (viewerEl) viewerEl.style.display = 'none';
if (catsEl) catsEl.style.display = 'flex';
if (searchEl) { var sc = searchEl.closest('.card'); if (sc) sc.style.display = ''; }
}
function showViewer(item) {
currentView = 'viewer';
var feedEl = document.getElementById('lh-feed');
var viewerEl = document.getElementById('lh-viewer');
var catsEl = document.getElementById('lh-categories');
var searchEl = document.getElementById('lh-search');
if (feedEl) feedEl.style.display = 'none';
if (viewerEl) viewerEl.style.display = 'block';
if (catsEl) catsEl.style.display = 'none';
if (searchEl) { var sc = searchEl.closest('.card'); if (sc) sc.style.display = 'none'; }
var titleEl = document.getElementById('lh-viewer-title');
var metaEl = document.getElementById('lh-viewer-meta');
var bodyEl = document.getElementById('lh-viewer-body');
var quizSection = document.getElementById('lh-quiz-section');
var resultsSection = document.getElementById('lh-quiz-results');
var progressSection = document.getElementById('lh-progress-section');
if (titleEl) titleEl.textContent = item.title;
if (metaEl) {
var parts = [];
if (item.category_name) parts.push(item.category_name);
if (item.subject) parts.push(item.subject);
if (item.author_name) parts.push('by ' + item.author_name);
if (item.created_at) parts.push(new Date(item.created_at).toLocaleDateString());
metaEl.textContent = parts.join(' | ');
}
var presViewer = document.getElementById('lh-presentation-viewer');
var bodyCard = bodyEl ? bodyEl.closest('.card') : null;
if (item.content_type === 'presentation') {
// Hide body card, show presentation card with View Slides button
if (bodyCard) bodyCard.style.display = 'none';
if (presViewer) {
var slideCount = (item.body || '').split(/\n---\n/).length;
presViewer.className = ''; // remove hidden
presViewer.innerHTML =
'
';
}).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 = ' Submitted'; }
resultsEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function setActiveCatPill(slug) {
document.querySelectorAll('.lh-cat-pill').forEach(function(p) {
p.classList.toggle('active', p.dataset.slug === slug);
});
}
// ============================================================
// ADMIN CMS
// ============================================================
function wireCmsTypeSelect() {
var typeEl = document.getElementById('lh-cms-edit-type');
if (typeEl) {
typeEl.addEventListener('change', function() { toggleEditorMode(typeEl.value); });
}
}
function wireCmsFileInput() {
var fileInput = document.getElementById('lh-ai-file');
var fileLabel = document.getElementById('lh-ai-file-label');
var filesList = document.getElementById('lh-ai-files-list');
var dropzone = document.getElementById('lh-ai-dropzone');
function updateFilesList() {
if (!fileInput || !fileLabel || !filesList) return;
var files = fileInput.files;
if (files.length === 0) {
fileLabel.textContent = 'Drop files here or click to browse';
filesList.style.display = 'none';
filesList.innerHTML = '';
} else if (files.length === 1) {
fileLabel.textContent = files[0].name;
filesList.style.display = 'none';
filesList.innerHTML = '';
} else {
fileLabel.textContent = files.length + ' files selected';
filesList.style.display = 'block';
filesList.innerHTML = '
' +
Array.from(files).map(function(f) {
return '
' + f.name + '
';
}).join('') + '
';
}
}
if (fileInput) {
fileInput.addEventListener('change', updateFilesList);
}
if (dropzone) {
dropzone.addEventListener('dragover', function(e) { e.preventDefault(); dropzone.style.borderColor = 'var(--blue)'; });
dropzone.addEventListener('dragleave', function() { dropzone.style.borderColor = ''; });
dropzone.addEventListener('drop', function(e) {
e.preventDefault();
dropzone.style.borderColor = '';
var files = e.dataTransfer.files;
if (files.length > 0 && fileInput) {
// Transfer to file input via DataTransfer
try {
var dt = new DataTransfer();
for (var i = 0; i < files.length && i < 10; i++) {
dt.items.add(files[i]);
}
fileInput.files = dt.files;
updateFilesList();
} catch(ex) {}
}
});
}
}
function loadCms() {
loadCmsCategories();
loadCmsContent();
loadCmsStats();
}
// ============================================================
// AI GENERATE PANEL
// ============================================================
var _aiWebdavCurrentPath = '/';
var _aiWebdavSelectedPath = '';
var _aiWebdavSelectedName = '';
function openAiPanel() {
var panel = document.getElementById('lh-ai-panel');
if (!panel) return;
panel.classList.remove('hidden');
populateAiModelSelect();
// Sync content type selector with the editor's current type
var editorType = (document.getElementById('lh-cms-edit-type') || {}).value || 'article';
var ctype = document.getElementById('lh-ai-ctype');
if (ctype) ctype.value = editorType;
updateAiOptions(editorType);
// Hide WebDAV tab if Nextcloud not configured
fetch('/api/auth/me', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(d) {
var tab = document.getElementById('lh-ai-tab-webdav');
if (tab) tab.style.display = (d.user && d.user.nextcloud_url) ? '' : 'none';
if (d.user && d.user.webdav_learning_path) _aiWebdavCurrentPath = d.user.webdav_learning_path || '/';
});
// Wire ctype change once (use a flag so it doesn't stack on re-opens)
var ctypeEl = document.getElementById('lh-ai-ctype');
var toggleEl = document.getElementById('lh-ai-q-toggle');
if (ctypeEl && !ctypeEl._wired) {
ctypeEl.addEventListener('change', function() { updateAiOptions(ctypeEl.value); });
ctypeEl._wired = true;
}
if (toggleEl && !toggleEl._wired) {
toggleEl.addEventListener('change', function() { updateAiOptions((document.getElementById('lh-ai-ctype') || {}).value); });
toggleEl._wired = true;
}
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function updateAiOptions(type) {
var wordsField = document.getElementById('lh-ai-words-field');
var slidesField = document.getElementById('lh-ai-slides-field');
var quizRow = document.getElementById('lh-ai-quiz-row');
var cardTitle = document.getElementById('lh-ai-quiz-card-title');
var toggleLabel = document.getElementById('lh-ai-q-toggle-label');
var toggleCb = document.getElementById('lh-ai-q-toggle');
var countWrap = document.getElementById('lh-ai-q-count-wrap');
// Use style.display directly — classList.toggle('hidden') doesn't work because
// .lh-ai-opt-field { display:flex } is defined later in CSS and overrides .hidden
if (wordsField) wordsField.style.display = (type === 'quiz' || type === 'presentation') ? 'none' : 'flex';
if (slidesField) slidesField.style.display = (type === 'presentation') ? 'flex' : 'none';
if (type === 'quiz') {
// Quiz: questions are the whole point — show the card prominently, no toggle needed
if (quizRow) quizRow.style.display = 'block';
if (cardTitle) cardTitle.textContent = 'Quiz Questions';
if (toggleLabel) toggleLabel.style.display = 'none';
if (toggleCb) { toggleCb.checked = true; toggleCb.disabled = true; }
if (countWrap) countWrap.style.display = 'flex';
} else if (type === 'presentation') {
// Presentation: optional questions (same as article)
if (quizRow) quizRow.style.display = 'block';
if (cardTitle) cardTitle.textContent = 'Quiz Questions (optional)';
if (toggleLabel) toggleLabel.style.display = 'flex';
if (toggleCb) toggleCb.disabled = false;
var checkedP = toggleCb ? toggleCb.checked : false;
if (countWrap) countWrap.style.display = checkedP ? 'flex' : 'none';
} else {
// Article / pearl: optional questions via toggle
if (quizRow) quizRow.style.display = 'block';
if (cardTitle) cardTitle.textContent = 'Quiz Questions (optional)';
if (toggleLabel) toggleLabel.style.display = 'flex';
if (toggleCb) toggleCb.disabled = false;
var checked = toggleCb ? toggleCb.checked : false;
if (countWrap) countWrap.style.display = checked ? 'flex' : 'none';
}
}
function closeAiPanel() {
var panel = document.getElementById('lh-ai-panel');
if (panel) panel.classList.add('hidden');
}
function populateAiModelSelect() {
var sel = document.getElementById('lh-ai-model');
if (!sel || sel.options.length > 1) return;
// Copy options from global model select
var globalSel = document.getElementById('global-model-select');
if (!globalSel) return;
sel.innerHTML = globalSel.innerHTML;
sel.value = globalSel.value;
}
function switchAiTab(tabName) {
document.querySelectorAll('.lh-ai-tab').forEach(function(t) { t.classList.toggle('active', t.dataset.aitab === tabName); });
document.querySelectorAll('.lh-ai-tabpanel').forEach(function(p) { p.classList.add('hidden'); });
var tp = document.getElementById('lh-ai-tp-' + tabName);
if (tp) tp.classList.remove('hidden');
if (tabName === 'webdav' && !document.querySelector('#lh-ai-webdav-list .lh-webdav-item')) {
browseWebdav(_aiWebdavCurrentPath);
}
}
// ── WebDAV browser ──────────────────────────────────────────
function browseWebdav(path) {
_aiWebdavCurrentPath = path || '/';
var list = document.getElementById('lh-ai-webdav-list');
var pathLabel = document.getElementById('lh-ai-webdav-path-label');
if (list) list.innerHTML = '
Loading...
';
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 = '
' + esc(data.error) + '
'; return; }
if (pathLabel) pathLabel.textContent = data.path;
renderWebdavList(data);
})
.catch(function(err) { if (list) list.innerHTML = '
' + esc(err.message) + '
'; });
}
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 += '
' +
' ..
';
}
if (data.items.length === 0 && html === '') {
html = '
Empty folder
';
}
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) ? ' (' + formatBytes(item.size) + ')' : '';
html += '
' +
'' +
'' + esc(item.name) + '' + sizeStr +
'
';
});
list.innerHTML = html;
}
function getFileIcon(mime, name) {
if (!name) name = '';
var ext = name.split('.').pop().toLowerCase();
if (mime && mime.includes('pdf') || ext === 'pdf') return 'fa-file-pdf';
if (['doc','docx'].includes(ext)) return 'fa-file-word';
if (['txt','md'].includes(ext)) return 'fa-file-lines';
if (['html','htm'].includes(ext)) return 'fa-file-code';
return 'fa-file';
}
function formatBytes(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024*1024) return (bytes/1024).toFixed(0) + ' KB';
return (bytes/1024/1024).toFixed(1) + ' MB';
}
function selectWebdavFile(path, name) {
_aiWebdavSelectedPath = path;
_aiWebdavSelectedName = name;
var browser = document.getElementById('lh-ai-webdav-browser');
var selDiv = document.getElementById('lh-ai-webdav-selected');
var selName = document.getElementById('lh-ai-webdav-selected-name');
// Hide browser, show selected indicator (use style.display — class="hidden" is overridden by inline styles)
if (browser) browser.style.display = 'none';
if (selDiv) selDiv.style.display = 'flex';
if (selName) selName.textContent = name;
}
function deselectWebdavFile() {
_aiWebdavSelectedPath = '';
_aiWebdavSelectedName = '';
var browser = document.getElementById('lh-ai-webdav-browser');
var selDiv = document.getElementById('lh-ai-webdav-selected');
if (selDiv) selDiv.style.display = 'none';
if (browser) browser.style.display = '';
}
// ── Generate ────────────────────────────────────────────────
function runAiGenerate() {
var activeTab = document.querySelector('.lh-ai-tab.active');
var tabName = activeTab ? activeTab.dataset.aitab : 'topic';
var model = document.getElementById('lh-ai-model') ? document.getElementById('lh-ai-model').value : getSelectedModel();
var contentType = document.getElementById('lh-ai-ctype') ? document.getElementById('lh-ai-ctype').value : 'article';
var refinement = document.getElementById('lh-ai-refinement') ? document.getElementById('lh-ai-refinement').value.trim() : '';
var toggleCb = document.getElementById('lh-ai-q-toggle');
var generateQs = contentType === 'quiz' || (toggleCb && toggleCb.checked);
var questionCount = generateQs ? (document.getElementById('lh-ai-q-count') ? document.getElementById('lh-ai-q-count').value : 5) : 0;
var wordCount = document.getElementById('lh-ai-word-count') ? document.getElementById('lh-ai-word-count').value : '';
var slideCount = document.getElementById('lh-ai-slide-count') ? document.getElementById('lh-ai-slide-count').value : '';
var formData = new FormData();
formData.append('model', model);
formData.append('questionCount', questionCount);
formData.append('contentType', contentType);
if (refinement) formData.append('refinement', refinement);
if (wordCount) formData.append('wordCount', wordCount);
if (slideCount) formData.append('slideCount', slideCount);
if (tabName === 'topic') {
var topic = document.getElementById('lh-ai-topic') ? document.getElementById('lh-ai-topic').value.trim() : '';
if (!topic) { showToast('Describe what you want AI to create', 'error'); return; }
formData.append('topic', topic);
} else if (tabName === 'upload') {
var fileInput = document.getElementById('lh-ai-file');
if (!fileInput || fileInput.files.length === 0) { showToast('Select at least one file to upload', 'error'); return; }
for (var i = 0; i < fileInput.files.length; i++) {
formData.append('files', fileInput.files[i]);
}
var uploadCtx = document.getElementById('lh-ai-upload-context');
if (uploadCtx && uploadCtx.value.trim()) formData.append('topic', uploadCtx.value.trim());
} else if (tabName === 'webdav') {
if (!_aiWebdavSelectedPath) { showToast('Select a file from Nextcloud', 'error'); return; }
formData.append('webdavPath', _aiWebdavSelectedPath);
var webdavCtx = document.getElementById('lh-ai-webdav-context');
if (webdavCtx && webdavCtx.value.trim()) formData.append('topic', webdavCtx.value.trim());
}
var btn = document.getElementById('btn-lh-ai-generate');
if (btn) { btn.disabled = true; btn.innerHTML = ' Generating...'; }
showBusy('AI is generating content...');
// FormData: no Content-Type header (browser sets it with boundary)
var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '';
fetch('/api/admin/learning/ai-generate', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: formData
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideBusy();
if (btn) { btn.disabled = false; btn.innerHTML = ' Generate Content'; }
if (!data.success) { showToast(data.error || 'Generation failed', 'error'); return; }
// Presentation returns marpMarkdown directly; others return content{}
var payload = data.contentType === 'presentation' ? data : data.content;
applyAiContent(payload, contentType);
closeAiPanel();
showToast('Content generated! Review and save.', 'success');
})
.catch(function(err) {
hideBusy();
if (btn) { btn.disabled = false; btn.innerHTML = ' Generate Content'; }
showToast(err.message, 'error');
});
}
function applyAiContent(content, contentType) {
// Set content type first so toggleEditorMode works correctly
var typeEl = document.getElementById('lh-cms-edit-type');
if (typeEl && contentType) { typeEl.value = contentType; toggleEditorMode(contentType); }
// Presentation: fill Marp textarea
if (contentType === 'presentation' && content.marpMarkdown) {
var marpTa = document.getElementById('lh-marp-editor');
if (marpTa) marpTa.value = content.marpMarkdown;
// Extract title from first # heading
var titleMatch = content.marpMarkdown.match(/^#\s+(.+)$/m);
var titleEl = document.getElementById('lh-cms-edit-title');
if (titleEl && titleMatch) titleEl.value = titleMatch[1];
// Fill questions if any were generated alongside slides
var qContainerP = document.getElementById('lh-cms-questions');
if (qContainerP) {
qContainerP.innerHTML = '';
if (content.questions && Array.isArray(content.questions)) {
content.questions.forEach(function(q) { addQuestionBlock(q); });
}
}
return;
}
// Fill title
var titleEl2 = document.getElementById('lh-cms-edit-title');
if (titleEl2 && content.title) titleEl2.value = content.title;
// Fill subject
var subjectEl = document.getElementById('lh-cms-edit-subject');
if (subjectEl && content.subject) subjectEl.value = content.subject;
// Fill body via Tiptap
if (_bodyEditor && content.body) _bodyEditor.commands.setContent(content.body);
// Replace questions
var qContainer = document.getElementById('lh-cms-questions');
if (qContainer) {
qContainer.innerHTML = '';
if (content.questions && Array.isArray(content.questions)) {
content.questions.forEach(function(q) { addQuestionBlock(q); });
}
}
}
function toggleRefineBar(forceShow) {
var bar = document.getElementById('lh-ai-refine-bar');
var input = document.getElementById('lh-ai-refine-input');
if (!bar) return;
var show = forceShow !== undefined ? forceShow : bar.style.display === 'none';
bar.style.display = show ? 'flex' : 'none';
if (show && input) { input.value = ''; input.focus(); }
}
function submitRefineBody() {
var input = document.getElementById('lh-ai-refine-input');
var instructions = input ? input.value.trim() : '';
if (!instructions) { showToast('Enter instructions for refinement', 'error'); return; }
var currentBody = _bodyEditor ? getTpHTML(_bodyEditor) : '';
if (!currentBody) { showToast('No body content to refine', 'error'); return; }
var model = document.getElementById('lh-ai-model') ? document.getElementById('lh-ai-model').value : getSelectedModel();
toggleRefineBar(false);
showBusy('Refining content...');
fetch('/api/admin/learning/ai-refine', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ content: currentBody, instructions: instructions, model: model })
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideBusy();
if (!data.success) { showToast(data.error || 'Refine failed', 'error'); return; }
if (_bodyEditor) _bodyEditor.commands.setContent(data.refined);
showToast('Body refined!', 'success');
})
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
}
function loadCmsStats() {
fetch('/api/admin/learning/stats', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) return;
var s = data.stats;
var set = function(id, val) { var el = document.getElementById(id); if (el) el.textContent = val; };
set('cms-stat-published', s.publishedContent);
set('cms-stat-drafts', s.totalContent - s.publishedContent);
set('cms-stat-categories', s.totalCategories);
set('cms-stat-quizzes', s.totalQuizzes);
set('cms-stat-attempts', s.totalAttempts);
});
}
function loadCmsCategories() {
fetch('/api/admin/learning/categories', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) return;
var container = document.getElementById('lh-cms-categories');
if (!container) return;
if (data.categories.length === 0) {
container.innerHTML = '
';
}
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) + '';
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 === '' || html === '') ? '' : html;
}
// ── Body editor ────────────────────────────────────────────
function initBodyEditor() {
var wrap = document.getElementById('lh-body-editor');
if (!wrap || _bodyEditor) return;
_bodyEditor = makeTpEditor(wrap, '', false, false);
}
// ── Rich text toggle for questions / options ───────────────
function toggleRichText(btn) {
var isActive = btn.classList.contains('active');
var target = btn.dataset.target;
var block = btn.closest(target === 'question' ? '.lh-question-block' : '.lh-option-row');
if (!block) return;
if (target === 'question') {
var ta = block.querySelector('.lh-q-text');
var wrap = block.querySelector('.lh-q-richtext-wrap');
if (!isActive) {
var val = ta ? ta.value : '';
btn.classList.add('active');
btn.textContent = 'Plain Text';
if (ta) ta.style.display = 'none';
if (!wrap) {
wrap = document.createElement('div');
wrap.className = 'lh-q-richtext-wrap';
ta.parentNode.insertBefore(wrap, ta.nextSibling);
}
wrap.style.display = '';
if (!block._questionEditor) block._questionEditor = makeTpEditor(wrap, val, true, false);
} else {
btn.classList.remove('active');
btn.textContent = 'Rich Text';
if (block._questionEditor) {
if (ta) { ta.value = getTpHTML(block._questionEditor); ta.style.display = ''; }
}
if (wrap) wrap.style.display = 'none';
}
} else {
var row = block;
var optInput = row.querySelector('.lh-opt-text');
var optWrap = row.querySelector('.lh-opt-richtext-wrap');
if (!isActive) {
var curVal = optInput ? optInput.value : '';
btn.classList.add('active');
btn.textContent = 'Plain';
if (optInput) optInput.style.display = 'none';
if (!optWrap) {
optWrap = document.createElement('div');
optWrap.className = 'lh-opt-richtext-wrap';
optInput.parentNode.insertBefore(optWrap, optInput.nextSibling);
}
optWrap.style.display = '';
if (!row._optEditor) row._optEditor = makeTpEditor(optWrap, curVal, true, true);
} else {
btn.classList.remove('active');
btn.textContent = 'Rich';
if (row._optEditor) {
if (optInput) { optInput.value = getTpHTML(row._optEditor); optInput.style.display = ''; }
}
if (optWrap) optWrap.style.display = 'none';
}
}
}
function getQText(block) {
var wrap = block.querySelector('.lh-q-richtext-wrap');
if (block._questionEditor && wrap && wrap.style.display !== 'none') return getTpHTML(block._questionEditor);
var ta = block.querySelector('.lh-q-text');
return ta ? ta.value.trim() : '';
}
function getOptText(row) {
var wrap = row.querySelector('.lh-opt-richtext-wrap');
if (row._optEditor && wrap && wrap.style.display !== 'none') return getTpHTML(row._optEditor);
var inp = row.querySelector('.lh-opt-text');
return inp ? inp.value.trim() : '';
}
function openEditor(contentId, contentType) {
var editor = document.getElementById('lh-cms-editor');
var listView = document.getElementById('cms-list-view');
if (!editor) return;
editor.classList.remove('hidden');
if (listView) listView.classList.add('hidden');
// Reset
document.getElementById('lh-cms-edit-id').value = '';
document.getElementById('lh-cms-edit-title').value = '';
document.getElementById('lh-cms-edit-subject').value = '';
document.getElementById('lh-cms-edit-category').value = '';
document.getElementById('lh-cms-edit-type').value = contentType || 'article';
document.getElementById('lh-cms-edit-published').value = 'false';
document.getElementById('lh-cms-questions').innerHTML = '';
document.getElementById('btn-lh-delete-content').classList.add('hidden');
hideDeleteConfirm();
toggleEditorMode(contentType || 'article');
initBodyEditor();
if (_bodyEditor) _bodyEditor.commands.clearContent(true);
var marpTa = document.getElementById('lh-marp-editor');
if (marpTa) marpTa.value = '';
// For quiz type, add a starter question
if (contentType === 'quiz' && !contentId) {
addQuestionBlock();
}
if (!contentId) return;
// Load existing content
fetch('/api/admin/learning/content/' + contentId, { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) { showToast('Failed to load', 'error'); return; }
var c = data.content;
document.getElementById('lh-cms-edit-id').value = c.id;
document.getElementById('lh-cms-edit-title').value = c.title;
document.getElementById('lh-cms-edit-subject').value = c.subject || '';
document.getElementById('lh-cms-edit-category').value = c.category_id || '';
document.getElementById('lh-cms-edit-type').value = c.content_type || 'article';
document.getElementById('lh-cms-edit-published').value = c.published ? 'true' : 'false';
document.getElementById('btn-lh-delete-content').classList.remove('hidden');
toggleEditorMode(c.content_type || 'article');
if (c.content_type === 'presentation') {
var marpEl = document.getElementById('lh-marp-editor');
if (marpEl) marpEl.value = c.body || '';
} else {
if (_bodyEditor) _bodyEditor.commands.setContent(c.body || '');
}
// Load questions
var qContainer = document.getElementById('lh-cms-questions');
qContainer.innerHTML = '';
if (c.questions && c.questions.length > 0) {
c.questions.forEach(function(q) { addQuestionBlock(q); });
}
editor.scrollIntoView({ behavior: 'smooth' });
});
}
function toggleEditorMode(contentType) {
var bodySection = document.getElementById('lh-body-section');
var marpSection = document.getElementById('lh-marp-section');
var isPresentation = contentType === 'presentation';
if (bodySection) bodySection.classList.toggle('hidden', isPresentation);
if (marpSection) marpSection.classList.toggle('hidden', !isPresentation);
}
function closeEditor() {
var editor = document.getElementById('lh-cms-editor');
var listView = document.getElementById('cms-list-view');
if (editor) editor.classList.add('hidden');
if (listView) listView.classList.remove('hidden');
hideDeleteConfirm();
}
function saveContent() {
var id = document.getElementById('lh-cms-edit-id').value;
var title = document.getElementById('lh-cms-edit-title').value.trim();
var subject = document.getElementById('lh-cms-edit-subject').value.trim();
var content_type = document.getElementById('lh-cms-edit-type').value;
var body = content_type === 'presentation'
? (document.getElementById('lh-marp-editor') ? document.getElementById('lh-marp-editor').value : '')
: (_bodyEditor ? getTpHTML(_bodyEditor) : '');
var category_id = document.getElementById('lh-cms-edit-category').value || null;
var published = document.getElementById('lh-cms-edit-published').value === 'true';
if (!title) { showToast('Title required', 'error'); return; }
// Gather questions with validation
var questions = [];
var validationError = false;
var qBlocks = document.querySelectorAll('#lh-cms-questions .lh-question-block');
for (var qi = 0; qi < qBlocks.length; qi++) {
var qBlock = qBlocks[qi];
var qText = getQText(qBlock);
if (!qText) continue;
var qType = qBlock.querySelector('.lh-q-type').value;
var qExpl = qBlock.querySelector('.lh-q-explanation').value.trim();
var qId = qBlock.dataset.questionId || null;
var options = [];
qBlock.querySelectorAll('.lh-option-row').forEach(function(row) {
var optText = getOptText(row);
if (!optText) return;
options.push({
option_text: optText,
is_correct: row.querySelector('.lh-opt-correct').checked,
explanation: row.querySelector('.lh-opt-expl').value.trim()
});
});
// Validate: must have a correct answer marked
var hasCorrect = options.some(function(o) { return o.is_correct; });
if (options.length > 0 && !hasCorrect) {
showToast('Question "' + qText.substring(0, 50) + '..." needs a correct answer marked', 'error');
qBlock.style.outline = '2px solid var(--red)';
qBlock.scrollIntoView({ behavior: 'smooth', block: 'center' });
validationError = true;
break;
}
qBlock.style.outline = '';
questions.push({
id: qId,
question_text: qText,
question_type: qType,
explanation: qExpl,
options: options
});
}
if (validationError) return;
var payload = { title: title, body: body, category_id: category_id, subject: subject, content_type: content_type, published: published };
var method = id ? 'PUT' : 'POST';
var url = id ? '/api/admin/learning/content/' + id : '/api/admin/learning/content';
showLoading('Saving...');
fetch(url, { method: method, headers: getAuthHeaders(), body: JSON.stringify(payload) })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) { hideLoading(); showToast(data.error || 'Save failed', 'error'); return; }
var contentId = id || data.id;
// Save questions sequentially
saveQuestions(contentId, questions, 0, function() {
hideLoading();
showToast('Content saved', 'success');
closeEditor();
loadCmsContent();
loadCmsStats();
loaded = false; // force refresh user feed
});
})
.catch(function() { hideLoading(); showToast('Save failed', 'error'); });
}
function saveQuestions(contentId, questions, index, callback) {
if (index >= questions.length) { callback(); return; }
var q = questions[index];
var qId = q.id;
var method, url;
if (qId) {
method = 'PUT';
url = '/api/admin/learning/questions/' + qId;
} else {
method = 'POST';
url = '/api/admin/learning/content/' + contentId + '/questions';
}
fetch(url, {
method: method,
headers: getAuthHeaders(),
body: JSON.stringify({
question_text: q.question_text,
question_type: q.question_type,
explanation: q.explanation,
options: q.options
})
})
.then(function(r) { return r.json(); })
.then(function() {
saveQuestions(contentId, questions, index + 1, callback);
})
.catch(function() {
saveQuestions(contentId, questions, index + 1, callback);
});
}
function showDeleteConfirm() {
var id = document.getElementById('lh-cms-edit-id').value;
if (!id) return;
var title = (document.getElementById('lh-cms-edit-title').value || 'this content').substring(0, 60);
var ctype = (document.getElementById('lh-cms-edit-type') || {}).value || 'article';
var bar = document.getElementById('lh-delete-confirm-bar');
var msgEl = document.getElementById('lh-delete-confirm-msg');
if (!bar) return;
// Build wording based on content type
var typeLabel = { article: 'article', quiz: 'quiz', pearl: 'clinical pearl', presentation: 'presentation' }[ctype] || 'content';
var consequence = '';
if (ctype === 'quiz') consequence = ' All quiz questions will be permanently removed.';
else if (ctype === 'article' || ctype === 'pearl') consequence = ' Any quiz questions attached to it will also be removed.';
// Presentation has no questions → no extra wording
if (msgEl) msgEl.innerHTML = 'Delete the ' + typeLabel + ' "' + esc(title) + '"?' + consequence + ' This cannot be undone.';
bar.classList.remove('hidden');
bar.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function hideDeleteConfirm() {
var bar = document.getElementById('lh-delete-confirm-bar');
if (bar) bar.classList.add('hidden');
}
function confirmDelete() {
var id = document.getElementById('lh-cms-edit-id').value;
if (!id) return;
hideDeleteConfirm();
fetch('/api/admin/learning/content/' + id, { method: 'DELETE', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
showToast('Content deleted', 'info');
closeEditor();
loadCmsContent();
loadCmsStats();
loaded = false;
} else showToast(data.error || 'Failed', 'error');
});
}
// ── Presentation / Marp ──────────────────────────────────────
function getMarpMarkdown() {
var ta = document.getElementById('lh-marp-editor');
return ta ? ta.value.trim() : '';
}
// ── Slide preview modal ──────────────────────────────────────
var _previewSlides = [];
var _previewIdx = 0;
// Open slide modal for a published presentation in the user-facing viewer
function openSlidesFromSlug(slug) {
showBusy('Loading slides...');
fetch('/api/learning/content/' + encodeURIComponent(slug) + '/slides', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
hideBusy();
if (!data.success) { showToast(data.error || 'Could not load slides', 'error'); return; }
openSlideModal(data.slides, data.css);
})
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
}
function previewSlides() {
var md = getMarpMarkdown();
if (!md) { showToast('No slide content yet. Use AI Generate first.', 'info'); return; }
showBusy('Rendering slides...');
fetch('/api/admin/learning/preview-slides', {
method: 'POST', headers: getAuthHeaders(),
body: JSON.stringify({ markdown: md })
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideBusy();
if (!data.success) { showToast(data.error || 'Preview failed', 'error'); return; }
openSlideModal(data.slides, data.css);
})
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
}
function openSlideModal(slides, css) {
_previewSlides = slides || [];
_previewIdx = 0;
var modal = document.getElementById('slide-preview-modal');
var cssEl = document.getElementById('slide-preview-css');
var dotsEl = document.getElementById('slide-preview-dots');
if (!modal) return;
// Inject Marp CSS once (scoped to preview frame)
if (cssEl) cssEl.textContent = css || '';
// Build dot indicators
if (dotsEl) {
dotsEl.innerHTML = _previewSlides.map(function(_, i) {
return '';
}).join('');
}
modal.classList.remove('hidden');
document.body.style.overflow = 'hidden';
renderSlide(0);
// Keyboard navigation
document._slideKeyHandler = function(e) {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') slideStep(1);
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') slideStep(-1);
else if (e.key === 'Escape') closeSlideModal();
};
document.addEventListener('keydown', document._slideKeyHandler);
// Touch/swipe
var touchStartX = 0;
modal._touchStart = function(e) { touchStartX = e.touches[0].clientX; };
modal._touchEnd = function(e) {
var dx = e.changedTouches[0].clientX - touchStartX;
if (Math.abs(dx) > 40) slideStep(dx < 0 ? 1 : -1);
};
modal.addEventListener('touchstart', modal._touchStart);
modal.addEventListener('touchend', modal._touchEnd);
}
function renderSlide(idx) {
var content = document.getElementById('slide-preview-content');
var counter = document.getElementById('slide-preview-counter');
var dots = document.querySelectorAll('.slide-dot');
var prev = document.getElementById('slide-nav-prev');
var next = document.getElementById('slide-nav-next');
if (!content || !_previewSlides.length) return;
_previewIdx = Math.max(0, Math.min(idx, _previewSlides.length - 1));
content.innerHTML = _previewSlides[_previewIdx];
if (counter) counter.textContent = (_previewIdx + 1) + ' / ' + _previewSlides.length;
if (prev) prev.style.opacity = _previewIdx === 0 ? '0.3' : '1';
if (next) next.style.opacity = _previewIdx === _previewSlides.length - 1 ? '0.3' : '1';
dots.forEach(function(d, i) { d.classList.toggle('active', i === _previewIdx); });
}
function slideStep(dir) { renderSlide(_previewIdx + dir); }
function closeSlideModal() {
var modal = document.getElementById('slide-preview-modal');
if (modal) modal.classList.add('hidden');
document.body.style.overflow = '';
if (document._slideKeyHandler) {
document.removeEventListener('keydown', document._slideKeyHandler);
document._slideKeyHandler = null;
}
}
// slide modal nav is wired via the main document click delegation below
function downloadPptx() {
var md = getMarpMarkdown();
if (!md) { showToast('No slide content yet', 'error'); return; }
var title = document.getElementById('lh-cms-edit-title').value || 'presentation';
var btn = document.getElementById('btn-lh-download-pptx');
if (btn) { btn.disabled = true; btn.innerHTML = ' Building...'; }
fetch('/api/admin/learning/generate-pptx', {
method: 'POST', headers: getAuthHeaders(),
body: JSON.stringify({ markdown: md, title: title })
})
.then(function(r) {
if (!r.ok) return r.json().then(function(e) { throw new Error(e.error); });
return r.blob();
})
.then(function(blob) {
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = title.replace(/\s+/g, '-').toLowerCase() + '.pptx';
a.click();
URL.revokeObjectURL(url);
showToast('PPTX downloaded!', 'success');
})
.catch(function(err) { showToast(err.message, 'error'); })
.finally(function() {
if (btn) { btn.disabled = false; btn.innerHTML = ' Download PPTX'; }
});
}
function addQuestionBlock(existingQ) {
var container = document.getElementById('lh-cms-questions');
if (!container) return;
var block = document.createElement('div');
block.className = 'lh-question-block';
if (existingQ && existingQ.id) block.dataset.questionId = existingQ.id;
var qNum = container.querySelectorAll('.lh-question-block').length + 1;
block.innerHTML =
'
' +
'
' +
'Q' + qNum + '' +
'' +
'' +
'
' +
'' +
'
' +
'' +
'
' +
'' +
'
' +
'' +
'' +
'
' +
'' +
'' +
'
';
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 =
'' +
'
' +
'' +
'' +
'
' +
'' +
'';
container.appendChild(row);
// Set option text value after insertion (can't use value="" on nested input via innerHTML)
row.querySelector('.lh-opt-text').value = opt ? opt.option_text || '' : '';
}
// ── Helpers ──────────────────────────────────────────────────
function esc(str) {
if (!str) return '';
return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
function sanitizeHtml(html) {
// DOMPurify is loaded from cdnjs via