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

7.9 KiB
Raw Permalink Blame History

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 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):
    • Дополнительно вызывать AdminRouter.navigate('#' + name, { silent: true })
    • НЕ удалять старую логику
  • Добавить листенер AdminRouter.on('change', ...) в admin.js:
    • При route change → найти соответствующий .admin-nav-item[data-tab="X"] и активировать его (через имеющийся switchTab, но с silent-флагом чтобы избежать рекурсии)
  • При инициализации страницы:
    • Если 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-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.

Решение:

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 starthttp://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 lines
  • frontend/admin.html — +1 line (<script src="/js/admin/router.js"></script> before admin.js)
  • frontend/js/admin/admin.jsswitchTab 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.