v3.1: Add Learning Hub with CMS, quizzes, and 3-role user system
- New Learning Hub under Pediatric menu with content feed, category browsing, search, content viewer, and interactive quizzes (MCQ, true/false) - Quiz system with per-option wrong-answer explanations and general explanations - Admin CMS for creating/editing categories, content (articles, pearls, quizzes), and inline question builder with answer options - 3-role system: admin (full access), moderator (can manage Learning Hub content), user (all clinical features + learning hub read access) - 5 new database tables: learning_categories, learning_content, learning_questions, learning_options, learning_progress - User progress tracking with score history per quiz - Moderators see "Content Manager" tab instead of full Admin panel
This commit is contained in:
parent
fc3c58e4eb
commit
4af622913f
13 changed files with 1616 additions and 12 deletions
|
|
@ -1,6 +1,6 @@
|
|||
services:
|
||||
pediatric-scribe:
|
||||
image: danielonyejesi/pediatric-ai-scribe-v3:v3.0
|
||||
image: danielonyejesi/pediatric-ai-scribe-v3:v3.1
|
||||
ports:
|
||||
- "3552:3000"
|
||||
env_file:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "pediatric-ai-scribe",
|
||||
"version": "3.0.0",
|
||||
"version": "3.1.0",
|
||||
"description": "AI-powered pediatric clinical documentation platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -494,3 +494,74 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
|
|||
.dx-result-item:last-child{border-bottom:none;}
|
||||
.dx-result-item:hover{background:var(--g50);}
|
||||
.dx-result-code{font-weight:700;color:var(--blue);margin-right:4px;}
|
||||
|
||||
/* ============================================================
|
||||
LEARNING HUB
|
||||
============================================================ */
|
||||
|
||||
/* Category bar */
|
||||
.lh-category-bar{display:flex;gap:6px;flex-wrap:wrap;padding:8px 0;}
|
||||
.lh-cat-pill{padding:5px 14px;border-radius:20px;border:1px solid var(--g300);background:white;font-size:13px;cursor:pointer;transition:all 0.15s;font-family:inherit;color:var(--g600);}
|
||||
.lh-cat-pill:hover{border-color:var(--blue);color:var(--blue);}
|
||||
.lh-cat-pill.active{background:var(--blue);color:white;border-color:var(--blue);}
|
||||
|
||||
/* Feed */
|
||||
.lh-feed{display:flex;flex-direction:column;gap:8px;}
|
||||
.lh-feed-item{padding:14px 16px;cursor:pointer;transition:box-shadow 0.15s,border-color 0.15s;}
|
||||
.lh-feed-item:hover{box-shadow:var(--shadow-md);border-color:var(--blue-light);}
|
||||
.lh-feed-item-header{display:flex;align-items:flex-start;gap:12px;}
|
||||
.lh-feed-title{font-size:14px;font-weight:600;color:var(--g800);}
|
||||
.lh-feed-meta{font-size:12px;color:var(--g500);display:flex;gap:8px;flex-wrap:wrap;margin-top:2px;}
|
||||
.lh-feed-meta span:not(:last-child)::after{content:'·';margin-left:8px;color:var(--g300);}
|
||||
.lh-feed-badges{display:flex;gap:6px;flex-shrink:0;align-items:flex-start;}
|
||||
.lh-badge{font-size:11px;padding:2px 8px;border-radius:10px;background:var(--g100);color:var(--g600);white-space:nowrap;}
|
||||
.lh-badge-quiz{background:var(--amber-light);color:#92400e;}
|
||||
|
||||
/* Viewer */
|
||||
.lh-viewer .card{margin-bottom:0;}
|
||||
.lh-content-body{font-size:14px;line-height:1.75;color:var(--g700);}
|
||||
.lh-content-body h1,.lh-content-body h2,.lh-content-body h3{margin:16px 0 8px;color:var(--g800);}
|
||||
.lh-content-body p{margin-bottom:12px;}
|
||||
.lh-content-body ul,.lh-content-body ol{margin:0 0 12px 20px;}
|
||||
.lh-content-body li{margin-bottom:4px;}
|
||||
.lh-content-body strong{color:var(--g900);}
|
||||
.lh-content-body code{background:var(--g100);padding:2px 6px;border-radius:4px;font-size:13px;}
|
||||
.lh-content-body blockquote{border-left:3px solid var(--blue);padding:8px 16px;margin:12px 0;background:var(--blue-light);border-radius:0 6px 6px 0;font-style:italic;}
|
||||
.lh-content-body table{width:100%;border-collapse:collapse;margin:12px 0;}
|
||||
.lh-content-body th,.lh-content-body td{padding:8px 12px;border:1px solid var(--g200);font-size:13px;}
|
||||
.lh-content-body th{background:var(--g50);font-weight:600;}
|
||||
|
||||
/* Quiz */
|
||||
.lh-quiz-q{margin-bottom:20px;padding-bottom:16px;border-bottom:1px solid var(--g100);}
|
||||
.lh-quiz-q:last-child{border-bottom:none;margin-bottom:0;}
|
||||
.lh-quiz-q-header{display:flex;gap:8px;align-items:center;margin-bottom:6px;}
|
||||
.lh-quiz-q-num{font-size:12px;font-weight:700;color:var(--blue);background:var(--blue-light);padding:2px 8px;border-radius:6px;}
|
||||
.lh-quiz-q-type{font-size:11px;color:var(--g400);}
|
||||
.lh-quiz-q-text{font-size:14px;font-weight:500;margin-bottom:10px;color:var(--g800);}
|
||||
.lh-quiz-options{display:flex;flex-direction:column;gap:6px;}
|
||||
.lh-quiz-option{display:flex;align-items:center;gap:10px;padding:10px 14px;border:1.5px solid var(--g200);border-radius:8px;cursor:pointer;transition:all 0.15s;font-size:13px;}
|
||||
.lh-quiz-option:hover{border-color:var(--blue-light);background:var(--g50);}
|
||||
.lh-quiz-option input[type="radio"]{accent-color:var(--blue);width:16px;height:16px;}
|
||||
.lh-quiz-option span{flex:1;}
|
||||
.lh-opt-correct{border-color:var(--green) !important;background:var(--green-light) !important;}
|
||||
.lh-opt-wrong{border-color:var(--red) !important;background:var(--red-light) !important;}
|
||||
|
||||
/* Quiz results */
|
||||
.lh-result-item{margin-bottom:14px;padding-bottom:14px;border-bottom:1px solid var(--g100);}
|
||||
.lh-result-item:last-child{border-bottom:none;}
|
||||
.lh-result-header{font-size:14px;margin-bottom:6px;}
|
||||
.lh-expl{font-size:13px;padding:8px 12px;border-radius:6px;margin-top:6px;}
|
||||
.lh-expl-wrong{background:var(--red-light);color:#991b1b;}
|
||||
.lh-expl-correct{background:var(--green-light);color:#166534;}
|
||||
.lh-expl-general{background:var(--blue-light);color:#1e40af;}
|
||||
|
||||
/* CMS question builder */
|
||||
.lh-question-block{padding:12px;border:1px solid var(--g200);border-radius:8px;margin-bottom:8px;background:var(--g50);}
|
||||
.lh-option-row{display:flex;align-items:center;gap:6px;margin-bottom:4px;}
|
||||
|
||||
@media(max-width:640px){
|
||||
.lh-feed-item-header{flex-direction:column;gap:6px;}
|
||||
.lh-feed-badges{align-self:flex-start;}
|
||||
.lh-option-row{flex-wrap:wrap;}
|
||||
.lh-option-row .lh-opt-expl{width:100%;}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,6 +195,10 @@
|
|||
<i class="fas fa-rotate"></i>
|
||||
<span>Catch-Up Schedule</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="learning">
|
||||
<i class="fas fa-graduation-cap"></i>
|
||||
<span>Learning Hub</span>
|
||||
</button>
|
||||
<span class="sidebar-section-label">Account</span>
|
||||
<button class="tab-btn" data-tab="settings">
|
||||
<i class="fas fa-cog"></i>
|
||||
|
|
@ -739,6 +743,72 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== TAB: LEARNING HUB ===== -->
|
||||
<section id="learning-tab" class="tab-content">
|
||||
<div class="module-header">
|
||||
<h2><i class="fas fa-graduation-cap"></i> Learning Hub</h2>
|
||||
<p>Pediatric education, clinical pearls, and self-assessment quizzes</p>
|
||||
</div>
|
||||
|
||||
<!-- Search bar -->
|
||||
<div class="card" style="padding:12px 16px;">
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<i class="fas fa-search" style="color:var(--g400);"></i>
|
||||
<input type="text" id="lh-search" class="full-input" placeholder="Search topics, subjects..." style="border:none;flex:1;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category pills -->
|
||||
<div id="lh-categories" class="lh-category-bar"></div>
|
||||
|
||||
<!-- Content feed -->
|
||||
<div id="lh-feed" class="lh-feed"></div>
|
||||
|
||||
<!-- Content viewer (hidden until item clicked) -->
|
||||
<div id="lh-viewer" class="lh-viewer hidden">
|
||||
<button id="lh-back" class="btn-sm btn-ghost" style="margin-bottom:12px;"><i class="fas fa-arrow-left"></i> Back to Feed</button>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 id="lh-viewer-title"></h3>
|
||||
<span id="lh-viewer-meta" style="font-size:12px;color:var(--g500);"></span>
|
||||
</div>
|
||||
<div id="lh-viewer-body" class="lh-content-body" style="padding:16px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Quiz section (if content has questions) -->
|
||||
<div id="lh-quiz-section" class="hidden" style="margin-top:16px;">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-clipboard-question"></i> Quiz</h3>
|
||||
<span id="lh-quiz-count" style="font-size:12px;color:var(--g500);"></span>
|
||||
</div>
|
||||
<div id="lh-quiz-questions" style="padding:16px;"></div>
|
||||
<div style="padding:0 16px 16px;">
|
||||
<button id="lh-submit-quiz" class="btn-generate"><i class="fas fa-check"></i> Submit Answers</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Results -->
|
||||
<div id="lh-quiz-results" class="hidden" style="margin-top:12px;">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-chart-bar"></i> Results</h3>
|
||||
<span id="lh-quiz-score" class="model-tag"></span>
|
||||
</div>
|
||||
<div id="lh-quiz-explanations" style="padding:16px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Past attempts -->
|
||||
<div id="lh-progress-section" class="hidden" style="margin-top:12px;">
|
||||
<div class="card" style="padding:12px 16px;">
|
||||
<h4 style="margin:0 0 8px;font-size:13px;color:var(--g600);"><i class="fas fa-history"></i> Your Past Attempts</h4>
|
||||
<div id="lh-progress-list" style="font-size:13px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== TAB: ADMIN ===== -->
|
||||
<section id="admin-tab" class="tab-content">
|
||||
<div class="module-header">
|
||||
|
|
@ -945,6 +1015,98 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CMS: Learning Hub ──────────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3><i class="fas fa-graduation-cap"></i> Learning Hub CMS</h3>
|
||||
<span id="lh-cms-stats" style="font-size:12px;color:var(--g500);"></span>
|
||||
</div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:16px;">
|
||||
|
||||
<!-- Categories management -->
|
||||
<div>
|
||||
<h4 style="margin:0 0 8px;font-size:14px;"><i class="fas fa-folder"></i> Categories</h4>
|
||||
<div id="lh-cms-categories" style="margin-bottom:8px;"></div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<input type="text" id="lh-cms-cat-name" placeholder="New category name" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:150px;">
|
||||
<button id="btn-lh-add-cat" class="btn-sm btn-primary"><i class="fas fa-plus"></i> Add Category</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr style="border:none;border-top:1px solid var(--g200);margin:0;">
|
||||
|
||||
<!-- Content management -->
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||||
<h4 style="margin:0;font-size:14px;"><i class="fas fa-file-alt"></i> Content</h4>
|
||||
<button id="btn-lh-new-content" class="btn-sm btn-primary"><i class="fas fa-plus"></i> New Content</button>
|
||||
</div>
|
||||
<div id="lh-cms-content-list" style="max-height:400px;overflow-y:auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Learning Hub Content Editor (hidden by default) -->
|
||||
<div id="lh-cms-editor" class="card hidden">
|
||||
<div class="card-header">
|
||||
<h3 id="lh-cms-editor-title"><i class="fas fa-edit"></i> New Content</h3>
|
||||
<button id="btn-lh-close-editor" class="btn-sm btn-ghost"><i class="fas fa-times"></i> Close</button>
|
||||
</div>
|
||||
<div style="padding:16px;display:flex;flex-direction:column;gap:12px;">
|
||||
<input type="hidden" id="lh-cms-edit-id" value="">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Title</label>
|
||||
<input type="text" id="lh-cms-edit-title" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;" placeholder="Content title">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Subject</label>
|
||||
<input type="text" id="lh-cms-edit-subject" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;" placeholder="e.g., Development, Cardiology">
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Category</label>
|
||||
<select id="lh-cms-edit-category" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;">
|
||||
<option value="">Uncategorized</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Type</label>
|
||||
<select id="lh-cms-edit-type" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;">
|
||||
<option value="article">Article</option>
|
||||
<option value="quiz">Quiz Only</option>
|
||||
<option value="pearl">Clinical Pearl</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Body (supports basic HTML)</label>
|
||||
<textarea id="lh-cms-edit-body" rows="10" style="width:100%;font-size:13px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;box-sizing:border-box;font-family:inherit;" placeholder="Content body..."></textarea>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<label style="font-size:13px;font-weight:600;">Published:</label>
|
||||
<select id="lh-cms-edit-published" style="font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">
|
||||
<option value="false">Draft</option>
|
||||
<option value="true">Published</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Quiz Questions Builder -->
|
||||
<hr style="border:none;border-top:1px solid var(--g200);margin:0;">
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||||
<h4 style="margin:0;font-size:14px;"><i class="fas fa-clipboard-question"></i> Quiz Questions</h4>
|
||||
<button id="btn-lh-add-question" class="btn-sm btn-ghost"><i class="fas fa-plus"></i> Add Question</button>
|
||||
</div>
|
||||
<div id="lh-cms-questions"></div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button id="btn-lh-save-content" class="btn-sm btn-primary"><i class="fas fa-save"></i> Save Content</button>
|
||||
<button id="btn-lh-delete-content" class="btn-sm hidden" style="background:var(--red-light);color:var(--red);border:none;border-radius:6px;padding:6px 14px;font-size:13px;cursor:pointer;"><i class="fas fa-trash"></i> Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CMS: Reset to Defaults ─────────────────────────────────── -->
|
||||
<div class="card" style="border:1px solid var(--red-light);">
|
||||
<div class="card-header"><h3 style="color:var(--red);"><i class="fas fa-triangle-exclamation"></i> Reset Settings</h3></div>
|
||||
|
|
@ -1516,6 +1678,7 @@
|
|||
<script src="/js/sickVisit.js"></script>
|
||||
<script src="/js/encounters.js"></script>
|
||||
<script src="/js/memories.js"></script>
|
||||
<script src="/js/learningHub.js"></script>
|
||||
<script src="/js/admin.js"></script>
|
||||
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -25,8 +25,35 @@
|
|||
});
|
||||
|
||||
function loadAdmin() {
|
||||
loadSettings();
|
||||
loadUsers();
|
||||
var isModerator = window._userRole === 'moderator';
|
||||
// Moderators only see Learning Hub CMS; hide other admin sections
|
||||
var adminTab = document.getElementById('admin-tab');
|
||||
if (adminTab) {
|
||||
adminTab.querySelectorAll('.card, .admin-stats').forEach(function(card) {
|
||||
// Show all for admin
|
||||
if (!isModerator) { card.style.display = ''; return; }
|
||||
// For moderator: only show Learning Hub CMS cards and editor
|
||||
var header = card.querySelector('.card-header h3');
|
||||
var headerText = header ? header.textContent.trim() : '';
|
||||
var isLearning = headerText.indexOf('Learning Hub') !== -1;
|
||||
var isEditor = card.id === 'lh-cms-editor';
|
||||
if (isLearning || isEditor) {
|
||||
card.style.display = '';
|
||||
} else {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
});
|
||||
// Hide module-header subtitle for moderators
|
||||
var moduleHeader = adminTab.querySelector('.module-header');
|
||||
if (moduleHeader && isModerator) {
|
||||
moduleHeader.querySelector('h2').innerHTML = '<i class="fas fa-pen-to-square"></i> Content Manager';
|
||||
moduleHeader.querySelector('p').textContent = 'Create and manage Learning Hub content';
|
||||
}
|
||||
}
|
||||
if (!isModerator) {
|
||||
loadSettings();
|
||||
loadUsers();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- SETTINGS + STATS ----
|
||||
|
|
@ -109,7 +136,7 @@
|
|||
|
||||
tbody.innerHTML = users.map(function(u) {
|
||||
var isSelf = currentUser.id && u.id === currentUser.id;
|
||||
var roleColor = u.role === 'admin' ? 'var(--blue)' : 'var(--g500)';
|
||||
var roleColor = u.role === 'admin' ? 'var(--blue)' : u.role === 'moderator' ? 'var(--purple)' : 'var(--g500)';
|
||||
var statusColor = u.disabled ? 'var(--red)' : (u.email_verified ? 'var(--green)' : 'var(--amber)');
|
||||
var statusText = u.disabled ? 'Disabled' : (u.email_verified ? 'Active' : 'Unverified');
|
||||
var joined = u.created_at ? new Date(u.created_at).toLocaleDateString() : '—';
|
||||
|
|
@ -129,6 +156,12 @@
|
|||
} else {
|
||||
actions.push('<button class="btn-sm btn-primary admin-action" data-action="promote" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Make Admin</button>');
|
||||
}
|
||||
if (u.role !== 'moderator') {
|
||||
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="set-moderator" data-id="' + u.id + '" data-email="' + esc(u.email) + '" style="color:var(--purple);">Set Moderator</button>');
|
||||
}
|
||||
if (u.role === 'moderator') {
|
||||
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="set-user" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Set User</button>');
|
||||
}
|
||||
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="reset-pw" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Reset PW</button>');
|
||||
actions.push('<button class="btn-sm admin-action" style="background:var(--red-light);color:var(--red);" data-action="delete" data-id="' + u.id + '" data-email="' + esc(u.email) + '" data-name="' + esc(u.name) + '">Delete</button>');
|
||||
} else {
|
||||
|
|
@ -189,6 +222,20 @@
|
|||
});
|
||||
break;
|
||||
|
||||
case 'set-moderator':
|
||||
if (!confirm('Set ' + email + ' as moderator? They will be able to manage Learning Hub content.')) return;
|
||||
adminPost('/api/admin/users/' + userId + '/role', { role: 'moderator' }, function() {
|
||||
showToast(email + ' is now a moderator', 'success'); loadUsers();
|
||||
});
|
||||
break;
|
||||
|
||||
case 'set-user':
|
||||
if (!confirm('Set ' + email + ' to full user role?')) return;
|
||||
adminPost('/api/admin/users/' + userId + '/role', { role: 'user' }, function() {
|
||||
showToast(email + ' is now a user', 'success'); loadUsers();
|
||||
});
|
||||
break;
|
||||
|
||||
case 'reset-pw':
|
||||
var newPw = prompt('New password for ' + email + ' (8+ characters):');
|
||||
if (!newPw) return;
|
||||
|
|
|
|||
|
|
@ -82,15 +82,23 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
}
|
||||
if (userName) userName.textContent = user.name || user.email;
|
||||
|
||||
// Show admin tab for admins
|
||||
// Show admin tab for admins; show as "Content Manager" for moderators
|
||||
var adminTabBtn = document.getElementById('admin-tab-btn');
|
||||
if (adminTabBtn) {
|
||||
if (user.role === 'admin') {
|
||||
adminTabBtn.classList.remove('hidden');
|
||||
adminTabBtn.querySelector('span').textContent = 'Admin';
|
||||
adminTabBtn.querySelector('i').className = 'fas fa-user-shield';
|
||||
} else if (user.role === 'moderator') {
|
||||
adminTabBtn.classList.remove('hidden');
|
||||
adminTabBtn.querySelector('span').textContent = 'Content Manager';
|
||||
adminTabBtn.querySelector('i').className = 'fas fa-pen-to-square';
|
||||
} else {
|
||||
adminTabBtn.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
// Store role globally for JS modules to check
|
||||
window._userRole = user.role;
|
||||
|
||||
console.log('[Auth] ✅ Entered app as:', user.email, 'Role:', user.role);
|
||||
|
||||
|
|
|
|||
747
public/js/learningHub.js
Normal file
747
public/js/learningHub.js
Normal file
|
|
@ -0,0 +1,747 @@
|
|||
// ============================================================
|
||||
// LEARNING HUB — User content feed, viewer, quizzes + Admin CMS
|
||||
// ============================================================
|
||||
|
||||
(function() {
|
||||
|
||||
var loaded = false;
|
||||
var cmsLoaded = false;
|
||||
var currentContent = null;
|
||||
var currentView = 'feed'; // 'feed' | 'category' | 'viewer'
|
||||
|
||||
// ── Load when tab activated ────────────────────────────────
|
||||
document.addEventListener('tabChanged', function(e) {
|
||||
if (e.detail && e.detail.tab === 'learning') {
|
||||
if (!loaded) { loadFeed(); loadCategories(); loaded = true; }
|
||||
}
|
||||
// Load CMS for both admin and moderator when they open admin/content manager tab
|
||||
if (e.detail && e.detail.tab === 'admin') {
|
||||
if (!cmsLoaded && (window._userRole === 'admin' || window._userRole === 'moderator')) {
|
||||
loadCms(); cmsLoaded = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Event delegation ───────────────────────────────────────
|
||||
document.addEventListener('click', function(e) {
|
||||
// Feed item click
|
||||
var feedItem = e.target.closest('.lh-feed-item');
|
||||
if (feedItem && feedItem.dataset.slug) {
|
||||
loadContent(feedItem.dataset.slug);
|
||||
return;
|
||||
}
|
||||
|
||||
// Category pill click
|
||||
var catPill = e.target.closest('.lh-cat-pill');
|
||||
if (catPill && catPill.dataset.slug) {
|
||||
if (catPill.dataset.slug === 'all') {
|
||||
loadFeed();
|
||||
setActiveCatPill('all');
|
||||
} else {
|
||||
loadCategoryContent(catPill.dataset.slug);
|
||||
setActiveCatPill(catPill.dataset.slug);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Back button
|
||||
if (e.target.closest('#lh-back')) {
|
||||
showFeed();
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit quiz
|
||||
if (e.target.closest('#lh-submit-quiz')) {
|
||||
submitQuiz();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── CMS events ─────────────────────────────
|
||||
if (e.target.closest('#btn-lh-add-cat')) { addCategory(); return; }
|
||||
if (e.target.closest('#btn-lh-new-content')) { openEditor(null); return; }
|
||||
if (e.target.closest('#btn-lh-close-editor')) { closeEditor(); return; }
|
||||
if (e.target.closest('#btn-lh-save-content')) { saveContent(); return; }
|
||||
if (e.target.closest('#btn-lh-delete-content')) { deleteContent(); return; }
|
||||
if (e.target.closest('#btn-lh-add-question')) { addQuestionBlock(); return; }
|
||||
|
||||
// CMS content item edit
|
||||
var cmsItem = e.target.closest('.lh-cms-content-item');
|
||||
if (cmsItem && cmsItem.dataset.id) { openEditor(cmsItem.dataset.id); return; }
|
||||
|
||||
// CMS delete category
|
||||
var delCat = e.target.closest('.lh-cms-del-cat');
|
||||
if (delCat) { deleteCategory(delCat.dataset.id); return; }
|
||||
|
||||
// Remove question block
|
||||
var rmQ = e.target.closest('.lh-rm-question');
|
||||
if (rmQ) { var qb = rmQ.closest('.lh-question-block'); if (qb) qb.remove(); return; }
|
||||
|
||||
// Add option to question
|
||||
var addOpt = e.target.closest('.lh-add-option');
|
||||
if (addOpt) {
|
||||
var qBlock = addOpt.closest('.lh-question-block');
|
||||
if (qBlock) addOptionRow(qBlock.querySelector('.lh-options-list'), null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove option
|
||||
var rmOpt = e.target.closest('.lh-rm-option');
|
||||
if (rmOpt) { var row = rmOpt.closest('.lh-option-row'); if (row) row.remove(); return; }
|
||||
});
|
||||
|
||||
// Search
|
||||
var searchTimeout = null;
|
||||
var searchEl = document.getElementById('lh-search');
|
||||
if (searchEl) {
|
||||
searchEl.addEventListener('input', function() {
|
||||
clearTimeout(searchTimeout);
|
||||
var q = searchEl.value.trim();
|
||||
if (!q) { loadFeed(); return; }
|
||||
searchTimeout = setTimeout(function() { searchContent(q); }, 300);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// USER-FACING: FEED, CATEGORIES, CONTENT, QUIZZES
|
||||
// ============================================================
|
||||
|
||||
function loadFeed() {
|
||||
var feedEl = document.getElementById('lh-feed');
|
||||
if (!feedEl) return;
|
||||
feedEl.innerHTML = '<p style="text-align:center;color:var(--g400);padding:24px;">Loading...</p>';
|
||||
showFeed();
|
||||
|
||||
fetch('/api/learning/feed', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) { feedEl.innerHTML = '<p style="text-align:center;color:var(--red);padding:24px;">Failed to load</p>'; return; }
|
||||
renderFeed(data.content, feedEl);
|
||||
})
|
||||
.catch(function() { feedEl.innerHTML = '<p style="text-align:center;color:var(--red);padding:24px;">Connection error</p>'; });
|
||||
}
|
||||
|
||||
function loadCategories() {
|
||||
fetch('/api/learning/categories', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.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;
|
||||
});
|
||||
}
|
||||
|
||||
function loadCategoryContent(slug) {
|
||||
var feedEl = document.getElementById('lh-feed');
|
||||
if (!feedEl) return;
|
||||
feedEl.innerHTML = '<p style="text-align:center;color:var(--g400);padding:24px;">Loading...</p>';
|
||||
showFeed();
|
||||
|
||||
fetch('/api/learning/category/' + encodeURIComponent(slug), { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) { feedEl.innerHTML = '<p style="text-align:center;color:var(--red);padding:24px;">Category not found</p>'; return; }
|
||||
renderFeed(data.content, feedEl);
|
||||
});
|
||||
}
|
||||
|
||||
function searchContent(q) {
|
||||
var feedEl = document.getElementById('lh-feed');
|
||||
if (!feedEl) return;
|
||||
showFeed();
|
||||
|
||||
fetch('/api/learning/search?q=' + encodeURIComponent(q), { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) return;
|
||||
renderFeed(data.content, feedEl);
|
||||
});
|
||||
}
|
||||
|
||||
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' : 'fa-file-alt';
|
||||
var typeColor = item.content_type === 'quiz' ? 'var(--amber)' : item.content_type === 'pearl' ? 'var(--purple, #8b5cf6)' : '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(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) { showToast(data.error || 'Content not found', 'error'); return; }
|
||||
currentContent = data.content;
|
||||
showViewer(data.content);
|
||||
})
|
||||
.catch(function() { showToast('Failed to load content', 'error'); });
|
||||
}
|
||||
|
||||
function showFeed() {
|
||||
currentView = 'feed';
|
||||
var feedEl = document.getElementById('lh-feed');
|
||||
var viewerEl = document.getElementById('lh-viewer');
|
||||
var catsEl = document.getElementById('lh-categories');
|
||||
var searchWrap = document.getElementById('lh-search');
|
||||
if (feedEl) feedEl.classList.remove('hidden');
|
||||
if (viewerEl) viewerEl.classList.add('hidden');
|
||||
if (catsEl) catsEl.classList.remove('hidden');
|
||||
if (searchWrap) searchWrap.closest('.card').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function showViewer(item) {
|
||||
currentView = 'viewer';
|
||||
var feedEl = document.getElementById('lh-feed');
|
||||
var viewerEl = document.getElementById('lh-viewer');
|
||||
var catsEl = document.getElementById('lh-categories');
|
||||
var searchWrap = document.getElementById('lh-search');
|
||||
if (feedEl) feedEl.classList.add('hidden');
|
||||
if (viewerEl) viewerEl.classList.remove('hidden');
|
||||
if (catsEl) catsEl.classList.add('hidden');
|
||||
if (searchWrap) searchWrap.closest('.card').classList.add('hidden');
|
||||
|
||||
var titleEl = document.getElementById('lh-viewer-title');
|
||||
var metaEl = document.getElementById('lh-viewer-meta');
|
||||
var bodyEl = document.getElementById('lh-viewer-body');
|
||||
var quizSection = document.getElementById('lh-quiz-section');
|
||||
var resultsSection = document.getElementById('lh-quiz-results');
|
||||
var progressSection = document.getElementById('lh-progress-section');
|
||||
|
||||
if (titleEl) titleEl.textContent = item.title;
|
||||
if (metaEl) {
|
||||
var parts = [];
|
||||
if (item.category_name) parts.push(item.category_name);
|
||||
if (item.subject) parts.push(item.subject);
|
||||
if (item.author_name) parts.push('by ' + item.author_name);
|
||||
if (item.created_at) parts.push(new Date(item.created_at).toLocaleDateString());
|
||||
metaEl.textContent = parts.join(' | ');
|
||||
}
|
||||
if (bodyEl) {
|
||||
// Render body as HTML (admin creates it)
|
||||
bodyEl.innerHTML = sanitizeHtml(item.body || '');
|
||||
}
|
||||
|
||||
// Quiz
|
||||
if (quizSection) {
|
||||
if (item.questions && item.questions.length > 0) {
|
||||
quizSection.classList.remove('hidden');
|
||||
renderQuiz(item.questions);
|
||||
} else {
|
||||
quizSection.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
if (resultsSection) resultsSection.classList.add('hidden');
|
||||
|
||||
// Progress
|
||||
if (progressSection && item.progress && item.progress.length > 0) {
|
||||
progressSection.classList.remove('hidden');
|
||||
var progHtml = item.progress.map(function(p) {
|
||||
var pct = p.total > 0 ? Math.round((p.score / p.total) * 100) : 0;
|
||||
var date = new Date(p.completed_at).toLocaleDateString();
|
||||
return '<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid var(--g100);">' +
|
||||
'<span>' + date + '</span>' +
|
||||
'<span style="font-weight:600;color:' + (pct >= 70 ? 'var(--green)' : 'var(--amber)') + ';">' + p.score + '/' + p.total + ' (' + pct + '%)</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
document.getElementById('lh-progress-list').innerHTML = progHtml;
|
||||
} else if (progressSection) {
|
||||
progressSection.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function renderQuiz(questions) {
|
||||
var container = document.getElementById('lh-quiz-questions');
|
||||
var countEl = document.getElementById('lh-quiz-count');
|
||||
if (!container) return;
|
||||
if (countEl) countEl.textContent = questions.length + ' question' + (questions.length !== 1 ? 's' : '');
|
||||
|
||||
container.innerHTML = questions.map(function(q, idx) {
|
||||
var typeLabel = q.question_type === 'true_false' ? 'True / False' : 'Multiple Choice';
|
||||
var optionsHtml = (q.options || []).map(function(opt) {
|
||||
return '<label class="lh-quiz-option">' +
|
||||
'<input type="radio" name="lh-q-' + q.id + '" value="' + opt.id + '">' +
|
||||
'<span>' + esc(opt.option_text) + '</span>' +
|
||||
'</label>';
|
||||
}).join('');
|
||||
|
||||
return '<div class="lh-quiz-q" data-qid="' + q.id + '">' +
|
||||
'<div class="lh-quiz-q-header">' +
|
||||
'<span class="lh-quiz-q-num">Q' + (idx + 1) + '</span>' +
|
||||
'<span class="lh-quiz-q-type">' + typeLabel + '</span>' +
|
||||
'</div>' +
|
||||
'<p class="lh-quiz-q-text">' + esc(q.question_text) + '</p>' +
|
||||
'<div class="lh-quiz-options">' + optionsHtml + '</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function submitQuiz() {
|
||||
if (!currentContent || !currentContent.questions) return;
|
||||
|
||||
var answers = [];
|
||||
currentContent.questions.forEach(function(q) {
|
||||
var selected = document.querySelector('input[name="lh-q-' + q.id + '"]:checked');
|
||||
answers.push({
|
||||
questionId: q.id,
|
||||
optionId: selected ? parseInt(selected.value) : null
|
||||
});
|
||||
});
|
||||
|
||||
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(); })
|
||||
.then(function(data) {
|
||||
hideLoading();
|
||||
if (!data.success) { showToast(data.error || 'Submit failed', 'error'); return; }
|
||||
showQuizResults(data);
|
||||
})
|
||||
.catch(function() { hideLoading(); showToast('Submit failed', 'error'); });
|
||||
}
|
||||
|
||||
function showQuizResults(data) {
|
||||
var resultsEl = document.getElementById('lh-quiz-results');
|
||||
var scoreEl = document.getElementById('lh-quiz-score');
|
||||
var explEl = document.getElementById('lh-quiz-explanations');
|
||||
if (!resultsEl) return;
|
||||
|
||||
resultsEl.classList.remove('hidden');
|
||||
var pct = data.percentage;
|
||||
var color = pct >= 80 ? 'var(--green)' : pct >= 50 ? 'var(--amber)' : 'var(--red)';
|
||||
if (scoreEl) {
|
||||
scoreEl.textContent = data.score + '/' + data.total + ' (' + pct + '%)';
|
||||
scoreEl.style.background = color;
|
||||
scoreEl.style.color = 'white';
|
||||
}
|
||||
|
||||
if (explEl) {
|
||||
explEl.innerHTML = data.results.map(function(r, idx) {
|
||||
var icon = r.isCorrect ? '<i class="fas fa-check-circle" style="color:var(--green);"></i>' : '<i class="fas fa-times-circle" style="color:var(--red);"></i>';
|
||||
var explHtml = '';
|
||||
if (!r.isCorrect && r.selectedExplanation) {
|
||||
explHtml += '<div class="lh-expl lh-expl-wrong"><strong>Why incorrect:</strong> ' + esc(r.selectedExplanation) + '</div>';
|
||||
}
|
||||
if (!r.isCorrect) {
|
||||
explHtml += '<div class="lh-expl lh-expl-correct"><strong>Correct answer:</strong> ' + esc(r.correctOptionText) + '</div>';
|
||||
}
|
||||
if (r.generalExplanation) {
|
||||
explHtml += '<div class="lh-expl lh-expl-general"><strong>Explanation:</strong> ' + esc(r.generalExplanation) + '</div>';
|
||||
}
|
||||
|
||||
return '<div class="lh-result-item">' +
|
||||
'<div class="lh-result-header">' + icon + ' <strong>Q' + (idx + 1) + ':</strong> ' + esc(r.questionText) + '</div>' +
|
||||
explHtml +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Highlight correct/incorrect in quiz
|
||||
data.results.forEach(function(r) {
|
||||
var qDiv = document.querySelector('.lh-quiz-q[data-qid="' + r.questionId + '"]');
|
||||
if (!qDiv) return;
|
||||
qDiv.querySelectorAll('.lh-quiz-option').forEach(function(label) {
|
||||
var input = label.querySelector('input');
|
||||
if (!input) return;
|
||||
var optId = parseInt(input.value);
|
||||
if (optId === r.correctOptionId) label.classList.add('lh-opt-correct');
|
||||
if (optId === r.selectedOptionId && !r.isCorrect) label.classList.add('lh-opt-wrong');
|
||||
input.disabled = true;
|
||||
});
|
||||
});
|
||||
|
||||
// Disable submit button
|
||||
var submitBtn = document.getElementById('lh-submit-quiz');
|
||||
if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = '<i class="fas fa-check"></i> Submitted'; }
|
||||
|
||||
resultsEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
function setActiveCatPill(slug) {
|
||||
document.querySelectorAll('.lh-cat-pill').forEach(function(p) {
|
||||
p.classList.toggle('active', p.dataset.slug === slug);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ADMIN CMS
|
||||
// ============================================================
|
||||
|
||||
function loadCms() {
|
||||
loadCmsCategories();
|
||||
loadCmsContent();
|
||||
loadCmsStats();
|
||||
}
|
||||
|
||||
function loadCmsStats() {
|
||||
fetch('/api/admin/learning/stats', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) return;
|
||||
var el = document.getElementById('lh-cms-stats');
|
||||
if (el) el.textContent = data.stats.publishedContent + ' published, ' + data.stats.totalContent + ' total, ' + data.stats.totalCategories + ' categories, ' + data.stats.totalAttempts + ' quiz attempts';
|
||||
});
|
||||
}
|
||||
|
||||
function loadCmsCategories() {
|
||||
fetch('/api/admin/learning/categories', { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) return;
|
||||
var container = document.getElementById('lh-cms-categories');
|
||||
if (!container) return;
|
||||
|
||||
if (data.categories.length === 0) {
|
||||
container.innerHTML = '<p style="font-size:13px;color:var(--g400);margin:0;">No categories yet.</p>';
|
||||
} else {
|
||||
container.innerHTML = data.categories.map(function(c) {
|
||||
return '<div style="display:flex;align-items:center;gap:8px;padding:4px 0;">' +
|
||||
'<span style="font-size:13px;font-weight:500;flex:1;">' + esc(c.name) + '</span>' +
|
||||
'<span style="font-size:11px;color:var(--g500);">' + c.content_count + ' items</span>' +
|
||||
'<button class="btn-sm btn-ghost lh-cms-del-cat" data-id="' + c.id + '" style="padding:2px 6px;font-size:11px;color:var(--red);"><i class="fas fa-trash"></i></button>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Update category dropdown in editor
|
||||
var sel = document.getElementById('lh-cms-edit-category');
|
||||
if (sel) {
|
||||
sel.innerHTML = '<option value="">Uncategorized</option>';
|
||||
data.categories.forEach(function(c) {
|
||||
sel.innerHTML += '<option value="' + c.id + '">' + esc(c.name) + '</option>';
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function addCategory() {
|
||||
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(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
input.value = '';
|
||||
loadCmsCategories();
|
||||
loadCategories(); // refresh user-facing pills too
|
||||
showToast('Category created', 'success');
|
||||
} else showToast(data.error || 'Failed', 'error');
|
||||
})
|
||||
.catch(function() { showToast('Request failed', 'error'); });
|
||||
}
|
||||
|
||||
function deleteCategory(id) {
|
||||
if (!confirm('Delete this category? Content will become uncategorized.')) return;
|
||||
fetch('/api/admin/learning/categories/' + id, { method: 'DELETE', headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) { loadCmsCategories(); loadCategories(); showToast('Category deleted', 'info'); }
|
||||
else showToast(data.error || 'Failed', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function loadCmsContent() {
|
||||
var container = document.getElementById('lh-cms-content-list');
|
||||
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(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) return;
|
||||
if (data.content.length === 0) {
|
||||
container.innerHTML = '<p style="text-align:center;color:var(--g400);padding:12px;">No content yet. Click "New Content" to create.</p>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = data.content.map(function(item) {
|
||||
var statusBadge = item.published
|
||||
? '<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:var(--green-light,#d1fae5);color:var(--green);">Published</span>'
|
||||
: '<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:var(--g100);color:var(--g500);">Draft</span>';
|
||||
var qBadge = item.question_count > 0 ? ' <span style="font-size:11px;color:var(--amber);">' + item.question_count + 'Q</span>' : '';
|
||||
|
||||
return '<div class="lh-cms-content-item" data-id="' + item.id + '" style="display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--g100);cursor:pointer;">' +
|
||||
'<div style="flex:1;min-width:0;">' +
|
||||
'<div style="font-size:13px;font-weight:500;">' + esc(item.title) + qBadge + '</div>' +
|
||||
'<div style="font-size:11px;color:var(--g500);">' + (item.category_name || 'Uncategorized') + (item.subject ? ' | ' + esc(item.subject) : '') + '</div>' +
|
||||
'</div>' +
|
||||
statusBadge +
|
||||
'<i class="fas fa-chevron-right" style="color:var(--g300);font-size:12px;"></i>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
});
|
||||
}
|
||||
|
||||
function openEditor(contentId) {
|
||||
var editor = document.getElementById('lh-cms-editor');
|
||||
if (!editor) return;
|
||||
editor.classList.remove('hidden');
|
||||
|
||||
// Reset
|
||||
document.getElementById('lh-cms-edit-id').value = '';
|
||||
document.getElementById('lh-cms-edit-title').value = '';
|
||||
document.getElementById('lh-cms-edit-subject').value = '';
|
||||
document.getElementById('lh-cms-edit-body').value = '';
|
||||
document.getElementById('lh-cms-edit-category').value = '';
|
||||
document.getElementById('lh-cms-edit-type').value = 'article';
|
||||
document.getElementById('lh-cms-edit-published').value = 'false';
|
||||
document.getElementById('lh-cms-questions').innerHTML = '';
|
||||
document.getElementById('lh-cms-editor-title').innerHTML = '<i class="fas fa-edit"></i> New Content';
|
||||
document.getElementById('btn-lh-delete-content').classList.add('hidden');
|
||||
|
||||
if (!contentId) {
|
||||
editor.scrollIntoView({ behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Load existing content
|
||||
fetch('/api/admin/learning/content/' + contentId, { headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) { showToast('Failed to load', 'error'); return; }
|
||||
var c = data.content;
|
||||
document.getElementById('lh-cms-edit-id').value = c.id;
|
||||
document.getElementById('lh-cms-edit-title').value = c.title;
|
||||
document.getElementById('lh-cms-edit-subject').value = c.subject || '';
|
||||
document.getElementById('lh-cms-edit-body').value = c.body || '';
|
||||
document.getElementById('lh-cms-edit-category').value = c.category_id || '';
|
||||
document.getElementById('lh-cms-edit-type').value = c.content_type || 'article';
|
||||
document.getElementById('lh-cms-edit-published').value = c.published ? 'true' : 'false';
|
||||
document.getElementById('lh-cms-editor-title').innerHTML = '<i class="fas fa-edit"></i> Edit: ' + esc(c.title);
|
||||
document.getElementById('btn-lh-delete-content').classList.remove('hidden');
|
||||
|
||||
// Load questions
|
||||
var qContainer = document.getElementById('lh-cms-questions');
|
||||
qContainer.innerHTML = '';
|
||||
if (c.questions && c.questions.length > 0) {
|
||||
c.questions.forEach(function(q) { addQuestionBlock(q); });
|
||||
}
|
||||
|
||||
editor.scrollIntoView({ behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
var editor = document.getElementById('lh-cms-editor');
|
||||
if (editor) editor.classList.add('hidden');
|
||||
}
|
||||
|
||||
function saveContent() {
|
||||
var id = document.getElementById('lh-cms-edit-id').value;
|
||||
var title = document.getElementById('lh-cms-edit-title').value.trim();
|
||||
var subject = document.getElementById('lh-cms-edit-subject').value.trim();
|
||||
var body = document.getElementById('lh-cms-edit-body').value;
|
||||
var category_id = document.getElementById('lh-cms-edit-category').value || null;
|
||||
var content_type = document.getElementById('lh-cms-edit-type').value;
|
||||
var published = document.getElementById('lh-cms-edit-published').value === 'true';
|
||||
|
||||
if (!title) { showToast('Title required', 'error'); return; }
|
||||
|
||||
// Gather questions
|
||||
var questions = [];
|
||||
document.querySelectorAll('#lh-cms-questions .lh-question-block').forEach(function(qBlock) {
|
||||
var qText = qBlock.querySelector('.lh-q-text').value.trim();
|
||||
if (!qText) return;
|
||||
var qType = qBlock.querySelector('.lh-q-type').value;
|
||||
var qExpl = qBlock.querySelector('.lh-q-explanation').value.trim();
|
||||
var qId = qBlock.dataset.questionId || null;
|
||||
|
||||
var options = [];
|
||||
qBlock.querySelectorAll('.lh-option-row').forEach(function(row) {
|
||||
var optText = row.querySelector('.lh-opt-text').value.trim();
|
||||
if (!optText) return;
|
||||
options.push({
|
||||
option_text: optText,
|
||||
is_correct: row.querySelector('.lh-opt-correct').checked,
|
||||
explanation: row.querySelector('.lh-opt-expl').value.trim()
|
||||
});
|
||||
});
|
||||
|
||||
questions.push({
|
||||
id: qId,
|
||||
question_text: qText,
|
||||
question_type: qType,
|
||||
explanation: qExpl,
|
||||
options: options
|
||||
});
|
||||
});
|
||||
|
||||
var payload = { title: title, body: body, category_id: category_id, subject: subject, content_type: content_type, published: published };
|
||||
|
||||
var method = id ? 'PUT' : 'POST';
|
||||
var url = id ? '/api/admin/learning/content/' + id : '/api/admin/learning/content';
|
||||
|
||||
showLoading('Saving...');
|
||||
|
||||
fetch(url, { method: method, headers: getAuthHeaders(), body: JSON.stringify(payload) })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) { hideLoading(); showToast(data.error || 'Save failed', 'error'); return; }
|
||||
var contentId = id || data.id;
|
||||
|
||||
// Save questions sequentially
|
||||
saveQuestions(contentId, questions, 0, function() {
|
||||
hideLoading();
|
||||
showToast('Content saved', 'success');
|
||||
closeEditor();
|
||||
loadCmsContent();
|
||||
loadCmsStats();
|
||||
loaded = false; // force refresh user feed
|
||||
});
|
||||
})
|
||||
.catch(function() { hideLoading(); showToast('Save failed', 'error'); });
|
||||
}
|
||||
|
||||
function saveQuestions(contentId, questions, index, callback) {
|
||||
if (index >= questions.length) { callback(); return; }
|
||||
|
||||
var q = questions[index];
|
||||
var qId = q.id;
|
||||
var method, url;
|
||||
|
||||
if (qId) {
|
||||
method = 'PUT';
|
||||
url = '/api/admin/learning/questions/' + qId;
|
||||
} else {
|
||||
method = 'POST';
|
||||
url = '/api/admin/learning/content/' + contentId + '/questions';
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function() {
|
||||
saveQuestions(contentId, questions, index + 1, callback);
|
||||
})
|
||||
.catch(function() {
|
||||
saveQuestions(contentId, questions, index + 1, callback);
|
||||
});
|
||||
}
|
||||
|
||||
function deleteContent() {
|
||||
var id = document.getElementById('lh-cms-edit-id').value;
|
||||
if (!id) return;
|
||||
if (!confirm('Delete this content and all its questions? This cannot be undone.')) return;
|
||||
|
||||
fetch('/api/admin/learning/content/' + id, { method: 'DELETE', headers: getAuthHeaders() })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.success) {
|
||||
showToast('Content deleted', 'info');
|
||||
closeEditor();
|
||||
loadCmsContent();
|
||||
loadCmsStats();
|
||||
loaded = false;
|
||||
} else showToast(data.error || 'Failed', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function addQuestionBlock(existingQ) {
|
||||
var container = document.getElementById('lh-cms-questions');
|
||||
if (!container) return;
|
||||
|
||||
var block = document.createElement('div');
|
||||
block.className = 'lh-question-block';
|
||||
if (existingQ && existingQ.id) block.dataset.questionId = existingQ.id;
|
||||
|
||||
block.innerHTML =
|
||||
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">' +
|
||||
'<span style="font-size:12px;font-weight:600;color:var(--g600);">Question</span>' +
|
||||
'<button class="btn-sm btn-ghost lh-rm-question" style="padding:2px 6px;font-size:11px;color:var(--red);"><i class="fas fa-trash"></i> Remove</button>' +
|
||||
'</div>' +
|
||||
'<input type="text" class="lh-q-text" placeholder="Question text..." value="' + esc(existingQ ? existingQ.question_text : '') + '" style="width:100%;font-size:13px;padding:7px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;margin-bottom:6px;">' +
|
||||
'<div style="display:flex;gap:8px;margin-bottom:6px;">' +
|
||||
'<select class="lh-q-type" style="font-size:12px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">' +
|
||||
'<option value="mcq"' + (existingQ && existingQ.question_type === 'mcq' ? ' selected' : '') + '>Multiple Choice</option>' +
|
||||
'<option value="true_false"' + (existingQ && existingQ.question_type === 'true_false' ? ' selected' : '') + '>True / False</option>' +
|
||||
'</select>' +
|
||||
'<input type="text" class="lh-q-explanation" placeholder="General explanation (shown after answering)" value="' + esc(existingQ ? existingQ.explanation || '' : '') + '" style="flex:1;font-size:12px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">' +
|
||||
'</div>' +
|
||||
'<div class="lh-options-list"></div>' +
|
||||
'<button class="btn-sm btn-ghost lh-add-option" style="margin-top:4px;font-size:12px;"><i class="fas fa-plus"></i> Add Option</button>';
|
||||
|
||||
container.appendChild(block);
|
||||
|
||||
var optList = block.querySelector('.lh-options-list');
|
||||
if (existingQ && existingQ.options && existingQ.options.length > 0) {
|
||||
existingQ.options.forEach(function(opt) { addOptionRow(optList, opt); });
|
||||
} else if (existingQ && existingQ.question_type === 'true_false') {
|
||||
addOptionRow(optList, { option_text: 'True', is_correct: false, explanation: '' });
|
||||
addOptionRow(optList, { option_text: 'False', is_correct: false, explanation: '' });
|
||||
} else {
|
||||
// Add 4 blank MCQ options
|
||||
for (var i = 0; i < 4; i++) addOptionRow(optList, null);
|
||||
}
|
||||
}
|
||||
|
||||
function addOptionRow(container, opt) {
|
||||
if (!container) return;
|
||||
var row = document.createElement('div');
|
||||
row.className = 'lh-option-row';
|
||||
row.innerHTML =
|
||||
'<label style="display:flex;align-items:center;gap:4px;cursor:pointer;" title="Mark as correct">' +
|
||||
'<input type="checkbox" class="lh-opt-correct"' + (opt && opt.is_correct ? ' checked' : '') + '>' +
|
||||
'<i class="fas fa-check" style="font-size:11px;color:var(--green);"></i>' +
|
||||
'</label>' +
|
||||
'<input type="text" class="lh-opt-text" placeholder="Option text" value="' + esc(opt ? opt.option_text : '') + '" style="flex:1;font-size:12px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">' +
|
||||
'<input type="text" class="lh-opt-expl" placeholder="Why wrong (optional)" value="' + esc(opt ? opt.explanation || '' : '') + '" style="flex:1;font-size:12px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">' +
|
||||
'<button class="btn-sm btn-ghost lh-rm-option" style="padding:2px 6px;font-size:11px;color:var(--red);"><i class="fas fa-times"></i></button>';
|
||||
container.appendChild(row);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
function esc(str) {
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function sanitizeHtml(html) {
|
||||
// Allow basic formatting tags, strip scripts
|
||||
var div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
div.querySelectorAll('script,iframe,object,embed,form').forEach(function(el) { el.remove(); });
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
})();
|
||||
12
server.js
12
server.js
|
|
@ -100,6 +100,11 @@ app.use(express.static(path.join(__dirname, 'public'), {
|
|||
|
||||
// Routes
|
||||
app.use('/api/auth', require('./src/routes/auth'));
|
||||
|
||||
// Learning Hub CMS — must come BEFORE general /api/admin to avoid adminMiddleware conflict
|
||||
// (moderators need access to /api/admin/learning but not other /api/admin routes)
|
||||
app.use('/api/admin/learning', require('./src/routes/learningAdmin'));
|
||||
|
||||
app.use('/api/admin', require('./src/routes/admin'));
|
||||
app.use('/api/admin', require('./src/routes/adminConfig'));
|
||||
|
||||
|
|
@ -119,7 +124,7 @@ app.get('/api/models', async (req, res) => {
|
|||
const { activeProvider } = require('./src/utils/ai');
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'running', version: '3.0.0', provider: activeProvider,
|
||||
status: 'running', version: '3.1.0', provider: activeProvider,
|
||||
timestamp: new Date().toISOString(),
|
||||
openrouter: process.env.OPENROUTER_API_KEY ? 'configured' : 'missing',
|
||||
bedrock: process.env.AWS_BEDROCK_REGION ? 'configured' : 'not configured',
|
||||
|
|
@ -128,6 +133,9 @@ app.get('/api/health', (req, res) => {
|
|||
});
|
||||
});
|
||||
|
||||
// Learning Hub routes (all authenticated users can read content & take quizzes)
|
||||
app.use('/api/learning', require('./src/routes/learningHub'));
|
||||
|
||||
// Authenticated feature routes
|
||||
app.use('/api', require('./src/routes/transcribe'));
|
||||
app.use('/api', require('./src/routes/hpi'));
|
||||
|
|
@ -160,7 +168,7 @@ const PORT = process.env.PORT || 3000;
|
|||
server.listen(PORT, () => {
|
||||
console.log('');
|
||||
console.log('==========================================');
|
||||
console.log('🏥 PEDIATRIC AI SCRIBE v3.0');
|
||||
console.log('🏥 PEDIATRIC AI SCRIBE v3.1');
|
||||
console.log('==========================================');
|
||||
console.log('🌐 http://localhost:' + PORT);
|
||||
console.log('🤖 Provider: ' + activeProvider);
|
||||
|
|
|
|||
|
|
@ -125,6 +125,63 @@ async function initDatabase() {
|
|||
CREATE INDEX IF NOT EXISTS idx_saved_enc_user ON saved_encounters(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_saved_enc_expires ON saved_encounters(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_user ON user_memories(user_id);
|
||||
|
||||
-- Learning Hub tables
|
||||
CREATE TABLE IF NOT EXISTS learning_categories (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS learning_content (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
category_id INTEGER REFERENCES learning_categories(id) ON DELETE SET NULL,
|
||||
subject TEXT DEFAULT '',
|
||||
content_type TEXT DEFAULT 'article',
|
||||
published BOOLEAN DEFAULT false,
|
||||
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS learning_questions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
content_id INTEGER REFERENCES learning_content(id) ON DELETE CASCADE,
|
||||
question_text TEXT NOT NULL,
|
||||
question_type TEXT NOT NULL DEFAULT 'mcq',
|
||||
explanation TEXT DEFAULT '',
|
||||
sort_order INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS learning_options (
|
||||
id SERIAL PRIMARY KEY,
|
||||
question_id INTEGER REFERENCES learning_questions(id) ON DELETE CASCADE,
|
||||
option_text TEXT NOT NULL,
|
||||
is_correct BOOLEAN DEFAULT false,
|
||||
explanation TEXT DEFAULT '',
|
||||
sort_order INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS learning_progress (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
content_id INTEGER REFERENCES learning_content(id) ON DELETE CASCADE,
|
||||
score INTEGER,
|
||||
total INTEGER,
|
||||
completed_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_learning_content_cat ON learning_content(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_learning_content_pub ON learning_content(published);
|
||||
CREATE INDEX IF NOT EXISTS idx_learning_questions_content ON learning_questions(content_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_learning_options_question ON learning_options(question_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_learning_progress_user ON learning_progress(user_id);
|
||||
`);
|
||||
|
||||
// Add columns if upgrading
|
||||
|
|
@ -135,7 +192,17 @@ async function initDatabase() {
|
|||
"CREATE TABLE IF NOT EXISTS user_memories (id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, category TEXT NOT NULL DEFAULT 'custom', name TEXT NOT NULL, content TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW())",
|
||||
"CREATE INDEX IF NOT EXISTS idx_saved_enc_user ON saved_encounters(user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_saved_enc_expires ON saved_encounters(expires_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_memories_user ON user_memories(user_id)"
|
||||
"CREATE INDEX IF NOT EXISTS idx_memories_user ON user_memories(user_id)",
|
||||
"CREATE TABLE IF NOT EXISTS learning_categories (id SERIAL PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE NOT NULL, description TEXT DEFAULT '', sort_order INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW())",
|
||||
"CREATE TABLE IF NOT EXISTS learning_content (id SERIAL PRIMARY KEY, title TEXT NOT NULL, slug TEXT UNIQUE NOT NULL, body TEXT NOT NULL DEFAULT '', category_id INTEGER REFERENCES learning_categories(id) ON DELETE SET NULL, subject TEXT DEFAULT '', content_type TEXT DEFAULT 'article', published BOOLEAN DEFAULT false, author_id INTEGER REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW())",
|
||||
"CREATE TABLE IF NOT EXISTS learning_questions (id SERIAL PRIMARY KEY, content_id INTEGER REFERENCES learning_content(id) ON DELETE CASCADE, question_text TEXT NOT NULL, question_type TEXT NOT NULL DEFAULT 'mcq', explanation TEXT DEFAULT '', sort_order INTEGER DEFAULT 0)",
|
||||
"CREATE TABLE IF NOT EXISTS learning_options (id SERIAL PRIMARY KEY, question_id INTEGER REFERENCES learning_questions(id) ON DELETE CASCADE, option_text TEXT NOT NULL, is_correct BOOLEAN DEFAULT false, explanation TEXT DEFAULT '', sort_order INTEGER DEFAULT 0)",
|
||||
"CREATE TABLE IF NOT EXISTS learning_progress (id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, content_id INTEGER REFERENCES learning_content(id) ON DELETE CASCADE, score INTEGER, total INTEGER, completed_at TIMESTAMPTZ DEFAULT NOW())",
|
||||
"CREATE INDEX IF NOT EXISTS idx_learning_content_cat ON learning_content(category_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_learning_content_pub ON learning_content(published)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_learning_questions_content ON learning_questions(content_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_learning_options_question ON learning_options(question_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_learning_progress_user ON learning_progress(user_id)"
|
||||
];
|
||||
for (var i = 0; i < migrations.length; i++) {
|
||||
try { await client.query(migrations[i]); } catch(e) {}
|
||||
|
|
|
|||
|
|
@ -44,4 +44,12 @@ async function adminMiddleware(req, res, next) {
|
|||
next();
|
||||
}
|
||||
|
||||
module.exports = { authMiddleware, adminMiddleware, JWT_SECRET };
|
||||
// Moderator or Admin middleware — for Learning Hub CMS
|
||||
async function moderatorMiddleware(req, res, next) {
|
||||
if (!req.user || (req.user.role !== 'admin' && req.user.role !== 'moderator')) {
|
||||
return res.status(403).json({ error: 'Moderator or admin access required' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { authMiddleware, adminMiddleware, moderatorMiddleware, JWT_SECRET };
|
||||
|
|
|
|||
|
|
@ -111,8 +111,8 @@ router.post('/users/:id/role', async function(req, res) {
|
|||
var userId = parseInt(req.params.id);
|
||||
var { role } = req.body;
|
||||
|
||||
if (!role || (role !== 'admin' && role !== 'user')) {
|
||||
return res.status(400).json({ error: 'Role must be "admin" or "user"' });
|
||||
if (!role || (role !== 'admin' && role !== 'user' && role !== 'moderator')) {
|
||||
return res.status(400).json({ error: 'Role must be "admin", "user", or "moderator"' });
|
||||
}
|
||||
|
||||
// Prevent removing your own admin
|
||||
|
|
|
|||
278
src/routes/learningAdmin.js
Normal file
278
src/routes/learningAdmin.js
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
// ============================================================
|
||||
// LEARNING ADMIN ROUTES — CMS for categories, content, quizzes
|
||||
// ============================================================
|
||||
|
||||
var express = require('express');
|
||||
var router = express.Router();
|
||||
var db = require('../db/database');
|
||||
var { authMiddleware, moderatorMiddleware } = require('../middleware/auth');
|
||||
|
||||
router.use(authMiddleware);
|
||||
router.use(moderatorMiddleware);
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
function slugify(text) {
|
||||
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').substring(0, 100);
|
||||
}
|
||||
|
||||
async function uniqueSlug(table, base) {
|
||||
var slug = slugify(base);
|
||||
var exists = await db.get('SELECT id FROM ' + table + ' WHERE slug = ?', [slug]);
|
||||
if (!exists) return slug;
|
||||
var i = 2;
|
||||
while (true) {
|
||||
var attempt = slug + '-' + i;
|
||||
var exists2 = await db.get('SELECT id FROM ' + table + ' WHERE slug = ?', [attempt]);
|
||||
if (!exists2) return attempt;
|
||||
i++;
|
||||
if (i > 100) return slug + '-' + Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CATEGORIES — CRUD
|
||||
// ============================================================
|
||||
|
||||
router.get('/categories', async function(req, res) {
|
||||
try {
|
||||
var categories = await db.all(
|
||||
`SELECT c.*, (SELECT COUNT(*) FROM learning_content lc WHERE lc.category_id = c.id) as content_count
|
||||
FROM learning_categories c ORDER BY c.sort_order ASC, c.name ASC`,
|
||||
[]
|
||||
);
|
||||
res.json({ success: true, categories: categories });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
router.post('/categories', async function(req, res) {
|
||||
try {
|
||||
var { name, description, sort_order } = req.body;
|
||||
if (!name || !name.trim()) return res.status(400).json({ error: 'Name required' });
|
||||
|
||||
var slug = await uniqueSlug('learning_categories', name.trim());
|
||||
|
||||
var result = await db.run(
|
||||
'INSERT INTO learning_categories (name, slug, description, sort_order) VALUES (?, ?, ?, ?)',
|
||||
[name.trim(), slug, description || '', sort_order || 0]
|
||||
);
|
||||
|
||||
res.json({ success: true, id: result.lastInsertRowid, slug: slug });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
router.put('/categories/:id', async function(req, res) {
|
||||
try {
|
||||
var { name, description, sort_order } = req.body;
|
||||
var cat = await db.get('SELECT * FROM learning_categories WHERE id = ?', [req.params.id]);
|
||||
if (!cat) return res.status(404).json({ error: 'Category not found' });
|
||||
|
||||
await db.run(
|
||||
'UPDATE learning_categories SET name = ?, description = ?, sort_order = ? WHERE id = ?',
|
||||
[name || cat.name, description !== undefined ? description : cat.description, sort_order !== undefined ? sort_order : cat.sort_order, cat.id]
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
router.delete('/categories/:id', async function(req, res) {
|
||||
try {
|
||||
// Set content in this category to uncategorized (NULL)
|
||||
await db.run('UPDATE learning_content SET category_id = NULL WHERE category_id = ?', [req.params.id]);
|
||||
await db.run('DELETE FROM learning_categories WHERE id = ?', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// CONTENT — CRUD
|
||||
// ============================================================
|
||||
|
||||
router.get('/content', async function(req, res) {
|
||||
try {
|
||||
var content = await db.all(
|
||||
`SELECT c.id, c.title, c.slug, c.subject, c.content_type, c.published, c.category_id,
|
||||
c.created_at, c.updated_at,
|
||||
cat.name as category_name,
|
||||
u.name as author_name,
|
||||
(SELECT COUNT(*) FROM learning_questions q WHERE q.content_id = c.id) as question_count
|
||||
FROM learning_content c
|
||||
LEFT JOIN learning_categories cat ON c.category_id = cat.id
|
||||
LEFT JOIN users u ON c.author_id = u.id
|
||||
ORDER BY c.updated_at DESC`,
|
||||
[]
|
||||
);
|
||||
res.json({ success: true, content: content });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
router.get('/content/:id', async function(req, res) {
|
||||
try {
|
||||
var item = await db.get(
|
||||
`SELECT c.*, cat.name as category_name
|
||||
FROM learning_content c
|
||||
LEFT JOIN learning_categories cat ON c.category_id = cat.id
|
||||
WHERE c.id = ?`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!item) return res.status(404).json({ error: 'Content not found' });
|
||||
|
||||
// Get questions + options
|
||||
var questions = await db.all(
|
||||
'SELECT * FROM learning_questions WHERE content_id = ? ORDER BY sort_order ASC',
|
||||
[item.id]
|
||||
);
|
||||
for (var i = 0; i < questions.length; i++) {
|
||||
questions[i].options = await db.all(
|
||||
'SELECT * FROM learning_options WHERE question_id = ? ORDER BY sort_order ASC',
|
||||
[questions[i].id]
|
||||
);
|
||||
}
|
||||
item.questions = questions;
|
||||
|
||||
res.json({ success: true, content: item });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
router.post('/content', async function(req, res) {
|
||||
try {
|
||||
var { title, body, category_id, subject, content_type, published } = req.body;
|
||||
if (!title || !title.trim()) return res.status(400).json({ error: 'Title required' });
|
||||
|
||||
var slug = await uniqueSlug('learning_content', title.trim());
|
||||
|
||||
var result = await db.run(
|
||||
'INSERT INTO learning_content (title, slug, body, category_id, subject, content_type, published, author_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[title.trim(), slug, body || '', category_id || null, subject || '', content_type || 'article', published ? true : false, req.user.id]
|
||||
);
|
||||
|
||||
res.json({ success: true, id: result.lastInsertRowid, slug: slug });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
router.put('/content/:id', async function(req, res) {
|
||||
try {
|
||||
var item = await db.get('SELECT * FROM learning_content WHERE id = ?', [req.params.id]);
|
||||
if (!item) return res.status(404).json({ error: 'Content not found' });
|
||||
|
||||
var { title, body, category_id, subject, content_type, published } = req.body;
|
||||
|
||||
await db.run(
|
||||
'UPDATE learning_content SET title = ?, body = ?, category_id = ?, subject = ?, content_type = ?, published = ?, updated_at = NOW() WHERE id = ?',
|
||||
[
|
||||
title !== undefined ? title : item.title,
|
||||
body !== undefined ? body : item.body,
|
||||
category_id !== undefined ? (category_id || null) : item.category_id,
|
||||
subject !== undefined ? subject : item.subject,
|
||||
content_type !== undefined ? content_type : item.content_type,
|
||||
published !== undefined ? published : item.published,
|
||||
item.id
|
||||
]
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
router.delete('/content/:id', async function(req, res) {
|
||||
try {
|
||||
await db.run('DELETE FROM learning_content WHERE id = ?', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// QUESTIONS — CRUD (nested under content)
|
||||
// ============================================================
|
||||
|
||||
router.post('/content/:contentId/questions', async function(req, res) {
|
||||
try {
|
||||
var { question_text, question_type, explanation, options } = req.body;
|
||||
if (!question_text) return res.status(400).json({ error: 'Question text required' });
|
||||
|
||||
// Get max sort_order
|
||||
var maxOrder = await db.get('SELECT COALESCE(MAX(sort_order), -1) as mx FROM learning_questions WHERE content_id = ?', [req.params.contentId]);
|
||||
var sortOrder = (maxOrder ? maxOrder.mx : -1) + 1;
|
||||
|
||||
var result = await db.run(
|
||||
'INSERT INTO learning_questions (content_id, question_text, question_type, explanation, sort_order) VALUES (?, ?, ?, ?, ?)',
|
||||
[req.params.contentId, question_text, question_type || 'mcq', explanation || '', sortOrder]
|
||||
);
|
||||
|
||||
var questionId = result.lastInsertRowid;
|
||||
|
||||
// Insert options if provided
|
||||
if (options && Array.isArray(options)) {
|
||||
for (var i = 0; i < options.length; i++) {
|
||||
var opt = options[i];
|
||||
await db.run(
|
||||
'INSERT INTO learning_options (question_id, option_text, is_correct, explanation, sort_order) VALUES (?, ?, ?, ?, ?)',
|
||||
[questionId, opt.option_text, opt.is_correct ? true : false, opt.explanation || '', i]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, id: questionId });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
router.put('/questions/:id', async function(req, res) {
|
||||
try {
|
||||
var q = await db.get('SELECT * FROM learning_questions WHERE id = ?', [req.params.id]);
|
||||
if (!q) return res.status(404).json({ error: 'Question not found' });
|
||||
|
||||
var { question_text, question_type, explanation, options } = req.body;
|
||||
|
||||
await db.run(
|
||||
'UPDATE learning_questions SET question_text = ?, question_type = ?, explanation = ? WHERE id = ?',
|
||||
[question_text || q.question_text, question_type || q.question_type, explanation !== undefined ? explanation : q.explanation, q.id]
|
||||
);
|
||||
|
||||
// Replace options if provided
|
||||
if (options && Array.isArray(options)) {
|
||||
await db.run('DELETE FROM learning_options WHERE question_id = ?', [q.id]);
|
||||
for (var i = 0; i < options.length; i++) {
|
||||
var opt = options[i];
|
||||
await db.run(
|
||||
'INSERT INTO learning_options (question_id, option_text, is_correct, explanation, sort_order) VALUES (?, ?, ?, ?, ?)',
|
||||
[q.id, opt.option_text, opt.is_correct ? true : false, opt.explanation || '', i]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
router.delete('/questions/:id', async function(req, res) {
|
||||
try {
|
||||
await db.run('DELETE FROM learning_questions WHERE id = ?', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// STATS
|
||||
// ============================================================
|
||||
router.get('/stats', async function(req, res) {
|
||||
try {
|
||||
var totalContent = await db.get('SELECT COUNT(*) as count FROM learning_content', []);
|
||||
var published = await db.get('SELECT COUNT(*) as count FROM learning_content WHERE published = true', []);
|
||||
var totalCategories = await db.get('SELECT COUNT(*) as count FROM learning_categories', []);
|
||||
var totalQuizzes = await db.get("SELECT COUNT(DISTINCT content_id) as count FROM learning_questions", []);
|
||||
var totalAttempts = await db.get('SELECT COUNT(*) as count FROM learning_progress', []);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
stats: {
|
||||
totalContent: parseInt(totalContent.count),
|
||||
publishedContent: parseInt(published.count),
|
||||
totalCategories: parseInt(totalCategories.count),
|
||||
totalQuizzes: parseInt(totalQuizzes.count),
|
||||
totalAttempts: parseInt(totalAttempts.count)
|
||||
}
|
||||
});
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
207
src/routes/learningHub.js
Normal file
207
src/routes/learningHub.js
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
// ============================================================
|
||||
// LEARNING HUB ROUTES — User-facing content & quizzes
|
||||
// ============================================================
|
||||
|
||||
var express = require('express');
|
||||
var router = express.Router();
|
||||
var db = require('../db/database');
|
||||
var { authMiddleware } = require('../middleware/auth');
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
// ============================================================
|
||||
// GET CATEGORIES
|
||||
// ============================================================
|
||||
router.get('/categories', async function(req, res) {
|
||||
try {
|
||||
var categories = await db.all(
|
||||
'SELECT id, name, slug, description FROM learning_categories ORDER BY sort_order ASC, name ASC',
|
||||
[]
|
||||
);
|
||||
res.json({ success: true, categories: categories });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// GET LATEST CONTENT (feed)
|
||||
// ============================================================
|
||||
router.get('/feed', async function(req, res) {
|
||||
try {
|
||||
var limit = Math.min(parseInt(req.query.limit) || 20, 50);
|
||||
var offset = parseInt(req.query.offset) || 0;
|
||||
|
||||
var content = await db.all(
|
||||
`SELECT c.id, c.title, c.slug, c.subject, c.content_type, c.created_at, c.updated_at,
|
||||
cat.name as category_name, cat.slug as category_slug,
|
||||
u.name as author_name,
|
||||
(SELECT COUNT(*) FROM learning_questions q WHERE q.content_id = c.id) as question_count
|
||||
FROM learning_content c
|
||||
LEFT JOIN learning_categories cat ON c.category_id = cat.id
|
||||
LEFT JOIN users u ON c.author_id = u.id
|
||||
WHERE c.published = true
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[limit, offset]
|
||||
);
|
||||
|
||||
var total = await db.get('SELECT COUNT(*) as count FROM learning_content WHERE published = true', []);
|
||||
|
||||
res.json({ success: true, content: content, total: total ? parseInt(total.count) : 0 });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// GET CONTENT BY CATEGORY
|
||||
// ============================================================
|
||||
router.get('/category/:slug', async function(req, res) {
|
||||
try {
|
||||
var cat = await db.get('SELECT * FROM learning_categories WHERE slug = ?', [req.params.slug]);
|
||||
if (!cat) return res.status(404).json({ error: 'Category not found' });
|
||||
|
||||
var content = await db.all(
|
||||
`SELECT c.id, c.title, c.slug, c.subject, c.content_type, c.created_at, c.updated_at,
|
||||
u.name as author_name,
|
||||
(SELECT COUNT(*) FROM learning_questions q WHERE q.content_id = c.id) as question_count
|
||||
FROM learning_content c
|
||||
LEFT JOIN users u ON c.author_id = u.id
|
||||
WHERE c.category_id = ? AND c.published = true
|
||||
ORDER BY c.created_at DESC`,
|
||||
[cat.id]
|
||||
);
|
||||
|
||||
res.json({ success: true, category: cat, content: content });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// GET SINGLE CONTENT (with questions)
|
||||
// ============================================================
|
||||
router.get('/content/:slug', async function(req, res) {
|
||||
try {
|
||||
var item = await db.get(
|
||||
`SELECT c.*, cat.name as category_name, cat.slug as category_slug, u.name as author_name
|
||||
FROM learning_content c
|
||||
LEFT JOIN learning_categories cat ON c.category_id = cat.id
|
||||
LEFT JOIN users u ON c.author_id = u.id
|
||||
WHERE c.slug = ? AND c.published = true`,
|
||||
[req.params.slug]
|
||||
);
|
||||
if (!item) return res.status(404).json({ error: 'Content not found' });
|
||||
|
||||
// Get questions + options
|
||||
var questions = await db.all(
|
||||
'SELECT * FROM learning_questions WHERE content_id = ? ORDER BY sort_order ASC',
|
||||
[item.id]
|
||||
);
|
||||
|
||||
for (var i = 0; i < questions.length; i++) {
|
||||
var options = await db.all(
|
||||
'SELECT id, option_text, sort_order FROM learning_options WHERE question_id = ? ORDER BY sort_order ASC',
|
||||
[questions[i].id]
|
||||
);
|
||||
questions[i].options = options;
|
||||
}
|
||||
|
||||
// Get user's past attempts
|
||||
var progress = await db.all(
|
||||
'SELECT score, total, completed_at FROM learning_progress WHERE user_id = ? AND content_id = ? ORDER BY completed_at DESC LIMIT 5',
|
||||
[req.user.id, item.id]
|
||||
);
|
||||
|
||||
item.questions = questions;
|
||||
item.progress = progress;
|
||||
|
||||
res.json({ success: true, content: item });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// SUBMIT QUIZ ANSWERS
|
||||
// ============================================================
|
||||
router.post('/submit-quiz', async function(req, res) {
|
||||
try {
|
||||
var { contentId, answers } = req.body;
|
||||
// answers = [ { questionId: 1, optionId: 5 }, ... ]
|
||||
|
||||
if (!contentId || !answers || !Array.isArray(answers)) {
|
||||
return res.status(400).json({ error: 'contentId and answers array required' });
|
||||
}
|
||||
|
||||
// Get all questions for this content
|
||||
var questions = await db.all(
|
||||
'SELECT q.id, q.question_text, q.explanation as general_explanation FROM learning_questions WHERE content_id = ? ORDER BY sort_order ASC',
|
||||
[contentId]
|
||||
);
|
||||
|
||||
var results = [];
|
||||
var score = 0;
|
||||
|
||||
for (var i = 0; i < questions.length; i++) {
|
||||
var q = questions[i];
|
||||
var userAnswer = answers.find(function(a) { return a.questionId === q.id; });
|
||||
var selectedOptionId = userAnswer ? userAnswer.optionId : null;
|
||||
|
||||
// Get all options with correct flag
|
||||
var options = await db.all(
|
||||
'SELECT id, option_text, is_correct, explanation FROM learning_options WHERE question_id = ? ORDER BY sort_order ASC',
|
||||
[q.id]
|
||||
);
|
||||
|
||||
var correctOption = options.find(function(o) { return o.is_correct; });
|
||||
var selectedOption = selectedOptionId ? options.find(function(o) { return o.id === selectedOptionId; }) : null;
|
||||
var isCorrect = selectedOption ? selectedOption.is_correct : false;
|
||||
if (isCorrect) score++;
|
||||
|
||||
results.push({
|
||||
questionId: q.id,
|
||||
questionText: q.question_text,
|
||||
selectedOptionId: selectedOptionId,
|
||||
isCorrect: isCorrect,
|
||||
correctOptionId: correctOption ? correctOption.id : null,
|
||||
correctOptionText: correctOption ? correctOption.option_text : '',
|
||||
selectedExplanation: selectedOption && !isCorrect ? selectedOption.explanation : '',
|
||||
generalExplanation: q.general_explanation || ''
|
||||
});
|
||||
}
|
||||
|
||||
// Save progress
|
||||
await db.run(
|
||||
'INSERT INTO learning_progress (user_id, content_id, score, total) VALUES (?, ?, ?, ?)',
|
||||
[req.user.id, contentId, score, questions.length]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
score: score,
|
||||
total: questions.length,
|
||||
percentage: questions.length > 0 ? Math.round((score / questions.length) * 100) : 0,
|
||||
results: results
|
||||
});
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// SEARCH CONTENT
|
||||
// ============================================================
|
||||
router.get('/search', async function(req, res) {
|
||||
try {
|
||||
var q = (req.query.q || '').trim();
|
||||
if (!q) return res.json({ success: true, content: [] });
|
||||
|
||||
var pattern = '%' + q + '%';
|
||||
var content = await db.all(
|
||||
`SELECT c.id, c.title, c.slug, c.subject, c.content_type, c.created_at,
|
||||
cat.name as category_name, cat.slug as category_slug,
|
||||
(SELECT COUNT(*) FROM learning_questions lq WHERE lq.content_id = c.id) as question_count
|
||||
FROM learning_content c
|
||||
LEFT JOIN learning_categories cat ON c.category_id = cat.id
|
||||
WHERE c.published = true AND (c.title ILIKE ? OR c.subject ILIKE ? OR c.body ILIKE ?)
|
||||
ORDER BY c.created_at DESC LIMIT 30`,
|
||||
[pattern, pattern, pattern]
|
||||
);
|
||||
|
||||
res.json({ success: true, content: content });
|
||||
} catch (err) { res.status(500).json({ error: err.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Loading…
Reference in a new issue