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>
8.7 KiB
Phase 4: Cmd+K command palette
Status: ✅ Done Parent plan: PLAN.md Domain: fullstack Parallelizable with: Phase 3, Phase 5
Objective
Глобальный палеттный поиск по Ctrl+K (Cmd+K на Mac) — нахоит entities (users, tests, classes, sessions) + actions ("выдать монеты ученику", "разбанить", "создать класс", deep-link routes). Радикально сокращает количество кликов для частых сценариев.
Tasks
- Backend: новый endpoint
GET /api/admin/search?q=X&limit=8:- Возвращает смешанный результат:
{ users: [{ id, name, email, role }], // top 5 по name LIKE / email LIKE tests: [{ id, name, subject_slug }], // top 3 classes: [{ id, name, code }], // top 3 sessions: [] // skip пока, добавим если нужно } - Контроллер: новая функция
globalSearchвadminController.js - Route:
router.get('/search', requireAdmin, globalSearch) - Каждая sub-query SELECT отдельно с LIMIT, общий ответ — простой json
- Auth: admin only (teachers видят только своих учеников; для упрощения — admin)
- Возвращает смешанный результат:
- Frontend:
frontend/js/admin/palette.js— palette модуль:- Не section, а глобальный widget — подключается в admin.js init
- Слушает
keydownнаCtrl+K/Cmd+K(preventDefault) - Открывает modal через
LS.modal():- Header: search input (autofocus)
- Body: список результатов с keyboard nav (↑↓ Enter Esc)
- Иконка типа справа от каждого результата (User, Test, Class, Action)
- Дебаунс поиска ~150ms
- Min длина query: 2 символа
- При query='' → показать "Recent Actions" hardcoded list
- Actions index (hardcoded в palette.js):
const ACTIONS = [ { id: 'award_coins', name: 'Выдать монеты', icon: 'coins', handler: () => AdminRouter.navigate('#shop') }, { id: 'award_xp', name: 'Выдать XP', icon: 'zap', handler: () => AdminRouter.navigate('#gam') }, { id: 'new_class', name: 'Создать класс', icon: 'plus-circle', handler: () => window.location.href = '/classes' }, { id: 'new_test', name: 'Создать тест', icon: 'file-plus', handler: () => AdminRouter.navigate('#tests') }, { id: 'view_users', name: 'Все пользователи', icon: 'users', handler: () => AdminRouter.navigate('#users') }, { id: 'view_sessions', name: 'Все сессии', icon: 'history', handler: () => AdminRouter.navigate('#sessions') }, { id: 'view_audit', name: 'Audit log', icon: 'shield', handler: () => AdminRouter.navigate('#sublog') }, // …добавлять по мере надобности ];- Fuzzy-match в JS (substring match по name) при query
- Открытие результата:
- 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
- User →
- Стили palette: глассморфизм/blur, центрировано, max-width 600px, dark/light theme-friendly. Использовать существующие токены
--surface,--border,--text-2. - Подсказка в UI: footer dialog'а "↑↓ — навигация · ↵ — выбрать · esc — закрыть"
Files to Modify/Create
backend/src/controllers/adminController.js— добавитьglobalSearch(~60L)backend/src/routes/admin.js— добавить/searchroutejs/api.js— добавитьLS.adminGlobalSearch(q)helper (~5L)frontend/js/admin/palette.js— новый, ~300-400Lfrontend/admin.html— добавить<script src="/js/admin/palette.js"></script>
Acceptance Criteria
- Ctrl+K (Cmd+K) открывает palette из любого таба admin
- Esc закрывает
- Печать "иван" → top users с именем "Иван..."
- Печать "монеты" → action "Выдать монеты"
- ↑↓ навигация работает, Enter выполняет
- Поиск отрабатывает <100ms для 8 результатов на тестовой БД
- Click outside / Esc закрывают
- LS.modal используется (не reinventing wheel)
- Auth: только admin может открыть (teachers — палетту не открывают)
Notes
Почему Ctrl+K а не /
Ctrl+K — индустри-стандарт (GitHub, Linear, Vercel, Slack). / конфликтует с input'ами.
Дебаунсинг
Простой setTimeout/clearTimeout. Без библиотек.
LS.modal compat
LS.modal сейчас принимает { title, body, footer, onOk, onClose, size }. Для palette нужен focus management — autofocus input при открытии. Можно использовать через колбэк onMount если он есть, либо setTimeout(() => input.focus(), 0) после открытия.
Что НЕ делать в этой фазе
- Не делать ML/fuzzy-search в backend (LIKE достаточно)
- Не делать historic recents (Cmd+K recents) — это уже после merge
- Не делать collaboration ("кто-то ещё печатает")
Review Checklist
- Ctrl+K не конфликтует с системными shortcut'ами браузера
- Palette не открывается если фокус в textarea / input (если требует ввод)... опционально, можно открывать всегда
- No SQL injection в /admin/search
- Эскейпинг через LS.esc для рендеринга имён пользователей
- No N+1 queries (один SELECT на тип сущности)
- Build passes
Handoff to Next Phase
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):
{
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 injs/api.js.