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:
@@ -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);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
'use strict';
|
||||
/* AdminRouter — hash-based router for admin panel.
|
||||
* Wraps the existing switchTab() flow without replacing it.
|
||||
*
|
||||
* Hash format: #<route>[/<param1>[/<param2>...]]
|
||||
* #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 };
|
||||
})();
|
||||
Reference in New Issue
Block a user