'use strict'; /* admin → Cmd+K / Ctrl+K command palette (Phase 4). * Self-initialized on DOMContentLoaded. Not a section — it's a global widget. * Overrides the generic /js/search.js Ctrl+K handler on admin pages by binding * in capture phase and calling stopImmediatePropagation. */ (function () { 'use strict'; /* ── Hardcoded actions ────────────────────────────────────────────────── */ const ACTIONS = [ { id: 'award_coins', name: 'Выдать монеты', hint: 'shop', icon: 'coins', go: () => navigateTo('#shop') }, { id: 'award_xp', name: 'Выдать XP', hint: 'gam', icon: 'zap', go: () => navigateTo('#gam') }, { id: 'new_class', name: 'Создать класс', hint: 'classes', icon: 'plus-circle', go: () => { window.location.href = '/classes'; } }, { id: 'new_test', name: 'Создать тест', hint: 'tests', icon: 'file-plus', go: () => navigateTo('#tests') }, { id: 'view_users', name: 'Все пользователи', hint: 'users', icon: 'users', go: () => navigateTo('#users') }, { id: 'view_sessions', name: 'Все сессии', hint: 'sessions', icon: 'history', go: () => navigateTo('#sessions') }, { id: 'view_audit', name: 'Audit log', hint: 'sublog', icon: 'shield', go: () => navigateTo('#sublog') }, { id: 'view_overview', name: 'Главная', hint: 'overview', icon: 'layout-dashboard', go: () => navigateTo('#overview') }, ]; /* ── State ────────────────────────────────────────────────────────────── */ let _overlay = null; let _input = null; let _results = null; let _timer = null; let _items = []; // flat list of result items in display order let _activeIdx = 0; let _lastQuery = ''; let _reqSeq = 0; // race-guard for async fetches /* ── Helpers ──────────────────────────────────────────────────────────── */ function navigateTo(hash) { if (window.AdminRouter) AdminRouter.navigate(hash); else window.location.hash = hash; } function esc(s) { return (window.LS && LS.esc) ? LS.esc(s) : String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>'); } function isOpen() { return !!(_overlay && _overlay.classList.contains('open')); } /* ── Styles (lazy injection) ──────────────────────────────────────────── */ function ensureStyles() { if (document.getElementById('akp-style')) return; const s = document.createElement('style'); s.id = 'akp-style'; s.textContent = ` .akp-ov { position: fixed; inset: 0; z-index: 9500; display: flex; align-items: flex-start; justify-content: center; padding: 96px 20px 20px; background: rgba(15,23,42,0.55); backdrop-filter: blur(8px); opacity: 0; pointer-events: none; transition: opacity .15s ease; } .akp-ov.open { opacity: 1; pointer-events: auto; } .akp-box { width: 100%; max-width: 600px; background: var(--surface, #fff); border: 1px solid var(--border, rgba(15,23,42,.08)); border-radius: 16px; box-shadow: 0 24px 80px rgba(15,23,42,0.32); display: flex; flex-direction: column; max-height: calc(100vh - 140px); overflow: hidden; transform: translateY(-8px) scale(.98); transition: transform .18s ease; } .akp-ov.open .akp-box { transform: translateY(0) scale(1); } .akp-input-wrap { display: flex; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--border, rgba(15,23,42,.08)); } .akp-input-wrap svg.akp-search-icon { width: 18px; height: 18px; color: var(--text-3, #64748b); flex-shrink: 0; } .akp-input { flex: 1; border: none; outline: none; background: transparent; font-family: inherit; font-size: 1.05rem; color: var(--text, #0F172A); padding: 4px 0; } .akp-input::placeholder { color: var(--text-3, #94a3b8); } .akp-kbd { font-family: ui-monospace, monospace; font-size: .7rem; background: rgba(15,23,42,.06); color: var(--text-3, #64748b); padding: 2px 6px; border-radius: 5px; border: 1px solid var(--border, rgba(15,23,42,.08)); flex-shrink: 0; } .akp-results { flex: 1; overflow-y: auto; padding: 6px 0; } .akp-group-label { font-family: 'Unbounded', sans-serif; font-size: .68rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--text-3, #64748b); padding: 10px 18px 6px; } .akp-item { display: flex; align-items: center; gap: 12px; padding: 9px 18px; cursor: pointer; user-select: none; border-left: 3px solid transparent; } .akp-item:hover, .akp-item.active { background: rgba(155,93,229,.08); border-left-color: var(--violet, #9B5DE5); } .akp-icon { width: 30px; height: 30px; border-radius: 8px; display: flex; align-items: center; justify-content: center; background: rgba(155,93,229,.1); color: var(--violet, #9B5DE5); flex-shrink: 0; } .akp-icon svg { width: 16px; height: 16px; } .akp-body { flex: 1; min-width: 0; } .akp-title { font-size: .92rem; font-weight: 600; color: var(--text, #0F172A); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .akp-sub { font-size: .76rem; color: var(--text-3, #64748b); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; } .akp-badge { font-size: .68rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-3, #64748b); padding: 2px 7px; border-radius: 5px; background: rgba(15,23,42,.06); border: 1px solid var(--border, rgba(15,23,42,.08)); flex-shrink: 0; } .akp-badge.role-admin { background: rgba(241,91,181,.1); color: var(--pink, #F15BB5); border-color: rgba(241,91,181,.25); } .akp-badge.role-teacher { background: rgba(6,214,224,.1); color: var(--cyan, #06D6E0); border-color: rgba(6,214,224,.25); } .akp-badge.role-student { background: rgba(155,93,229,.1); color: var(--violet,#9B5DE5); border-color: rgba(155,93,229,.25); } .akp-empty { padding: 22px 18px; text-align: center; color: var(--text-3, #64748b); font-size: .88rem; } .akp-footer { display: flex; align-items: center; gap: 14px; padding: 9px 18px; font-size: .72rem; color: var(--text-3, #64748b); border-top: 1px solid var(--border, rgba(15,23,42,.08)); background: rgba(15,23,42,.02); flex-shrink: 0; } .akp-footer span { display: inline-flex; align-items: center; gap: 5px; } .akp-footer kbd { font-family: ui-monospace, monospace; font-size: .7rem; padding: 1px 5px; border-radius: 4px; background: var(--surface, #fff); border: 1px solid var(--border, rgba(15,23,42,.12)); } @media (max-width: 540px) { .akp-ov { padding: 40px 12px 12px; } .akp-box { max-height: calc(100vh - 60px); } } `; document.head.appendChild(s); } /* ── Lucide icon helper (inline SVG fallback if Lucide missing) ───────── */ function iconHtml(name) { return ``; } /* ── Build DOM ────────────────────────────────────────────────────────── */ function build() { if (_overlay) return; ensureStyles(); _overlay = document.createElement('div'); _overlay.className = 'akp-ov'; _overlay.setAttribute('role', 'dialog'); _overlay.setAttribute('aria-modal', 'true'); _overlay.setAttribute('aria-label', 'Командная палитра'); _overlay.innerHTML = ` `; document.body.appendChild(_overlay); _input = _overlay.querySelector('.akp-input'); _results = _overlay.querySelector('.akp-results'); // Click outside (backdrop) closes _overlay.addEventListener('mousedown', (e) => { if (e.target === _overlay) close(); }); // Box click does not close (handled by stopPropagation on the box) _overlay.querySelector('.akp-box').addEventListener('mousedown', (e) => { e.stopPropagation(); }); // Input handling _input.addEventListener('input', () => { clearTimeout(_timer); _timer = setTimeout(runSearch, 150); }); // Keyboard navigation _input.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); return; } if (e.key === 'ArrowDown') { e.preventDefault(); moveActive(1); return; } if (e.key === 'ArrowUp') { e.preventDefault(); moveActive(-1); return; } if (e.key === 'Enter') { e.preventDefault(); const it = _items[_activeIdx]; if (it) fire(it); return; } }); // Click on result _results.addEventListener('click', (e) => { const row = e.target.closest('.akp-item'); if (!row) return; const idx = Number(row.dataset.idx); const it = _items[idx]; if (it) fire(it); }); } /* ── Matching / filtering ─────────────────────────────────────────────── */ function filterActions(q) { if (!q) return ACTIONS.slice(); const lq = q.toLowerCase(); return ACTIONS.filter(a => a.name.toLowerCase().includes(lq) || (a.hint && a.hint.toLowerCase().includes(lq)) ); } /* ── Fire (handle result selection) ───────────────────────────────────── */ function fire(item) { close(); try { if (item.kind === 'action' && typeof item.go === 'function') { item.go(); } else if (item.kind === 'user') { navigateTo('#users/' + item.id); } else if (item.kind === 'test') { navigateTo('#tests'); } else if (item.kind === 'class') { window.location.href = '/classes#' + item.id; } } catch (err) { if (window.LS && LS.toast) LS.toast('Не удалось открыть: ' + (err && err.message || err), 'error'); } } /* ── Render ───────────────────────────────────────────────────────────── */ function render(groups) { _items = []; let html = ''; let idx = 0; function pushGroup(label, arr, makeItem) { if (!arr || !arr.length) return; html += `
${esc(label)}
`; for (const x of arr) { const item = makeItem(x); _items.push(item); const isActive = idx === _activeIdx ? ' active' : ''; html += `
${iconHtml(item.icon)}
${esc(item.title)}
${item.subtitle ? `
${esc(item.subtitle)}
` : ''}
${item.badge ? `${esc(item.badge)}` : ''}
`; idx++; } } pushGroup('Действия', groups.actions, a => ({ kind: 'action', id: a.id, title: a.name, icon: a.icon, go: a.go, })); pushGroup('Пользователи', groups.users, u => ({ kind: 'user', id: u.id, title: u.name || '(без имени)', subtitle: u.email || '', icon: 'user', badge: u.role || '', badgeClass: u.role ? ('role-' + u.role) : '', })); pushGroup('Тесты', groups.tests, t => ({ kind: 'test', id: t.id, title: t.name || '(без названия)', subtitle: t.subject_slug ? ('предмет: ' + t.subject_slug) : '', icon: 'clipboard-list', })); pushGroup('Классы', groups.classes, c => ({ kind: 'class', id: c.id, title: c.name || '(без названия)', subtitle: c.code ? ('код: ' + c.code) : '', icon: 'graduation-cap', })); if (!_items.length) { _results.innerHTML = '
Ничего не найдено
'; _activeIdx = 0; return; } if (_activeIdx >= _items.length) _activeIdx = 0; _results.innerHTML = html; if (window.lucide) { try { lucide.createIcons({ nodes: _results.querySelectorAll('[data-lucide]') }); } catch {} } } function moveActive(dir) { const total = _items.length; if (!total) return; _activeIdx = (_activeIdx + dir + total) % total; // Re-paint active class without rebuilding html const rows = _results.querySelectorAll('.akp-item'); rows.forEach((r, i) => r.classList.toggle('active', i === _activeIdx)); const cur = rows[_activeIdx]; if (cur) cur.scrollIntoView({ block: 'nearest' }); } /* ── Search executor ──────────────────────────────────────────────────── */ async function runSearch() { const q = _input.value.trim(); _lastQuery = q; _activeIdx = 0; // No query: actions only if (q.length < 2) { render({ actions: ACTIONS, users: [], tests: [], classes: [] }); return; } const localActions = filterActions(q); // Show actions immediately, then update with server results render({ actions: localActions, users: [], tests: [], classes: [] }); const seq = ++_reqSeq; try { const data = (window.LS && LS.adminGlobalSearch) ? await LS.adminGlobalSearch(q) : { users: [], tests: [], classes: [] }; // Stale response: ignore if (seq !== _reqSeq || q !== _lastQuery) return; render({ actions: localActions, users: data.users || [], tests: data.tests || [], classes: data.classes || [], }); } catch (err) { if (seq !== _reqSeq) return; _results.innerHTML = '
Ошибка поиска
'; } } /* ── Open / close ─────────────────────────────────────────────────────── */ function open() { build(); _activeIdx = 0; _items = []; _input.value = ''; render({ actions: ACTIONS, users: [], tests: [], classes: [] }); _overlay.classList.add('open'); setTimeout(() => { try { _input.focus(); _input.select(); } catch {} }, 30); } function close() { if (!_overlay) return; _overlay.classList.remove('open'); } /* ── Global shortcut: Ctrl+K / Cmd+K ──────────────────────────────────── */ // Capture phase + stopImmediatePropagation prevents the generic /js/search.js // handler (also on Ctrl+K) from firing on admin pages. document.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); e.stopImmediatePropagation(); if (isOpen()) close(); else open(); } }, true); // Expose for debugging / future cross-section calls window.AdminPalette = { open, close, isOpen }; })();