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.