From d4e57177e420d93bc6010aa9d99b1941380d7aa5 Mon Sep 17 00:00:00 2001 From: Daniel Onyejesi Date: Mon, 23 Mar 2026 22:37:29 -0400 Subject: [PATCH] Add Quill rich text editor, multi-select questions, per-field rich text toggle - Replace textarea+toolbar with Quill.js (free, MIT) for body editor - Full WYSIWYG: headings, bold/italic, lists, blockquote, code, link (inline, no popup) - Rich Text toggle button on each question text field and option text field - Plain textarea by default; toggle activates mini Quill editor per field - New 'Multiple Select' question type: checkboxes, all correct must be chosen - Backend scoring: allCorrectChosen && noWrongChosen - Results show all correct options highlighted, wrong selections flagged - quiz display uses sanitizeHtml for question/option text (supports formatted HTML) - sanitizeHtml allows Quill class attrs (ql-indent, etc.) --- public/components/cms.html | 21 +--- public/css/styles.css | 31 +++++- public/index.html | 2 + public/js/learningHub.js | 222 +++++++++++++++++++++++++++++-------- src/routes/learningHub.js | 55 ++++++--- 5 files changed, 248 insertions(+), 83 deletions(-) diff --git a/public/components/cms.html b/public/components/cms.html index 2c366ce..740cfd0 100644 --- a/public/components/cms.html +++ b/public/components/cms.html @@ -112,27 +112,10 @@ - +
-
- - - - - - - - - - - - - - - -
- +
diff --git a/public/css/styles.css b/public/css/styles.css index 99b3fdb..350df3d 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -651,7 +651,7 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(-- .cms-stat{padding:8px 10px;} .cms-stat-value{font-size:18px;} } -.lh-option-row{display:flex;align-items:center;gap:8px;margin-bottom:6px;padding:6px 8px;border-radius:6px;background:var(--g50);} +.lh-option-row{display:flex;align-items:flex-start;gap:8px;margin-bottom:6px;padding:6px 8px;border-radius:6px;background:var(--g50);} @media(max-width:640px){ .lh-feed-item-header{flex-direction:column;gap:6px;} @@ -659,3 +659,32 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(-- .lh-option-row{flex-wrap:wrap;} .lh-option-row .lh-opt-expl{width:100%;} } + +/* ── Quill editor overrides ───────────────────────────────── */ +.cms-quill-body{border:1px solid var(--g300);border-radius:8px;overflow:hidden;background:white;} +.cms-quill-body .ql-toolbar{border:none;border-bottom:1px solid var(--g200);background:var(--g50);border-radius:0;padding:8px;} +.cms-quill-body .ql-container{border:none;font-family:'Inter',system-ui,sans-serif;font-size:14px;} +.cms-quill-body .ql-editor{min-height:280px;padding:16px;} +.cms-quill-body .ql-editor p{margin-bottom:8px;} +.cms-quill-body .ql-editor h2{font-size:20px;font-weight:700;margin:16px 0 8px;} +.cms-quill-body .ql-editor h3{font-size:16px;font-weight:600;margin:12px 0 6px;} +.cms-quill-body .ql-editor blockquote{border-left:4px solid var(--blue);padding-left:12px;color:var(--g600);margin:10px 0;} +.cms-quill-body .ql-editor pre{background:var(--g900);color:#e5e7eb;padding:12px;border-radius:6px;font-size:13px;overflow-x:auto;} + +/* Mini quill on questions/options */ +.lh-q-richtext-wrap{border:1.5px solid var(--g300);border-radius:8px;overflow:hidden;margin-bottom:8px;} +.lh-q-richtext-wrap .ql-toolbar{border:none;border-bottom:1px solid var(--g200);background:var(--g50);padding:4px 6px;} +.lh-q-richtext-wrap .ql-container{border:none;font-size:14px;font-family:'Inter',system-ui,sans-serif;} +.lh-q-richtext-wrap .ql-editor{min-height:60px;padding:10px;} +.lh-opt-richtext-wrap{border:1.5px solid var(--g300);border-radius:6px;overflow:hidden;flex:2;} +.lh-opt-richtext-wrap .ql-toolbar{border:none;border-bottom:1px solid var(--g200);background:var(--g50);padding:2px 4px;} +.lh-opt-richtext-wrap .ql-container{border:none;font-size:13px;font-family:'Inter',system-ui,sans-serif;} +.lh-opt-richtext-wrap .ql-editor{min-height:40px;padding:6px 8px;} + +/* Rich text toggle button */ +.lh-richtext-btn{font-size:11px;padding:3px 8px;border:1px solid var(--g300);border-radius:4px;background:white;cursor:pointer;color:var(--g500);white-space:nowrap;} +.lh-richtext-btn.active{background:var(--blue-light);border-color:var(--blue);color:var(--blue);font-weight:600;} + +/* Multi-select quiz options */ +.lh-quiz-option input[type="checkbox"]{accent-color:var(--blue);width:18px;height:18px;flex-shrink:0;} +.lh-quiz-multi-hint{font-size:12px;color:var(--g500);margin-bottom:10px;font-style:italic;} diff --git a/public/index.html b/public/index.html index 501e7aa..d36598f 100644 --- a/public/index.html +++ b/public/index.html @@ -10,6 +10,7 @@ + @@ -265,6 +266,7 @@
+ diff --git a/public/js/learningHub.js b/public/js/learningHub.js index 69e934e..782e096 100644 --- a/public/js/learningHub.js +++ b/public/js/learningHub.js @@ -8,6 +8,7 @@ var cmsLoaded = false; var currentContent = null; var currentView = 'feed'; // 'feed' | 'category' | 'viewer' + var _bodyQuill = null; // Quill instance for body editor // ── Load when tab activated ──────────────────────────────── document.addEventListener('tabChanged', function(e) { @@ -64,9 +65,9 @@ if (e.target.closest('#btn-lh-delete-content')) { deleteContent(); return; } if (e.target.closest('#btn-lh-add-question')) { addQuestionBlock(); return; } - // Editor toolbar buttons - var tbBtn = e.target.closest('.cms-tb-btn'); - if (tbBtn && tbBtn.dataset.insert) { insertTag(tbBtn.dataset.insert); 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'); @@ -286,20 +287,28 @@ if (countEl) countEl.textContent = questions.length + ' question' + (questions.length !== 1 ? 's' : ''); container.innerHTML = questions.map(function(q, idx) { - var typeLabel = q.question_type === 'true_false' ? 'True / False' : 'Multiple Choice'; + var 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 ''; }).join(''); - return '
' + + var hint = isMulti ? '
Select all that apply
' : ''; + + return '
' + '
' + 'Q' + (idx + 1) + '' + '' + typeLabel + '' + '
' + '

' + sanitizeHtml(q.question_text) + '

' + + hint + '
' + optionsHtml + '
' + '
'; }).join(''); @@ -310,11 +319,15 @@ var answers = []; currentContent.questions.forEach(function(q) { - var selected = document.querySelector('input[name="lh-q-' + q.id + '"]:checked'); - answers.push({ - questionId: q.id, - optionId: selected ? parseInt(selected.value) : null - }); + 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...'); @@ -373,12 +386,20 @@ 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 (optId === r.correctOptionId) label.classList.add('lh-opt-correct'); - if (optId === r.selectedOptionId && !r.isCorrect) label.classList.add('lh-opt-wrong'); + 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; }); }); @@ -560,30 +581,131 @@ renderCmsContentList(filtered); } - // Editor toolbar — insert HTML tags at cursor in textarea - function insertTag(tag) { - var ta = document.getElementById('lh-cms-edit-body'); - if (!ta) return; - var start = ta.selectionStart, end = ta.selectionEnd; - var sel = ta.value.substring(start, end); - var insert = ''; - switch(tag) { - case 'b': case 'i': case 'u': case 'code': case 'h2': case 'h3': - insert = '<' + tag + '>' + (sel || 'text') + ''; break; - case 'a': - var url = prompt('Enter URL:'); - if (!url) return; - insert = '' + (sel || 'link text') + ''; break; - case 'ul': insert = ''; break; - case 'ol': insert = '
    \n
  1. ' + (sel || 'item') + '
  2. \n
  3. \n
