From 3dc4ddea4bf53914199ecd548e5eda8a90acc50d Mon Sep 17 00:00:00 2001 From: Nate Emmert Date: Tue, 21 Jul 2026 19:59:52 -0400 Subject: [PATCH] Add rich text editor for lesson content input Replaces the plain textarea with a WYSIWYG contenteditable editor so the formatting the parent sees while writing matches what the student sees when reading. Toolbar supports bold, italic, underline, H2/H3, bullet/numbered lists, links, and YouTube embeds. Selection is saved on editor blur and restored before each execCommand so toolbar clicks don't lose the user's selection. Co-Authored-By: Claude Sonnet 4.6 --- public/admin.html | 21 +++++++++++++--- public/admin.js | 63 ++++++++++++++++++++++++++++++++++++++++++++--- public/styles.css | 41 ++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 7 deletions(-) diff --git a/public/admin.html b/public/admin.html index a9b2888..6175c1b 100644 --- a/public/admin.html +++ b/public/admin.html @@ -162,9 +162,24 @@
- +
Lesson content
+
+ + + + + + + + + + + + + +
+
diff --git a/public/admin.js b/public/admin.js index 977d593..9f11c5f 100644 --- a/public/admin.js +++ b/public/admin.js @@ -337,7 +337,7 @@ async function openItemEditor(unitId, itemId) { $('#ie-heading').textContent = `Edit: ${item.title}`; $('#ie-type').value = item.type; $('#ie-title').value = item.title; - $('#ie-body-lesson').value = item.body; + $('#ie-body-lesson').innerHTML = item.body || ''; $('#ie-body-assignment').value = item.body; $('#ie-points').value = item.points || 10; $('#ie-due-date').value = item.due_date || ''; @@ -358,7 +358,7 @@ async function openItemEditor(unitId, itemId) { $('#ie-heading').textContent = 'New item'; $('#ie-type').value = 'lesson'; $('#ie-title').value = ''; - $('#ie-body-lesson').value = ''; + $('#ie-body-lesson').innerHTML = ''; $('#ie-body-assignment').value = ''; $('#ie-points').value = 10; $('#ie-due-date').value = ''; @@ -386,6 +386,61 @@ function updateItemFieldVisibility() { } $('#ie-type').addEventListener('change', updateItemFieldVisibility); +// ---------- rich text editor (lesson body) ---------- + +function getLessonBody() { + const html = $('#ie-body-lesson').innerHTML.trim(); + return html === '
' ? '' : html; +} + +let rtesavedRange = null; + +// Save selection whenever the editor loses focus (e.g. when clicking a toolbar button) +$('#ie-body-lesson').addEventListener('blur', () => { + const sel = window.getSelection(); + rtesavedRange = sel.rangeCount > 0 ? sel.getRangeAt(0).cloneRange() : null; +}); + +function rteRestoreAndExec(cmd, val = null) { + $('#ie-body-lesson').focus(); + if (rtesavedRange) { + const sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(rtesavedRange); + } + document.execCommand(cmd, false, val); +} + +// Format buttons (bold, italic, heading, lists, etc.) +document.querySelectorAll('.rte-btn[data-cmd]').forEach((btn) => { + btn.addEventListener('click', () => { + rteRestoreAndExec(btn.dataset.cmd, btn.dataset.val || null); + }); +}); + +// Link button +$('#rte-link-btn').addEventListener('click', () => { + const savedRange = rtesavedRange; + const url = prompt('Enter URL (e.g. https://example.com):'); + if (url) { + rteRestoreAndExec('createLink', url); + } +}); + +// YouTube embed button +$('#rte-youtube-btn').addEventListener('click', () => { + const input = prompt('YouTube URL or video ID:'); + if (!input) return; + const match = input.match(/(?:youtu\.be\/|youtube\.com\/(?:embed\/|watch\?v=))([a-zA-Z0-9_-]{11})/); + const id = match ? match[1] : input.trim(); + if (id) { + rteRestoreAndExec( + 'insertHTML', + `

` + ); + } +}); + // ---------- scan a page (photo -> lesson/quiz content via local Ollama) ---------- let scanImageBase64 = null; @@ -459,7 +514,7 @@ $('#scan-go-btn').addEventListener('click', async () => { const providerLabel = result._provider === 'cloud' ? 'cloud API' : 'local Ollama'; const fallbackNote = result._fallback ? ` (primary failed β€” used ${providerLabel} as fallback: ${result._primaryError})` : ` via ${providerLabel}`; if (mode === 'lesson') { - $('#ie-body-lesson').value = result.body || ''; + $('#ie-body-lesson').innerHTML = result.body || ''; $('#scan-status').textContent = `βœ… Scanned${fallbackNote}. Review the text below before saving.`; } else { $('#ie-questions').innerHTML = ''; @@ -590,7 +645,7 @@ $('#item-form').addEventListener('submit', async (e) => { unitId: Number($('#ie-unit-id').value), type, title, - body: type === 'lesson' ? $('#ie-body-lesson').value : type === 'assignment' ? $('#ie-body-assignment').value : '', + body: type === 'lesson' ? getLessonBody() : type === 'assignment' ? $('#ie-body-assignment').value : '', points: type === 'assignment' ? Number($('#ie-points').value) || 0 : 0, refId: type === 'spelling_practice' || type === 'spelling_test' ? Number($('#ie-list').value) : type === 'flashcards' ? Number($('#ie-deck').value) : null, diff --git a/public/styles.css b/public/styles.css index b1807fb..dd6df45 100644 --- a/public/styles.css +++ b/public/styles.css @@ -483,6 +483,47 @@ td.overdue, .overdue { background: var(--bad-bg) !important; } .parent-comment { background: var(--good-bg); border-radius: 10px; padding: 0.75rem 1rem; margin: 0.75rem 0; color: var(--good); } .status-banner.bad { background: var(--bad-bg); color: var(--bad); border-radius: 10px; padding: 0.75rem 1rem; margin: 0.75rem 0; } +/* ---------- rich text editor (lesson input) ---------- */ +.rte-label { + font-weight: 700; font-size: 0.9rem; margin-bottom: 0.3rem; +} +.rte-toolbar { + display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; + padding: 0.4rem 0.6rem; + background: #f0ead8; + border: 2px solid #d8d2c0; + border-bottom: none; + border-radius: 10px 10px 0 0; +} +.rte-btn { + font-size: 0.85rem; font-family: inherit; font-weight: 600; + background: var(--card); color: var(--ink); + border: 1.5px solid #c8c2b0; border-radius: 6px; + padding: 0.25rem 0.55rem; cursor: pointer; line-height: 1.4; +} +.rte-btn:hover { background: var(--accent); color: #fff; border-color: var(--accent); } +.rte-sep { + display: inline-block; width: 1px; height: 1.4rem; + background: #c8c2b0; margin: 0 0.15rem; flex-shrink: 0; +} +.rte-editor { + min-height: 200px; outline: none; cursor: text; + border: 2px solid #d8d2c0; border-radius: 0 0 10px 10px; + padding: 0.75rem; margin-bottom: 0; + font-size: 1.05rem; line-height: 1.6; + white-space: pre-wrap; +} +.rte-editor:focus { border-color: var(--accent); } +.rte-editor:empty::before { + content: attr(data-placeholder); + color: var(--muted); pointer-events: none; white-space: pre-wrap; +} +.rte-editor h2 { font-size: 1.3rem; margin: 0.6rem 0 0.3rem; } +.rte-editor h3 { font-size: 1.1rem; margin: 0.5rem 0 0.25rem; } +.rte-editor ul, .rte-editor ol { margin: 0.4rem 0 0.4rem 1.5rem; } +.rte-editor a { color: var(--accent-dark); } +.rte-editor img, .rte-editor iframe { max-width: 100%; border-radius: 10px; } + /* ---------- offline 3-state tasks ---------- */ .today-row-offline { background: var(--card); border: 1.5px solid #e2dccf; border-radius: 14px; padding: 0.75rem 1rem; margin-bottom: 0.5rem; text-align: left; } .offline-main { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }