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 }) => {
|
test('register link is present but currently disabled (display:none)', async ({ page }) => {
|
||||||
// Daniel's instance has invite-only registration — the "Create account"
|
// Invite-only registration hides the link while keeping the form in the DOM.
|
||||||
// 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.
|
|
||||||
await page.goto(E2E_BASE + '/');
|
await page.goto(E2E_BASE + '/');
|
||||||
await page.waitForSelector('#auth-screen', { timeout: 10000 });
|
await page.waitForSelector('#auth-screen', { timeout: 10000 });
|
||||||
const display = await page.locator('#show-register').evaluate(el => el.style.display);
|
const display = await page.locator('#show-register').evaluate(el => el.style.display);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,4 @@
|
||||||
/**
|
// Adds soft-delete support for personal_notes via deleted_at.
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
exports.up = (pgm) => {
|
exports.up = (pgm) => {
|
||||||
pgm.addColumn('personal_notes', {
|
pgm.addColumn('personal_notes', {
|
||||||
|
|
|
||||||
|
|
@ -214,14 +214,6 @@
|
||||||
<div class="settings-section card">
|
<div class="settings-section card">
|
||||||
<h3><i class="fas fa-shield-halved"></i> Compliance & Usage</h3>
|
<h3><i class="fas fa-shield-halved"></i> Compliance & Usage</h3>
|
||||||
<div class="hipaa-info">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// LEARNING HUB — User content feed, viewer, quizzes + Admin CMS
|
// 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 loaded = false;
|
||||||
var cmsLoaded = false;
|
var cmsLoaded = false;
|
||||||
var currentContent = null;
|
var currentContent = null;
|
||||||
|
|
@ -159,8 +170,7 @@
|
||||||
feedEl.innerHTML = '<p style="text-align:center;color:var(--g400);padding:24px;">Loading...</p>';
|
feedEl.innerHTML = '<p style="text-align:center;color:var(--g400);padding:24px;">Loading...</p>';
|
||||||
showFeed();
|
showFeed();
|
||||||
|
|
||||||
fetch('/api/learning/feed', { headers: getAuthHeaders() })
|
getJson('/api/learning/feed')
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (!data.success) { feedEl.innerHTML = '<p style="text-align:center;color:var(--red);padding:24px;">Failed to load</p>'; return; }
|
if (!data.success) { feedEl.innerHTML = '<p style="text-align:center;color:var(--red);padding:24px;">Failed to load</p>'; return; }
|
||||||
renderFeed(data.content, feedEl);
|
renderFeed(data.content, feedEl);
|
||||||
|
|
@ -169,17 +179,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadCategories() {
|
function loadCategories() {
|
||||||
fetch('/api/learning/categories', { headers: getAuthHeaders() })
|
getJson('/api/learning/categories')
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (!data.success) return;
|
if (!data.success) return;
|
||||||
var bar = document.getElementById('lh-categories');
|
var bar = document.getElementById('lh-categories');
|
||||||
if (!bar) return;
|
if (!bar) return;
|
||||||
var html = '<button class="lh-cat-pill active" data-slug="all">All</button>';
|
bar.innerHTML = renderCategoryPills(data.categories);
|
||||||
data.categories.forEach(function(c) {
|
|
||||||
html += '<button class="lh-cat-pill" data-slug="' + esc(c.slug) + '">' + esc(c.name) + '</button>';
|
|
||||||
});
|
|
||||||
bar.innerHTML = html;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -189,8 +194,7 @@
|
||||||
feedEl.innerHTML = '<p style="text-align:center;color:var(--g400);padding:24px;">Loading...</p>';
|
feedEl.innerHTML = '<p style="text-align:center;color:var(--g400);padding:24px;">Loading...</p>';
|
||||||
showFeed();
|
showFeed();
|
||||||
|
|
||||||
fetch('/api/learning/category/' + encodeURIComponent(slug), { headers: getAuthHeaders() })
|
getJson('/api/learning/category/' + encodeURIComponent(slug))
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (!data.success) { feedEl.innerHTML = '<p style="text-align:center;color:var(--red);padding:24px;">Category not found</p>'; return; }
|
if (!data.success) { feedEl.innerHTML = '<p style="text-align:center;color:var(--red);padding:24px;">Category not found</p>'; return; }
|
||||||
renderFeed(data.content, feedEl);
|
renderFeed(data.content, feedEl);
|
||||||
|
|
@ -206,25 +210,13 @@
|
||||||
'<i class="fas fa-search"></i> Searching for <strong>"' + esc(q) + '"</strong>…' +
|
'<i class="fas fa-search"></i> Searching for <strong>"' + esc(q) + '"</strong>…' +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|
||||||
fetch('/api/learning/search?q=' + encodeURIComponent(q), { headers: getAuthHeaders() })
|
getJson('/api/learning/search?q=' + encodeURIComponent(q))
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (!data.success) { feedEl.innerHTML = '<p style="color:var(--red);padding:12px;">Search failed</p>'; return; }
|
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;">' +
|
var header = renderSearchHeader(data.content.length, q);
|
||||||
'<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>';
|
|
||||||
|
|
||||||
if (data.content.length === 0) {
|
if (data.content.length === 0) {
|
||||||
feedEl.innerHTML = header +
|
feedEl.innerHTML = header + renderEmptySearchMessage();
|
||||||
'<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>';
|
|
||||||
} else {
|
} else {
|
||||||
var tempEl = document.createElement('div');
|
var tempEl = document.createElement('div');
|
||||||
renderFeed(data.content, tempEl);
|
renderFeed(data.content, tempEl);
|
||||||
|
|
@ -243,39 +235,8 @@
|
||||||
.catch(function() { feedEl.innerHTML = '<p style="color:var(--red);padding:12px;">Search error</p>'; });
|
.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) {
|
function loadContent(slug) {
|
||||||
fetch('/api/learning/content/' + encodeURIComponent(slug), { headers: getAuthHeaders() })
|
getJson('/api/learning/content/' + encodeURIComponent(slug))
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (!data.success) { showToast(data.error || 'Content not found', 'error'); return; }
|
if (!data.success) { showToast(data.error || 'Content not found', 'error'); return; }
|
||||||
currentContent = data.content;
|
currentContent = data.content;
|
||||||
|
|
@ -446,12 +407,7 @@
|
||||||
|
|
||||||
showLoading('Submitting quiz...');
|
showLoading('Submitting quiz...');
|
||||||
|
|
||||||
fetch('/api/learning/submit-quiz', {
|
sendJson('/api/learning/submit-quiz', 'POST', { contentId: currentContent.id, answers: answers })
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(),
|
|
||||||
body: JSON.stringify({ contentId: currentContent.id, answers: answers })
|
|
||||||
})
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
hideLoading();
|
hideLoading();
|
||||||
if (!data.success) { showToast(data.error || 'Submit failed', 'error'); return; }
|
if (!data.success) { showToast(data.error || 'Submit failed', 'error'); return; }
|
||||||
|
|
@ -621,8 +577,7 @@
|
||||||
updateAiOptions(editorType);
|
updateAiOptions(editorType);
|
||||||
|
|
||||||
// Hide WebDAV tab if Nextcloud not configured
|
// Hide WebDAV tab if Nextcloud not configured
|
||||||
fetch('/api/auth/me', { headers: getAuthHeaders() })
|
getJson('/api/auth/me')
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
var tab = document.getElementById('lh-ai-tab-webdav');
|
var tab = document.getElementById('lh-ai-tab-webdav');
|
||||||
if (tab) tab.style.display = (d.user && d.user.nextcloud_url) ? '' : 'none';
|
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 (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;
|
if (pathLabel) pathLabel.textContent = _aiWebdavCurrentPath;
|
||||||
|
|
||||||
fetch('/api/admin/learning/webdav-browse?path=' + encodeURIComponent(_aiWebdavCurrentPath), { headers: getAuthHeaders() })
|
getJson('/api/admin/learning/webdav-browse?path=' + encodeURIComponent(_aiWebdavCurrentPath))
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
.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 (!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;
|
if (pathLabel) pathLabel.textContent = data.path;
|
||||||
|
|
@ -849,9 +803,10 @@
|
||||||
if (!data.success) { showToast(data.error || 'Generation failed', 'error'); return; }
|
if (!data.success) { showToast(data.error || 'Generation failed', 'error'); return; }
|
||||||
// Presentation returns marpMarkdown directly; others return content{}
|
// Presentation returns marpMarkdown directly; others return content{}
|
||||||
var payload = data.contentType === 'presentation' ? data : data.content;
|
var payload = data.contentType === 'presentation' ? data : data.content;
|
||||||
applyAiContent(payload, contentType);
|
return applyAiContent(payload, contentType).then(function() {
|
||||||
closeAiPanel();
|
closeAiPanel();
|
||||||
showToast('Content generated! Review and save.', 'success');
|
showToast('Content generated! Review and save.', 'success');
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
hideBusy();
|
hideBusy();
|
||||||
|
|
@ -881,7 +836,7 @@
|
||||||
content.questions.forEach(function(q) { addQuestionBlock(q); });
|
content.questions.forEach(function(q) { addQuestionBlock(q); });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return applyAiCategory(content.category_name || content.category);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill title
|
// Fill title
|
||||||
|
|
@ -903,6 +858,44 @@
|
||||||
content.questions.forEach(function(q) { addQuestionBlock(q); });
|
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) {
|
function toggleRefineBar(forceShow) {
|
||||||
|
|
@ -926,12 +919,7 @@
|
||||||
toggleRefineBar(false);
|
toggleRefineBar(false);
|
||||||
showBusy('Refining content...');
|
showBusy('Refining content...');
|
||||||
|
|
||||||
fetch('/api/admin/learning/ai-refine', {
|
sendJson('/api/admin/learning/ai-refine', 'POST', { content: currentBody, instructions: instructions, model: model })
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(),
|
|
||||||
body: JSON.stringify({ content: currentBody, instructions: instructions, model: model })
|
|
||||||
})
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
hideBusy();
|
hideBusy();
|
||||||
if (!data.success) { showToast(data.error || 'Refine failed', 'error'); return; }
|
if (!data.success) { showToast(data.error || 'Refine failed', 'error'); return; }
|
||||||
|
|
@ -942,8 +930,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadCmsStats() {
|
function loadCmsStats() {
|
||||||
fetch('/api/admin/learning/stats', { headers: getAuthHeaders() })
|
getJson('/api/admin/learning/stats')
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (!data.success) return;
|
if (!data.success) return;
|
||||||
var s = data.stats;
|
var s = data.stats;
|
||||||
|
|
@ -957,8 +944,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadCmsCategories() {
|
function loadCmsCategories() {
|
||||||
fetch('/api/admin/learning/categories', { headers: getAuthHeaders() })
|
getJson('/api/admin/learning/categories')
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (!data.success) return;
|
if (!data.success) return;
|
||||||
var container = document.getElementById('lh-cms-categories');
|
var container = document.getElementById('lh-cms-categories');
|
||||||
|
|
@ -998,12 +984,7 @@
|
||||||
var input = document.getElementById('lh-cms-cat-name');
|
var input = document.getElementById('lh-cms-cat-name');
|
||||||
if (!input || !input.value.trim()) { showToast('Enter category name', 'error'); return; }
|
if (!input || !input.value.trim()) { showToast('Enter category name', 'error'); return; }
|
||||||
|
|
||||||
fetch('/api/admin/learning/categories', {
|
sendJson('/api/admin/learning/categories', 'POST', { name: input.value.trim() })
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(),
|
|
||||||
body: JSON.stringify({ name: input.value.trim() })
|
|
||||||
})
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
input.value = '';
|
input.value = '';
|
||||||
|
|
@ -1023,8 +1004,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.__confirmDeleteCat = null;
|
window.__confirmDeleteCat = null;
|
||||||
fetch('/api/admin/learning/categories/' + id, { method: 'DELETE', headers: getAuthHeaders() })
|
deleteJson('/api/admin/learning/categories/' + id)
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (data.success) { loadCmsCategories(); loadCategories(); showToast('Category deleted', 'info'); }
|
if (data.success) { loadCmsCategories(); loadCategories(); showToast('Category deleted', 'info'); }
|
||||||
else showToast(data.error || 'Failed', 'error');
|
else showToast(data.error || 'Failed', 'error');
|
||||||
|
|
@ -1036,8 +1016,7 @@
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
container.innerHTML = '<p style="text-align:center;color:var(--g400);padding:12px;">Loading...</p>';
|
container.innerHTML = '<p style="text-align:center;color:var(--g400);padding:12px;">Loading...</p>';
|
||||||
|
|
||||||
fetch('/api/admin/learning/content', { headers: getAuthHeaders() })
|
getJson('/api/admin/learning/content')
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (!data.success) return;
|
if (!data.success) return;
|
||||||
if (data.content.length === 0) {
|
if (data.content.length === 0) {
|
||||||
|
|
@ -1101,168 +1080,6 @@
|
||||||
renderCmsContentList(filtered);
|
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 ────────────────────────────────────────────
|
// ── Body editor ────────────────────────────────────────────
|
||||||
function initBodyEditor() {
|
function initBodyEditor() {
|
||||||
var wrap = document.getElementById('lh-body-editor');
|
var wrap = document.getElementById('lh-body-editor');
|
||||||
|
|
@ -1375,8 +1192,7 @@
|
||||||
if (!contentId) return;
|
if (!contentId) return;
|
||||||
|
|
||||||
// Load existing content
|
// Load existing content
|
||||||
fetch('/api/admin/learning/content/' + contentId, { headers: getAuthHeaders() })
|
getJson('/api/admin/learning/content/' + contentId)
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (!data.success) { showToast('Failed to load', 'error'); return; }
|
if (!data.success) { showToast('Failed to load', 'error'); return; }
|
||||||
var c = data.content;
|
var c = data.content;
|
||||||
|
|
@ -1487,8 +1303,7 @@
|
||||||
|
|
||||||
showLoading('Saving...');
|
showLoading('Saving...');
|
||||||
|
|
||||||
fetch(url, { method: method, headers: getAuthHeaders(), body: JSON.stringify(payload) })
|
sendJson(url, method, payload)
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (!data.success) { hideLoading(); showToast(data.error || 'Save failed', 'error'); return; }
|
if (!data.success) { hideLoading(); showToast(data.error || 'Save failed', 'error'); return; }
|
||||||
var contentId = id || data.id;
|
var contentId = id || data.id;
|
||||||
|
|
@ -1521,17 +1336,12 @@
|
||||||
url = '/api/admin/learning/content/' + contentId + '/questions';
|
url = '/api/admin/learning/content/' + contentId + '/questions';
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch(url, {
|
sendJson(url, method, {
|
||||||
method: method,
|
question_text: q.question_text,
|
||||||
headers: getAuthHeaders(),
|
question_type: q.question_type,
|
||||||
body: JSON.stringify({
|
explanation: q.explanation,
|
||||||
question_text: q.question_text,
|
options: q.options
|
||||||
question_type: q.question_type,
|
|
||||||
explanation: q.explanation,
|
|
||||||
options: q.options
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function() {
|
.then(function() {
|
||||||
saveQuestions(contentId, questions, index + 1, callback);
|
saveQuestions(contentId, questions, index + 1, callback);
|
||||||
})
|
})
|
||||||
|
|
@ -1570,8 +1380,7 @@
|
||||||
var id = document.getElementById('lh-cms-edit-id').value;
|
var id = document.getElementById('lh-cms-edit-id').value;
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
hideDeleteConfirm();
|
hideDeleteConfirm();
|
||||||
fetch('/api/admin/learning/content/' + id, { method: 'DELETE', headers: getAuthHeaders() })
|
deleteJson('/api/admin/learning/content/' + id)
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast('Content deleted', 'info');
|
showToast('Content deleted', 'info');
|
||||||
|
|
@ -1596,8 +1405,7 @@
|
||||||
// Open slide modal for a published presentation in the user-facing viewer
|
// Open slide modal for a published presentation in the user-facing viewer
|
||||||
function openSlidesFromSlug(slug) {
|
function openSlidesFromSlug(slug) {
|
||||||
showBusy('Loading slides...');
|
showBusy('Loading slides...');
|
||||||
fetch('/api/learning/content/' + encodeURIComponent(slug) + '/slides', { headers: getAuthHeaders() })
|
getJson('/api/learning/content/' + encodeURIComponent(slug) + '/slides')
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
hideBusy();
|
hideBusy();
|
||||||
if (!data.success) { showToast(data.error || 'Could not load slides', 'error'); return; }
|
if (!data.success) { showToast(data.error || 'Could not load slides', 'error'); return; }
|
||||||
|
|
@ -1612,11 +1420,7 @@
|
||||||
|
|
||||||
showBusy('Rendering slides...');
|
showBusy('Rendering slides...');
|
||||||
|
|
||||||
fetch('/api/admin/learning/preview-slides', {
|
sendJson('/api/admin/learning/preview-slides', 'POST', { markdown: md })
|
||||||
method: 'POST', headers: getAuthHeaders(),
|
|
||||||
body: JSON.stringify({ markdown: md })
|
|
||||||
})
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
hideBusy();
|
hideBusy();
|
||||||
if (!data.success) { showToast(data.error || 'Preview failed', 'error'); return; }
|
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)
|
// 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 || '' : '';
|
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 ──────────────────────────────────────────
|
// ── Build AI prompt ──────────────────────────────────────────
|
||||||
|
|
||||||
function buildGeneratePrompt(opts) {
|
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
|
var source = docText
|
||||||
? 'Based on the following document/resource text, generate educational content.\n\nDOCUMENT:\n"""\n' + docText.substring(0, 50000) + '\n"""\n'
|
? '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';
|
: '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 refineInstr = refinement ? '\n\nAdditional instructions for tone/style/focus: ' + refinement : '';
|
||||||
|
var categoryInstr = buildCategoryInstruction(existingCategories);
|
||||||
|
|
||||||
// ── Presentation ──
|
// ── Presentation ──
|
||||||
if (contentType === 'presentation') {
|
if (contentType === 'presentation') {
|
||||||
|
|
@ -171,11 +172,12 @@ Then each slide separated by ---. Guidelines:
|
||||||
// With questions — return JSON containing both Marp markdown and questions
|
// With questions — return JSON containing both Marp markdown and questions
|
||||||
return source + '\n' +
|
return source + '\n' +
|
||||||
'Create a professional Marp presentation (' + slideHint + ') suitable for medical education, ' +
|
'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:
|
Return ONLY a valid JSON object (no markdown, no code fences) with this exact structure:
|
||||||
{
|
{
|
||||||
"title": "string",
|
"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---)",
|
"marpMarkdown": "string (the complete Marp markdown starting with frontmatter ---\\nmarp: true\\ntheme: default\\n---)",
|
||||||
"questions": [
|
"questions": [
|
||||||
{
|
{
|
||||||
|
|
@ -210,11 +212,12 @@ Marp guidelines inside marpMarkdown:
|
||||||
typeInstr = 'This is an article. Write a comprehensive, well-structured body.' + wordHint + ' ' + qInstr;
|
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:
|
Return ONLY a valid JSON object (no markdown, no code fences) with this exact structure:
|
||||||
{
|
{
|
||||||
"title": "string",
|
"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)",
|
"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)",
|
"body": "string (valid HTML using only: p, h2, h3, ul, ol, li, strong, em, blockquote, code — no inline styles)",
|
||||||
"questions": [
|
"questions": [
|
||||||
|
|
@ -231,11 +234,20 @@ Return ONLY a valid JSON object (no markdown, no code fences) with this exact st
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- Each MCQ must have exactly 4 options, exactly 1 marked is_correct: true
|
- 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")
|
- 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
|
- body must be clean HTML, no raw markdown
|
||||||
- Do not include any text outside the JSON object`;
|
- 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 ────────────────────
|
// ── POST /api/admin/learning/ai-generate ────────────────────
|
||||||
// Accepts: multipart/form-data OR application/json
|
// 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.' });
|
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(
|
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; }
|
try { parsedPres = m ? JSON.parse(m[0]) : null; } catch(e2) { parsedPres = null; }
|
||||||
}
|
}
|
||||||
if (parsedPres && parsedPres.marpMarkdown) {
|
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)
|
// 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;
|
var { name, description, sort_order } = req.body;
|
||||||
if (!name || !name.trim()) return res.status(400).json({ error: 'Name required' });
|
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 slug = await uniqueSlug('learning_categories', name.trim());
|
||||||
|
|
||||||
var result = await db.run(
|
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 fs = require('node:fs');
|
||||||
const path = require('node:path');
|
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', () => {
|
test('Learning Hub destroys embedded Tiptap editors before removing CMS rows', () => {
|
||||||
assert.match(source, /function destroyTpEditor\(editor\)/);
|
assert.match(source, /from '\.\/learningHub\/tiptapEditor\.js'/);
|
||||||
assert.match(source, /function destroyQuestionBlockEditors\(block\)/);
|
assert.match(editorModule, /export function destroyTpEditor\(editor\)/);
|
||||||
assert.match(source, /function clearQuestionBlocks\(container\)/);
|
assert.match(editorModule, /export function destroyQuestionBlockEditors\(block\)/);
|
||||||
|
assert.match(editorModule, /export function clearQuestionBlocks\(container\)/);
|
||||||
assert.match(source, /destroyQuestionBlockEditors\(qb\); qb\.remove\(\)/);
|
assert.match(source, /destroyQuestionBlockEditors\(qb\); qb\.remove\(\)/);
|
||||||
assert.match(source, /destroyOptionEditor\(row\); row\.remove\(\)/);
|
assert.match(source, /destroyOptionEditor\(row\); row\.remove\(\)/);
|
||||||
assert.doesNotMatch(source, /document\.getElementById\('lh-cms-questions'\)\.innerHTML = ''/);
|
assert.doesNotMatch(source, /document\.getElementById\('lh-cms-questions'\)\.innerHTML = ''/);
|
||||||
assert.doesNotMatch(source, /qContainer\.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