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>
7.9 KiB
Phase 1: Hash-router
Status: ✅ Implemented (awaiting review) Parent plan: PLAN.md Domain: frontend
Objective
Заложить фундамент для URL-роутинга admin-панели через location.hash. После этой фазы можно делать F5 на #users, делиться deep-links, использовать browser back/forward. Старая система табов (switchTab) продолжает работать без изменений — router её обёртывает, а не заменяет.
Tasks
- Создать
frontend/js/admin/router.jsсwindow.AdminRouter:parse(hash)→{ route: 'users', params: ['123'], raw }navigate(routeOrHash, { replace?, silent? })— программная навигацияcurrent()→ текущий route objecton(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):- Дополнительно вызывать
AdminRouter.navigate('#' + name, { silent: true }) - НЕ удалять старую логику
- Дополнительно вызывать
- Добавить листенер
AdminRouter.on('change', ...)в admin.js:- При route change → найти соответствующий
.admin-nav-item[data-tab="X"]и активировать его (через имеющийся switchTab, но сsilent-флагом чтобы избежать рекурсии)
- При route change → найти соответствующий
- При инициализации страницы:
- Если
location.hashпустой → set default#stats - Если есть hash → распарсить и переключить на соответствующий tab
- Если
- Логировать unknown routes:
console.warn('AdminRouter: unknown route', route)+ fallback на#stats - Защита от инфинит-loop'а: флаг
_routerNavigatingпри programmatic-навигации, чтобы handler не реагировал на свой же hash change
Files to Modify/Create
frontend/js/admin/router.js— новый, ~80-120Lfrontend/admin.html— добавить<script src="/js/admin/router.js"></script>в<head>или перед admin.jsfrontend/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.
Решение:
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:
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:
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 linesfrontend/admin.html— +1 line (<script src="/js/admin/router.js"></script>before admin.js)frontend/js/admin/admin.js—switchTabsignature(btn, opts), +6 lines for hash-sync; new ~36-lineinitAdminRouterIIFE in init block
Backward compat verified:
- All 21
onclick="switchTab(this)"callsites untouched. goAddQuestion(slug)works (callsswitchTab(qBtn)withoutopts→ URL also updates to#questions).onclick="switchTab(this);loadAvatarRequests()"on the avatars tab still works.