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:
+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