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>
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
|
||||
(будет обновляться после каждой фазы)
|
||||
|
||||
- ⬜ Phase 1 not started — старый switchTab всё ещё единственный роутер
|
||||
- ✅ 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 not started — все 13 секций в admin.js монолите
|
||||
- ⬜ Phase 3-6 not started
|
||||
|
||||
@@ -19,6 +19,23 @@
|
||||
- **Phase 6 depends on Phase 2:** deep page для user/session — это новые sections в той же структуре
|
||||
- **Phase 6 removes** старую `.user-panel` overlay из admin.html — фазы 1-5 НЕ должны её удалять
|
||||
|
||||
## Router Contract (Phase 1)
|
||||
|
||||
```js
|
||||
// Subscribe in any future module:
|
||||
AdminRouter.on('change', ({ route, params, raw }) => { /* ... */ });
|
||||
|
||||
// Programmatic deep-link without polluting history:
|
||||
AdminRouter.navigate('#users/123', { replace: true, silent: true });
|
||||
```
|
||||
|
||||
- Events emitted: `'change'` only (payload: parsed route).
|
||||
- Late subscribers do NOT receive replay — call `AdminRouter.current()` on init.
|
||||
- `silent: true` suppresses the synchronous emit but native `hashchange` still fires;
|
||||
the internal `_navigating` flag in router.js prevents the listener from re-firing.
|
||||
- `switchTab(btn, { fromRouter: true })` — call from router handlers to skip the
|
||||
reverse-sync write to `location.hash` (avoids redundant `replaceState`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Существующая структура (что менять / что НЕ менять)
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
## Phases
|
||||
|
||||
- [ ] Phase 1: Hash-router [domain: frontend] → [subplan](./phase-1-hash-router.md)
|
||||
- [x] Phase 1: Hash-router [domain: frontend] → [subplan](./phase-1-hash-router.md)
|
||||
- [ ] Phase 2: Split admin.html → per-section modules [domain: frontend] → [subplan](./phase-2-split-sections.md)
|
||||
- [ ] 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)
|
||||
@@ -48,7 +48,7 @@
|
||||
|
||||
| Phase | Domain | Status | Review | Build | Committed |
|
||||
|-------|--------|--------|--------|-------|-----------|
|
||||
| Phase 1: Hash-router | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 1: Hash-router | frontend | ✅ Implemented | ⬜ | ✅ | ⬜ |
|
||||
| Phase 2: Split sections | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 3: Dashboard | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 4: Palette | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Phase 1: Hash-router
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Status:** ✅ Implemented (awaiting review)
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
|
||||
@@ -10,23 +10,23 @@
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] Создать `frontend/js/admin/router.js` с `window.AdminRouter`:
|
||||
- [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`
|
||||
- [ ] Подключить `router.js` в `admin.html` ДО `admin.js`
|
||||
- [ ] В `admin.js` модифицировать `switchTab(btn)`:
|
||||
- [x] Подключить `router.js` в `admin.html` ДО `admin.js`
|
||||
- [x] В `admin.js` модифицировать `switchTab(btn)`:
|
||||
- Дополнительно вызывать `AdminRouter.navigate('#' + name, { silent: true })`
|
||||
- НЕ удалять старую логику
|
||||
- [ ] Добавить листенер `AdminRouter.on('change', ...)` в admin.js:
|
||||
- [x] Добавить листенер `AdminRouter.on('change', ...)` в admin.js:
|
||||
- При route change → найти соответствующий `.admin-nav-item[data-tab="X"]` и активировать его (через имеющийся switchTab, но с `silent`-флагом чтобы избежать рекурсии)
|
||||
- [ ] При инициализации страницы:
|
||||
- [x] При инициализации страницы:
|
||||
- Если `location.hash` пустой → set default `#stats`
|
||||
- Если есть hash → распарсить и переключить на соответствующий tab
|
||||
- [ ] Логировать unknown routes: `console.warn('AdminRouter: unknown route', route)` + fallback на `#stats`
|
||||
- [ ] Защита от инфинит-loop'а: флаг `_routerNavigating` при programmatic-навигации, чтобы handler не реагировал на свой же hash change
|
||||
- [x] Логировать unknown routes: `console.warn('AdminRouter: unknown route', route)` + fallback на `#stats`
|
||||
- [x] Защита от инфинит-loop'а: флаг `_routerNavigating` при programmatic-навигации, чтобы handler не реагировал на свой же hash change
|
||||
|
||||
## Files to Modify/Create
|
||||
|
||||
@@ -86,5 +86,48 @@ window.addEventListener('hashchange', () => {
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
<!-- Заполнит implementer после фазы.
|
||||
Должно содержать: где живёт router API, какие события эмитятся, как Phase 2 sections подписываются на route changes. -->
|
||||
**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.
|
||||
|
||||
Reference in New Issue
Block a user