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
+1175
View File
File diff suppressed because it is too large Load Diff
+155
View File
@@ -0,0 +1,155 @@
/* ═══════════════════════════════════════════════════════
LearnSpace — Mobile Drawer & Topbar
Injected into all sidebar-layout pages
═══════════════════════════════════════════════════════ */
(function () {
const MOBILE_BP = 768;
function isMobile() { return window.innerWidth <= MOBILE_BP; }
/* ── Inject mob-bar and backdrop ── */
function injectMobBar() {
if (document.getElementById('ls-mob-bar')) return;
const layout = document.querySelector('.app-layout');
if (!layout) return;
/* Backdrop overlay */
const backdrop = document.createElement('div');
backdrop.className = 'sb-backdrop';
backdrop.id = 'ls-sb-backdrop';
document.body.appendChild(backdrop);
/* Top bar */
const bar = document.createElement('div');
bar.className = 'mob-bar';
bar.id = 'ls-mob-bar';
bar.innerHTML = `
<a href="/" class="mob-bar-logo">Learn<span>Space</span></a>
<div class="mob-bar-actions">
<button class="mob-icon-btn" id="mob-notif-btn" title="Уведомления" style="display:none">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="mob-notif-dot" id="mob-notif-dot" style="display:none;position:absolute;top:5px;right:5px;width:8px;height:8px;border-radius:50%;background:#F15BB5;border:2px solid #EEF2FF;"></span>
</button>
<button class="mob-icon-btn" id="mob-hamburger" title="Меню">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 6h16M4 12h16M4 18h16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
`;
document.body.insertBefore(bar, document.body.firstChild);
}
/* ── Open / Close drawer ── */
function openDrawer() {
const layout = document.querySelector('.app-layout');
const backdrop = document.getElementById('ls-sb-backdrop');
if (!layout) return;
layout.classList.add('sb-open');
if (backdrop) backdrop.classList.add('open');
document.body.style.overflow = 'hidden';
}
function closeDrawer() {
const layout = document.querySelector('.app-layout');
const backdrop = document.getElementById('ls-sb-backdrop');
if (!layout) return;
layout.classList.remove('sb-open');
if (backdrop) backdrop.classList.remove('open');
document.body.style.overflow = '';
}
/* ── Wire up events ── */
function wireEvents() {
/* Hamburger */
document.addEventListener('click', function (e) {
if (e.target.closest('#mob-hamburger')) {
const layout = document.querySelector('.app-layout');
if (layout && layout.classList.contains('sb-open')) {
closeDrawer();
} else {
openDrawer();
}
return;
}
/* Backdrop tap */
if (e.target.id === 'ls-sb-backdrop') {
closeDrawer();
return;
}
/* Sidebar nav link tap — close drawer */
if (isMobile() && e.target.closest('.sb-link')) {
setTimeout(closeDrawer, 80);
return;
}
/* Mob notif button — delegate to LS.notif.toggle or legacy toggleNotifDrop */
if (e.target.closest('#mob-notif-btn')) {
if (typeof LS !== 'undefined' && LS.notif && LS.notif.toggle) {
LS.notif.toggle();
} else if (typeof toggleNotifDrop === 'function') {
toggleNotifDrop();
}
return;
}
});
/* Escape key */
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeDrawer();
});
/* Close drawer on resize to desktop */
window.addEventListener('resize', function () {
if (!isMobile()) closeDrawer();
});
/* Close drawer on orientation change */
window.addEventListener('orientationchange', function () {
setTimeout(function () {
if (!isMobile()) closeDrawer();
}, 150);
});
}
/* ── Sync mob notif button visibility with page's notif button ── */
function syncNotifBtn() {
const mobBtn = document.getElementById('mob-notif-btn');
if (!mobBtn) return;
/* Look for existing notif button in sidebar */
const sbNotif = document.querySelector('.sb-link[onclick*="Notif"], .sb-link[onclick*="notif"], #notif-btn, [data-notif]');
if (sbNotif) {
mobBtn.style.display = 'flex';
/* Mirror badge count */
const badge = sbNotif.querySelector('.sb-badge');
const dot = document.getElementById('mob-notif-dot');
if (badge && dot) {
const observer = new MutationObserver(function () {
dot.style.display = (badge.textContent.trim() !== '0' && badge.style.display !== 'none') ? 'block' : 'none';
});
observer.observe(badge, { childList: true, attributes: true, attributeFilter: ['style'] });
}
}
}
/* ── Init ── */
function init() {
if (window._lsMobileInited) return;
window._lsMobileInited = true;
injectMobBar();
wireEvents();
/* Defer notif sync until page JS has run */
setTimeout(syncNotifBtn, 600);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
+89
View File
@@ -0,0 +1,89 @@
/* ── Shared notification dropdown module ──────────────────────────── */
(function() {
let _notifOpen = false;
let _sse = null;
function renderNotifDrop(data) {
const drop = document.getElementById('notif-drop');
const badge = document.getElementById('notif-badge');
if (!drop || !badge) return;
if (data.unread > 0) { badge.textContent = data.unread > 9 ? '9+' : data.unread; badge.style.display = ''; }
else badge.style.display = 'none';
drop.innerHTML = `
<div class="notif-drop-header">
<span class="notif-drop-title">Уведомления</span>
${data.unread > 0 ? `<button class="notif-read-all" onclick="LS.notif.markAllRead()">Отметить все прочитанными</button>` : ''}
</div>
${data.notifications.length ? data.notifications.map(n => `
<a class="notif-item${n.is_read ? '' : ' unread'}" href="${LS.safeHref(n.link)}" onclick="LS.notif.click(event,${n.id},'${LS.safeHref(n.link)}')">
<div class="notif-dot${n.is_read ? ' read' : ''}"></div>
<div><div class="notif-msg">${LS.esc(n.message)}</div><div class="notif-time">${LS.fmtRelTime(n.created_at)}</div></div>
</a>`).join('') : '<div class="notif-empty">Уведомлений нет</div>'}`;
}
async function load() {
try { renderNotifDrop(await LS.getNotifications()); } catch {}
}
function toggle() {
const drop = document.getElementById('notif-drop');
if (!drop) return;
_notifOpen = !_notifOpen;
if (_notifOpen) {
const btn = document.getElementById('notif-btn');
if (btn) {
const r = btn.getBoundingClientRect();
const vh = window.innerHeight;
// Anchor bottom of dropdown to button bottom, but don't go above viewport
const bottom = vh - r.bottom;
drop.style.top = 'auto';
drop.style.bottom = Math.max(8, bottom) + 'px';
drop.style.left = (r.right + 8) + 'px';
}
drop.style.display = 'block';
load();
} else {
drop.style.display = 'none';
}
}
async function clickNotif(e, id, link) {
e.preventDefault();
await LS.markNotifRead(id).catch(() => {});
await load();
if (link && link !== '#') window.location.href = link;
}
async function markAllRead() {
await LS.markAllNotifsRead().catch(() => {});
await load();
}
let _inited = false;
function init() {
if (_inited) return;
_inited = true;
// Note: button already has onclick="LS.notif.toggle()" in HTML
// Close on outside click
document.addEventListener('click', e => {
const drop = document.getElementById('notif-drop');
if (!drop || !_notifOpen) return;
if (!e.target.closest('#notif-drop') && !e.target.closest('#notif-btn')) {
_notifOpen = false;
drop.style.display = 'none';
}
});
// SSE real-time
_sse = LS.connectSSE(ev => {
if (ev.type) load();
});
// Initial load
load();
}
// Expose as LS.notif
window.LS = window.LS || {};
LS.notif = { init, toggle, load, click: clickNotif, markAllRead };
})();
+176
View File
@@ -0,0 +1,176 @@
/* ═══════════════════════════════════════════════════════════════
LearnSpace Global Search — /js/search.js
Include on any sidebar page after api.js.
Opens with Ctrl+K or click on search button in sidebar.
═══════════════════════════════════════════════════════════════ */
(function () {
if (!window.LS) return;
const ICONS = {
lesson: 'book-open',
course: 'graduation-cap',
file: 'file-text',
question: 'help-circle',
};
const LABELS = {
lesson: 'Уроки',
course: 'Курсы',
file: 'Файлы',
question: 'Вопросы',
};
let _overlay = null;
let _input = null;
let _results = null;
let _timer = null;
let _items = [];
let _activeIdx = -1;
function esc(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function build() {
if (_overlay) return;
_overlay = document.createElement('div');
_overlay.className = 'gs-overlay';
_overlay.innerHTML = `
<div class="gs-box">
<div class="gs-input-wrap">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.3-4.3"/></svg>
<input class="gs-input" placeholder="Поиск уроков, курсов, файлов…" autocomplete="off" />
<span class="gs-kbd">ESC</span>
</div>
<div class="gs-results">
<div class="gs-empty">Начните вводить для поиска</div>
</div>
</div>`;
document.body.appendChild(_overlay);
_input = _overlay.querySelector('.gs-input');
_results = _overlay.querySelector('.gs-results');
// Close on backdrop click
_overlay.addEventListener('click', e => { if (e.target === _overlay) close(); });
// Event delegation for result clicks — один listener вместо N
_results.addEventListener('click', e => {
const item = e.target.closest('.gs-item');
if (item) window.location.href = item.dataset.url;
});
// Input handling
_input.addEventListener('input', () => {
clearTimeout(_timer);
_timer = setTimeout(doSearch, 250);
});
// Keyboard nav
_input.addEventListener('keydown', e => {
if (e.key === 'Escape') { close(); return; }
if (e.key === 'ArrowDown') { e.preventDefault(); navigate(1); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); navigate(-1); return; }
if (e.key === 'Enter') {
e.preventDefault();
const active = _items[_activeIdx];
if (active) window.location.href = active.url;
return;
}
});
}
async function doSearch() {
const q = _input.value.trim();
if (q.length < 2) {
_results.innerHTML = '<div class="gs-empty">Начните вводить для поиска</div>';
_items = [];
_activeIdx = -1;
return;
}
try {
const data = await LS.globalSearch(q);
_items = data.results || [];
_activeIdx = -1;
render();
} catch {
_results.innerHTML = '<div class="gs-empty">Ошибка поиска</div>';
}
}
function render() {
if (!_items.length) {
_results.innerHTML = '<div class="gs-empty">Ничего не найдено</div>';
return;
}
// Group by type
const groups = {};
for (const item of _items) {
if (!groups[item.type]) groups[item.type] = [];
groups[item.type].push(item);
}
let html = '';
let idx = 0;
for (const [type, items] of Object.entries(groups)) {
html += `<div class="gs-group-label">${LABELS[type] || type}</div>`;
for (const item of items) {
const iconCls = `gs-icon-${type}`;
const iconName = ICONS[type] || 'bookmark';
html += `<div class="gs-item${idx === _activeIdx ? ' active' : ''}" data-idx="${idx}" data-url="${esc(item.url)}">
<div class="gs-item-icon ${iconCls}">
<i data-lucide="${iconName}" style="width:16px;height:16px"></i>
</div>
<div class="gs-item-body">
<div class="gs-item-title">${esc(item.title)}</div>
${item.subtitle ? `<div class="gs-item-sub">${esc(item.subtitle)}</div>` : ''}
</div>
<svg class="gs-item-arrow" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
</div>`;
idx++;
}
}
_results.innerHTML = html;
// Lucide icons
if (window.lucide) lucide.createIcons({ nodes: _results.querySelectorAll('[data-lucide]') });
}
function navigate(dir) {
const total = _items.length;
if (!total) return;
const prev = _results.querySelector('.gs-item.active');
if (prev) prev.classList.remove('active');
_activeIdx = (_activeIdx + dir + total) % total;
const next = _results.querySelector(`.gs-item[data-idx="${_activeIdx}"]`);
if (next) { next.classList.add('active'); next.scrollIntoView({ block: 'nearest' }); }
}
function open() {
build();
_overlay.classList.add('open');
_input.value = '';
_results.innerHTML = '<div class="gs-empty">Начните вводить для поиска</div>';
_items = [];
_activeIdx = -1;
setTimeout(() => _input.focus(), 50);
}
function close() {
if (_overlay) _overlay.classList.remove('open');
}
// Global shortcut: Ctrl+K
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
if (_overlay?.classList.contains('open')) close();
else open();
}
});
// Expose
window.lsSearchOpen = open;
window.lsSearchClose = close;
})();