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:
|
services:
|
||||||
pediatric-scribe:
|
pediatric-scribe:
|
||||||
image: danielonyejesi/pediatric-ai-scribe-v3:v3.0
|
image: danielonyejesi/pediatric-ai-scribe-v3:v3.1
|
||||||
ports:
|
ports:
|
||||||
- "3552:3000"
|
- "3552:3000"
|
||||||
env_file:
|
env_file:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "pediatric-ai-scribe",
|
"name": "pediatric-ai-scribe",
|
||||||
"version": "3.0.0",
|
"version": "3.1.0",
|
||||||
"description": "AI-powered pediatric clinical documentation platform",
|
"description": "AI-powered pediatric clinical documentation platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"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:last-child{border-bottom:none;}
|
||||||
.dx-result-item:hover{background:var(--g50);}
|
.dx-result-item:hover{background:var(--g50);}
|
||||||
.dx-result-code{font-weight:700;color:var(--blue);margin-right:4px;}
|
.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>
|
<i class="fas fa-rotate"></i>
|
||||||
<span>Catch-Up Schedule</span>
|
<span>Catch-Up Schedule</span>
|
||||||
</button>
|
</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>
|
<span class="sidebar-section-label">Account</span>
|
||||||
<button class="tab-btn" data-tab="settings">
|
<button class="tab-btn" data-tab="settings">
|
||||||
<i class="fas fa-cog"></i>
|
<i class="fas fa-cog"></i>
|
||||||
|
|
@ -739,6 +743,72 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 ===== -->
|
<!-- ===== TAB: ADMIN ===== -->
|
||||||
<section id="admin-tab" class="tab-content">
|
<section id="admin-tab" class="tab-content">
|
||||||
<div class="module-header">
|
<div class="module-header">
|
||||||
|
|
@ -945,6 +1015,98 @@
|
||||||
</div>
|
</div>
|
||||||
</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 ─────────────────────────────────── -->
|
<!-- ── CMS: Reset to Defaults ─────────────────────────────────── -->
|
||||||
<div class="card" style="border:1px solid var(--red-light);">
|
<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>
|
<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/sickVisit.js"></script>
|
||||||
<script src="/js/encounters.js"></script>
|
<script src="/js/encounters.js"></script>
|
||||||
<script src="/js/memories.js"></script>
|
<script src="/js/memories.js"></script>
|
||||||
|
<script src="/js/learningHub.js"></script>
|
||||||
<script src="/js/admin.js"></script>
|
<script src="/js/admin.js"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,35 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
function loadAdmin() {
|
function loadAdmin() {
|
||||||
loadSettings();
|
var isModerator = window._userRole === 'moderator';
|
||||||
loadUsers();
|
// 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 ----
|
// ---- SETTINGS + STATS ----
|
||||||
|
|
@ -109,7 +136,7 @@
|
||||||
|
|
||||||
tbody.innerHTML = users.map(function(u) {
|
tbody.innerHTML = users.map(function(u) {
|
||||||
var isSelf = currentUser.id && u.id === currentUser.id;
|
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 statusColor = u.disabled ? 'var(--red)' : (u.email_verified ? 'var(--green)' : 'var(--amber)');
|
||||||
var statusText = u.disabled ? 'Disabled' : (u.email_verified ? 'Active' : 'Unverified');
|
var statusText = u.disabled ? 'Disabled' : (u.email_verified ? 'Active' : 'Unverified');
|
||||||
var joined = u.created_at ? new Date(u.created_at).toLocaleDateString() : '—';
|
var joined = u.created_at ? new Date(u.created_at).toLocaleDateString() : '—';
|
||||||
|
|
@ -129,6 +156,12 @@
|
||||||
} else {
|
} 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>');
|
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 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>');
|
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 {
|
} else {
|
||||||
|
|
@ -189,6 +222,20 @@
|
||||||
});
|
});
|
||||||
break;
|
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':
|
case 'reset-pw':
|
||||||
var newPw = prompt('New password for ' + email + ' (8+ characters):');
|
var newPw = prompt('New password for ' + email + ' (8+ characters):');
|
||||||
if (!newPw) return;
|
if (!newPw) return;
|
||||||
|
|
|
||||||
|
|
@ -82,15 +82,23 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
}
|
}
|
||||||
if (userName) userName.textContent = user.name || user.email;
|
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');
|
var adminTabBtn = document.getElementById('admin-tab-btn');
|
||||||
if (adminTabBtn) {
|
if (adminTabBtn) {
|
||||||
if (user.role === 'admin') {
|
if (user.role === 'admin') {
|
||||||
adminTabBtn.classList.remove('hidden');
|
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 {
|
} else {
|
||||||
adminTabBtn.classList.add('hidden');
|
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);
|
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
|
// Routes
|
||||||
app.use('/api/auth', require('./src/routes/auth'));
|
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/admin'));
|
||||||
app.use('/api/admin', require('./src/routes/adminConfig'));
|
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');
|
const { activeProvider } = require('./src/utils/ai');
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
status: 'running', version: '3.0.0', provider: activeProvider,
|
status: 'running', version: '3.1.0', provider: activeProvider,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
openrouter: process.env.OPENROUTER_API_KEY ? 'configured' : 'missing',
|
openrouter: process.env.OPENROUTER_API_KEY ? 'configured' : 'missing',
|
||||||
bedrock: process.env.AWS_BEDROCK_REGION ? 'configured' : 'not configured',
|
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
|
// Authenticated feature routes
|
||||||
app.use('/api', require('./src/routes/transcribe'));
|
app.use('/api', require('./src/routes/transcribe'));
|
||||||
app.use('/api', require('./src/routes/hpi'));
|
app.use('/api', require('./src/routes/hpi'));
|
||||||
|
|
@ -160,7 +168,7 @@ const PORT = process.env.PORT || 3000;
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('==========================================');
|
console.log('==========================================');
|
||||||
console.log('🏥 PEDIATRIC AI SCRIBE v3.0');
|
console.log('🏥 PEDIATRIC AI SCRIBE v3.1');
|
||||||
console.log('==========================================');
|
console.log('==========================================');
|
||||||
console.log('🌐 http://localhost:' + PORT);
|
console.log('🌐 http://localhost:' + PORT);
|
||||||
console.log('🤖 Provider: ' + activeProvider);
|
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_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_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);
|
||||||
|
|
||||||
|
-- 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
|
// 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 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_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_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++) {
|
for (var i = 0; i < migrations.length; i++) {
|
||||||
try { await client.query(migrations[i]); } catch(e) {}
|
try { await client.query(migrations[i]); } catch(e) {}
|
||||||
|
|
|
||||||
|
|
@ -44,4 +44,12 @@ async function adminMiddleware(req, res, next) {
|
||||||
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 userId = parseInt(req.params.id);
|
||||||
var { role } = req.body;
|
var { role } = req.body;
|
||||||
|
|
||||||
if (!role || (role !== 'admin' && role !== 'user')) {
|
if (!role || (role !== 'admin' && role !== 'user' && role !== 'moderator')) {
|
||||||
return res.status(400).json({ error: 'Role must be "admin" or "user"' });
|
return res.status(400).json({ error: 'Role must be "admin", "user", or "moderator"' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent removing your own admin
|
// 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