'; break; - case 'blockquote': insert = '
' + (sel || 'quote') + '
'; break; - case 'table': insert = '\n\n\n
Header 1Header 2
' + (sel || 'data') + '
'; break; - case 'hr': insert = '
'; break; - default: return; + // ── Quill body editor ───────────────────────────────────── + function initBodyEditor() { + var container = document.getElementById('lh-body-editor'); + if (!container || _bodyQuill) return; + _bodyQuill = new Quill(container, { + theme: 'snow', + modules: { + toolbar: [ + [{ header: [2, 3, false] }], + ['bold', 'italic', 'underline', 'strike'], + [{ list: 'ordered' }, { list: 'bullet' }, { indent: '-1' }, { indent: '+1' }], + ['blockquote', 'code-block'], + ['link'], + [{ color: [] }], + ['clean'] + ] + } + }); + } + + // Mini Quill for question text or option text + function makeMinQuill(container, existingHtml, isOption) { + var toolbar = isOption + ? [['bold', 'italic'], ['link']] + : [['bold', 'italic'], [{ list: 'ordered' }, { list: 'bullet' }], ['link'], ['clean']]; + var q = new Quill(container, { + theme: 'snow', + modules: { toolbar: toolbar } + }); + if (existingHtml) q.clipboard.dangerouslyPasteHTML(existingHtml); + return q; + } + + // Toggle rich text on a question text area or option text area + function toggleRichText(btn) { + var isActive = btn.classList.contains('active'); + var target = btn.dataset.target; // 'question' or 'option' + 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) { + // Enable: hide textarea, show quill + var currentVal = 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._questionQuill) { + block._questionQuill = makeMinQuill(wrap, currentVal, false); + } else { + wrap.style.display = ''; + } + } else { + // Disable: get HTML from quill, restore textarea + btn.classList.remove('active'); + btn.textContent = 'Rich Text'; + if (block._questionQuill) { + var html = block._questionQuill.root.innerHTML; + if (html === '


