improve learning hub generation flow
This commit is contained in:
parent
d8e9ba149e
commit
6d13765fc4
13 changed files with 457 additions and 334 deletions
7
.mailmap
Normal file
7
.mailmap
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Remap historical AI co-author trailers so repository contributors reflect human maintainers.
|
||||
Daniel <danitex@danvics.com> vendor model Opus 4.6 <noreply@anthropic.com>
|
||||
Daniel <danitex@danvics.com> vendor model Opus 4.6 (1M context) <noreply@anthropic.com>
|
||||
Daniel <danitex@danvics.com> vendor model Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
Daniel <danitex@danvics.com> vendor model Sonnet 4.5 <noreply@anthropic.com>
|
||||
Daniel <danitex@danvics.com> vendor model Sonnet 4.6 <noreply@anthropic.com>
|
||||
Daniel <danitex@danvics.com> vendor model Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
||||
|
|
@ -21,10 +21,7 @@ test.describe('Unauthenticated auth screen', () => {
|
|||
});
|
||||
|
||||
test('register link is present but currently disabled (display:none)', async ({ page }) => {
|
||||
// Daniel's instance has invite-only registration — the "Create account"
|
||||
// link is explicitly hidden via inline style, so the HTML is there but
|
||||
// users can't reach the register form through the UI. Verify the hidden
|
||||
// state so flipping the style to re-enable it fails loudly.
|
||||
// Invite-only registration hides the link while keeping the form in the DOM.
|
||||
await page.goto(E2E_BASE + '/');
|
||||
await page.waitForSelector('#auth-screen', { timeout: 10000 });
|
||||
const display = await page.locator('#show-register').evaluate(el => el.style.display);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,4 @@
|
|||
/**
|
||||
* Soft-delete for personal_notes — Daniel asked for "deleted notes go to
|
||||
* trash" so a slip of the finger doesn't lose work. Adds a deleted_at
|
||||
* timestamp; NULL means active. Trash listing filters by NOT NULL,
|
||||
* regular listing filters by NULL.
|
||||
*
|
||||
* Restore = clear deleted_at. Empty Trash = real DELETE. No retention
|
||||
* policy yet — items stay in trash until the user empties it.
|
||||
*/
|
||||
// Adds soft-delete support for personal_notes via deleted_at.
|
||||
|
||||
exports.up = (pgm) => {
|
||||
pgm.addColumn('personal_notes', {
|
||||
|
|
|
|||
|
|
@ -214,14 +214,6 @@
|
|||
<div class="settings-section card">
|
||||
<h3><i class="fas fa-shield-halved"></i> Compliance & Usage</h3>
|
||||
<div class="hipaa-info">
|
||||
<p><strong>AWS Bedrock</strong> is available with a Business Associate Agreement (BAA) for HIPAA-eligible workloads.</p>
|
||||
<ul>
|
||||
<li>✅ All connections use HTTPS/TLS encryption</li>
|
||||
<li>✅ Authentication with optional 2FA</li>
|
||||
<li>✅ No patient data stored on server beyond session</li>
|
||||
<li>✅ AWS Bedrock supports BAA for HIPAA compliance</li>
|
||||
<li>✅ Azure OpenAI supports BAA for HIPAA compliance</li>
|
||||
</ul>
|
||||
<p><strong>Important:</strong> Check with your institution's guidelines and policies before use. This tool is not intended for production clinical use without proper organizational authorization and provider BAAs in place. Use with caution.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,17 @@
|
|||
// ============================================================
|
||||
// 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 { renderCategoryPills, renderEmptySearchMessage, renderFeed, renderSearchHeader } from './learningHub/feedRenderer.js';
|
||||
|
||||
var loaded = false;
|
||||
var cmsLoaded = false;
|
||||
var currentContent = null;
|
||||
|
|
@ -159,8 +170,7 @@
|
|||
feedEl.innerHTML = '<p style="text-align:center;color:var(--g400);padding:24px;">Loading...</p>';
|
||||
showFeed();
|
||||
|
||||
fetch('/api/learning/feed', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
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);
|
||||
|
|
@ -169,17 +179,12 @@
|
|||
}
|
||||
|
||||
function loadCategories() {
|
||||
fetch('/api/learning/categories', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
getJson('/api/learning/categories')
|
||||
.then(function(data) {
|
||||
if (!data.success) return;
|
||||
var bar = document.getElementById('lh-categories');
|
||||
if (!bar) return;
|
||||
var html = '<button class="lh-cat-pill active" data-slug="all">All</button>';
|
||||
data.categories.forEach(function(c) {
|
||||
html += '<button class="lh-cat-pill" data-slug="' + esc(c.slug) + '">' + esc(c.name) + '</button>';
|
||||
});
|
||||
bar.innerHTML = html;
|
||||
bar.innerHTML = renderCategoryPills(data.categories);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -189,8 +194,7 @@
|
|||
feedEl.innerHTML = '<p style="text-align:center;color:var(--g400);padding:24px;">Loading...</p>';
|
||||
showFeed();
|
||||
|
||||
fetch('/api/learning/category/' + encodeURIComponent(slug), { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
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);
|
||||
|
|
@ -206,25 +210,13 @@
|
|||
'<i class="fas fa-search"></i> Searching for <strong>"' + esc(q) + '"</strong>…' +
|
||||
'</div>';
|
||||
|
||||
fetch('/api/learning/search?q=' + encodeURIComponent(q), { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
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 = '<div style="padding:8px 0 12px;display:flex;align-items:center;justify-content:space-between;">' +
|
||||
'<span style="font-size:13px;color:var(--g600);">' +
|
||||
(data.content.length > 0
|
||||
? '<strong>' + data.content.length + '</strong> result' + (data.content.length !== 1 ? 's' : '') + ' for <strong>"' + esc(q) + '"</strong>'
|
||||
: 'No results for <strong>"' + esc(q) + '"</strong>') +
|
||||
'</span>' +
|
||||
'<button id="lh-search-clear" style="background:none;border:none;font-size:12px;color:var(--blue);cursor:pointer;padding:0;">Clear search</button>' +
|
||||
'</div>';
|
||||
var header = renderSearchHeader(data.content.length, q);
|
||||
|
||||
if (data.content.length === 0) {
|
||||
feedEl.innerHTML = header +
|
||||
'<div style="text-align:center;padding:40px;color:var(--g400);">' +
|
||||
'<i class="fas fa-search" style="font-size:32px;margin-bottom:12px;display:block;opacity:0.4;"></i>' +
|
||||
'Try different keywords or browse by category' +
|
||||
'</div>';
|
||||
feedEl.innerHTML = header + renderEmptySearchMessage();
|
||||
} else {
|
||||
var tempEl = document.createElement('div');
|
||||
renderFeed(data.content, tempEl);
|
||||
|
|
@ -243,39 +235,8 @@
|
|||
.catch(function() { feedEl.innerHTML = '<p style="color:var(--red);padding:12px;">Search error</p>'; });
|
||||
}
|
||||
|
||||
function renderFeed(items, feedEl) {
|
||||
if (!items || items.length === 0) {
|
||||
feedEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--g400);"><i class="fas fa-book-open" style="font-size:32px;margin-bottom:12px;display:block;"></i>No content yet. Check back soon!</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
feedEl.innerHTML = items.map(function(item) {
|
||||
var typeIcon = item.content_type === 'quiz' ? 'fa-clipboard-question' : item.content_type === 'pearl' ? 'fa-gem' : item.content_type === 'presentation' ? 'fa-presentation-screen' : 'fa-file-alt';
|
||||
var typeColor = item.content_type === 'quiz' ? 'var(--amber)' : item.content_type === 'pearl' ? 'var(--purple, #8b5cf6)' : item.content_type === 'presentation' ? '#065f46' : 'var(--blue)';
|
||||
var quizBadge = item.question_count > 0 ? '<span class="lh-badge lh-badge-quiz"><i class="fas fa-clipboard-question"></i> ' + item.question_count + ' Q</span>' : '';
|
||||
var catBadge = item.category_name ? '<span class="lh-badge">' + esc(item.category_name) + '</span>' : '<span class="lh-badge" style="opacity:0.5;">Uncategorized</span>';
|
||||
var date = item.created_at ? new Date(item.created_at).toLocaleDateString() : '';
|
||||
|
||||
return '<div class="lh-feed-item card" data-slug="' + esc(item.slug) + '">' +
|
||||
'<div class="lh-feed-item-header">' +
|
||||
'<i class="fas ' + typeIcon + '" style="color:' + typeColor + ';font-size:16px;"></i>' +
|
||||
'<div style="flex:1;min-width:0;">' +
|
||||
'<div class="lh-feed-title">' + esc(item.title) + '</div>' +
|
||||
'<div class="lh-feed-meta">' +
|
||||
(item.subject ? '<span>' + esc(item.subject) + '</span>' : '') +
|
||||
(item.author_name ? '<span>by ' + esc(item.author_name) + '</span>' : '') +
|
||||
'<span>' + date + '</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="lh-feed-badges">' + catBadge + quizBadge + '</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function loadContent(slug) {
|
||||
fetch('/api/learning/content/' + encodeURIComponent(slug), { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
getJson('/api/learning/content/' + encodeURIComponent(slug))
|
||||
.then(function(data) {
|
||||
if (!data.success) { showToast(data.error || 'Content not found', 'error'); return; }
|
||||
currentContent = data.content;
|
||||
|
|
@ -446,12 +407,7 @@
|
|||
|
||||
showLoading('Submitting quiz...');
|
||||
|
||||
fetch('/api/learning/submit-quiz', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ contentId: currentContent.id, answers: answers })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
sendJson('/api/learning/submit-quiz', 'POST', { contentId: currentContent.id, answers: answers })
|
||||
.then(function(data) {
|
||||
hideLoading();
|
||||
if (!data.success) { showToast(data.error || 'Submit failed', 'error'); return; }
|
||||
|
|
@ -621,8 +577,7 @@
|
|||
updateAiOptions(editorType);
|
||||
|
||||
// Hide WebDAV tab if Nextcloud not configured
|
||||
fetch('/api/auth/me', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
getJson('/api/auth/me')
|
||||
.then(function(d) {
|
||||
var tab = document.getElementById('lh-ai-tab-webdav');
|
||||
if (tab) tab.style.display = (d.user && d.user.nextcloud_url) ? '' : 'none';
|
||||
|
|
@ -715,8 +670,7 @@
|
|||
if (list) list.innerHTML = '<div style="padding:12px;text-align:center;color:var(--g400);font-size:13px;"><i class="fas fa-spinner fa-spin"></i> Loading...</div>';
|
||||
if (pathLabel) pathLabel.textContent = _aiWebdavCurrentPath;
|
||||
|
||||
fetch('/api/admin/learning/webdav-browse?path=' + encodeURIComponent(_aiWebdavCurrentPath), { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
getJson('/api/admin/learning/webdav-browse?path=' + encodeURIComponent(_aiWebdavCurrentPath))
|
||||
.then(function(data) {
|
||||
if (!data.success) { if (list) list.innerHTML = '<div style="padding:12px;color:var(--red);font-size:13px;">' + esc(data.error) + '</div>'; return; }
|
||||
if (pathLabel) pathLabel.textContent = data.path;
|
||||
|
|
@ -849,9 +803,10 @@
|
|||
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');
|
||||
return applyAiContent(payload, contentType).then(function() {
|
||||
closeAiPanel();
|
||||
showToast('Content generated! Review and save.', 'success');
|
||||
});
|
||||
})
|
||||
.catch(function(err) {
|
||||
hideBusy();
|
||||
|
|
@ -881,7 +836,7 @@
|
|||
content.questions.forEach(function(q) { addQuestionBlock(q); });
|
||||
}
|
||||
}
|
||||
return;
|
||||
return applyAiCategory(content.category_name || content.category);
|
||||
}
|
||||
|
||||
// Fill title
|
||||
|
|
@ -903,6 +858,44 @@
|
|||
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);
|
||||
loadCmsCategories();
|
||||
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) {
|
||||
|
|
@ -926,12 +919,7 @@
|
|||
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(); })
|
||||
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; }
|
||||
|
|
@ -942,8 +930,7 @@
|
|||
}
|
||||
|
||||
function loadCmsStats() {
|
||||
fetch('/api/admin/learning/stats', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
getJson('/api/admin/learning/stats')
|
||||
.then(function(data) {
|
||||
if (!data.success) return;
|
||||
var s = data.stats;
|
||||
|
|
@ -957,8 +944,7 @@
|
|||
}
|
||||
|
||||
function loadCmsCategories() {
|
||||
fetch('/api/admin/learning/categories', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
getJson('/api/admin/learning/categories')
|
||||
.then(function(data) {
|
||||
if (!data.success) return;
|
||||
var container = document.getElementById('lh-cms-categories');
|
||||
|
|
@ -998,12 +984,7 @@
|
|||
var input = document.getElementById('lh-cms-cat-name');
|
||||
if (!input || !input.value.trim()) { showToast('Enter category name', 'error'); return; }
|
||||
|
||||
fetch('/api/admin/learning/categories', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ name: input.value.trim() })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
sendJson('/api/admin/learning/categories', 'POST', { name: input.value.trim() })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
input.value = '';
|
||||
|
|
@ -1023,8 +1004,7 @@
|
|||
return;
|
||||
}
|
||||
window.__confirmDeleteCat = null;
|
||||
fetch('/api/admin/learning/categories/' + id, { method: 'DELETE', headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
deleteJson('/api/admin/learning/categories/' + id)
|
||||
.then(function(data) {
|
||||
if (data.success) { loadCmsCategories(); loadCategories(); showToast('Category deleted', 'info'); }
|
||||
else showToast(data.error || 'Failed', 'error');
|
||||
|
|
@ -1036,8 +1016,7 @@
|
|||
if (!container) return;
|
||||
container.innerHTML = '<p style="text-align:center;color:var(--g400);padding:12px;">Loading...</p>';
|
||||
|
||||
fetch('/api/admin/learning/content', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
getJson('/api/admin/learning/content')
|
||||
.then(function(data) {
|
||||
if (!data.success) return;
|
||||
if (data.content.length === 0) {
|
||||
|
|
@ -1101,168 +1080,6 @@
|
|||
renderCmsContentList(filtered);
|
||||
}
|
||||
|
||||
// ── Tiptap editor helpers ──────────────────────────────────
|
||||
var T = window.Tiptap || {};
|
||||
|
||||
function buildTpToolbar(mini, isOption) {
|
||||
var btns;
|
||||
if (isOption) {
|
||||
btns = '<button type="button" class="tp-btn" data-cmd="bold" title="Bold"><i class="fas fa-bold"></i></button>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="italic" title="Italic"><i class="fas fa-italic"></i></button>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="link" title="Link"><i class="fas fa-link"></i></button>';
|
||||
} else if (mini) {
|
||||
btns = '<button type="button" class="tp-btn" data-cmd="bold" title="Bold"><i class="fas fa-bold"></i></button>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="italic" title="Italic"><i class="fas fa-italic"></i></button>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="bulletList" title="List"><i class="fas fa-list-ul"></i></button>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="link" title="Link"><i class="fas fa-link"></i></button>';
|
||||
} else {
|
||||
btns = '<button type="button" class="tp-btn" data-cmd="bold" title="Bold"><i class="fas fa-bold"></i></button>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="italic" title="Italic"><i class="fas fa-italic"></i></button>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="underline" title="Underline"><i class="fas fa-underline"></i></button>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="strike" title="Strike"><i class="fas fa-strikethrough"></i></button>' +
|
||||
'<span class="tp-sep"></span>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="h2" title="Heading 2">H2</button>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="h3" title="Heading 3">H3</button>' +
|
||||
'<span class="tp-sep"></span>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="bulletList" title="Bullet List"><i class="fas fa-list-ul"></i></button>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="orderedList" title="Numbered List"><i class="fas fa-list-ol"></i></button>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="blockquote" title="Blockquote"><i class="fas fa-quote-left"></i></button>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="codeBlock" title="Code"><i class="fas fa-code"></i></button>' +
|
||||
'<span class="tp-sep"></span>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="link" title="Link"><i class="fas fa-link"></i></button>' +
|
||||
'<span class="tp-sep"></span>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="clear" title="Clear formatting"><i class="fas fa-remove-format"></i></button>';
|
||||
}
|
||||
return '<div class="tp-toolbar' + (mini ? ' tp-toolbar-mini' : '') + '">' + btns + '</div>' +
|
||||
'<div class="tp-link-bar" style="display:none;">' +
|
||||
'<input type="url" class="tp-link-input" placeholder="https://">' +
|
||||
'<button type="button" class="tp-link-apply">Apply</button>' +
|
||||
'<button type="button" class="tp-link-remove">Remove</button>' +
|
||||
'<button type="button" class="tp-link-cancel">✕</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function wireTpToolbar(wrap, editor) {
|
||||
var linkBar = wrap.querySelector('.tp-link-bar');
|
||||
var linkInput = wrap.querySelector('.tp-link-input');
|
||||
|
||||
wrap.querySelector('.tp-toolbar').addEventListener('mousedown', function(e) {
|
||||
var btn = e.target.closest('.tp-btn[data-cmd]');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
var cmd = btn.dataset.cmd;
|
||||
switch (cmd) {
|
||||
case 'bold': editor.chain().focus().toggleBold().run(); break;
|
||||
case 'italic': editor.chain().focus().toggleItalic().run(); break;
|
||||
case 'underline': editor.chain().focus().toggleUnderline().run(); break;
|
||||
case 'strike': editor.chain().focus().toggleStrike().run(); break;
|
||||
case 'h2': editor.chain().focus().toggleHeading({ level: 2 }).run(); break;
|
||||
case 'h3': editor.chain().focus().toggleHeading({ level: 3 }).run(); break;
|
||||
case 'bulletList': editor.chain().focus().toggleBulletList().run(); break;
|
||||
case 'orderedList': editor.chain().focus().toggleOrderedList().run(); break;
|
||||
case 'blockquote': editor.chain().focus().toggleBlockquote().run(); break;
|
||||
case 'codeBlock': editor.chain().focus().toggleCodeBlock().run(); break;
|
||||
case 'clear': editor.chain().focus().unsetAllMarks().clearNodes().run(); break;
|
||||
case 'link':
|
||||
if (linkBar.style.display === 'none') {
|
||||
linkInput.value = editor.getAttributes('link').href || '';
|
||||
linkBar.style.display = 'flex';
|
||||
setTimeout(function() { linkInput.focus(); }, 0);
|
||||
} else {
|
||||
linkBar.style.display = 'none';
|
||||
}
|
||||
break;
|
||||
}
|
||||
updateTpState(wrap, editor);
|
||||
});
|
||||
|
||||
wrap.querySelector('.tp-link-apply').addEventListener('mousedown', function(e) {
|
||||
e.preventDefault();
|
||||
var url = linkInput.value.trim();
|
||||
if (url) editor.chain().focus().setLink({ href: url }).run();
|
||||
linkBar.style.display = 'none';
|
||||
});
|
||||
wrap.querySelector('.tp-link-remove').addEventListener('mousedown', function(e) {
|
||||
e.preventDefault();
|
||||
editor.chain().focus().unsetLink().run();
|
||||
linkBar.style.display = 'none';
|
||||
});
|
||||
wrap.querySelector('.tp-link-cancel').addEventListener('mousedown', function(e) {
|
||||
e.preventDefault();
|
||||
linkBar.style.display = 'none';
|
||||
});
|
||||
linkInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); wrap.querySelector('.tp-link-apply').dispatchEvent(new MouseEvent('mousedown')); }
|
||||
if (e.key === 'Escape') linkBar.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function updateTpState(wrap, editor) {
|
||||
wrap.querySelectorAll('.tp-btn[data-cmd]').forEach(function(btn) {
|
||||
var a = false;
|
||||
switch (btn.dataset.cmd) {
|
||||
case 'bold': a = editor.isActive('bold'); break;
|
||||
case 'italic': a = editor.isActive('italic'); break;
|
||||
case 'underline': a = editor.isActive('underline'); break;
|
||||
case 'strike': a = editor.isActive('strike'); break;
|
||||
case 'h2': a = editor.isActive('heading', { level: 2 }); break;
|
||||
case 'h3': a = editor.isActive('heading', { level: 3 }); break;
|
||||
case 'bulletList': a = editor.isActive('bulletList'); break;
|
||||
case 'orderedList': a = editor.isActive('orderedList'); break;
|
||||
case 'blockquote': a = editor.isActive('blockquote'); break;
|
||||
case 'codeBlock': a = editor.isActive('codeBlock'); break;
|
||||
case 'link': a = editor.isActive('link'); break;
|
||||
}
|
||||
btn.classList.toggle('active', a);
|
||||
});
|
||||
}
|
||||
|
||||
function makeTpEditor(wrap, existingHtml, mini, isOption) {
|
||||
wrap.innerHTML = buildTpToolbar(mini, isOption) + '<div class="tp-content"></div>';
|
||||
var ed = new T.Editor({
|
||||
element: wrap.querySelector('.tp-content'),
|
||||
extensions: [
|
||||
T.StarterKit,
|
||||
T.Link.configure({ openOnClick: false, autolink: true }),
|
||||
T.Underline
|
||||
],
|
||||
content: existingHtml || '',
|
||||
onUpdate: function() { updateTpState(wrap, ed); },
|
||||
onSelectionUpdate: function() { updateTpState(wrap, ed); }
|
||||
});
|
||||
wireTpToolbar(wrap, ed);
|
||||
return ed;
|
||||
}
|
||||
|
||||
function getTpHTML(editor) {
|
||||
if (!editor) return '';
|
||||
var html = editor.getHTML();
|
||||
return (html === '<p></p>' || html === '') ? '' : html;
|
||||
}
|
||||
|
||||
function destroyTpEditor(editor) {
|
||||
if (editor && typeof editor.destroy === 'function') editor.destroy();
|
||||
}
|
||||
|
||||
function destroyOptionEditor(row) {
|
||||
if (!row || !row._optEditor) return;
|
||||
destroyTpEditor(row._optEditor);
|
||||
row._optEditor = null;
|
||||
}
|
||||
|
||||
function destroyQuestionBlockEditors(block) {
|
||||
if (!block) return;
|
||||
destroyTpEditor(block._questionEditor);
|
||||
block._questionEditor = null;
|
||||
block.querySelectorAll('.lh-option-row').forEach(destroyOptionEditor);
|
||||
}
|
||||
|
||||
function clearQuestionBlocks(container) {
|
||||
if (!container) return;
|
||||
container.querySelectorAll('.lh-question-block').forEach(destroyQuestionBlockEditors);
|
||||
container.innerHTML = '';
|
||||
}
|
||||
|
||||
// ── Body editor ────────────────────────────────────────────
|
||||
function initBodyEditor() {
|
||||
var wrap = document.getElementById('lh-body-editor');
|
||||
|
|
@ -1375,8 +1192,7 @@
|
|||
if (!contentId) return;
|
||||
|
||||
// Load existing content
|
||||
fetch('/api/admin/learning/content/' + contentId, { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
getJson('/api/admin/learning/content/' + contentId)
|
||||
.then(function(data) {
|
||||
if (!data.success) { showToast('Failed to load', 'error'); return; }
|
||||
var c = data.content;
|
||||
|
|
@ -1487,8 +1303,7 @@
|
|||
|
||||
showLoading('Saving...');
|
||||
|
||||
fetch(url, { method: method, headers: getAuthHeaders(), body: JSON.stringify(payload) })
|
||||
.then(function(r) { return r.json(); })
|
||||
sendJson(url, method, payload)
|
||||
.then(function(data) {
|
||||
if (!data.success) { hideLoading(); showToast(data.error || 'Save failed', 'error'); return; }
|
||||
var contentId = id || data.id;
|
||||
|
|
@ -1521,17 +1336,12 @@
|
|||
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
|
||||
})
|
||||
sendJson(url, method, {
|
||||
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);
|
||||
})
|
||||
|
|
@ -1570,8 +1380,7 @@
|
|||
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(); })
|
||||
deleteJson('/api/admin/learning/content/' + id)
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
showToast('Content deleted', 'info');
|
||||
|
|
@ -1596,8 +1405,7 @@
|
|||
// 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(); })
|
||||
getJson('/api/learning/content/' + encodeURIComponent(slug) + '/slides')
|
||||
.then(function(data) {
|
||||
hideBusy();
|
||||
if (!data.success) { showToast(data.error || 'Could not load slides', 'error'); return; }
|
||||
|
|
@ -1612,11 +1420,7 @@
|
|||
|
||||
showBusy('Rendering slides...');
|
||||
|
||||
fetch('/api/admin/learning/preview-slides', {
|
||||
method: 'POST', headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ markdown: md })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
sendJson('/api/admin/learning/preview-slides', 'POST', { markdown: md })
|
||||
.then(function(data) {
|
||||
hideBusy();
|
||||
if (!data.success) { showToast(data.error || 'Preview failed', 'error'); return; }
|
||||
|
|
@ -1799,30 +1603,3 @@
|
|||
// 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, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function sanitizeHtml(html) {
|
||||
// DOMPurify is loaded from cdnjs via <script> in index.html. If it somehow
|
||||
// fails to load, refuse to render unsafe content rather than falling back
|
||||
// to a regex sanitizer (historical bypass risk).
|
||||
if (!window.DOMPurify || typeof window.DOMPurify.sanitize !== 'function') {
|
||||
console.warn('[learningHub] DOMPurify unavailable — rendering as plain text.');
|
||||
var d = document.createElement('div');
|
||||
d.textContent = String(html == null ? '' : html);
|
||||
return d.innerHTML;
|
||||
}
|
||||
return window.DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: ['p','br','b','strong','i','em','u','s','h1','h2','h3','h4','h5','h6',
|
||||
'ul','ol','li','a','blockquote','code','pre','table','thead','tbody','tr','th','td',
|
||||
'hr','div','span','sub','sup','dl','dt','dd'],
|
||||
ALLOWED_ATTR: ['href','colspan','rowspan','class','target','rel'],
|
||||
ADD_ATTR: ['target'],
|
||||
FORBID_ATTR: ['style','onerror','onload','onclick','onmouseover'],
|
||||
ALLOW_DATA_ATTR: false
|
||||
});
|
||||
}
|
||||
|
|
|
|||
15
public/js/learningHub/api.js
Normal file
15
public/js/learningHub/api.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
export function getJson(url) {
|
||||
return fetch(url, { headers: getAuthHeaders() }).then(function(r) { return r.json(); });
|
||||
}
|
||||
|
||||
export function sendJson(url, method, payload) {
|
||||
return fetch(url, {
|
||||
method: method,
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(payload || {})
|
||||
}).then(function(r) { return r.json(); });
|
||||
}
|
||||
|
||||
export function deleteJson(url) {
|
||||
return fetch(url, { method: 'DELETE', headers: getAuthHeaders() }).then(function(r) { return r.json(); });
|
||||
}
|
||||
60
public/js/learningHub/feedRenderer.js
Normal file
60
public/js/learningHub/feedRenderer.js
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { esc } from './sanitize.js';
|
||||
|
||||
export function renderCategoryPills(categories) {
|
||||
var html = '<button class="lh-cat-pill active" data-slug="all">All</button>';
|
||||
(categories || []).forEach(function(c) {
|
||||
html += '<button class="lh-cat-pill" data-slug="' + esc(c.slug) + '">' + esc(c.name) + '</button>';
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
export function renderFeed(items, feedEl) {
|
||||
if (!feedEl) return;
|
||||
if (!items || items.length === 0) {
|
||||
feedEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--g400);"><i class="fas fa-book-open" style="font-size:32px;margin-bottom:12px;display:block;"></i>No content yet. Check back soon!</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
feedEl.innerHTML = items.map(renderFeedItem).join('');
|
||||
}
|
||||
|
||||
export function renderSearchHeader(count, query) {
|
||||
return '<div style="padding:8px 0 12px;display:flex;align-items:center;justify-content:space-between;">' +
|
||||
'<span style="font-size:13px;color:var(--g600);">' +
|
||||
(count > 0
|
||||
? '<strong>' + count + '</strong> result' + (count !== 1 ? 's' : '') + ' for <strong>"' + esc(query) + '"</strong>'
|
||||
: 'No results for <strong>"' + esc(query) + '"</strong>') +
|
||||
'</span>' +
|
||||
'<button id="lh-search-clear" style="background:none;border:none;font-size:12px;color:var(--blue);cursor:pointer;padding:0;">Clear search</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
export function renderEmptySearchMessage() {
|
||||
return '<div style="text-align:center;padding:40px;color:var(--g400);">' +
|
||||
'<i class="fas fa-search" style="font-size:32px;margin-bottom:12px;display:block;opacity:0.4;"></i>' +
|
||||
'Try different keywords or browse by category' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderFeedItem(item) {
|
||||
var typeIcon = item.content_type === 'quiz' ? 'fa-clipboard-question' : item.content_type === 'pearl' ? 'fa-gem' : item.content_type === 'presentation' ? 'fa-presentation-screen' : 'fa-file-alt';
|
||||
var typeColor = item.content_type === 'quiz' ? 'var(--amber)' : item.content_type === 'pearl' ? 'var(--purple, #8b5cf6)' : item.content_type === 'presentation' ? '#065f46' : 'var(--blue)';
|
||||
var quizBadge = item.question_count > 0 ? '<span class="lh-badge lh-badge-quiz"><i class="fas fa-clipboard-question"></i> ' + item.question_count + ' Q</span>' : '';
|
||||
var catBadge = item.category_name ? '<span class="lh-badge">' + esc(item.category_name) + '</span>' : '<span class="lh-badge" style="opacity:0.5;">Uncategorized</span>';
|
||||
var date = item.created_at ? new Date(item.created_at).toLocaleDateString() : '';
|
||||
|
||||
return '<div class="lh-feed-item card" data-slug="' + esc(item.slug) + '">' +
|
||||
'<div class="lh-feed-item-header">' +
|
||||
'<i class="fas ' + typeIcon + '" style="color:' + typeColor + ';font-size:16px;"></i>' +
|
||||
'<div style="flex:1;min-width:0;">' +
|
||||
'<div class="lh-feed-title">' + esc(item.title) + '</div>' +
|
||||
'<div class="lh-feed-meta">' +
|
||||
(item.subject ? '<span>' + esc(item.subject) + '</span>' : '') +
|
||||
(item.author_name ? '<span>by ' + esc(item.author_name) + '</span>' : '') +
|
||||
'<span>' + date + '</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="lh-feed-badges">' + catBadge + quizBadge + '</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
24
public/js/learningHub/sanitize.js
Normal file
24
public/js/learningHub/sanitize.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
export function esc(str) {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export function sanitizeHtml(html) {
|
||||
// DOMPurify is loaded globally from index.html. If it is unavailable, render
|
||||
// as plain text rather than attempting partial sanitization.
|
||||
if (!window.DOMPurify || typeof window.DOMPurify.sanitize !== 'function') {
|
||||
console.warn('[learningHub] DOMPurify unavailable - rendering as plain text.');
|
||||
var d = document.createElement('div');
|
||||
d.textContent = String(html == null ? '' : html);
|
||||
return d.innerHTML;
|
||||
}
|
||||
return window.DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: ['p','br','b','strong','i','em','u','s','h1','h2','h3','h4','h5','h6',
|
||||
'ul','ol','li','a','blockquote','code','pre','table','thead','tbody','tr','th','td',
|
||||
'hr','div','span','sub','sup','dl','dt','dd'],
|
||||
ALLOWED_ATTR: ['href','colspan','rowspan','class','target','rel'],
|
||||
ADD_ATTR: ['target'],
|
||||
FORBID_ATTR: ['style','onerror','onload','onclick','onmouseover'],
|
||||
ALLOW_DATA_ATTR: false
|
||||
});
|
||||
}
|
||||
163
public/js/learningHub/tiptapEditor.js
Normal file
163
public/js/learningHub/tiptapEditor.js
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
function getTiptap() {
|
||||
return window.Tiptap || {};
|
||||
}
|
||||
|
||||
function buildTpToolbar(mini, isOption) {
|
||||
var btns;
|
||||
if (isOption) {
|
||||
btns = '<button type="button" class="tp-btn" data-cmd="bold" title="Bold"><i class="fas fa-bold"></i></button>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="italic" title="Italic"><i class="fas fa-italic"></i></button>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="link" title="Link"><i class="fas fa-link"></i></button>';
|
||||
} else if (mini) {
|
||||
btns = '<button type="button" class="tp-btn" data-cmd="bold" title="Bold"><i class="fas fa-bold"></i></button>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="italic" title="Italic"><i class="fas fa-italic"></i></button>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="bulletList" title="List"><i class="fas fa-list-ul"></i></button>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="link" title="Link"><i class="fas fa-link"></i></button>';
|
||||
} else {
|
||||
btns = '<button type="button" class="tp-btn" data-cmd="bold" title="Bold"><i class="fas fa-bold"></i></button>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="italic" title="Italic"><i class="fas fa-italic"></i></button>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="underline" title="Underline"><i class="fas fa-underline"></i></button>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="strike" title="Strike"><i class="fas fa-strikethrough"></i></button>' +
|
||||
'<span class="tp-sep"></span>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="h2" title="Heading 2">H2</button>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="h3" title="Heading 3">H3</button>' +
|
||||
'<span class="tp-sep"></span>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="bulletList" title="Bullet List"><i class="fas fa-list-ul"></i></button>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="orderedList" title="Numbered List"><i class="fas fa-list-ol"></i></button>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="blockquote" title="Blockquote"><i class="fas fa-quote-left"></i></button>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="codeBlock" title="Code"><i class="fas fa-code"></i></button>' +
|
||||
'<span class="tp-sep"></span>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="link" title="Link"><i class="fas fa-link"></i></button>' +
|
||||
'<span class="tp-sep"></span>' +
|
||||
'<button type="button" class="tp-btn" data-cmd="clear" title="Clear formatting"><i class="fas fa-remove-format"></i></button>';
|
||||
}
|
||||
return '<div class="tp-toolbar' + (mini ? ' tp-toolbar-mini' : '') + '">' + btns + '</div>' +
|
||||
'<div class="tp-link-bar" style="display:none;">' +
|
||||
'<input type="url" class="tp-link-input" placeholder="https://">' +
|
||||
'<button type="button" class="tp-link-apply">Apply</button>' +
|
||||
'<button type="button" class="tp-link-remove">Remove</button>' +
|
||||
'<button type="button" class="tp-link-cancel">×</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function wireTpToolbar(wrap, editor) {
|
||||
var linkBar = wrap.querySelector('.tp-link-bar');
|
||||
var linkInput = wrap.querySelector('.tp-link-input');
|
||||
|
||||
wrap.querySelector('.tp-toolbar').addEventListener('mousedown', function(e) {
|
||||
var btn = e.target.closest('.tp-btn[data-cmd]');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
var cmd = btn.dataset.cmd;
|
||||
switch (cmd) {
|
||||
case 'bold': editor.chain().focus().toggleBold().run(); break;
|
||||
case 'italic': editor.chain().focus().toggleItalic().run(); break;
|
||||
case 'underline': editor.chain().focus().toggleUnderline().run(); break;
|
||||
case 'strike': editor.chain().focus().toggleStrike().run(); break;
|
||||
case 'h2': editor.chain().focus().toggleHeading({ level: 2 }).run(); break;
|
||||
case 'h3': editor.chain().focus().toggleHeading({ level: 3 }).run(); break;
|
||||
case 'bulletList': editor.chain().focus().toggleBulletList().run(); break;
|
||||
case 'orderedList': editor.chain().focus().toggleOrderedList().run(); break;
|
||||
case 'blockquote': editor.chain().focus().toggleBlockquote().run(); break;
|
||||
case 'codeBlock': editor.chain().focus().toggleCodeBlock().run(); break;
|
||||
case 'clear': editor.chain().focus().unsetAllMarks().clearNodes().run(); break;
|
||||
case 'link':
|
||||
if (linkBar.style.display === 'none') {
|
||||
linkInput.value = editor.getAttributes('link').href || '';
|
||||
linkBar.style.display = 'flex';
|
||||
setTimeout(function() { linkInput.focus(); }, 0);
|
||||
} else {
|
||||
linkBar.style.display = 'none';
|
||||
}
|
||||
break;
|
||||
}
|
||||
updateTpState(wrap, editor);
|
||||
});
|
||||
|
||||
wrap.querySelector('.tp-link-apply').addEventListener('mousedown', function(e) {
|
||||
e.preventDefault();
|
||||
var url = linkInput.value.trim();
|
||||
if (url) editor.chain().focus().setLink({ href: url }).run();
|
||||
linkBar.style.display = 'none';
|
||||
});
|
||||
wrap.querySelector('.tp-link-remove').addEventListener('mousedown', function(e) {
|
||||
e.preventDefault();
|
||||
editor.chain().focus().unsetLink().run();
|
||||
linkBar.style.display = 'none';
|
||||
});
|
||||
wrap.querySelector('.tp-link-cancel').addEventListener('mousedown', function(e) {
|
||||
e.preventDefault();
|
||||
linkBar.style.display = 'none';
|
||||
});
|
||||
linkInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); wrap.querySelector('.tp-link-apply').dispatchEvent(new MouseEvent('mousedown')); }
|
||||
if (e.key === 'Escape') linkBar.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function updateTpState(wrap, editor) {
|
||||
wrap.querySelectorAll('.tp-btn[data-cmd]').forEach(function(btn) {
|
||||
var a = false;
|
||||
switch (btn.dataset.cmd) {
|
||||
case 'bold': a = editor.isActive('bold'); break;
|
||||
case 'italic': a = editor.isActive('italic'); break;
|
||||
case 'underline': a = editor.isActive('underline'); break;
|
||||
case 'strike': a = editor.isActive('strike'); break;
|
||||
case 'h2': a = editor.isActive('heading', { level: 2 }); break;
|
||||
case 'h3': a = editor.isActive('heading', { level: 3 }); break;
|
||||
case 'bulletList': a = editor.isActive('bulletList'); break;
|
||||
case 'orderedList': a = editor.isActive('orderedList'); break;
|
||||
case 'blockquote': a = editor.isActive('blockquote'); break;
|
||||
case 'codeBlock': a = editor.isActive('codeBlock'); break;
|
||||
case 'link': a = editor.isActive('link'); break;
|
||||
}
|
||||
btn.classList.toggle('active', a);
|
||||
});
|
||||
}
|
||||
|
||||
export function makeTpEditor(wrap, existingHtml, mini, isOption) {
|
||||
var T = getTiptap();
|
||||
wrap.innerHTML = buildTpToolbar(mini, isOption) + '<div class="tp-content"></div>';
|
||||
var ed = new T.Editor({
|
||||
element: wrap.querySelector('.tp-content'),
|
||||
extensions: [
|
||||
T.StarterKit,
|
||||
T.Link.configure({ openOnClick: false, autolink: true }),
|
||||
T.Underline
|
||||
],
|
||||
content: existingHtml || '',
|
||||
onUpdate: function() { updateTpState(wrap, ed); },
|
||||
onSelectionUpdate: function() { updateTpState(wrap, ed); }
|
||||
});
|
||||
wireTpToolbar(wrap, ed);
|
||||
return ed;
|
||||
}
|
||||
|
||||
export function getTpHTML(editor) {
|
||||
if (!editor) return '';
|
||||
var html = editor.getHTML();
|
||||
return (html === '<p></p>' || html === '') ? '' : html;
|
||||
}
|
||||
|
||||
export function destroyTpEditor(editor) {
|
||||
if (editor && typeof editor.destroy === 'function') editor.destroy();
|
||||
}
|
||||
|
||||
export function destroyOptionEditor(row) {
|
||||
if (!row || !row._optEditor) return;
|
||||
destroyTpEditor(row._optEditor);
|
||||
row._optEditor = null;
|
||||
}
|
||||
|
||||
export function destroyQuestionBlockEditors(block) {
|
||||
if (!block) return;
|
||||
destroyTpEditor(block._questionEditor);
|
||||
block._questionEditor = null;
|
||||
block.querySelectorAll('.lh-option-row').forEach(destroyOptionEditor);
|
||||
}
|
||||
|
||||
export function clearQuestionBlocks(container) {
|
||||
if (!container) return;
|
||||
container.querySelectorAll('.lh-question-block').forEach(destroyQuestionBlockEditors);
|
||||
container.innerHTML = '';
|
||||
}
|
||||
|
|
@ -135,13 +135,14 @@ async function extractText(buffer, mimetype, filename) {
|
|||
// ── Build AI prompt ──────────────────────────────────────────
|
||||
|
||||
function buildGeneratePrompt(opts) {
|
||||
var { topic, docText, contentType, questionCount, refinement, wordCount, slideCount } = opts;
|
||||
var { topic, docText, contentType, questionCount, refinement, wordCount, slideCount, existingCategories } = opts;
|
||||
|
||||
var source = docText
|
||||
? 'Based on the following document/resource text, generate educational content.\n\nDOCUMENT:\n"""\n' + docText.substring(0, 50000) + '\n"""\n'
|
||||
: 'Generate educational content on the following topic for a medical professional audience (pediatrics / primary care).\n\nTOPIC: ' + topic + '\n';
|
||||
|
||||
var refineInstr = refinement ? '\n\nAdditional instructions for tone/style/focus: ' + refinement : '';
|
||||
var categoryInstr = buildCategoryInstruction(existingCategories);
|
||||
|
||||
// ── Presentation ──
|
||||
if (contentType === 'presentation') {
|
||||
|
|
@ -171,11 +172,12 @@ Then each slide separated by ---. Guidelines:
|
|||
// With questions — return JSON containing both Marp markdown and questions
|
||||
return source + '\n' +
|
||||
'Create a professional Marp presentation (' + slideHint + ') suitable for medical education, ' +
|
||||
'AND ' + questionCount + ' quiz questions based on the content.' + refineInstr + `
|
||||
'AND ' + questionCount + ' quiz questions based on the content.' + categoryInstr + refineInstr + `
|
||||
|
||||
Return ONLY a valid JSON object (no markdown, no code fences) with this exact structure:
|
||||
{
|
||||
"title": "string",
|
||||
"category_name": "string (best existing category name, or a concise new category if none fit)",
|
||||
"marpMarkdown": "string (the complete Marp markdown starting with frontmatter ---\\nmarp: true\\ntheme: default\\n---)",
|
||||
"questions": [
|
||||
{
|
||||
|
|
@ -210,11 +212,12 @@ Marp guidelines inside marpMarkdown:
|
|||
typeInstr = 'This is an article. Write a comprehensive, well-structured body.' + wordHint + ' ' + qInstr;
|
||||
}
|
||||
|
||||
return source + '\n' + typeInstr + refineInstr + `
|
||||
return source + '\n' + typeInstr + categoryInstr + refineInstr + `
|
||||
|
||||
Return ONLY a valid JSON object (no markdown, no code fences) with this exact structure:
|
||||
{
|
||||
"title": "string",
|
||||
"category_name": "string (best existing category name, or a concise new category if none fit)",
|
||||
"subject": "string (1-3 word sub-topic label)",
|
||||
"body": "string (valid HTML using only: p, h2, h3, ul, ol, li, strong, em, blockquote, code — no inline styles)",
|
||||
"questions": [
|
||||
|
|
@ -231,11 +234,20 @@ Return ONLY a valid JSON object (no markdown, no code fences) with this exact st
|
|||
|
||||
Rules:
|
||||
- Each MCQ must have exactly 4 options, exactly 1 marked is_correct: true
|
||||
- Prefer an existing category_name when it fits; create a short broad category_name only when no existing category fits
|
||||
- question_type must be "mcq" or "true_false" (true_false has exactly 2 options: "True" and "False")
|
||||
- body must be clean HTML, no raw markdown
|
||||
- Do not include any text outside the JSON object`;
|
||||
}
|
||||
|
||||
function buildCategoryInstruction(existingCategories) {
|
||||
var names = (existingCategories || []).map(function(c) { return c.name; }).filter(Boolean);
|
||||
if (!names.length) {
|
||||
return '\nAssign a short, broad category_name for this content.';
|
||||
}
|
||||
return '\nChoose the best category_name from this existing list when appropriate: ' + names.join(', ') + '. If none fit, create one short broad category_name.';
|
||||
}
|
||||
|
||||
// ── POST /api/admin/learning/ai-generate ────────────────────
|
||||
// Accepts: multipart/form-data OR application/json
|
||||
|
||||
|
|
@ -298,7 +310,8 @@ router.post('/ai-generate', upload.array('files', 10), async function(req, res)
|
|||
return res.status(400).json({ error: 'Provide a topic or upload a file.' });
|
||||
}
|
||||
|
||||
var prompt = buildGeneratePrompt({ topic, docText, contentType, questionCount, refinement, wordCount, slideCount });
|
||||
var existingCategories = await db.all('SELECT name FROM learning_categories ORDER BY sort_order ASC, name ASC', []);
|
||||
var prompt = buildGeneratePrompt({ topic, docText, contentType, questionCount, refinement, wordCount, slideCount, existingCategories });
|
||||
|
||||
var result = await callAI(
|
||||
[
|
||||
|
|
@ -327,7 +340,7 @@ router.post('/ai-generate', upload.array('files', 10), async function(req, res)
|
|||
try { parsedPres = m ? JSON.parse(m[0]) : null; } catch(e2) { parsedPres = null; }
|
||||
}
|
||||
if (parsedPres && parsedPres.marpMarkdown) {
|
||||
return res.json({ success: true, contentType: 'presentation', marpMarkdown: parsedPres.marpMarkdown, questions: parsedPres.questions || [], model: result.model });
|
||||
return res.json({ success: true, contentType: 'presentation', marpMarkdown: parsedPres.marpMarkdown, category_name: parsedPres.category_name || '', questions: parsedPres.questions || [], model: result.model });
|
||||
}
|
||||
}
|
||||
// Plain Marp markdown (no questions requested, or parse failed)
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ router.post('/categories', async function(req, res) {
|
|||
var { name, description, sort_order } = req.body;
|
||||
if (!name || !name.trim()) return res.status(400).json({ error: 'Name required' });
|
||||
|
||||
var existing = await db.get('SELECT id, slug FROM learning_categories WHERE LOWER(name) = LOWER(?)', [name.trim()]);
|
||||
if (existing) return res.json({ success: true, id: existing.id, slug: existing.slug, existing: true });
|
||||
|
||||
var slug = await uniqueSlug('learning_categories', name.trim());
|
||||
|
||||
var result = await db.run(
|
||||
|
|
|
|||
37
test/learning-hub-ai-category.test.js
Normal file
37
test/learning-hub-ai-category.test.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(__dirname, '..', relativePath), 'utf8');
|
||||
}
|
||||
|
||||
const frontend = read('public/js/learningHub.js');
|
||||
const learningAI = read('src/routes/learningAI.js');
|
||||
const learningAdmin = read('src/routes/learningAdmin.js');
|
||||
|
||||
test('Learning AI prompts request category_name using existing categories', () => {
|
||||
assert.match(learningAI, /function buildCategoryInstruction\(existingCategories\)/);
|
||||
assert.match(learningAI, /SELECT name FROM learning_categories ORDER BY sort_order ASC, name ASC/);
|
||||
assert.match(learningAI, /"category_name": "string \(best existing category name/);
|
||||
assert.match(learningAI, /Prefer an existing category_name when it fits/);
|
||||
});
|
||||
|
||||
test('Learning AI presentation JSON preserves generated category_name', () => {
|
||||
assert.match(learningAI, /category_name: parsedPres\.category_name \|\| ''/);
|
||||
});
|
||||
|
||||
test('Learning Hub applies AI-generated category names to the editor', () => {
|
||||
assert.match(frontend, /return applyAiContent\(payload, contentType\)\.then/);
|
||||
assert.match(frontend, /function applyAiCategory\(categoryName\)/);
|
||||
assert.match(frontend, /sendJson\('\/api\/admin\/learning\/categories', 'POST', \{ name: categoryName \}\)/);
|
||||
assert.match(frontend, /findCategoryOption\(sel, categoryName\)/);
|
||||
assert.match(frontend, /loadCmsCategories\(\);/);
|
||||
assert.match(frontend, /loadCategories\(\);/);
|
||||
});
|
||||
|
||||
test('Learning category creation is idempotent by name', () => {
|
||||
assert.match(learningAdmin, /WHERE LOWER\(name\) = LOWER\(\?\)/);
|
||||
assert.match(learningAdmin, /existing: true/);
|
||||
});
|
||||
|
|
@ -3,14 +3,57 @@ const assert = require('node:assert/strict');
|
|||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'learningHub.js'), 'utf8');
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(__dirname, '..', relativePath), 'utf8');
|
||||
}
|
||||
|
||||
const source = read('public/js/learningHub.js');
|
||||
const apiModule = read('public/js/learningHub/api.js');
|
||||
const editorModule = read('public/js/learningHub/tiptapEditor.js');
|
||||
const feedRendererModule = read('public/js/learningHub/feedRenderer.js');
|
||||
const sanitizeModule = read('public/js/learningHub/sanitize.js');
|
||||
|
||||
test('Learning Hub destroys embedded Tiptap editors before removing CMS rows', () => {
|
||||
assert.match(source, /function destroyTpEditor\(editor\)/);
|
||||
assert.match(source, /function destroyQuestionBlockEditors\(block\)/);
|
||||
assert.match(source, /function clearQuestionBlocks\(container\)/);
|
||||
assert.match(source, /from '\.\/learningHub\/tiptapEditor\.js'/);
|
||||
assert.match(editorModule, /export function destroyTpEditor\(editor\)/);
|
||||
assert.match(editorModule, /export function destroyQuestionBlockEditors\(block\)/);
|
||||
assert.match(editorModule, /export function clearQuestionBlocks\(container\)/);
|
||||
assert.match(source, /destroyQuestionBlockEditors\(qb\); qb\.remove\(\)/);
|
||||
assert.match(source, /destroyOptionEditor\(row\); row\.remove\(\)/);
|
||||
assert.doesNotMatch(source, /document\.getElementById\('lh-cms-questions'\)\.innerHTML = ''/);
|
||||
assert.doesNotMatch(source, /qContainer\.innerHTML = ''/);
|
||||
});
|
||||
|
||||
test('Learning Hub sanitization helpers are isolated for renderer reuse', () => {
|
||||
assert.match(source, /from '\.\/learningHub\/sanitize\.js'/);
|
||||
assert.match(sanitizeModule, /export function esc\(str\)/);
|
||||
assert.match(sanitizeModule, /export function sanitizeHtml\(html\)/);
|
||||
assert.match(sanitizeModule, /DOMPurify\.sanitize/);
|
||||
assert.doesNotMatch(sanitizeModule, /ai-generate|ai-refine|FormData|\/api\/admin\/learning/);
|
||||
assert.doesNotMatch(source, /function sanitizeHtml\(html\)/);
|
||||
});
|
||||
|
||||
test('Learning Hub API helpers centralize auth JSON fetches', () => {
|
||||
assert.match(source, /from '\.\/learningHub\/api\.js'/);
|
||||
assert.match(apiModule, /export function getJson\(url\)/);
|
||||
assert.match(apiModule, /getAuthHeaders\(\)/);
|
||||
assert.match(apiModule, /export function sendJson\(url, method, payload\)/);
|
||||
assert.match(apiModule, /export function deleteJson\(url\)/);
|
||||
});
|
||||
|
||||
test('Learning Hub Tiptap helpers are isolated from content generation flow', () => {
|
||||
assert.match(editorModule, /export function makeTpEditor/);
|
||||
assert.match(editorModule, /window\.Tiptap/);
|
||||
assert.doesNotMatch(editorModule, /ai-generate|ai-refine|FormData|\/api\/admin\/learning/);
|
||||
});
|
||||
|
||||
test('Learning Hub feed renderers are isolated from API and editor flows', () => {
|
||||
assert.match(source, /from '\.\/learningHub\/feedRenderer\.js'/);
|
||||
assert.match(feedRendererModule, /export function renderCategoryPills\(categories\)/);
|
||||
assert.match(feedRendererModule, /export function renderFeed\(items, feedEl\)/);
|
||||
assert.match(feedRendererModule, /export function renderSearchHeader\(count, query\)/);
|
||||
assert.match(feedRendererModule, /export function renderEmptySearchMessage\(\)/);
|
||||
assert.match(feedRendererModule, /fa-presentation-screen/);
|
||||
assert.doesNotMatch(feedRendererModule, /fetch\(|getJson|sendJson|deleteJson|ai-generate|FormData|window\.Tiptap/);
|
||||
assert.doesNotMatch(source, /function renderFeed\(items, feedEl\)/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue