be4d43105e
Node.js/Express backend + vanilla JS frontend. Features: real-time collaborative whiteboard (SSE), multi-page support, LaTeX formulas, shapes/connectors, coordinate systems, number lines, compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with rotation & resize controls, minimap navigation overlay, auto-measurements, multi-page thumbnails sidebar, PNG export, page templates. Student/teacher workflows: classes, assignments, library, dashboard. Mobile responsive. SQLite (better-sqlite3). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
318 lines
11 KiB
JavaScript
318 lines
11 KiB
JavaScript
const db = require('../db/db');
|
|
|
|
/* ══════════════════════════════════════════════════════════════════════
|
|
COURSE TEMPLATES
|
|
══════════════════════════════════════════════════════════════════════ */
|
|
|
|
/* ── GET /api/templates/courses ──────────────────────────────────────── */
|
|
function listCourseTemplates(req, res) {
|
|
const uid = req.user.id;
|
|
const { my, subject } = req.query;
|
|
|
|
let where;
|
|
const args = [];
|
|
|
|
if (my) {
|
|
where = 'WHERE ct.created_by = ?';
|
|
args.push(uid);
|
|
} else if (subject) {
|
|
where = 'WHERE (ct.is_public = 1 OR ct.created_by = ?) AND ct.subject_slug = ?';
|
|
args.push(uid, subject);
|
|
} else {
|
|
where = 'WHERE ct.is_public = 1 OR ct.created_by = ?';
|
|
args.push(uid);
|
|
}
|
|
|
|
const rows = db.prepare(`
|
|
SELECT ct.*, u.name AS creator_name
|
|
FROM course_templates ct
|
|
LEFT JOIN users u ON ct.created_by = u.id
|
|
${where}
|
|
ORDER BY ct.created_at DESC
|
|
`).all(...args);
|
|
|
|
res.json(rows.map(r => ({
|
|
id: r.id,
|
|
title: r.title,
|
|
description: r.description || '',
|
|
category: r.category,
|
|
subjectSlug: r.subject_slug,
|
|
structure: safeJSON(r.structure, {}),
|
|
isPublic: r.is_public === 1,
|
|
createdBy: r.created_by,
|
|
creatorName: r.creator_name || '',
|
|
createdAt: r.created_at,
|
|
})));
|
|
}
|
|
|
|
/* ── POST /api/templates/courses ─────────────────────────────────────── */
|
|
function saveCourseTemplate(req, res) {
|
|
const uid = req.user.id;
|
|
const { title, description, category, subject_slug, courseId } = req.body;
|
|
if (!title) return res.status(400).json({ error: 'title required' });
|
|
|
|
let structure = {};
|
|
|
|
if (courseId) {
|
|
// Snapshot course structure
|
|
const course = db.prepare('SELECT * FROM courses WHERE id = ?').get(courseId);
|
|
if (!course) return res.status(404).json({ error: 'Course not found' });
|
|
|
|
const sections = db.prepare(
|
|
'SELECT * FROM course_sections WHERE course_id = ? ORDER BY order_index, id'
|
|
).all(courseId);
|
|
|
|
const lessons = db.prepare(
|
|
'SELECT * FROM lessons WHERE course_id = ? ORDER BY order_index, id'
|
|
).all(courseId);
|
|
|
|
const sectionArr = sections.map(s => {
|
|
const sectionLessons = lessons.filter(l => l.section_id === s.id);
|
|
return {
|
|
title: s.title,
|
|
lessons: sectionLessons.map(l => ({
|
|
title: l.title,
|
|
blocks: db.prepare(
|
|
'SELECT type, data FROM lesson_blocks WHERE lesson_id = ? ORDER BY order_index, id'
|
|
).all(l.id).map(b => ({ type: b.type, data: safeJSON(b.data, {}) })),
|
|
})),
|
|
};
|
|
});
|
|
|
|
// Lessons without a section
|
|
const unsectioned = lessons.filter(l => !l.section_id);
|
|
if (unsectioned.length) {
|
|
sectionArr.unshift({
|
|
title: null,
|
|
lessons: unsectioned.map(l => ({
|
|
title: l.title,
|
|
blocks: db.prepare(
|
|
'SELECT type, data FROM lesson_blocks WHERE lesson_id = ? ORDER BY order_index, id'
|
|
).all(l.id).map(b => ({ type: b.type, data: safeJSON(b.data, {}) })),
|
|
})),
|
|
});
|
|
}
|
|
|
|
structure = { sections: sectionArr };
|
|
}
|
|
|
|
const r = db.prepare(`
|
|
INSERT INTO course_templates (title, description, category, subject_slug, structure, is_public, created_by)
|
|
VALUES (?, ?, ?, ?, ?, 1, ?)
|
|
`).run(
|
|
title.trim(),
|
|
description || null,
|
|
category || 'general',
|
|
subject_slug || null,
|
|
JSON.stringify(structure),
|
|
uid
|
|
);
|
|
|
|
res.status(201).json({ id: r.lastInsertRowid });
|
|
}
|
|
|
|
/* ── POST /api/templates/courses/:id/create ──────────────────────────── */
|
|
function createFromCourseTemplate(req, res) {
|
|
const tpl = db.prepare('SELECT * FROM course_templates WHERE id = ?').get(req.params.id);
|
|
if (!tpl) return res.status(404).json({ error: 'Template not found' });
|
|
|
|
const { title, subjectSlug } = req.body;
|
|
const structure = safeJSON(tpl.structure, {});
|
|
const sections = structure.sections || [];
|
|
|
|
let newCourseId;
|
|
db.transaction(() => {
|
|
const cr = db.prepare(`
|
|
INSERT INTO courses (subject_slug, title, description, cover_emoji, order_index, is_published, created_by)
|
|
VALUES (?, ?, ?, '', 0, 0, ?)
|
|
`).run(
|
|
subjectSlug || tpl.subject_slug || 'other',
|
|
title || tpl.title,
|
|
tpl.description || null,
|
|
req.user.id
|
|
);
|
|
newCourseId = cr.lastInsertRowid;
|
|
|
|
let lessonOrder = 0;
|
|
for (const sec of sections) {
|
|
let sectionId = null;
|
|
if (sec.title) {
|
|
const sr = db.prepare(
|
|
'INSERT INTO course_sections (course_id, title, order_index) VALUES (?, ?, ?)'
|
|
).run(newCourseId, sec.title, lessonOrder);
|
|
sectionId = sr.lastInsertRowid;
|
|
}
|
|
|
|
for (const lesson of (sec.lessons || [])) {
|
|
const lr = db.prepare(
|
|
'INSERT INTO lessons (course_id, title, order_index, section_id) VALUES (?, ?, ?, ?)'
|
|
).run(newCourseId, lesson.title, lessonOrder++, sectionId);
|
|
const newLid = lr.lastInsertRowid;
|
|
|
|
(lesson.blocks || []).forEach((b, i) => {
|
|
db.prepare(
|
|
'INSERT INTO lesson_blocks (lesson_id, type, order_index, data) VALUES (?, ?, ?, ?)'
|
|
).run(newLid, b.type, i, JSON.stringify(b.data || {}));
|
|
});
|
|
}
|
|
}
|
|
})();
|
|
|
|
res.status(201).json({ id: newCourseId });
|
|
}
|
|
|
|
/* ── DELETE /api/templates/courses/:id ────────────────────────────────── */
|
|
function deleteCourseTemplate(req, res) {
|
|
const tpl = db.prepare('SELECT * FROM course_templates WHERE id = ?').get(req.params.id);
|
|
if (!tpl) return res.status(404).json({ error: 'Template not found' });
|
|
if (tpl.created_by !== req.user.id && req.user.role !== 'admin')
|
|
return res.status(403).json({ error: 'Forbidden' });
|
|
|
|
db.prepare('DELETE FROM course_templates WHERE id = ?').run(tpl.id);
|
|
res.json({ ok: true });
|
|
}
|
|
|
|
|
|
/* ══════════════════════════════════════════════════════════════════════
|
|
LESSON TEMPLATES
|
|
══════════════════════════════════════════════════════════════════════ */
|
|
|
|
/* ── GET /api/templates/lessons ──────────────────────────────────────── */
|
|
function listLessonTemplates(req, res) {
|
|
const uid = req.user.id;
|
|
const { my, category } = req.query;
|
|
|
|
let where;
|
|
const args = [];
|
|
|
|
if (my) {
|
|
where = 'WHERE lt.created_by = ?';
|
|
args.push(uid);
|
|
} else if (category) {
|
|
where = 'WHERE (lt.is_public = 1 OR lt.created_by = ?) AND lt.category = ?';
|
|
args.push(uid, category);
|
|
} else {
|
|
where = 'WHERE lt.is_public = 1 OR lt.created_by = ?';
|
|
args.push(uid);
|
|
}
|
|
|
|
const rows = db.prepare(`
|
|
SELECT lt.*, u.name AS creator_name
|
|
FROM lesson_templates lt
|
|
LEFT JOIN users u ON lt.created_by = u.id
|
|
${where}
|
|
ORDER BY lt.created_at DESC
|
|
`).all(...args);
|
|
|
|
res.json(rows.map(r => ({
|
|
id: r.id,
|
|
title: r.title,
|
|
category: r.category,
|
|
subjectSlug: r.subject_slug,
|
|
blocks: safeJSON(r.blocks, []),
|
|
isPublic: r.is_public === 1,
|
|
createdBy: r.created_by,
|
|
creatorName: r.creator_name || '',
|
|
createdAt: r.created_at,
|
|
})));
|
|
}
|
|
|
|
/* ── POST /api/templates/lessons ─────────────────────────────────────── */
|
|
function saveLessonTemplate(req, res) {
|
|
const uid = req.user.id;
|
|
const { title, category, subject_slug, lessonId } = req.body;
|
|
if (!title) return res.status(400).json({ error: 'title required' });
|
|
|
|
let blocksJSON = '[]';
|
|
|
|
if (lessonId) {
|
|
const lesson = db.prepare('SELECT id FROM lessons WHERE id = ?').get(lessonId);
|
|
if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
|
|
|
|
const rawBlocks = db.prepare(
|
|
'SELECT type, data FROM lesson_blocks WHERE lesson_id = ? ORDER BY order_index, id'
|
|
).all(lesson.id);
|
|
|
|
blocksJSON = JSON.stringify(rawBlocks.map(b => ({
|
|
type: b.type,
|
|
data: safeJSON(b.data, {}),
|
|
})));
|
|
}
|
|
|
|
const r = db.prepare(`
|
|
INSERT INTO lesson_templates (title, category, subject_slug, blocks, is_public, created_by)
|
|
VALUES (?, ?, ?, ?, 1, ?)
|
|
`).run(
|
|
title.trim(),
|
|
category || 'general',
|
|
subject_slug || null,
|
|
blocksJSON,
|
|
uid
|
|
);
|
|
|
|
res.status(201).json({ id: r.lastInsertRowid });
|
|
}
|
|
|
|
/* ── POST /api/templates/lessons/:id/create ──────────────────────────── */
|
|
function createFromLessonTemplate(req, res) {
|
|
const tpl = db.prepare('SELECT * FROM lesson_templates WHERE id = ?').get(req.params.id);
|
|
if (!tpl) return res.status(404).json({ error: 'Template not found' });
|
|
|
|
const { courseId, sectionId, title } = req.body;
|
|
if (!courseId) return res.status(400).json({ error: 'courseId required' });
|
|
|
|
const course = db.prepare('SELECT id FROM courses WHERE id = ?').get(courseId);
|
|
if (!course) return res.status(404).json({ error: 'Course not found' });
|
|
|
|
const tplBlocks = safeJSON(tpl.blocks, []);
|
|
|
|
let newLessonId;
|
|
db.transaction(() => {
|
|
// Get max order_index
|
|
const maxOrd = db.prepare(
|
|
'SELECT MAX(order_index) AS mx FROM lessons WHERE course_id = ?'
|
|
).get(courseId);
|
|
|
|
const lr = db.prepare(
|
|
'INSERT INTO lessons (course_id, title, order_index, section_id) VALUES (?, ?, ?, ?)'
|
|
).run(courseId, title || tpl.title, (maxOrd?.mx ?? -1) + 1, sectionId || null);
|
|
newLessonId = lr.lastInsertRowid;
|
|
|
|
tplBlocks.forEach((b, i) => {
|
|
db.prepare(
|
|
'INSERT INTO lesson_blocks (lesson_id, type, order_index, data) VALUES (?, ?, ?, ?)'
|
|
).run(newLessonId, b.type, i, JSON.stringify(b.data || {}));
|
|
});
|
|
})();
|
|
|
|
res.status(201).json({ id: newLessonId });
|
|
}
|
|
|
|
/* ── DELETE /api/templates/lessons/:id ────────────────────────────────── */
|
|
function deleteLessonTemplate(req, res) {
|
|
const tpl = db.prepare('SELECT * FROM lesson_templates WHERE id = ?').get(req.params.id);
|
|
if (!tpl) return res.status(404).json({ error: 'Template not found' });
|
|
if (tpl.created_by !== req.user.id && req.user.role !== 'admin')
|
|
return res.status(403).json({ error: 'Forbidden' });
|
|
|
|
db.prepare('DELETE FROM lesson_templates WHERE id = ?').run(tpl.id);
|
|
res.json({ ok: true });
|
|
}
|
|
|
|
|
|
/* ── helpers ─────────────────────────────────────────────────────────── */
|
|
function safeJSON(str, fallback) {
|
|
try { return JSON.parse(str); } catch { return fallback; }
|
|
}
|
|
|
|
module.exports = {
|
|
listCourseTemplates,
|
|
saveCourseTemplate,
|
|
createFromCourseTemplate,
|
|
deleteCourseTemplate,
|
|
listLessonTemplates,
|
|
saveLessonTemplate,
|
|
createFromLessonTemplate,
|
|
deleteLessonTemplate,
|
|
};
|