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,246 @@
|
||||
const db = require('../db/db');
|
||||
|
||||
/* ── SM-2 algorithm ───────────────────────────────────────────────────────
|
||||
quality: 0 = blackout, 1 = wrong, 2 = wrong but familiar,
|
||||
3 = correct with difficulty, 4 = correct, 5 = perfect
|
||||
─────────────────────────────────────────────────────────────────────── */
|
||||
function sm2(easeFactor, intervalDays, repetitions, quality) {
|
||||
let ef = easeFactor;
|
||||
let n = repetitions;
|
||||
let iv = intervalDays;
|
||||
|
||||
if (quality < 3) {
|
||||
n = 0;
|
||||
iv = 1;
|
||||
} else {
|
||||
if (n === 0) iv = 1;
|
||||
else if (n === 1) iv = 6;
|
||||
else iv = Math.round(iv * ef);
|
||||
n++;
|
||||
}
|
||||
ef = Math.max(1.3, ef + 0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
|
||||
const due = new Date(Date.now() + iv * 86400000).toISOString();
|
||||
return { easeFactor: ef, intervalDays: iv, repetitions: n, dueAt: due };
|
||||
}
|
||||
|
||||
/* ── GET /api/flashcards/decks ─────────────────────────────────────────── */
|
||||
function listDecks(req, res) {
|
||||
const uid = req.user.id;
|
||||
const decks = db.prepare(`
|
||||
SELECT d.*,
|
||||
(SELECT COUNT(*) FROM flashcard_cards c WHERE c.deck_id = d.id) AS card_count,
|
||||
(SELECT COUNT(*) FROM flashcard_cards c
|
||||
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
|
||||
WHERE c.deck_id = d.id AND (r.id IS NULL OR r.due_at <= datetime('now'))) AS due_count
|
||||
FROM flashcard_decks d
|
||||
WHERE d.user_id = ?
|
||||
ORDER BY d.created_at DESC
|
||||
`).all(uid, uid);
|
||||
res.json({ decks });
|
||||
}
|
||||
|
||||
/* ── POST /api/flashcards/decks ────────────────────────────────────────── */
|
||||
function createDeck(req, res) {
|
||||
const uid = req.user.id;
|
||||
const { title, description = '', color = '#9B5DE5' } = req.body;
|
||||
if (!title?.trim()) return res.status(400).json({ error: 'Title required' });
|
||||
const r = db.prepare(
|
||||
`INSERT INTO flashcard_decks (user_id, title, description, color) VALUES (?,?,?,?)`
|
||||
).run(uid, title.trim(), description, color);
|
||||
res.json({ id: r.lastInsertRowid, title: title.trim(), description, color, card_count: 0, due_count: 0 });
|
||||
}
|
||||
|
||||
/* ── PUT /api/flashcards/decks/:id ─────────────────────────────────────── */
|
||||
function updateDeck(req, res) {
|
||||
const uid = req.user.id;
|
||||
const { title, description, color } = req.body;
|
||||
const deck = db.prepare(`SELECT * FROM flashcard_decks WHERE id = ? AND user_id = ?`)
|
||||
.get(req.params.id, uid);
|
||||
if (!deck) return res.status(404).json({ error: 'Not found' });
|
||||
db.prepare(`UPDATE flashcard_decks SET title=?, description=?, color=? WHERE id=?`)
|
||||
.run(title ?? deck.title, description ?? deck.description, color ?? deck.color, deck.id);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
/* ── DELETE /api/flashcards/decks/:id ──────────────────────────────────── */
|
||||
function deleteDeck(req, res) {
|
||||
const uid = req.user.id;
|
||||
const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`)
|
||||
.get(req.params.id, uid);
|
||||
if (!deck) return res.status(404).json({ error: 'Not found' });
|
||||
db.prepare(`DELETE FROM flashcard_decks WHERE id = ?`).run(deck.id);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
/* ── GET /api/flashcards/decks/:id/cards ───────────────────────────────── */
|
||||
function getCards(req, res) {
|
||||
const uid = req.user.id;
|
||||
const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`)
|
||||
.get(req.params.id, uid);
|
||||
if (!deck) return res.status(404).json({ error: 'Not found' });
|
||||
const cards = db.prepare(`
|
||||
SELECT c.*, r.ease_factor, r.interval_days, r.repetitions, r.due_at, r.last_reviewed
|
||||
FROM flashcard_cards c
|
||||
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
|
||||
WHERE c.deck_id = ?
|
||||
ORDER BY c.order_idx, c.id
|
||||
`).all(uid, deck.id);
|
||||
res.json({ cards });
|
||||
}
|
||||
|
||||
/* ── POST /api/flashcards/decks/:id/cards ──────────────────────────────── */
|
||||
function addCard(req, res) {
|
||||
const uid = req.user.id;
|
||||
const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`)
|
||||
.get(req.params.id, uid);
|
||||
if (!deck) return res.status(404).json({ error: 'Not found' });
|
||||
const { front = '', back = '' } = req.body;
|
||||
const maxIdx = db.prepare(`SELECT MAX(order_idx) AS m FROM flashcard_cards WHERE deck_id = ?`)
|
||||
.get(deck.id)?.m ?? -1;
|
||||
const r = db.prepare(`INSERT INTO flashcard_cards (deck_id, front, back, order_idx) VALUES (?,?,?,?)`)
|
||||
.run(deck.id, front, back, maxIdx + 1);
|
||||
res.json({ id: r.lastInsertRowid, deck_id: deck.id, front, back, order_idx: maxIdx + 1 });
|
||||
}
|
||||
|
||||
/* ── POST /api/flashcards/decks/:id/cards/bulk ──────────────────────────── */
|
||||
function addCardsBulk(req, res) {
|
||||
const uid = req.user.id;
|
||||
const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`)
|
||||
.get(req.params.id, uid);
|
||||
if (!deck) return res.status(404).json({ error: 'Not found' });
|
||||
const { cards } = req.body;
|
||||
if (!Array.isArray(cards) || !cards.length) return res.status(400).json({ error: 'cards[] required' });
|
||||
const maxIdx = db.prepare(`SELECT MAX(order_idx) AS m FROM flashcard_cards WHERE deck_id = ?`)
|
||||
.get(deck.id)?.m ?? -1;
|
||||
const stmt = db.prepare(`INSERT INTO flashcard_cards (deck_id, front, back, order_idx) VALUES (?,?,?,?)`);
|
||||
const inserted = [];
|
||||
const ins = db.transaction(() => {
|
||||
cards.forEach((c, i) => {
|
||||
const r = stmt.run(deck.id, c.front || '', c.back || '', maxIdx + 1 + i);
|
||||
inserted.push({ id: r.lastInsertRowid, front: c.front, back: c.back });
|
||||
});
|
||||
});
|
||||
ins();
|
||||
res.json({ inserted });
|
||||
}
|
||||
|
||||
/* ── PUT /api/flashcards/cards/:id ─────────────────────────────────────── */
|
||||
function updateCard(req, res) {
|
||||
const uid = req.user.id;
|
||||
const card = db.prepare(`
|
||||
SELECT c.* FROM flashcard_cards c
|
||||
JOIN flashcard_decks d ON d.id = c.deck_id
|
||||
WHERE c.id = ? AND d.user_id = ?
|
||||
`).get(req.params.id, uid);
|
||||
if (!card) return res.status(404).json({ error: 'Not found' });
|
||||
const { front, back } = req.body;
|
||||
db.prepare(`UPDATE flashcard_cards SET front=?, back=? WHERE id=?`)
|
||||
.run(front ?? card.front, back ?? card.back, card.id);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
/* ── DELETE /api/flashcards/cards/:id ──────────────────────────────────── */
|
||||
function deleteCard(req, res) {
|
||||
const uid = req.user.id;
|
||||
const card = db.prepare(`
|
||||
SELECT c.id FROM flashcard_cards c
|
||||
JOIN flashcard_decks d ON d.id = c.deck_id
|
||||
WHERE c.id = ? AND d.user_id = ?
|
||||
`).get(req.params.id, uid);
|
||||
if (!card) return res.status(404).json({ error: 'Not found' });
|
||||
db.prepare(`DELETE FROM flashcard_cards WHERE id = ?`).run(card.id);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
/* ── GET /api/flashcards/decks/:id/study ───────────────────────────────── */
|
||||
function getStudySession(req, res) {
|
||||
const uid = req.user.id;
|
||||
const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`)
|
||||
.get(req.params.id, uid);
|
||||
if (!deck) return res.status(404).json({ error: 'Not found' });
|
||||
// due cards first, then new cards (no review yet), limit 20
|
||||
const cards = db.prepare(`
|
||||
SELECT c.id, c.front, c.back,
|
||||
COALESCE(r.ease_factor, 2.5) AS ease_factor,
|
||||
COALESCE(r.interval_days, 1) AS interval_days,
|
||||
COALESCE(r.repetitions, 0) AS repetitions,
|
||||
COALESCE(r.due_at, datetime('now')) AS due_at,
|
||||
r.last_reviewed,
|
||||
CASE WHEN r.id IS NULL THEN 0 ELSE 1 END AS seen
|
||||
FROM flashcard_cards c
|
||||
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
|
||||
WHERE c.deck_id = ?
|
||||
AND (r.id IS NULL OR r.due_at <= datetime('now'))
|
||||
ORDER BY seen ASC, r.due_at ASC
|
||||
LIMIT 20
|
||||
`).all(uid, deck.id);
|
||||
const total_due = db.prepare(`
|
||||
SELECT COUNT(*) AS n FROM flashcard_cards c
|
||||
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
|
||||
WHERE c.deck_id = ? AND (r.id IS NULL OR r.due_at <= datetime('now'))
|
||||
`).get(uid, deck.id).n;
|
||||
res.json({ cards, total_due });
|
||||
}
|
||||
|
||||
/* ── POST /api/flashcards/cards/:id/review ─────────────────────────────── */
|
||||
function submitReview(req, res) {
|
||||
const uid = req.user.id;
|
||||
const { quality } = req.body; // 0..5
|
||||
if (quality === undefined || quality < 0 || quality > 5)
|
||||
return res.status(400).json({ error: 'quality 0-5 required' });
|
||||
|
||||
const card = db.prepare(`
|
||||
SELECT c.id FROM flashcard_cards c
|
||||
JOIN flashcard_decks d ON d.id = c.deck_id
|
||||
WHERE c.id = ? AND d.user_id = ?
|
||||
`).get(req.params.id, uid);
|
||||
if (!card) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
const existing = db.prepare(
|
||||
`SELECT * FROM flashcard_reviews WHERE user_id = ? AND card_id = ?`
|
||||
).get(uid, card.id);
|
||||
|
||||
const prev = existing || { ease_factor: 2.5, interval_days: 1, repetitions: 0 };
|
||||
const next = sm2(prev.ease_factor, prev.interval_days, prev.repetitions, quality);
|
||||
|
||||
if (existing) {
|
||||
db.prepare(`
|
||||
UPDATE flashcard_reviews
|
||||
SET ease_factor=?, interval_days=?, repetitions=?, due_at=?, last_reviewed=datetime('now')
|
||||
WHERE user_id=? AND card_id=?
|
||||
`).run(next.easeFactor, next.intervalDays, next.repetitions, next.dueAt, uid, card.id);
|
||||
} else {
|
||||
db.prepare(`
|
||||
INSERT INTO flashcard_reviews (user_id, card_id, ease_factor, interval_days, repetitions, due_at, last_reviewed)
|
||||
VALUES (?,?,?,?,?,?,datetime('now'))
|
||||
`).run(uid, card.id, next.easeFactor, next.intervalDays, next.repetitions, next.dueAt);
|
||||
}
|
||||
res.json({ ok: true, next_review: next.dueAt, interval_days: next.intervalDays });
|
||||
}
|
||||
|
||||
/* ── GET /api/flashcards/stats ─────────────────────────────────────────── */
|
||||
function getStats(req, res) {
|
||||
const uid = req.user.id;
|
||||
const decks_count = db.prepare(`SELECT COUNT(*) AS n FROM flashcard_decks WHERE user_id=?`).get(uid).n;
|
||||
const cards_count = db.prepare(`
|
||||
SELECT COUNT(*) AS n FROM flashcard_cards c
|
||||
JOIN flashcard_decks d ON d.id = c.deck_id WHERE d.user_id=?
|
||||
`).get(uid).n;
|
||||
const due_count = db.prepare(`
|
||||
SELECT COUNT(*) AS n FROM flashcard_cards c
|
||||
JOIN flashcard_decks d ON d.id = c.deck_id
|
||||
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
|
||||
WHERE d.user_id = ? AND (r.id IS NULL OR r.due_at <= datetime('now'))
|
||||
`).get(uid, uid).n;
|
||||
const reviewed_today = db.prepare(`
|
||||
SELECT COUNT(*) AS n FROM flashcard_reviews
|
||||
WHERE user_id = ? AND date(last_reviewed) = date('now')
|
||||
`).get(uid).n;
|
||||
res.json({ decks_count, cards_count, due_count, reviewed_today });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listDecks, createDeck, updateDeck, deleteDeck,
|
||||
getCards, addCard, addCardsBulk, updateCard, deleteCard,
|
||||
getStudySession, submitReview, getStats,
|
||||
};
|
||||
Reference in New Issue
Block a user