pediatric-ai-scribe-v3/public/js/learningHub.js
Daniel 99a72734fb
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 48s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m20s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
feat: the Learning screen can ask for grounding, and says what it got
The route accepted useCorpus and reported grounding, but nothing in the admin
screen sent the flag or showed the result — so the feature existed and was
unreachable.

Opt-out in the UI rather than opt-in. For clinical teaching the library is
nearly always the right source, so someone who never notices the checkbox
should get the grounded version. The help text explains when to turn it OFF,
which is the non-obvious case: a topic the library does not cover is better
written without grounding than padded with the nearest unrelated excerpts.

Afterwards it says what happened — "Written from 12 library excerpts", or "Not
grounded — nothing indexed matched. Written from the model alone." Ungrounded
material presented as grounded is the failure worth preventing here, so the
wording never implies the library was used when it was not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 14:39:49 +02:00

1022 lines
43 KiB
JavaScript

import { createLearningImages } from './learningHub/images.js';
// ============================================================
// LEARNING HUB — User content feed, viewer, quizzes + Admin CMS
// ============================================================
import {
clearQuestionBlocks,
destroyOptionEditor,
destroyQuestionBlockEditors,
getTpHTML,
makeTpEditor
} from './learningHub/tiptapEditor.js';
import { esc, sanitizeHtml } from './learningHub/sanitize.js';
import { deleteJson, getJson, sendJson } from './learningHub/api.js';
import { createAiPanelController } from './learningHub/aiPanelController.js';
import {
renderOptionRowShell,
renderQuestionBlockShell
} from './learningHub/cmsRenderer.js';
import { createCmsController } from './learningHub/cmsController.js';
import { renderCategoryPills, renderEmptySearchMessage, renderFeed, renderSearchHeader } from './learningHub/feedRenderer.js';
import { createQuizController } from './learningHub/quizController.js';
import { createSlideController } from './learningHub/slideController.js';
import { buildViewerMeta, renderPresentationCard, renderProgressList } from './learningHub/viewerRenderer.js';
import { createWebdavController } from './learningHub/webdavController.js';
var loaded = false;
var cmsLoaded = false;
var currentContent = null;
var currentView = 'feed'; // 'feed' | 'category' | 'viewer'
var learningImages = createLearningImages(function() { return _bodyEditor; }, function() { return (document.getElementById('lh-cms-edit-type') || {}).value === 'presentation' ? document.getElementById('lh-marp-editor') : null; });
var _bodyEditor = null; // Tiptap Editor instance for body
var slides = createSlideController({
getMarkdown: getMarpMarkdown,
getTitle: getPresentationTitle,
showBusy: function(message) { showBusy(message); },
hideBusy: function() { hideBusy(); },
showToast: function(message, type) { showToast(message, type); }
});
var cms = createCmsController({
refreshUserCategories: function() { loadCategories(); },
showToast: function(message, type) { showToast(message, type); }
});
var quiz = createQuizController({
getCurrentContent: function() { return currentContent; },
showLoading: function(message) { showLoading(message); },
hideLoading: function() { hideLoading(); },
showToast: function(message, type) { showToast(message, type); }
});
var webdav = createWebdavController({});
var aiPanel = createAiPanelController({
browseWebdav: function(path) { webdav.browse(path); },
getEditorType: function() { return (document.getElementById('lh-cms-edit-type') || {}).value; },
getWebdavPath: function() { return webdav.getCurrentPath(); },
setWebdavPath: function(path) { webdav.setCurrentPath(path); }
});
// ── Load when tab activated ────────────────────────────────
document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'learning') {
if (!loaded) { loadFeed(); loadCategories(); loaded = true; wireSearch(); }
}
// Load CMS when Content Manager tab is opened
if (e.detail && e.detail.tab === 'cms') {
if (!cmsLoaded) { loadCms(); cmsLoaded = true; wireCmsFileInput(); wireCmsTypeSelect(); }
}
});
// ── Event delegation ───────────────────────────────────────
document.addEventListener('click', function(e) {
// Slide preview modal controls (checked first — modal is outside CMS)
if (e.target.closest('#slide-preview-close') || e.target.id === 'slide-preview-overlay') { slides.closeSlideModal(); return; }
if (e.target.closest('#slide-nav-prev')) { slides.slideStep(-1); return; }
if (e.target.closest('#slide-nav-next')) { slides.slideStep(1); return; }
var dot = e.target.closest('.slide-dot');
if (dot) { slides.renderSlide(parseInt(dot.dataset.idx)); return; }
// Feed item click
var feedItem = e.target.closest('.lh-feed-item');
if (feedItem && feedItem.dataset.slug) {
loadContent(feedItem.dataset.slug);
return;
}
// Category pill click
var catPill = e.target.closest('.lh-cat-pill');
if (catPill && catPill.dataset.slug) {
if (catPill.dataset.slug === 'all') {
loadFeed();
setActiveCatPill('all');
} else {
loadCategoryContent(catPill.dataset.slug);
setActiveCatPill(catPill.dataset.slug);
}
return;
}
// Back button
if (e.target.closest('#lh-back')) {
showFeed();
return;
}
// Submit quiz
if (e.target.closest('#lh-submit-quiz')) {
quiz.submitQuiz();
return;
}
// ── CMS events ─────────────────────────────
if (e.target.closest('#btn-lh-refresh-content')) { cms.loadContent(); cms.loadStats(); return; }
if (e.target.closest('#btn-lh-new-content')) { openEditor(null, 'article'); return; }
if (e.target.closest('#btn-lh-new-quiz')) { openEditor(null, 'quiz'); return; }
if (e.target.closest('#btn-lh-new-pearl')) { openEditor(null, 'pearl'); return; }
if (e.target.closest('#btn-lh-new-presentation')) { openEditor(null, 'presentation'); return; }
if (e.target.closest('#btn-lh-close-editor')) { closeEditor(); return; }
if (e.target.closest('#btn-lh-save-content')) { saveContent(); return; }
if (e.target.closest('#btn-lh-delete-content')) { showDeleteConfirm(); return; }
if (e.target.closest('#btn-lh-delete-yes')) { confirmDelete(); return; }
if (e.target.closest('#btn-lh-delete-cancel')) { hideDeleteConfirm(); return; }
if (e.target.closest('#btn-lh-add-question')) { addQuestionBlock(); return; }
// Presentation
if (e.target.closest('#btn-lh-preview-slides')) { slides.previewCurrentSlides(); return; }
if (e.target.closest('#btn-lh-download-pptx')) { slides.downloadPptx(); return; }
// AI panel
if (e.target.closest('#btn-lh-ai-open')) { aiPanel.open(); return; }
if (e.target.closest('#btn-lh-ai-close')) { aiPanel.close(); return; }
if (e.target.closest('#btn-lh-ai-generate')) { runAiGenerate(); return; }
if (e.target.closest('#btn-lh-ai-refine-body')) { toggleRefineBar(); return; }
if (e.target.closest('#btn-lh-refine-submit')) { submitRefineBody(); return; }
if (e.target.closest('#btn-lh-refine-cancel')) { toggleRefineBar(false); return; }
if (e.target.closest('#btn-lh-webdav-refresh')) { webdav.browse(webdav.getCurrentPath()); return; }
if (e.target.closest('#btn-lh-webdav-deselect')) { webdav.deselectFile(); return; }
// AI tabs
var aiTab = e.target.closest('.lh-ai-tab');
if (aiTab && aiTab.dataset.aitab) { aiPanel.switchTab(aiTab.dataset.aitab); return; }
// WebDAV list items
var wdItem = e.target.closest('.lh-webdav-item');
if (wdItem) {
webdav.openItem(wdItem);
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) { cms.deleteCategory(delCat.dataset.id); return; }
// CMS sidebar category filter
var cmsCat = e.target.closest('.lh-cms-cat-filter');
if (cmsCat && cmsCat.dataset.id) { cms.filterByCategory(cmsCat.dataset.id); return; }
// Remove question block
var rmQ = e.target.closest('.lh-rm-question');
if (rmQ) { var qb = rmQ.closest('.lh-question-block'); if (qb) { destroyQuestionBlockEditors(qb); qb.remove(); } return; }
// 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) { destroyOptionEditor(row); row.remove(); } return; }
});
// Native form submission handles both the visible Add button and Enter.
document.addEventListener('submit', function(e) {
if (e.target.id !== 'lh-cms-add-category') return;
e.preventDefault();
cms.addCategory();
});
document.addEventListener('input', function(e) { cms.handleFilterInput(e); });
document.addEventListener('change', function(e) { cms.handleFilterChange(e); });
// wireSearch() called after component is in DOM
var _searchTimeout = null;
function wireSearch() {
var searchEl = document.getElementById('lh-search');
if (!searchEl || searchEl._wired) return;
searchEl._wired = true;
searchEl.addEventListener('input', function() {
clearTimeout(_searchTimeout);
var q = searchEl.value.trim();
if (!q) { loadFeed(); return; }
_searchTimeout = setTimeout(function() { searchContent(q); }, 350);
});
searchEl.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { searchEl.value = ''; loadFeed(); }
});
}
// ============================================================
// USER-FACING: FEED, CATEGORIES, CONTENT, QUIZZES
// ============================================================
function loadFeed() {
var feedEl = document.getElementById('lh-feed');
if (!feedEl) return;
feedEl.innerHTML = '<p style="text-align:center;color:var(--g400);padding:24px;">Loading...</p>';
showFeed();
getJson('/api/learning/feed')
.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() {
getJson('/api/learning/categories')
.then(function(data) {
if (!data.success) return;
var bar = document.getElementById('lh-categories');
if (!bar) return;
bar.innerHTML = renderCategoryPills(data.categories);
});
}
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();
getJson('/api/learning/category/' + encodeURIComponent(slug))
.then(function(data) {
if (!data.success) { feedEl.innerHTML = '<p style="text-align:center;color:var(--red);padding:24px;">Category not found</p>'; return; }
renderFeed(data.content, feedEl);
});
}
function searchContent(q) {
var feedEl = document.getElementById('lh-feed');
if (!feedEl) return;
showFeed();
feedEl.innerHTML =
'<div style="padding:8px 0 12px;font-size:13px;color:var(--g500);">' +
'<i class="fas fa-search"></i> Searching for <strong>"' + esc(q) + '"</strong>…' +
'</div>';
getJson('/api/learning/search?q=' + encodeURIComponent(q))
.then(function(data) {
if (!data.success) { feedEl.innerHTML = '<p style="color:var(--red);padding:12px;">Search failed</p>'; return; }
var header = renderSearchHeader(data.content.length, q);
if (data.content.length === 0) {
feedEl.innerHTML = header + renderEmptySearchMessage();
} else {
var tempEl = document.createElement('div');
renderFeed(data.content, tempEl);
feedEl.innerHTML = header + tempEl.innerHTML;
}
var clearBtn = document.getElementById('lh-search-clear');
if (clearBtn) {
clearBtn.addEventListener('click', function() {
var s = document.getElementById('lh-search');
if (s) s.value = '';
loadFeed();
});
}
})
.catch(function() { feedEl.innerHTML = '<p style="color:var(--red);padding:12px;">Search error</p>'; });
}
function loadContent(slug) {
getJson('/api/learning/content/' + encodeURIComponent(slug))
.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) metaEl.textContent = buildViewerMeta(item);
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) {
presViewer.className = ''; // remove hidden
presViewer.innerHTML = renderPresentationCard(item);
// Auto-load and open slides
document.getElementById('btn-lh-view-slides').addEventListener('click', function() {
slides.openSlidesFromSlug(item.slug);
});
// Open automatically on load
slides.openSlidesFromSlug(item.slug);
}
// Quiz for presentations (optional)
if (quizSection) {
if (item.questions && item.questions.length > 0) {
quizSection.classList.remove('hidden');
quiz.renderQuiz(item.questions);
} else {
quizSection.classList.add('hidden');
}
}
} else {
// Normal content — show body card, hide presentation card
if (bodyCard) bodyCard.style.display = '';
if (presViewer) presViewer.className = 'hidden';
if (bodyEl) bodyEl.innerHTML = sanitizeHtml(item.body || '');
if (quizSection) {
if (item.questions && item.questions.length > 0) {
quizSection.classList.remove('hidden');
quiz.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');
document.getElementById('lh-progress-list').innerHTML = renderProgressList(item.progress);
} else if (progressSection) {
progressSection.classList.add('hidden');
}
}
function setActiveCatPill(slug) {
document.querySelectorAll('.lh-cat-pill').forEach(function(p) {
p.classList.toggle('active', p.dataset.slug === slug);
});
}
// ============================================================
// ADMIN CMS
// ============================================================
function wireCmsTypeSelect() {
var typeEl = document.getElementById('lh-cms-edit-type');
if (typeEl) {
typeEl.addEventListener('change', function() { toggleEditorMode(typeEl.value); });
}
}
function wireCmsFileInput() {
var fileInput = document.getElementById('lh-ai-file');
var fileLabel = document.getElementById('lh-ai-file-label');
var filesList = document.getElementById('lh-ai-files-list');
var dropzone = document.getElementById('lh-ai-dropzone');
function updateFilesList() {
if (!fileInput || !fileLabel || !filesList) return;
var files = fileInput.files;
if (files.length === 0) {
fileLabel.textContent = 'Drop files here or click to browse';
filesList.style.display = 'none';
filesList.innerHTML = '';
} else if (files.length === 1) {
fileLabel.textContent = files[0].name;
filesList.style.display = 'none';
filesList.innerHTML = '';
} else {
fileLabel.textContent = files.length + ' files selected';
filesList.style.display = 'block';
filesList.innerHTML = '<ul style="list-style:none;padding:0;margin:0;font-size:0.9em;">' +
Array.from(files).map(function(f) {
return '<li style="padding:2px 0;"><i class="fas fa-file-pdf" style="color:var(--blue);margin-right:4px;"></i>' + f.name + '</li>';
}).join('') + '</ul>';
}
}
if (fileInput) {
fileInput.addEventListener('change', updateFilesList);
}
if (dropzone) {
dropzone.addEventListener('dragover', function(e) { e.preventDefault(); dropzone.style.borderColor = 'var(--blue)'; });
dropzone.addEventListener('dragleave', function() { dropzone.style.borderColor = ''; });
dropzone.addEventListener('drop', function(e) {
e.preventDefault();
dropzone.style.borderColor = '';
var files = e.dataTransfer.files;
if (files.length > 0 && fileInput) {
// Transfer to file input via DataTransfer
try {
var dt = new DataTransfer();
for (var i = 0; i < files.length && i < 10; i++) {
dt.items.add(files[i]);
}
fileInput.files = dt.files;
updateFilesList();
} catch(ex) {}
}
});
}
}
function loadCms() {
cms.refreshAll();
}
// ============================================================
// AI GENERATE PANEL
// ============================================================
// ── 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('idempotencyKey', crypto.randomUUID());
if (model) 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') {
var webdavPath = webdav.getSelectedPath();
if (!webdavPath) { showToast('Select a file from Nextcloud', 'error'); return; }
formData.append('webdavPath', webdavPath);
var webdavCtx = document.getElementById('lh-ai-webdav-context');
if (webdavCtx && webdavCtx.value.trim()) formData.append('topic', webdavCtx.value.trim());
}
// Grounding is opt-out rather than opt-in in the UI: for clinical teaching
// the library is nearly always the right source, and someone who forgets
// the checkbox should get the grounded version.
var corpusBox = document.getElementById('lh-ai-use-corpus');
formData.append('useCorpus', corpusBox && corpusBox.checked === false ? 'false' : 'true');
var groundingLine = document.getElementById('lh-ai-grounding-result');
if (groundingLine) groundingLine.textContent = '';
var btn = document.getElementById('btn-lh-ai-generate');
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating...'; }
showBusy('AI is generating content...');
// FormData: no Content-Type header (browser sets it with boundary)
var headers = getAuthHeaders();
delete headers['Content-Type'];
fetch('/api/admin/learning/ai-generate', {
method: 'POST',
headers: headers,
body: formData
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideBusy();
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-wand-magic-sparkles"></i> Generate Content'; }
if (!data.success) { showToast(data.error || 'Generation failed', 'error'); return; }
// Say what it was written from. Ungrounded material presented as grounded
// is the failure worth preventing, so this never implies the library was
// used when it was not.
if (groundingLine) {
var g = data.grounding || {};
groundingLine.textContent = g.used
? 'Written from ' + g.count + ' library excerpt' + (g.count === 1 ? '' : 's') + '.'
: 'Not grounded' + (g.reason ? ' \u2014 ' + g.reason : '') + '. Written from the model alone.';
groundingLine.style.color = g.used ? 'var(--green)' : 'var(--g600)';
}
// Presentation returns marpMarkdown directly; others return content{}
var payload = data.contentType === 'presentation' ? data : data.content;
return applyAiContent(payload, contentType).then(function() {
learningImages.show(data.imageJobs || []);
aiPanel.close();
showToast('Content generated! Review and save.', 'success');
});
})
.catch(function(err) {
hideBusy();
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-wand-magic-sparkles"></i> Generate Content'; }
showToast(err.message, 'error');
});
}
function applyAiContent(content, contentType) {
// Set content type first so toggleEditorMode works correctly
var typeEl = document.getElementById('lh-cms-edit-type');
if (typeEl && contentType) { typeEl.value = contentType; toggleEditorMode(contentType); }
// Presentation: fill Marp textarea
if (contentType === 'presentation' && content.marpMarkdown) {
var marpTa = document.getElementById('lh-marp-editor');
if (marpTa) marpTa.value = content.marpMarkdown;
// Extract title from first # heading
var titleMatch = content.marpMarkdown.match(/^#\s+(.+)$/m);
var titleEl = document.getElementById('lh-cms-edit-title');
if (titleEl && titleMatch) titleEl.value = titleMatch[1];
// Fill questions if any were generated alongside slides
var qContainerP = document.getElementById('lh-cms-questions');
if (qContainerP) {
clearQuestionBlocks(qContainerP);
if (content.questions && Array.isArray(content.questions)) {
content.questions.forEach(function(q) { addQuestionBlock(q); });
}
}
return applyAiCategory(content.category_name || content.category);
}
// 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) {
clearQuestionBlocks(qContainer);
if (content.questions && Array.isArray(content.questions)) {
content.questions.forEach(function(q) { addQuestionBlock(q); });
}
}
return applyAiCategory(content.category_name || content.category);
}
function applyAiCategory(categoryName) {
categoryName = (categoryName || '').trim();
if (!categoryName) return Promise.resolve();
var sel = document.getElementById('lh-cms-edit-category');
if (!sel) return Promise.resolve();
var existing = findCategoryOption(sel, categoryName);
if (existing) {
sel.value = existing.value;
return Promise.resolve();
}
return sendJson('/api/admin/learning/categories', 'POST', { name: categoryName })
.then(function(data) {
if (!data.success || !data.id) throw new Error(data.error || 'Category creation failed');
var opt = document.createElement('option');
opt.value = data.id;
opt.textContent = categoryName;
sel.appendChild(opt);
sel.value = String(data.id);
cms.loadCategories(String(data.id));
loadCategories();
})
.catch(function(err) {
showToast('Generated category was not created: ' + err.message, 'error');
});
}
function findCategoryOption(sel, categoryName) {
var wanted = categoryName.toLowerCase();
for (var i = 0; i < sel.options.length; i++) {
if ((sel.options[i].textContent || '').trim().toLowerCase() === wanted) return sel.options[i];
}
return null;
}
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...');
sendJson('/api/admin/learning/ai-refine', 'POST', { content: currentBody, instructions: instructions, model: model })
.then(function(data) {
hideBusy();
if (!data.success) { showToast(data.error || 'Refine failed', 'error'); return; }
if (_bodyEditor && !data.bodyPreserved) _bodyEditor.commands.setContent(data.refined);
learningImages.show(data.imageJobs || []);
showToast(data.bodyPreserved ? 'Body unchanged; image queued for insertion.' : 'Body refined!', 'success');
})
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
}
// ── Body editor ────────────────────────────────────────────
function initBodyEditor() {
var wrap = document.getElementById('lh-body-editor');
if (!wrap || _bodyEditor) return;
_bodyEditor = makeTpEditor(wrap, '', false, false);
learningImages.mount();
}
// ── 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';
clearQuestionBlocks(document.getElementById('lh-cms-questions'));
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
getJson('/api/admin/learning/content/' + contentId)
.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');
clearQuestionBlocks(qContainer);
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...');
sendJson(url, method, payload)
.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();
cms.refreshAll();
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';
}
sendJson(url, method, {
question_text: q.question_text,
question_type: q.question_type,
explanation: q.explanation,
options: q.options
})
.then(function() {
saveQuestions(contentId, questions, index + 1, callback);
})
.catch(function() {
saveQuestions(contentId, questions, index + 1, callback);
});
}
function showDeleteConfirm() {
var id = document.getElementById('lh-cms-edit-id').value;
if (!id) return;
var title = (document.getElementById('lh-cms-edit-title').value || 'this content').substring(0, 60);
var ctype = (document.getElementById('lh-cms-edit-type') || {}).value || 'article';
var bar = document.getElementById('lh-delete-confirm-bar');
var msgEl = document.getElementById('lh-delete-confirm-msg');
if (!bar) return;
// Build wording based on content type
var typeLabel = { article: 'article', quiz: 'quiz', pearl: 'clinical pearl', presentation: 'presentation' }[ctype] || 'content';
var consequence = '';
if (ctype === 'quiz') consequence = ' All quiz questions will be permanently removed.';
else if (ctype === 'article' || ctype === 'pearl') consequence = ' Any quiz questions attached to it will also be removed.';
// Presentation has no questions → no extra wording
if (msgEl) msgEl.innerHTML = 'Delete the ' + typeLabel + ' <strong>"' + esc(title) + '"</strong>?' + consequence + ' This cannot be undone.';
bar.classList.remove('hidden');
bar.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function hideDeleteConfirm() {
var bar = document.getElementById('lh-delete-confirm-bar');
if (bar) bar.classList.add('hidden');
}
function confirmDelete() {
var id = document.getElementById('lh-cms-edit-id').value;
if (!id) return;
hideDeleteConfirm();
deleteJson('/api/admin/learning/content/' + id)
.then(function(data) {
if (data.success) {
showToast('Content deleted', 'info');
closeEditor();
cms.refreshAll();
loaded = false;
} else showToast(data.error || 'Failed', 'error');
});
}
// ── Presentation / Marp ──────────────────────────────────────
function getMarpMarkdown() {
var ta = document.getElementById('lh-marp-editor');
return ta ? ta.value.trim() : '';
}
function getPresentationTitle() {
var titleEl = document.getElementById('lh-cms-edit-title');
return titleEl ? titleEl.value : 'presentation';
}
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 = renderQuestionBlockShell(existingQ, 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 = renderOptionRowShell(opt);
container.appendChild(row);
// Set option text value after insertion (can't use value="" on nested input via innerHTML)
row.querySelector('.lh-opt-text').value = opt ? opt.option_text || '' : '';
}