LearnSpace: full-stack educational whiteboard platform
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>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
const db = require('../db/db');
|
||||
|
||||
const VALID_TYPES = ['lesson', 'course', 'file', 'question'];
|
||||
|
||||
/* ── GET /api/bookmarks?type=lesson ── list user bookmarks ─────────── */
|
||||
function list(req, res) {
|
||||
const uid = req.user.id;
|
||||
const { type } = req.query;
|
||||
|
||||
let where = 'WHERE b.user_id = ?';
|
||||
const args = [uid];
|
||||
if (type && VALID_TYPES.includes(type)) {
|
||||
where += ' AND b.entity_type = ?';
|
||||
args.push(type);
|
||||
}
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT b.id, b.entity_type, b.entity_id, b.created_at
|
||||
FROM bookmarks b ${where}
|
||||
ORDER BY b.created_at DESC
|
||||
`).all(...args);
|
||||
|
||||
// Batch-fetch titles by type to avoid N+1
|
||||
const byType = {};
|
||||
for (const r of rows) (byType[r.entity_type] ||= []).push(r);
|
||||
|
||||
const titleMap = new Map();
|
||||
|
||||
if (byType.lesson?.length) {
|
||||
const ids = byType.lesson.map(r => r.entity_id);
|
||||
const ph = ids.map(() => '?').join(',');
|
||||
for (const l of db.prepare(`SELECT l.id, l.title, c.title AS course_title, c.id AS course_id FROM lessons l JOIN courses c ON l.course_id = c.id WHERE l.id IN (${ph})`).all(...ids))
|
||||
titleMap.set(`lesson:${l.id}`, { title: l.title, courseTitle: l.course_title, courseId: l.course_id });
|
||||
}
|
||||
if (byType.course?.length) {
|
||||
const ids = byType.course.map(r => r.entity_id);
|
||||
const ph = ids.map(() => '?').join(',');
|
||||
for (const c of db.prepare(`SELECT id, title, subject_slug, cover_emoji FROM courses WHERE id IN (${ph})`).all(...ids))
|
||||
titleMap.set(`course:${c.id}`, { title: c.title, subjectSlug: c.subject_slug, coverEmoji: c.cover_emoji });
|
||||
}
|
||||
if (byType.file?.length) {
|
||||
const ids = byType.file.map(r => r.entity_id);
|
||||
const ph = ids.map(() => '?').join(',');
|
||||
for (const f of db.prepare(`SELECT id, title, original_name FROM files WHERE id IN (${ph})`).all(...ids))
|
||||
titleMap.set(`file:${f.id}`, { title: f.title || f.original_name });
|
||||
}
|
||||
if (byType.question?.length) {
|
||||
const ids = byType.question.map(r => r.entity_id);
|
||||
const ph = ids.map(() => '?').join(',');
|
||||
for (const q of db.prepare(`SELECT id, text FROM questions WHERE id IN (${ph})`).all(...ids))
|
||||
titleMap.set(`question:${q.id}`, { title: q.text.slice(0, 100) });
|
||||
}
|
||||
|
||||
const enriched = rows.map(r => {
|
||||
const info = titleMap.get(`${r.entity_type}:${r.entity_id}`);
|
||||
if (!info) return null;
|
||||
const { title, ...extra } = info;
|
||||
return { ...r, title, ...extra };
|
||||
}).filter(Boolean);
|
||||
|
||||
res.json(enriched);
|
||||
}
|
||||
|
||||
/* ── POST /api/bookmarks ── add bookmark ───────────────────────────── */
|
||||
function add(req, res) {
|
||||
const { entityType, entityId } = req.body;
|
||||
if (!entityType || !entityId) return res.status(400).json({ error: 'entityType and entityId required' });
|
||||
if (!VALID_TYPES.includes(entityType)) return res.status(400).json({ error: 'Invalid entity type' });
|
||||
|
||||
try {
|
||||
const r = db.prepare(
|
||||
'INSERT INTO bookmarks (user_id, entity_type, entity_id) VALUES (?, ?, ?)'
|
||||
).run(req.user.id, entityType, entityId);
|
||||
res.status(201).json({ id: Number(r.lastInsertRowid) });
|
||||
} catch (e) {
|
||||
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'Already bookmarked' });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── DELETE /api/bookmarks/:id ── remove bookmark ──────────────────── */
|
||||
function remove(req, res) {
|
||||
const bm = db.prepare('SELECT id FROM bookmarks WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
|
||||
if (!bm) return res.status(404).json({ error: 'Bookmark not found' });
|
||||
db.prepare('DELETE FROM bookmarks WHERE id = ?').run(bm.id);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
/* ── DELETE /api/bookmarks/entity/:type/:entityId ── remove by entity ── */
|
||||
function removeByEntity(req, res) {
|
||||
const { type, entityId } = req.params;
|
||||
db.prepare('DELETE FROM bookmarks WHERE user_id = ? AND entity_type = ? AND entity_id = ?').run(req.user.id, type, entityId);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
/* ── GET /api/bookmarks/check/:type/:entityId ── check if bookmarked ── */
|
||||
function check(req, res) {
|
||||
const { type, entityId } = req.params;
|
||||
const row = db.prepare('SELECT id FROM bookmarks WHERE user_id = ? AND entity_type = ? AND entity_id = ?').get(req.user.id, type, entityId);
|
||||
res.json({ bookmarked: !!row, id: row?.id || null });
|
||||
}
|
||||
|
||||
module.exports = { list, add, remove, removeByEntity, check };
|
||||
Reference in New Issue
Block a user