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>
104 lines
4.3 KiB
JavaScript
104 lines
4.3 KiB
JavaScript
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 };
|