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:
+155
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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;
|
||||
})();
|
||||
Reference in New Issue
Block a user