Files
Maxim Dolgolyov f562fe4a71 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>
2026-05-16 23:39:59 +03:00

8.7 KiB
Raw Permalink Blame History

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
  • Стили 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 — добавить /search route
  • js/api.js — добавить LS.adminGlobalSearch(q) helper (~5L)
  • frontend/js/admin/palette.js — новый, ~300-400L
  • frontend/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 in js/api.js.