Files
Learn_System/plans/admin-redesign/phase-4-palette.md
T
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

151 lines
8.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 4: Cmd+K command palette
**Status:** ✅ Done
**Parent plan:** [PLAN.md](./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
- [x] Backend: новый endpoint `GET /api/admin/search?q=X&limit=8`:
- Возвращает смешанный результат:
```js
{
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)
- [x] 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
- [x] Actions index (hardcoded в palette.js):
```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
- [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
- [x] Стили palette: глассморфизм/blur, центрировано, max-width 600px, dark/light theme-friendly. Использовать существующие токены `--surface`, `--border`, `--text-2`.
- [x] Подсказка в 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):
```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`.