- New GET /api/learning/content/:slug/slides (authMiddleware only)
Renders Marp markdown via marp-core, returns {css, slides[]} for
any authenticated user — no moderator access required
- showViewer: when content_type === 'presentation', hides the body card
and shows a presentation card with title, slide count, and View Slides button
Auto-opens the slide modal immediately on load; button allows re-opening
- openSlidesFromSlug(): fetches slides from user-accessible endpoint
then opens the existing slide preview modal (arrow keys, swipe, dots)
- Viewer already correctly hides feed/categories/search when viewing content;
"Back to Feed" button returns to the main list
254 lines
10 KiB
JavaScript
254 lines
10 KiB
JavaScript
// ============================================================
|
|
// 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) { console.error('[LearningHub]', err.message); res.status(500).json({ error: 'Internal server error' }); }
|
|
});
|
|
|
|
// ============================================================
|
|
// 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) { console.error('[LearningHub]', err.message); res.status(500).json({ error: 'Internal server error' }); }
|
|
});
|
|
|
|
// ============================================================
|
|
// 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) { console.error('[LearningHub]', err.message); res.status(500).json({ error: 'Internal server error' }); }
|
|
});
|
|
|
|
// ============================================================
|
|
// 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) { console.error('[LearningHub]', err.message); res.status(500).json({ error: 'Internal server error' }); }
|
|
});
|
|
|
|
// ============================================================
|
|
// RENDER PRESENTATION SLIDES (user-accessible)
|
|
// ============================================================
|
|
router.get('/content/:slug/slides', async function(req, res) {
|
|
try {
|
|
var item = await db.get(
|
|
'SELECT body, content_type FROM learning_content WHERE slug = ? AND published = true',
|
|
[req.params.slug]
|
|
);
|
|
if (!item) return res.status(404).json({ error: 'Content not found' });
|
|
if (item.content_type !== 'presentation') return res.status(400).json({ error: 'Not a presentation' });
|
|
|
|
var { Marp } = require('@marp-team/marp-core');
|
|
var marp = new Marp({ html: false });
|
|
var { html, css } = marp.render(item.body || '');
|
|
|
|
var slides = [];
|
|
var sectionReg = /<section[^>]*>[\s\S]*?<\/section>/g;
|
|
var match;
|
|
while ((match = sectionReg.exec(html)) !== null) slides.push(match[0]);
|
|
if (slides.length === 0) slides.push(html);
|
|
|
|
res.json({ success: true, css: css, slides: slides });
|
|
} 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 (include question_type)
|
|
var questions = await db.all(
|
|
'SELECT id, question_text, question_type, 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; });
|
|
|
|
// 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 isCorrect, resultEntry;
|
|
|
|
if (q.question_type === 'multi') {
|
|
var selectedIds = (userAnswer && Array.isArray(userAnswer.optionIds)) ? userAnswer.optionIds.map(Number) : [];
|
|
var correctIds = options.filter(function(o) { return o.is_correct; }).map(function(o) { return o.id; });
|
|
var allCorrectChosen = correctIds.length > 0 && correctIds.every(function(id) { return selectedIds.indexOf(id) !== -1; });
|
|
var noWrongChosen = selectedIds.every(function(id) { return correctIds.indexOf(id) !== -1; });
|
|
isCorrect = allCorrectChosen && noWrongChosen && selectedIds.length > 0;
|
|
if (isCorrect) score++;
|
|
resultEntry = {
|
|
questionId: q.id,
|
|
questionType: 'multi',
|
|
questionText: q.question_text,
|
|
selectedOptionIds: selectedIds,
|
|
correctOptionIds: correctIds,
|
|
isCorrect: isCorrect,
|
|
generalExplanation: q.general_explanation || ''
|
|
};
|
|
} else {
|
|
var selectedOptionId = userAnswer ? userAnswer.optionId : null;
|
|
var correctOption = options.find(function(o) { return o.is_correct; });
|
|
var selectedOption = selectedOptionId ? options.find(function(o) { return o.id === selectedOptionId; }) : null;
|
|
isCorrect = selectedOption ? selectedOption.is_correct : false;
|
|
if (isCorrect) score++;
|
|
resultEntry = {
|
|
questionId: q.id,
|
|
questionType: q.question_type,
|
|
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 || ''
|
|
};
|
|
}
|
|
results.push(resultEntry);
|
|
}
|
|
|
|
// 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) { console.error('[LearningHub]', err.message); res.status(500).json({ error: 'Internal server error' }); }
|
|
});
|
|
|
|
// ============================================================
|
|
// 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) { console.error('[LearningHub]', err.message); res.status(500).json({ error: 'Internal server error' }); }
|
|
});
|
|
|
|
module.exports = router;
|