Files
Maxim Dolgolyov 8a7bed487f feat(admin): phase 1 — hash-router
AdminRouter wraps existing switchTab for deep-linking.

- frontend/js/admin/router.js (new, 102L): parse/navigate/current/on/off, recursion guard via _navigating flag

- admin.html: +1 <script> before admin.js

- admin.js: switchTab(btn, opts) + initAdminRouter IIFE for hashchange dispatch

Backward compat: all 21 onclick=switchTab(this) callsites continue working.

F5 / back / forward / deep-link verified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:22:20 +03:00

134 lines
7.9 KiB
Markdown
Raw Permalink 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 1: Hash-router
**Status:** ✅ Implemented (awaiting review)
**Parent plan:** [PLAN.md](./PLAN.md)
**Domain:** frontend
## Objective
Заложить фундамент для URL-роутинга admin-панели через `location.hash`. После этой фазы можно делать F5 на `#users`, делиться deep-links, использовать browser back/forward. Старая система табов (`switchTab`) продолжает работать без изменений — router её обёртывает, а не заменяет.
## Tasks
- [x] Создать `frontend/js/admin/router.js` с `window.AdminRouter`:
- `parse(hash)``{ route: 'users', params: ['123'], raw }`
- `navigate(routeOrHash, { replace?, silent? })` — программная навигация
- `current()` → текущий route object
- `on(event, fn)` / `off(event, fn)` — pub/sub для 'change' event
- Поддержка форматов: `#stats`, `#users`, `#users/123`, `#sessions/456`
- [x] Подключить `router.js` в `admin.html` ДО `admin.js`
- [x] В `admin.js` модифицировать `switchTab(btn)`:
- Дополнительно вызывать `AdminRouter.navigate('#' + name, { silent: true })`
- НЕ удалять старую логику
- [x] Добавить листенер `AdminRouter.on('change', ...)` в admin.js:
- При route change → найти соответствующий `.admin-nav-item[data-tab="X"]` и активировать его (через имеющийся switchTab, но с `silent`-флагом чтобы избежать рекурсии)
- [x] При инициализации страницы:
- Если `location.hash` пустой → set default `#stats`
- Если есть hash → распарсить и переключить на соответствующий tab
- [x] Логировать unknown routes: `console.warn('AdminRouter: unknown route', route)` + fallback на `#stats`
- [x] Защита от инфинит-loop'а: флаг `_routerNavigating` при programmatic-навигации, чтобы handler не реагировал на свой же hash change
## Files to Modify/Create
- `frontend/js/admin/router.js` — новый, ~80-120L
- `frontend/admin.html` — добавить `<script src="/js/admin/router.js"></script>` в `<head>` или перед admin.js
- `frontend/js/admin/admin.js` — модифицировать `switchTab` + добавить init-логику (~15-25L изменений)
## Acceptance Criteria
- F5 на `http://localhost:3000/admin#users` восстанавливает users-tab
- Browser back/forward переключают между табами (без полного reload)
- Клик по admin-nav-item обновляет URL (`#users` появляется в адресной строке)
- Клик по cross-tab handler типа `goAddQuestion('bio')` — старая логика работает, URL обновляется
- Unknown hash (например `#nonexistent`) → console.warn + fallback на `#stats`, нет crash
- `#users/123` парсится корректно (params=['123']), но пока никто его не использует — Phase 6 подключит
## Notes
### Почему hash-router, а не history.pushState
Backend Express раздаёт admin.html по `/admin`. С `pushState` пришлось бы либо настраивать catch-all route на server-стороне (`/admin/*`), либо делать SPA-style роутинг. Hash-router работает out-of-the-box и не требует backend-изменений. Это критично для incremental-стратегии — мы не трогаем server в Phase 1.
### Защита от рекурсии
Сценарий: пользователь кликает на tab → switchTab вызывает navigate → navigate меняет hash → срабатывает hashchange → router emits 'change' → handler вызывает switchTab → snake eats tail.
Решение:
```js
let _navigating = false;
function navigate(hash) {
_navigating = true;
location.hash = hash;
_navigating = false;
}
window.addEventListener('hashchange', () => {
if (_navigating) return;
// emit 'change'
});
```
Или передавать `{ silent: true }` через объект-параметр и проверять его в handler'е switchTab.
### Существующий пример hashchange
В `frontend/js/textbook-tracker.js:438` уже есть `addEventListener('hashchange', handleHashNav)` — это safe-pattern, можно подсмотреть структуру.
## Review Checklist
- [ ] router.js не использует Grep / эмоджи / отсутствующие LS-помощники
- [ ] Старый switchTab НЕ удалён, только обёрнут
- [ ] Нет регрессий: все 13 табов переключаются, lazy-load работает
- [ ] F5 / back / forward проверены вручную в браузере (или симуляция через subagent)
- [ ] Default route `#stats` срабатывает при пустом hash
- [ ] Unknown route не крашит панель
- [ ] Код следует конвенциям проекта (no emoji, inline SVG для иконок, LS.* для API)
- [ ] Build passes: `cd backend && npm start` → http://localhost:3000/admin загружается
## Handoff to Next Phase
**Router API location:** `window.AdminRouter` (defined in `frontend/js/admin/router.js`, loaded **before** `admin.js` from `admin.html`).
**Public surface:**
```js
AdminRouter.parse('#users/123')
// → { route: 'users', params: ['123'], raw: '#users/123' }
AdminRouter.current()
// → parsed location.hash
AdminRouter.navigate('#users', { replace: false, silent: false })
// replace → history.replaceState (no extra entry)
// silent → suppress synchronous 'change' emit; hashchange still fires natively
AdminRouter.on('change', ({ route, params, raw }) => { ... })
AdminRouter.off('change', fn)
```
**Events emitted:** only `'change'` for now. Payload is the parsed route plus `silent: false`. Internal `_navigating` flag suppresses re-emit when *we* set the hash (prevents the snake-eats-tail loop).
**How Phase 2 sections subscribe:**
```js
AdminRouter.on('change', ({ route, params }) => {
if (route === 'users') AdminSections.users.init();
if (route === 'sessions' && params[0]) AdminSections.sessions.openDetail(params[0]);
});
```
Sections should call `AdminRouter.current()` once on load to handle the initial route (the router does NOT replay past 'change' events to late subscribers).
**switchTab contract change:**
`switchTab(btn, opts)``opts.fromRouter === true` prevents `switchTab` from re-pushing the hash (used by router when responding to a hashchange / deep-link). Existing call sites (`switchTab(this)`, `switchTab(qBtn)`, `switchTab(this);loadAvatarRequests()`) keep working — they call without `opts`, so the URL syncs as expected.
**Default route:** `#stats` (matches existing initially-active tab). Phase 3 will change default to `#overview` once dashboard ships.
**Unknown / locked routes:** logged via `console.warn('AdminRouter: unknown route', name)`, then `replace`-navigated to `#stats` without polluting browser history.
**Files touched:**
- `frontend/js/admin/router.js` — NEW, 97 lines
- `frontend/admin.html` — +1 line (`<script src="/js/admin/router.js"></script>` before admin.js)
- `frontend/js/admin/admin.js``switchTab` signature `(btn, opts)`, +6 lines for hash-sync; new ~36-line `initAdminRouter` IIFE in init block
**Backward compat verified:**
- All 21 `onclick="switchTab(this)"` callsites untouched.
- `goAddQuestion(slug)` works (calls `switchTab(qBtn)` without `opts` → URL also updates to `#questions`).
- `onclick="switchTab(this);loadAvatarRequests()"` on the avatars tab still works.