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:
Maxim Dolgolyov
2026-04-12 10:10:37 +03:00
commit be4d43105e
204 changed files with 118117 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
const db = require('../db/db');
/* ═══════════════════════════════════════════════════════════════════════
Shop — Items, Purchases, Coins
═══════════════════════════════════════════════════════════════════════ */
/* GET /api/shop/items — list all active shop items + owned status */
function getItems(req, res) {
const userId = req.user.id;
const items = db.prepare(`
SELECT si.*,
(SELECT 1 FROM user_purchases up WHERE up.item_id = si.id AND up.user_id = ?) AS owned
FROM shop_items si
WHERE si.is_active = 1
ORDER BY si.price
`).all(userId);
const user = db.prepare('SELECT coins FROM users WHERE id = ?').get(userId);
res.json({ items, coins: (user && user.coins) || 0 });
}
/* POST /api/shop/items/:id/purchase — buy an item (atomic transaction) */
function purchaseItem(req, res) {
const userId = req.user.id;
const itemId = Number(req.params.id);
const item = db.prepare('SELECT * FROM shop_items WHERE id = ? AND is_active = 1').get(itemId);
if (!item) return res.status(404).json({ error: 'Предмет не найден' });
const alreadyOwned = db.prepare('SELECT 1 FROM user_purchases WHERE user_id = ? AND item_id = ?').get(userId, itemId);
if (alreadyOwned) return res.status(400).json({ error: 'Вы уже купили этот предмет' });
// Atomic: check balance + deduct + insert purchase in one transaction
const doPurchase = db.transaction(() => {
const user = db.prepare('SELECT coins FROM users WHERE id = ?').get(userId);
if (!user || (user.coins || 0) < item.price) return { err: 'Недостаточно монет' };
db.prepare('UPDATE users SET coins = coins - ? WHERE id = ?').run(item.price, userId);
db.prepare('INSERT INTO user_purchases (user_id, item_id) VALUES (?, ?)').run(userId, itemId);
const updated = db.prepare('SELECT coins FROM users WHERE id = ?').get(userId);
return { coins: (updated && updated.coins) || 0 };
});
const result = doPurchase();
if (result.err) return res.status(400).json({ error: result.err });
res.json({ ok: true, coins: result.coins, item });
}
/* GET /api/shop/purchases — list user's purchases with item details */
function getPurchases(req, res) {
const rows = db.prepare(`
SELECT up.id AS purchase_id, up.purchased_at, si.*
FROM user_purchases up
JOIN shop_items si ON si.id = up.item_id
WHERE up.user_id = ?
ORDER BY up.purchased_at DESC
`).all(req.user.id);
res.json(rows);
}
/* GET /api/shop/coins — return user's coin balance */
function getCoins(req, res) {
const user = db.prepare('SELECT coins FROM users WHERE id = ?').get(req.user.id);
res.json({ coins: (user && user.coins) || 0 });
}
/* GET /api/shop/my-active — return user's active cosmetics */
function getMyActive(req, res) {
const u = db.prepare('SELECT avatar_frame, active_title, active_effect FROM users WHERE id = ?').get(req.user.id);
if (!u) return res.json({});
// Resolve full data for each active item
const result = { frame: null, title: null, effect: null };
// Frame from avatar_frame (gamification frames) — handled separately
// Shop frame override
if (u.avatar_frame && u.avatar_frame !== 'default') {
result.frame = { id: u.avatar_frame };
}
if (u.active_title) {
const item = db.prepare('SELECT data FROM shop_items WHERE id = ?').get(u.active_title);
if (item) try { result.title = JSON.parse(item.data); } catch {}
}
if (u.active_effect) {
const item = db.prepare('SELECT data FROM shop_items WHERE id = ?').get(u.active_effect);
if (item) try { result.effect = JSON.parse(item.data); } catch {}
}
res.json(result);
}
/* POST /api/shop/activate — activate a purchased item (or deactivate with itemId=null) */
function activateItem(req, res) {
const userId = req.user.id;
const { itemId } = req.body;
// Deactivate: pass itemId = null and type
if (!itemId) {
const { type } = req.body;
if (type === 'title') db.prepare('UPDATE users SET active_title = NULL WHERE id = ?').run(userId);
if (type === 'effect') db.prepare('UPDATE users SET active_effect = NULL WHERE id = ?').run(userId);
if (type === 'frame') db.prepare("UPDATE users SET avatar_frame = 'default' WHERE id = ?").run(userId);
return res.json({ ok: true });
}
const item = db.prepare('SELECT * FROM shop_items WHERE id = ?').get(itemId);
if (!item) return res.status(404).json({ error: 'Предмет не найден' });
const owned = db.prepare('SELECT 1 FROM user_purchases WHERE user_id = ? AND item_id = ?').get(userId, itemId);
if (!owned) return res.status(403).json({ error: 'Предмет не куплен' });
let data;
try { data = JSON.parse(item.data); } catch { data = {}; }
if (item.type === 'frame') db.prepare('UPDATE users SET avatar_frame = ? WHERE id = ?').run('shop_' + itemId, userId);
if (item.type === 'title') db.prepare('UPDATE users SET active_title = ? WHERE id = ?').run(itemId, userId);
if (item.type === 'effect') db.prepare('UPDATE users SET active_effect = ? WHERE id = ?').run(itemId, userId);
res.json({ ok: true, type: item.type, data });
}
/* ═══════════════════════════════════════════════════════════════════════
Admin — CRUD shop items, award coins, stats
═══════════════════════════════════════════════════════════════════════ */
/* GET /api/shop/admin/items — all items (including inactive) */
function adminGetItems(_req, res) {
const items = db.prepare(`
SELECT si.*,
(SELECT COUNT(*) FROM user_purchases up WHERE up.item_id = si.id) AS sold_count
FROM shop_items si ORDER BY si.id
`).all();
res.json(items);
}
/* POST /api/shop/admin/items — create item */
function adminCreateItem(req, res) {
const { name, description, type, category, price, data, icon, is_active } = req.body;
if (!name || !type || price == null) return res.status(400).json({ error: 'name, type, price required' });
const r = db.prepare(
'INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active) VALUES (?,?,?,?,?,?,?,?)'
).run(name, description || '', type, category || 'cosmetic', price, data || '{}', icon || 'star', is_active ?? 1);
res.json({ ok: true, id: r.lastInsertRowid });
}
/* PUT /api/shop/admin/items/:id — update item */
function adminUpdateItem(req, res) {
const id = Number(req.params.id);
const item = db.prepare('SELECT * FROM shop_items WHERE id = ?').get(id);
if (!item) return res.status(404).json({ error: 'Item not found' });
const { name, description, type, category, price, data, icon, is_active } = req.body;
db.prepare(`UPDATE shop_items SET
name=COALESCE(?,name), description=COALESCE(?,description), type=COALESCE(?,type),
category=COALESCE(?,category), price=COALESCE(?,price), data=COALESCE(?,data),
icon=COALESCE(?,icon), is_active=COALESCE(?,is_active) WHERE id=?`
).run(name, description, type, category, price, data, icon, is_active, id);
res.json({ ok: true });
}
/* DELETE /api/shop/admin/items/:id — delete item */
function adminDeleteItem(req, res) {
const id = Number(req.params.id);
db.prepare('DELETE FROM user_purchases WHERE item_id = ?').run(id);
db.prepare('DELETE FROM shop_items WHERE id = ?').run(id);
res.json({ ok: true });
}
/* POST /api/shop/admin/award-coins — award coins to user */
function adminAwardCoins(req, res) {
const { userId, amount, reason } = req.body;
if (!userId || !amount || amount < 0) return res.status(400).json({ error: 'userId and positive amount required' });
db.prepare('UPDATE users SET coins = coins + ? WHERE id = ?').run(amount, userId);
const user = db.prepare('SELECT coins FROM users WHERE id = ?').get(userId);
res.json({ ok: true, coins: user?.coins || 0 });
}
/* GET /api/shop/admin/stats — shop stats */
function adminShopStats(_req, res) {
const totalItems = db.prepare('SELECT COUNT(*) as c FROM shop_items').get().c;
const activeItems = db.prepare('SELECT COUNT(*) as c FROM shop_items WHERE is_active=1').get().c;
const totalPurchases = db.prepare('SELECT COUNT(*) as c FROM user_purchases').get().c;
const totalCoinsInCirculation = db.prepare('SELECT COALESCE(SUM(coins),0) as c FROM users').get().c;
const topItems = db.prepare(`
SELECT si.name, si.price, COUNT(up.id) as sold
FROM shop_items si LEFT JOIN user_purchases up ON up.item_id = si.id
GROUP BY si.id ORDER BY sold DESC LIMIT 5
`).all();
res.json({ totalItems, activeItems, totalPurchases, totalCoinsInCirculation, topItems });
}
module.exports = {
getItems, purchaseItem, getPurchases, getCoins, getMyActive, activateItem,
adminGetItems, adminCreateItem, adminUpdateItem, adminDeleteItem, adminAwardCoins, adminShopStats
};