feat(admin): phase 4 — Cmd+K command palette
Global search modal: actions + users + tests + classes.
- GET /api/admin/search?q=X (~50L controller): 3 parameterized LIKE queries, admin-only
- frontend/js/admin/palette.js (~366L): custom lightweight modal (not LS.modal — footer-button oriented), Ctrl+K/Cmd+K capture-phase override of generic /js/search.js, debounce 150ms, race-guard via _reqSeq, min-query 2 chars, 8 hardcoded actions, ↑↓ wrap + Enter, click-outside close
- adminGlobalSearch helper: drop ignored 'limit' param (server hardcodes 5/3/3)
window.AdminPalette = { open, close, isOpen } exposed for Phase 5/6 use.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -82,6 +82,51 @@ function getOverview(_req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Global search (Phase 4 command palette) — prepared statements ────── */
|
||||
const searchStmts = {
|
||||
users: db.prepare(`
|
||||
SELECT id, name, email, role
|
||||
FROM users
|
||||
WHERE name LIKE ? OR email LIKE ?
|
||||
ORDER BY (CASE WHEN name LIKE ? THEN 0 ELSE 1 END), id DESC
|
||||
LIMIT 5
|
||||
`),
|
||||
tests: db.prepare(`
|
||||
SELECT id, title AS name, subject_slug
|
||||
FROM tests
|
||||
WHERE title LIKE ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 3
|
||||
`),
|
||||
classes: db.prepare(`
|
||||
SELECT id, name, invite_code AS code
|
||||
FROM classes
|
||||
WHERE name LIKE ? OR invite_code LIKE ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 3
|
||||
`),
|
||||
};
|
||||
|
||||
/* ── GET /api/admin/search?q=X ────────────────────────────────────────── */
|
||||
function globalSearch(req, res) {
|
||||
const q = (req.query.q || '').trim();
|
||||
if (q.length < 2) {
|
||||
return res.json({ users: [], tests: [], classes: [] });
|
||||
}
|
||||
try {
|
||||
const like = `%${q}%`;
|
||||
const prefix = `${q}%`;
|
||||
res.json({
|
||||
users: searchStmts.users.all(like, like, prefix),
|
||||
tests: searchStmts.tests.all(like),
|
||||
classes: searchStmts.classes.all(like, like),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[admin.search]', err.message);
|
||||
res.status(500).json({ error: 'Search failed' });
|
||||
}
|
||||
}
|
||||
|
||||
/* ── GET /api/admin/users?page=1&limit=50&role=student&q=name ─────────── */
|
||||
function getUsers(req, res) {
|
||||
const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 50));
|
||||
@@ -591,7 +636,7 @@ function broadcast(req, res) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getStats, getOverview,
|
||||
getStats, getOverview, globalSearch,
|
||||
getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
|
||||
clearUserSessions, updateUser, banUser, deleteUser,
|
||||
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
|
||||
|
||||
@@ -15,6 +15,7 @@ router.use(requireRole('admin'));
|
||||
|
||||
router.get('/stats', ctrl.getStats);
|
||||
router.get('/overview', ctrl.getOverview);
|
||||
router.get('/search', ctrl.globalSearch);
|
||||
router.get('/users', ctrl.getUsers);
|
||||
router.patch('/users/:id/role', ctrl.updateRole);
|
||||
router.get('/users/:id/sessions', ctrl.getUserSessions);
|
||||
|
||||
@@ -2006,6 +2006,7 @@
|
||||
<script src="/js/admin/sections/questions.js"></script>
|
||||
<script src="/js/admin/sections/users.js"></script>
|
||||
<script src="/js/admin/sections/sessions.js"></script>
|
||||
<script src="/js/admin/palette.js"></script>
|
||||
<script src="/js/admin/admin.js"></script>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
'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, '<').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 `<i data-lucide="${esc(name)}"></i>`;
|
||||
}
|
||||
|
||||
/* ── 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 = `
|
||||
<div class="akp-box" role="presentation">
|
||||
<div class="akp-input-wrap">
|
||||
<svg class="akp-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||
<input class="akp-input" type="text" autocomplete="off" spellcheck="false"
|
||||
placeholder="Поиск: пользователь, тест, класс, действие…" />
|
||||
<span class="akp-kbd">esc</span>
|
||||
</div>
|
||||
<div class="akp-results"></div>
|
||||
<div class="akp-footer">
|
||||
<span><kbd>↑</kbd><kbd>↓</kbd> навигация</span>
|
||||
<span><kbd>↵</kbd> выбрать</span>
|
||||
<span><kbd>esc</kbd> закрыть</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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 += `<div class="akp-group-label">${esc(label)}</div>`;
|
||||
for (const x of arr) {
|
||||
const item = makeItem(x);
|
||||
_items.push(item);
|
||||
const isActive = idx === _activeIdx ? ' active' : '';
|
||||
html += `<div class="akp-item${isActive}" data-idx="${idx}">
|
||||
<div class="akp-icon">${iconHtml(item.icon)}</div>
|
||||
<div class="akp-body">
|
||||
<div class="akp-title">${esc(item.title)}</div>
|
||||
${item.subtitle ? `<div class="akp-sub">${esc(item.subtitle)}</div>` : ''}
|
||||
</div>
|
||||
${item.badge ? `<span class="akp-badge ${item.badgeClass || ''}">${esc(item.badge)}</span>` : ''}
|
||||
</div>`;
|
||||
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 = '<div class="akp-empty">Ничего не найдено</div>';
|
||||
_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 = '<div class="akp-empty">Ошибка поиска</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 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 };
|
||||
})();
|
||||
@@ -151,6 +151,10 @@ async function importQuestions(formData) {
|
||||
/* ── admin ────────────────────────────────────────────────────────────── */
|
||||
async function adminGetStats() { return req('GET', '/admin/stats'); }
|
||||
async function adminGetOverview() { return req('GET', '/admin/overview'); }
|
||||
async function adminGlobalSearch(q) {
|
||||
// Limits are hardcoded server-side (top 5 users / 3 tests / 3 classes).
|
||||
return req('GET', `/admin/search?q=${encodeURIComponent(q)}`);
|
||||
}
|
||||
async function adminGetUsers(params = {}) {
|
||||
const p = new URLSearchParams();
|
||||
if (params.page) p.set('page', params.page);
|
||||
@@ -940,7 +944,7 @@ window.LS = {
|
||||
register, login, fetchMe, updateProfile,
|
||||
getSubjects, updateSubject, getTopics,
|
||||
startSession, sendAnswer, finishSession, getResult, getHistory, getWeakTopics, getStudentStats, getSessionQuestions,
|
||||
adminGetStats, adminGetOverview, adminGetUsers, adminUpdateRole, adminGetUserSessions, adminGetSessions, adminGetSessionDetail, adminClearUserSessions, adminUpdateUser, adminBanUser, adminDeleteUser,
|
||||
adminGetStats, adminGetOverview, adminGlobalSearch, adminGetUsers, adminUpdateRole, adminGetUserSessions, adminGetSessions, adminGetSessionDetail, adminClearUserSessions, adminUpdateUser, adminBanUser, adminDeleteUser,
|
||||
getQuestions, createQuestion, duplicateQuestion, updateQuestion, deleteQuestion, importQuestions,
|
||||
getClasses, createClass, getClassDetail, updateClass, deleteClass, kickMember, addClassMember, createAssignment, createDirectAssignment, updateAssignment, deleteAssignment,
|
||||
regenerateInviteCode, classJournal,
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
- ✅ Phase 1 implemented — `window.AdminRouter` обёртывает старый `switchTab` (hash ↔ tab двусторонне). `switchTab` принимает 2-й аргумент `{ fromRouter: true }` для предотвращения рекурсии. Default = `#stats`. Файлы: `frontend/js/admin/router.js` (новый), `frontend/admin.html` (+1 строка), `frontend/js/admin/admin.js` (модификация `switchTab` + IIFE `initAdminRouter`).
|
||||
- ✅ Phase 2 implemented (commit 92030b4) — admin.js ужат с ~3591L до 701L. Все 13 plan-tabs живут в `frontend/js/admin/sections/*.js` (IIFE pattern) + `frontend/js/admin/_shared.js` (window.AdminCtx). switchTab() диспетчит в `AdminSections[ROUTE_TO_SECTION[name]].init()`. Lazy-load работает (inited флаг внутри каждой IIFE). System tabs (topics/audit/errors/health/classroom/avatars) остались inline в admin.js — Phase 2 их не extract'ил.
|
||||
- ✅ Phase 3 implemented — `#overview` стал дефолтным route'ом admin-панели. Backend: `GET /api/admin/overview` (admin-only, ~0.08ms/call) возвращает digest за 24ч: новые регистрации, запущенные сессии, активные юзеры, активные классы, failed-сессии, забаненные за неделю (из audit log), топ-5 завершённых сессий. Frontend: `frontend/js/admin/sections/overview.js` (~205L) рендерит bento-grid карточки + alerts + топ-таблицу + quick-links (deep-link через `AdminRouter.navigate`). `admin.js`: дефолт `'stats'` → `'overview'` в `activate()`, initial nav, и initial init. Old `#stats` остался работающим (доступен через nav-item). Файлы: `frontend/js/admin/sections/overview.js` (NEW), `backend/src/controllers/adminController.js` (+57L: `overviewStmts` + `getOverview`), `backend/src/routes/admin.js` (+1L), `js/api.js` (+1 helper), `frontend/admin.html` (nav-item + tab-pane + script tag), `frontend/js/admin/admin.js` (ROUTE_TO_SECTION + default route refs).
|
||||
- ⬜ Phase 4-6 not started
|
||||
- ✅ Phase 4 implemented — Cmd+K (Ctrl+K) global command palette. Backend: `GET /api/admin/search?q=X` (admin-only) returns `{users[5], tests[3], classes[3]}` via 3 prepared LIKE queries (`title AS name` for tests, `invite_code AS code` for classes). Frontend: `frontend/js/admin/palette.js` (~320L) — custom modal (NOT LS.modal) with capture-phase Ctrl+K listener that `stopImmediatePropagation`'s to override `/js/search.js`. Debounced 150ms, ↑↓ Enter Esc keyboard nav, click-outside close. Action registry (8 entries) is hardcoded — extend by appending to `ACTIONS` const. Result interactions: user → `AdminRouter.navigate('#users/' + id)` (Phase 6 deep page hook), test → `#tests`, class → `/classes#id`. Exposed: `window.AdminPalette = { open, close, isOpen }`, `LS.adminGlobalSearch(q)`. Files: `frontend/js/admin/palette.js` (NEW), `backend/src/controllers/adminController.js` (+50L: `searchStmts` + `globalSearch`), `backend/src/routes/admin.js` (+1L), `js/api.js` (+4L helper + export), `frontend/admin.html` (+1 script tag).
|
||||
- ⬜ Phase 5-6 not started
|
||||
|
||||
## Temporary Workarounds
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
- [x] Phase 1: Hash-router [domain: frontend] → [subplan](./phase-1-hash-router.md)
|
||||
- [x] Phase 2: Split admin.html → per-section modules [domain: frontend] → [subplan](./phase-2-split-sections.md)
|
||||
- [x] Phase 3: Dashboard #overview [domain: fullstack] → [subplan](./phase-3-dashboard.md) (parallelizable with 4, 5)
|
||||
- [ ] Phase 4: Cmd+K command palette [domain: fullstack] → [subplan](./phase-4-palette.md) (parallelizable with 3, 5)
|
||||
- [x] Phase 4: Cmd+K command palette [domain: fullstack] → [subplan](./phase-4-palette.md) (parallelizable with 3, 5)
|
||||
- [ ] Phase 5: Per-row quick actions [domain: frontend] → [subplan](./phase-5-quick-actions.md) (parallelizable with 3, 4)
|
||||
- [ ] Phase 6: Deep entity pages [domain: frontend] → [subplan](./phase-6-deep-pages.md)
|
||||
|
||||
@@ -50,8 +50,8 @@
|
||||
|-------|--------|--------|--------|-------|-----------|
|
||||
| Phase 1: Hash-router | frontend | ✅ Done | ✅ PASS w/ notes | ✅ | ✅ 8a7bed4 |
|
||||
| Phase 2: Split sections | frontend | ✅ Done | ✅ PASS (1 blocker fixed: fa67ad1) | ✅ node --check | ✅ 92030b4 + fa67ad1 |
|
||||
| Phase 3: Dashboard | fullstack | ✅ Done | ⬜ pending | ✅ node --check + queries verified | ⬜ |
|
||||
| Phase 4: Palette | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 3: Dashboard | fullstack | ✅ Done | ✅ PASS w/ 3 SQL warnings (post-merge polish) | ✅ | ✅ 41acbdd |
|
||||
| Phase 4: Palette | fullstack | ✅ Done | ⬜ | ✅ node --check | ⬜ |
|
||||
| Phase 5: Quick actions | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 6: Deep pages | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Phase 4: Cmd+K command palette
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Status:** ✅ Done
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
**Parallelizable with:** Phase 3, Phase 5
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Backend: новый endpoint `GET /api/admin/search?q=X&limit=8`:
|
||||
- [x] Backend: новый endpoint `GET /api/admin/search?q=X&limit=8`:
|
||||
- Возвращает смешанный результат:
|
||||
```js
|
||||
{
|
||||
@@ -25,7 +25,7 @@
|
||||
- Route: `router.get('/search', requireAdmin, globalSearch)`
|
||||
- Каждая sub-query SELECT отдельно с LIMIT, общий ответ — простой json
|
||||
- Auth: admin only (teachers видят только своих учеников; для упрощения — admin)
|
||||
- [ ] Frontend: `frontend/js/admin/palette.js` — palette модуль:
|
||||
- [x] Frontend: `frontend/js/admin/palette.js` — palette модуль:
|
||||
- Не section, а глобальный widget — подключается в admin.js init
|
||||
- Слушает `keydown` на `Ctrl+K` / `Cmd+K` (preventDefault)
|
||||
- Открывает modal через `LS.modal()`:
|
||||
@@ -35,7 +35,7 @@
|
||||
- Дебаунс поиска ~150ms
|
||||
- Min длина query: 2 символа
|
||||
- При query='' → показать "Recent Actions" hardcoded list
|
||||
- [ ] Actions index (hardcoded в palette.js):
|
||||
- [x] Actions index (hardcoded в palette.js):
|
||||
```js
|
||||
const ACTIONS = [
|
||||
{ id: 'award_coins', name: 'Выдать монеты', icon: 'coins', handler: () => AdminRouter.navigate('#shop') },
|
||||
@@ -49,13 +49,13 @@
|
||||
];
|
||||
```
|
||||
- Fuzzy-match в JS (substring match по name) при query
|
||||
- [ ] Открытие результата:
|
||||
- [x] Открытие результата:
|
||||
- User → `AdminRouter.navigate('#users/' + id)` (Phase 6 будет рендерить deep page; пока fallback на `#users` + opening user-panel через имеющийся `openUserPanel`)
|
||||
- Test → `AdminRouter.navigate('#tests')` + scroll к row (если поддерживается, иначе просто tab)
|
||||
- Class → `window.location.href = '/classes#' + id`
|
||||
- Action → выполнить handler
|
||||
- [ ] Стили palette: глассморфизм/blur, центрировано, max-width 600px, dark/light theme-friendly. Использовать существующие токены `--surface`, `--border`, `--text-2`.
|
||||
- [ ] Подсказка в UI: footer dialog'а "↑↓ — навигация · ↵ — выбрать · esc — закрыть"
|
||||
- [x] Стили palette: глассморфизм/blur, центрировано, max-width 600px, dark/light theme-friendly. Использовать существующие токены `--surface`, `--border`, `--text-2`.
|
||||
- [x] Подсказка в UI: footer dialog'а "↑↓ — навигация · ↵ — выбрать · esc — закрыть"
|
||||
|
||||
## Files to Modify/Create
|
||||
|
||||
@@ -108,6 +108,43 @@ LS.modal сейчас принимает `{ title, body, footer, onOk, onClose,
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
<!-- Implementer: записать, какой формат ответа /admin/search,
|
||||
как palette вызывает navigate (важно для Phase 6 — deep user page будет ловить #users/N),
|
||||
какие actions zarejestrowano (Phase 6 может добавить ещё). -->
|
||||
### Endpoint contract — `GET /api/admin/search?q=<query>&limit=8`
|
||||
|
||||
Auth: admin only (inside `requireRole('admin')` block in `backend/src/routes/admin.js`).
|
||||
If `q.trim().length < 2`, returns empty arrays without hitting DB. Errors → 500 `{error:'Search failed'}`.
|
||||
|
||||
Response shape (top 5 users / top 3 tests / top 3 classes):
|
||||
```js
|
||||
{
|
||||
users: [{ id, name, email, role }],
|
||||
tests: [{ id, name, subject_slug }], // alias: tests.title AS name
|
||||
classes: [{ id, name, code }], // alias: classes.invite_code AS code
|
||||
}
|
||||
```
|
||||
|
||||
Backend perf: 3 simple parameterised SELECTs with LIMIT — well under 100ms.
|
||||
|
||||
### Navigation contract from palette → router
|
||||
|
||||
| Result kind | Action |
|
||||
|-------------|--------|
|
||||
| Action | calls the hardcoded `go()` callback (most go through `AdminRouter.navigate('#…')`) |
|
||||
| User | `AdminRouter.navigate('#users/' + id)` — params parsed by router, but ROUTE_TO_SECTION currently only dispatches `users` section. **Phase 6** can add a `user` section that reads `params.id` and renders a deep page. |
|
||||
| Test | `AdminRouter.navigate('#tests')` (no deep page yet) |
|
||||
| Class | `window.location.href = '/classes#' + id` — leaves admin (classes UI is a separate page) |
|
||||
|
||||
### Action registry (hardcoded in `frontend/js/admin/palette.js`)
|
||||
|
||||
`award_coins → #shop`, `award_xp → #gam`, `new_class → /classes`, `new_test → #tests`,
|
||||
`view_users → #users`, `view_sessions → #sessions`, `view_audit → #sublog`, `view_overview → #overview`.
|
||||
|
||||
Phase 5 (quick actions) and Phase 6 (deep pages) may extend the `ACTIONS` array — just add to it; the action's `name` field is what users see and what is fuzzy-matched (lowercase substring on `name` + optional `hint` keyword).
|
||||
|
||||
### Ctrl+K conflict with `/js/search.js`
|
||||
|
||||
`/js/search.js` is also loaded on admin.html and binds its own Ctrl+K listener (bubble phase). Palette binds in **capture phase** + `e.stopImmediatePropagation()`, so on admin pages the palette wins. On non-admin pages the generic search remains intact (palette.js is only loaded from admin.html).
|
||||
|
||||
### Exposed globals
|
||||
|
||||
- `window.AdminPalette = { open, close, isOpen }` — for future programmatic open from quick-actions.
|
||||
- `LS.adminGlobalSearch(q)` — exported helper in `js/api.js`.
|
||||
|
||||
Reference in New Issue
Block a user