') html = ''; + if (ta) { ta.value = html; ta.style.display = ''; } + } + if (wrap) wrap.style.display = 'none'; + } + } else { + // option + 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._optQuill) { + row._optQuill = makeMinQuill(optWrap, curVal, true); + } + } else { + btn.classList.remove('active'); + btn.textContent = 'Rich'; + if (row._optQuill) { + var optHtml = row._optQuill.root.innerHTML; + if (optHtml === '


') optHtml = ''; + if (optInput) { optInput.value = optHtml; optInput.style.display = ''; } + } + if (optWrap) optWrap.style.display = 'none'; + } } - ta.value = ta.value.substring(0, start) + insert + ta.value.substring(end); - ta.focus(); - ta.selectionStart = ta.selectionEnd = start + insert.length; + } + + // Read question text whether in rich text mode or plain + function getQText(block) { + if (block._questionQuill && block.querySelector('.lh-q-richtext-wrap') && + block.querySelector('.lh-q-richtext-wrap').style.display !== 'none') { + var html = block._questionQuill.root.innerHTML; + return html === '


' ? '' : html; + } + var ta = block.querySelector('.lh-q-text'); + return ta ? ta.value.trim() : ''; + } + + // Read option text whether in rich text mode or plain + function getOptText(row) { + if (row._optQuill && row.querySelector('.lh-opt-richtext-wrap') && + row.querySelector('.lh-opt-richtext-wrap').style.display !== 'none') { + var html = row._optQuill.root.innerHTML; + return html === '


' ? '' : html; + } + var inp = row.querySelector('.lh-opt-text'); + return inp ? inp.value.trim() : ''; } function openEditor(contentId, contentType) { @@ -597,12 +719,13 @@ document.getElementById('lh-cms-edit-id').value = ''; document.getElementById('lh-cms-edit-title').value = ''; document.getElementById('lh-cms-edit-subject').value = ''; - document.getElementById('lh-cms-edit-body').value = ''; document.getElementById('lh-cms-edit-category').value = ''; document.getElementById('lh-cms-edit-type').value = 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 (_bodyQuill) _bodyQuill.setContents([{ insert: '\n' }]); // For quiz type, add a starter question if (contentType === 'quiz' && !contentId) { @@ -620,7 +743,7 @@ document.getElementById('lh-cms-edit-id').value = c.id; document.getElementById('lh-cms-edit-title').value = c.title; document.getElementById('lh-cms-edit-subject').value = c.subject || ''; - document.getElementById('lh-cms-edit-body').value = c.body || ''; + if (_bodyQuill) _bodyQuill.clipboard.dangerouslyPasteHTML(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'; @@ -648,7 +771,7 @@ var id = document.getElementById('lh-cms-edit-id').value; var title = document.getElementById('lh-cms-edit-title').value.trim(); var subject = document.getElementById('lh-cms-edit-subject').value.trim(); - var body = document.getElementById('lh-cms-edit-body').value; + var body = _bodyQuill ? (_bodyQuill.root.innerHTML === '


' ? '' : _bodyQuill.root.innerHTML) : ''; 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'; @@ -661,7 +784,7 @@ var qBlocks = document.querySelectorAll('#lh-cms-questions .lh-question-block'); for (var qi = 0; qi < qBlocks.length; qi++) { var qBlock = qBlocks[qi]; - var qText = qBlock.querySelector('.lh-q-text').value.trim(); + var qText = getQText(qBlock); if (!qText) continue; var qType = qBlock.querySelector('.lh-q-type').value; var qExpl = qBlock.querySelector('.lh-q-explanation').value.trim(); @@ -669,7 +792,7 @@ var options = []; qBlock.querySelectorAll('.lh-option-row').forEach(function(row) { - var optText = row.querySelector('.lh-opt-text').value.trim(); + var optText = getOptText(row); if (!optText) return; options.push({ option_text: optText, @@ -792,8 +915,10 @@ 'Q' + qNum + '' + '' + + '' + '
' + '' + '' + @@ -831,13 +956,18 @@ var row = document.createElement('div'); row.className = 'lh-option-row'; row.innerHTML = - '