diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index 3e58d4e..6e337b4 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -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, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index d3d6b72..7847cb8 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -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); diff --git a/frontend/admin.html b/frontend/admin.html index 90d12d3..971bea7 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -2006,6 +2006,7 @@ + diff --git a/frontend/js/admin/palette.js b/frontend/js/admin/palette.js new file mode 100644 index 0000000..f59da73 --- /dev/null +++ b/frontend/js/admin/palette.js @@ -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, '>'); + } + + 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 }; +})(); diff --git a/js/api.js b/js/api.js index 828155f..9a4beeb 100644 --- a/js/api.js +++ b/js/api.js @@ -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, diff --git a/plans/admin-redesign/CONTEXT.md b/plans/admin-redesign/CONTEXT.md index f4079fe..ec35607 100644 --- a/plans/admin-redesign/CONTEXT.md +++ b/plans/admin-redesign/CONTEXT.md @@ -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 diff --git a/plans/admin-redesign/PLAN.md b/plans/admin-redesign/PLAN.md index 67ea04e..1460a90 100644 --- a/plans/admin-redesign/PLAN.md +++ b/plans/admin-redesign/PLAN.md @@ -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 | ⬜ | ⬜ | ⬜ | diff --git a/plans/admin-redesign/phase-4-palette.md b/plans/admin-redesign/phase-4-palette.md index 956cde2..8d4c3f8 100644 --- a/plans/admin-redesign/phase-4-palette.md +++ b/plans/admin-redesign/phase-4-palette.md @@ -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 - +### Endpoint contract — `GET /api/admin/search?q=&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`.