diff --git a/frontend/admin.html b/frontend/admin.html index 816e69b..b52b9a7 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -1981,6 +1981,7 @@ + diff --git a/frontend/js/admin/admin.js b/frontend/js/admin/admin.js index 5b68719..b891c3f 100644 --- a/frontend/js/admin/admin.js +++ b/frontend/js/admin/admin.js @@ -54,7 +54,7 @@ /* ─── Tabs ─── */ let questionsInited = false, testsInited = false, assignmentsInited = false, usersInited = false, sessionsInited = false, subjectsInited = false, permissionsInited = false, shopInited = false, gamInited = false, tplInited = false, simsInited = false, gamesInited = false, sublogInited = false; - function switchTab(btn) { + function switchTab(btn, opts) { if (btn.classList.contains('locked')) { LS.toast('Этот раздел доступен только администраторам', 'warn'); return; @@ -77,6 +77,12 @@ if (name === 'sims' && !simsInited) { simsInited = true; loadSimsAdmin(); } if (name === 'games' && !gamesInited) { gamesInited = true; loadGamesAdmin(); loadFsFeatures(); } if (name === 'sublog' && !sublogInited) { sublogInited = true; loadSubmissionLog(); } + // Sync URL hash. `silent: true` only suppresses the router's own emit; + // a subsequent hashchange event will not re-fire switchTab thanks to + // the router's _navigating guard. + if (!(opts && opts.fromRouter) && window.AdminRouter) { + AdminRouter.navigate('#' + name, { silent: true }); + } } /* Переход к вопросам конкретного предмета с открытием формы */ @@ -3546,3 +3552,40 @@ loadStats(); loadAvatarRequests(); // load badge count on page open if (window.lucide) lucide.createIcons(); + + /* ─── Hash router wiring ─── */ + (function initAdminRouter() { + if (!window.AdminRouter) return; + + function activate(route, opts) { + const name = route || 'stats'; + const btn = document.querySelector('.admin-nav-item[data-tab="' + name + '"]'); + if (!btn) { + console.warn('AdminRouter: unknown route', name); + AdminRouter.navigate('#stats', { replace: true, silent: true }); + const fallback = document.querySelector('.admin-nav-item[data-tab="stats"]'); + if (fallback) switchTab(fallback, { fromRouter: true }); + return; + } + // Skip locked tabs (non-admin clicked an admin-only deep-link). + if (btn.classList.contains('locked')) { + LS.toast('Этот раздел доступен только администраторам', 'warn'); + AdminRouter.navigate('#stats', { replace: true, silent: true }); + const fallback = document.querySelector('.admin-nav-item[data-tab="stats"]'); + if (fallback) switchTab(fallback, { fromRouter: true }); + return; + } + switchTab(btn, { fromRouter: true }); + } + + AdminRouter.on('change', (r) => activate(r.route)); + + // Initial dispatch: respect existing hash, else default to #stats. + const initial = AdminRouter.current(); + if (!initial.route) { + AdminRouter.navigate('#stats', { replace: true, silent: true }); + // #stats tab-pane is already .active in markup; no switchTab needed. + } else if (initial.route !== 'stats') { + activate(initial.route); + } + })(); diff --git a/frontend/js/admin/router.js b/frontend/js/admin/router.js new file mode 100644 index 0000000..4074ba0 --- /dev/null +++ b/frontend/js/admin/router.js @@ -0,0 +1,101 @@ +'use strict'; +/* AdminRouter — hash-based router for admin panel. + * Wraps the existing switchTab() flow without replacing it. + * + * Hash format: #[/[/...]] + * #stats → { route: 'stats', params: [] } + * #users → { route: 'users', params: [] } + * #users/123 → { route: 'users', params: ['123'] } + * #sessions/456/foo → { route: 'sessions', params: ['456','foo'] } + * + * Public API: window.AdminRouter + * parse(hash) → route object + * current() → route object for location.hash + * navigate(hash, { replace, silent }) + * on(event, fn) / off(event, fn) — 'change' event only + * + * Recursion guard: programmatic navigate() sets a flag so the hashchange + * listener does not re-emit 'change' for our own writes. + */ +(function () { + 'use strict'; + + const listeners = { change: new Set() }; + let _navigating = false; + + function parse(hash) { + const raw = String(hash || ''); + const stripped = raw.charAt(0) === '#' ? raw.slice(1) : raw; + const parts = stripped.split('/').filter(Boolean); + const route = parts.length ? decodeURIComponent(parts[0]) : ''; + const params = parts.slice(1).map(p => { + try { return decodeURIComponent(p); } catch { return p; } + }); + return { route, params, raw: raw || '' }; + } + + function current() { + return parse(location.hash); + } + + function normalizeHash(input) { + const s = String(input || ''); + if (!s) return ''; + return s.charAt(0) === '#' ? s : '#' + s; + } + + function emit(event, payload) { + const set = listeners[event]; + if (!set) return; + set.forEach(fn => { + try { fn(payload); } catch (e) { console.error('AdminRouter listener error', e); } + }); + } + + function navigate(routeOrHash, opts) { + const options = opts || {}; + const target = normalizeHash(routeOrHash); + const currentHash = location.hash || ''; + + // Same hash → no-op (avoids spurious listener fires). + if (target === currentHash) { + if (!options.silent) emit('change', { ...parse(target), silent: false }); + return; + } + + _navigating = true; + try { + if (options.replace && typeof history !== 'undefined' && history.replaceState) { + // Preserve current path/query, swap hash without adding history entry. + const url = location.pathname + location.search + target; + history.replaceState(history.state, '', url); + } else { + location.hash = target; + } + } finally { + // hashchange fires async; clear flag after dispatch. + setTimeout(() => { _navigating = false; }, 0); + } + + if (!options.silent) { + emit('change', { ...parse(target), silent: false }); + } + } + + function on(event, fn) { + if (!listeners[event] || typeof fn !== 'function') return; + listeners[event].add(fn); + } + + function off(event, fn) { + if (!listeners[event]) return; + listeners[event].delete(fn); + } + + window.addEventListener('hashchange', () => { + if (_navigating) return; + emit('change', { ...current(), silent: false }); + }); + + window.AdminRouter = { parse, current, navigate, on, off }; +})(); diff --git a/plans/admin-redesign/CONTEXT.md b/plans/admin-redesign/CONTEXT.md index 73e3ad6..b7062a0 100644 --- a/plans/admin-redesign/CONTEXT.md +++ b/plans/admin-redesign/CONTEXT.md @@ -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 ### Существующая структура (что менять / что НЕ менять) diff --git a/plans/admin-redesign/PLAN.md b/plans/admin-redesign/PLAN.md index 638e9be..58d6181 100644 --- a/plans/admin-redesign/PLAN.md +++ b/plans/admin-redesign/PLAN.md @@ -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 | ⬜ | ⬜ | ⬜ | diff --git a/plans/admin-redesign/phase-1-hash-router.md b/plans/admin-redesign/phase-1-hash-router.md index d6cca3a..1a71ccf 100644 --- a/plans/admin-redesign/phase-1-hash-router.md +++ b/plans/admin-redesign/phase-1-hash-router.md @@ -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 - +**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 (`` 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.