Files
Maxim Dolgolyov be4d43105e 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>
2026-04-12 10:10:37 +03:00

177 lines
5.9 KiB
JavaScript

/* ═══════════════════════════════════════════════════════════════
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;
})();