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,342 @@
|
||||
const db = require('../db/db');
|
||||
const { awardXP, checkRedBookAchievements } = require('./gamificationController');
|
||||
|
||||
/* ── helpers ─────────────────────────────────────────────────────────── */
|
||||
const stmts = {
|
||||
groups: db.prepare('SELECT * FROM rb_groups ORDER BY name_ru'),
|
||||
habitats: db.prepare('SELECT * FROM rb_habitats ORDER BY name'),
|
||||
speciesById: db.prepare(`
|
||||
SELECT s.*, g.name_ru as group_name, g.icon as group_icon, g.color as group_color,
|
||||
h.name as habitat_name, h.type as habitat_type
|
||||
FROM rb_species s
|
||||
JOIN rb_groups g ON g.id = s.group_id
|
||||
LEFT JOIN rb_habitats h ON h.id = s.habitat_id
|
||||
WHERE s.id = ?
|
||||
`),
|
||||
regionsBySpecies: db.prepare('SELECT region_code FROM rb_species_regions WHERE species_id = ?'),
|
||||
popdata: db.prepare('SELECT year, count_estimate, source FROM rb_population_data WHERE species_id = ? ORDER BY year'),
|
||||
foodWebPrey: db.prepare(`
|
||||
SELECT fw.strength, s.id, s.name_ru, s.name_lat, s.category,
|
||||
g.icon as group_icon, g.color as group_color
|
||||
FROM rb_food_web fw
|
||||
JOIN rb_species s ON s.id = fw.prey_id
|
||||
JOIN rb_groups g ON g.id = s.group_id
|
||||
WHERE fw.predator_id = ?
|
||||
`),
|
||||
foodWebPredators: db.prepare(`
|
||||
SELECT fw.strength, s.id, s.name_ru, s.name_lat, s.category,
|
||||
g.icon as group_icon, g.color as group_color
|
||||
FROM rb_food_web fw
|
||||
JOIN rb_species s ON s.id = fw.predator_id
|
||||
JOIN rb_groups g ON g.id = s.group_id
|
||||
WHERE fw.prey_id = ?
|
||||
`),
|
||||
collection: db.prepare('SELECT species_id, unlock_method, notes, unlocked_at FROM rb_user_collection WHERE user_id = ?'),
|
||||
hasCollect: db.prepare('SELECT 1 FROM rb_user_collection WHERE user_id = ? AND species_id = ?'),
|
||||
addCollect: db.prepare('INSERT OR IGNORE INTO rb_user_collection (user_id, species_id, unlock_method) VALUES (?,?,?)'),
|
||||
quests: db.prepare('SELECT * FROM rb_quests ORDER BY id'),
|
||||
userQuests: db.prepare('SELECT * FROM rb_user_quests WHERE user_id = ?'),
|
||||
startQuest: db.prepare('INSERT OR IGNORE INTO rb_user_quests (user_id, quest_id) VALUES (?,?)'),
|
||||
questById: db.prepare('SELECT * FROM rb_quests WHERE id = ?'),
|
||||
userQuestRow:db.prepare('SELECT * FROM rb_user_quests WHERE user_id = ? AND quest_id = ?'),
|
||||
completeQ: db.prepare("UPDATE rb_user_quests SET status='completed', completed_at=datetime('now') WHERE user_id=? AND quest_id=?"),
|
||||
sightings: db.prepare(`
|
||||
SELECT rs.*, u.name as user_name, s.name_ru as species_name
|
||||
FROM rb_sightings rs
|
||||
JOIN users u ON u.id = rs.user_id
|
||||
JOIN rb_species s ON s.id = rs.species_id
|
||||
ORDER BY rs.created_at DESC LIMIT 50
|
||||
`),
|
||||
addSighting: db.prepare('INSERT INTO rb_sightings (user_id, species_id, region_code, description) VALUES (?,?,?,?)'),
|
||||
mapData: db.prepare(`
|
||||
SELECT r.region_code, COUNT(r.species_id) as total,
|
||||
SUM(CASE WHEN s.category='CR' THEN 1 ELSE 0 END) as cr,
|
||||
SUM(CASE WHEN s.category='EN' THEN 1 ELSE 0 END) as en,
|
||||
SUM(CASE WHEN s.category='VU' THEN 1 ELSE 0 END) as vu
|
||||
FROM rb_species_regions r
|
||||
JOIN rb_species s ON s.id = r.species_id
|
||||
GROUP BY r.region_code
|
||||
`),
|
||||
};
|
||||
|
||||
function buildSpeciesList(filter = {}) {
|
||||
let sql = `
|
||||
SELECT s.id, s.name_ru, s.name_be, s.name_lat, s.category, s.by_category,
|
||||
s.photo_url, s.model_type, s.biomass_kg, s.interesting_fact,
|
||||
g.name_ru as group_name, g.icon as group_icon, g.color as group_color,
|
||||
h.name as habitat_name, h.type as habitat_type
|
||||
FROM rb_species s
|
||||
JOIN rb_groups g ON g.id = s.group_id
|
||||
LEFT JOIN rb_habitats h ON h.id = s.habitat_id
|
||||
`;
|
||||
const cond = [], params = [];
|
||||
if (filter.group_id) { cond.push('s.group_id = ?'); params.push(filter.group_id); }
|
||||
if (filter.category) { cond.push('s.category = ?'); params.push(filter.category); }
|
||||
if (filter.habitat_id) { cond.push('s.habitat_id = ?'); params.push(filter.habitat_id); }
|
||||
if (filter.region) {
|
||||
sql += ' JOIN rb_species_regions rr ON rr.species_id = s.id';
|
||||
cond.push('rr.region_code = ?'); params.push(filter.region);
|
||||
}
|
||||
if (filter.q) {
|
||||
cond.push('(s.name_ru LIKE ? OR s.name_lat LIKE ? OR s.name_be LIKE ?)');
|
||||
const like = `%${filter.q}%`;
|
||||
params.push(like, like, like);
|
||||
}
|
||||
if (cond.length) sql += ' WHERE ' + cond.join(' AND ');
|
||||
sql += ' ORDER BY s.category, s.name_ru';
|
||||
const limit = Math.min(parseInt(filter.limit) || 50, 100);
|
||||
const offset = parseInt(filter.offset) || 0;
|
||||
sql += ` LIMIT ${limit} OFFSET ${offset}`;
|
||||
return db.prepare(sql).all(...params);
|
||||
}
|
||||
|
||||
/* ── GET /api/red-book/groups ───────────────────────────────────────── */
|
||||
exports.getGroups = (req, res) => {
|
||||
const groups = stmts.groups.all();
|
||||
// attach count
|
||||
const counts = db.prepare(`
|
||||
SELECT group_id, COUNT(*) as n,
|
||||
SUM(CASE WHEN category='CR' THEN 1 ELSE 0 END) as cr,
|
||||
SUM(CASE WHEN category='EN' THEN 1 ELSE 0 END) as en
|
||||
FROM rb_species GROUP BY group_id
|
||||
`).all();
|
||||
const countMap = {};
|
||||
counts.forEach(c => countMap[c.group_id] = { n: c.n, cr: c.cr, en: c.en });
|
||||
groups.forEach(g => Object.assign(g, countMap[g.id] || { n: 0, cr: 0, en: 0 }));
|
||||
res.json(groups);
|
||||
};
|
||||
|
||||
/* ── GET /api/red-book/habitats ─────────────────────────────────────── */
|
||||
exports.getHabitats = (req, res) => {
|
||||
res.json(stmts.habitats.all());
|
||||
};
|
||||
|
||||
/* ── GET /api/red-book/species ──────────────────────────────────────── */
|
||||
exports.getSpecies = (req, res) => {
|
||||
const list = buildSpeciesList(req.query);
|
||||
// total for pagination
|
||||
const total = db.prepare('SELECT COUNT(*) as n FROM rb_species').get().n;
|
||||
// user collection ids
|
||||
let collected = new Set();
|
||||
if (req.user) {
|
||||
stmts.collection.all(req.user.id).forEach(r => collected.add(r.species_id));
|
||||
}
|
||||
list.forEach(s => {
|
||||
try { s.threats = JSON.parse(s.threats || '[]'); } catch { s.threats = []; }
|
||||
s.collected = collected.has(s.id);
|
||||
});
|
||||
res.json({ total, species: list });
|
||||
};
|
||||
|
||||
/* ── GET /api/red-book/species/:id ─────────────────────────────────── */
|
||||
exports.getSpeciesById = (req, res) => {
|
||||
const s = stmts.speciesById.get(req.params.id);
|
||||
if (!s) return res.status(404).json({ error: 'Вид не найден' });
|
||||
s.regions = stmts.regionsBySpecies.all(s.id).map(r => r.region_code);
|
||||
s.population_data = stmts.popdata.all(s.id);
|
||||
s.prey = stmts.foodWebPrey.all(s.id);
|
||||
s.predators = stmts.foodWebPredators.all(s.id);
|
||||
try { s.threats = JSON.parse(s.threats || '[]'); } catch { s.threats = []; }
|
||||
try { s.population_trend = JSON.parse(s.population_trend || '[]'); } catch { s.population_trend = []; }
|
||||
if (req.user) {
|
||||
s.collected = !!stmts.hasCollect.get(req.user.id, s.id);
|
||||
}
|
||||
res.json(s);
|
||||
};
|
||||
|
||||
/* ── GET /api/red-book/map-data ─────────────────────────────────────── */
|
||||
exports.getMapData = (req, res) => {
|
||||
res.json(stmts.mapData.all());
|
||||
};
|
||||
|
||||
/* ── GET /api/red-book/food-web ─────────────────────────────────────── */
|
||||
exports.getFoodWeb = (req, res) => {
|
||||
const ids = (req.query.species_ids || '').split(',').map(Number).filter(Boolean);
|
||||
if (!ids.length) {
|
||||
// return full web
|
||||
const nodes = db.prepare(`
|
||||
SELECT s.id, s.name_ru, s.name_lat, s.category, s.biomass_kg, s.description,
|
||||
g.icon, g.color, g.id as group_id,
|
||||
h.id as habitat_id, h.type as habitat_type, h.name as habitat_name
|
||||
FROM rb_species s
|
||||
JOIN rb_groups g ON g.id = s.group_id
|
||||
LEFT JOIN rb_habitats h ON h.id = s.habitat_id
|
||||
`).all();
|
||||
const links = db.prepare('SELECT predator_id as source, prey_id as target, strength FROM rb_food_web').all();
|
||||
return res.json({ nodes, links });
|
||||
}
|
||||
const ph = ids.map(() => '?').join(',');
|
||||
const nodes = db.prepare(`
|
||||
SELECT s.id, s.name_ru, s.name_lat, s.category, s.biomass_kg, s.description, g.icon, g.color
|
||||
FROM rb_species s JOIN rb_groups g ON g.id = s.group_id
|
||||
WHERE s.id IN (${ph})
|
||||
`).all(...ids);
|
||||
const links = db.prepare(`
|
||||
SELECT predator_id as source, prey_id as target, strength FROM rb_food_web
|
||||
WHERE predator_id IN (${ph}) OR prey_id IN (${ph})
|
||||
`).all(...ids, ...ids);
|
||||
res.json({ nodes, links });
|
||||
};
|
||||
|
||||
/* ── GET /api/red-book/biome/:habitatId ─────────────────────────────── */
|
||||
exports.getBiomeSpecies = (req, res) => {
|
||||
const list = db.prepare(`
|
||||
SELECT s.id, s.name_ru, s.name_lat, s.category, s.photo_url, s.model_type,
|
||||
s.biomass_kg, g.icon, g.color
|
||||
FROM rb_species s JOIN rb_groups g ON g.id = s.group_id
|
||||
WHERE s.habitat_id = ?
|
||||
ORDER BY s.category, s.name_ru
|
||||
`).all(req.params.habitatId);
|
||||
res.json(list);
|
||||
};
|
||||
|
||||
/* ── POST /api/red-book/species/:id/collect ─────────────────────────── */
|
||||
exports.collectSpecies = (req, res) => {
|
||||
const userId = req.user.id;
|
||||
const speciesId = parseInt(req.params.id);
|
||||
const sp = stmts.speciesById.get(speciesId);
|
||||
if (!sp) return res.status(404).json({ error: 'Вид не найден' });
|
||||
|
||||
const already = stmts.hasCollect.get(userId, speciesId);
|
||||
if (already) return res.json({ already: true, message: 'Вид уже в коллекции' });
|
||||
|
||||
stmts.addCollect.run(userId, speciesId, req.body?.method || 'explore');
|
||||
|
||||
// Award XP (via gamification service — also updates level in DB)
|
||||
const xpMap = { CR: 50, EN: 40, VU: 30, NT: 20, LC: 10 };
|
||||
const xp = xpMap[sp.category] || 20;
|
||||
try { awardXP(userId, xp, `Открыт вид: ${sp.name_ru}`); } catch {}
|
||||
|
||||
// Auto-complete any active quests that required this species
|
||||
const completedQuests = [];
|
||||
try {
|
||||
const activeQuests = db.prepare(`
|
||||
SELECT q.*, uq.quest_id FROM rb_user_quests uq
|
||||
JOIN rb_quests q ON q.id = uq.quest_id
|
||||
WHERE uq.user_id = ? AND uq.status = 'active'
|
||||
`).all(userId);
|
||||
|
||||
// Get all collected species ids for this user
|
||||
const collectedIds = new Set(
|
||||
db.prepare('SELECT species_id FROM rb_user_collection WHERE user_id = ?').all(userId).map(r => r.species_id)
|
||||
);
|
||||
|
||||
for (const quest of activeQuests) {
|
||||
const requiredIds = JSON.parse(quest.species_ids || '[]');
|
||||
if (requiredIds.every(id => collectedIds.has(id))) {
|
||||
stmts.completeQ.run(userId, quest.id);
|
||||
// Award quest XP via gamification service
|
||||
try { awardXP(userId, quest.xp_reward, `Квест выполнен: ${quest.title}`); } catch {}
|
||||
completedQuests.push({ id: quest.id, title: quest.title, xp_reward: quest.xp_reward });
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Check Red Book achievements (non-blocking)
|
||||
try { checkRedBookAchievements(userId); } catch {}
|
||||
|
||||
res.json({ collected: true, xp_earned: xp, species: { id: sp.id, name_ru: sp.name_ru, category: sp.category }, completed_quests: completedQuests });
|
||||
};
|
||||
|
||||
/* ── GET /api/red-book/collection ───────────────────────────────────── */
|
||||
exports.getCollection = (req, res) => {
|
||||
const rows = stmts.collection.all(req.user.id);
|
||||
const ids = rows.map(r => r.species_id);
|
||||
if (!ids.length) return res.json({ total: 0, species: [] });
|
||||
const ph = ids.map(() => '?').join(',');
|
||||
const species = db.prepare(`
|
||||
SELECT s.id, s.name_ru, s.name_lat, s.category, s.photo_url,
|
||||
g.icon, g.color
|
||||
FROM rb_species s JOIN rb_groups g ON g.id = s.group_id
|
||||
WHERE s.id IN (${ph})
|
||||
`).all(...ids);
|
||||
const meta = {};
|
||||
rows.forEach(r => meta[r.species_id] = { method: r.unlock_method, notes: r.notes, at: r.unlocked_at });
|
||||
species.forEach(s => Object.assign(s, meta[s.id] || {}));
|
||||
res.json({ total: species.length, species });
|
||||
};
|
||||
|
||||
/* ── GET /api/red-book/quests ───────────────────────────────────────── */
|
||||
exports.getQuests = (req, res) => {
|
||||
const quests = stmts.quests.all();
|
||||
quests.forEach(q => q.species_ids = JSON.parse(q.species_ids || '[]'));
|
||||
if (!req.user) return res.json(quests);
|
||||
const userQ = {};
|
||||
stmts.userQuests.all(req.user.id).forEach(uq => userQ[uq.quest_id] = uq);
|
||||
quests.forEach(q => {
|
||||
const uq = userQ[q.id];
|
||||
q.user_status = uq?.status || 'locked';
|
||||
q.user_progress = uq ? JSON.parse(uq.progress || '{}') : {};
|
||||
});
|
||||
res.json(quests);
|
||||
};
|
||||
|
||||
/* ── POST /api/red-book/quests/:id/start ────────────────────────────── */
|
||||
exports.startQuest = (req, res) => {
|
||||
const q = stmts.questById.get(req.params.id);
|
||||
if (!q) return res.status(404).json({ error: 'Квест не найден' });
|
||||
stmts.startQuest.run(req.user.id, q.id);
|
||||
res.json({ started: true });
|
||||
};
|
||||
|
||||
/* ── GET /api/red-book/sightings ────────────────────────────────────── */
|
||||
exports.getSightings = (req, res) => {
|
||||
const speciesId = req.query.species_id ? parseInt(req.query.species_id) : null;
|
||||
let sql = `
|
||||
SELECT rs.id, rs.species_id, rs.region_code, rs.description,
|
||||
rs.confirmed_by_teacher, rs.created_at,
|
||||
u.name as user_name,
|
||||
s.name_ru as species_name,
|
||||
g.icon as species_icon
|
||||
FROM rb_sightings rs
|
||||
JOIN users u ON u.id = rs.user_id
|
||||
JOIN rb_species s ON s.id = rs.species_id
|
||||
JOIN rb_groups g ON g.id = s.group_id
|
||||
`;
|
||||
const params = [];
|
||||
if (speciesId) { sql += ' WHERE rs.species_id = ?'; params.push(speciesId); }
|
||||
sql += ' ORDER BY rs.created_at DESC LIMIT 50';
|
||||
res.json(db.prepare(sql).all(...params));
|
||||
};
|
||||
|
||||
/* ── POST /api/red-book/sightings ───────────────────────────────────── */
|
||||
exports.addSighting = (req, res) => {
|
||||
const { species_id, region_code, description } = req.body || {};
|
||||
if (!species_id) return res.status(400).json({ error: 'species_id обязателен' });
|
||||
const id = stmts.addSighting.run(req.user.id, species_id, region_code || '', description || '').lastInsertRowid;
|
||||
try { checkRedBookAchievements(req.user.id); } catch {}
|
||||
res.json({ id });
|
||||
};
|
||||
|
||||
/* ── GET /api/red-book/stats ────────────────────────────────────────── */
|
||||
exports.getStats = (req, res) => {
|
||||
const totals = db.prepare(`
|
||||
SELECT COUNT(*) as total,
|
||||
SUM(CASE WHEN category='CR' THEN 1 ELSE 0 END) as cr,
|
||||
SUM(CASE WHEN category='EN' THEN 1 ELSE 0 END) as en,
|
||||
SUM(CASE WHEN category='VU' THEN 1 ELSE 0 END) as vu,
|
||||
SUM(CASE WHEN category='NT' THEN 1 ELSE 0 END) as nt,
|
||||
SUM(CASE WHEN category='LC' THEN 1 ELSE 0 END) as lc
|
||||
FROM rb_species
|
||||
`).get();
|
||||
let collected = 0;
|
||||
if (req.user) {
|
||||
collected = db.prepare('SELECT COUNT(*) as n FROM rb_user_collection WHERE user_id = ?').get(req.user.id).n;
|
||||
}
|
||||
res.json({ ...totals, collected });
|
||||
};
|
||||
|
||||
/* ── GET /api/red-book/daily ────────────────────────────────────────── */
|
||||
exports.getDaily = (req, res) => {
|
||||
// deterministic by day of year
|
||||
const dayOfYear = Math.floor((Date.now() - new Date(new Date().getFullYear(), 0, 0)) / 86400000);
|
||||
const total = db.prepare('SELECT COUNT(*) as n FROM rb_species').get().n;
|
||||
const offset = dayOfYear % total;
|
||||
const daily = db.prepare(`
|
||||
SELECT s.id, s.name_ru, s.name_lat, s.category, s.interesting_fact,
|
||||
s.description, s.photo_url, g.icon, g.color
|
||||
FROM rb_species s JOIN rb_groups g ON g.id = s.group_id
|
||||
ORDER BY s.id LIMIT 1 OFFSET ?
|
||||
`).get(offset);
|
||||
res.json(daily);
|
||||
};
|
||||
Reference in New Issue
Block a user