diff --git a/frontend/admin.html b/frontend/admin.html
index dc35bff..e00a60e 100644
--- a/frontend/admin.html
+++ b/frontend/admin.html
@@ -229,6 +229,12 @@
font-weight: 700;
}
.admin-nav-item.active svg { opacity: 1; color: var(--violet); }
+ .admin-nav-item.locked { opacity: .42; cursor: not-allowed; }
+ .admin-nav-item.locked:hover { background: transparent; color: var(--text-3); }
+ .admin-nav-item.locked svg { opacity: .55; }
+ .admin-nav-item.locked .adm-lock {
+ margin-left: auto; width: 11px; height: 11px; opacity: .8; flex-shrink: 0;
+ }
.admin-main { flex: 1; min-width: 0; padding-left: 28px; }
diff --git a/frontend/js/admin/admin.js b/frontend/js/admin/admin.js
index d61bc7d..afffc1a 100644
--- a/frontend/js/admin/admin.js
+++ b/frontend/js/admin/admin.js
@@ -7,14 +7,22 @@
document.getElementById('page-sub').textContent =
isAdmin ? 'Администратор · полный доступ' : 'Учитель · просмотр статистики';
- if (isAdmin) {
- ['btn-tab-subjects','btn-tab-permissions','btn-tab-shop','btn-tab-gam','btn-tab-tpl','btn-tab-sims','btn-tab-games'].forEach(id => {
- const el = document.getElementById(id);
- if (el) el.style.display = '';
- });
- const sysGroup = document.getElementById('admin-nav-system-group');
- if (sysGroup) sysGroup.style.display = '';
- }
+ /* Admin-only tabs: show to everyone for discoverability, but lock for non-admins */
+ const ADMIN_ONLY_TABS = ['btn-tab-subjects','btn-tab-permissions','btn-tab-shop','btn-tab-gam','btn-tab-tpl','btn-tab-sims','btn-tab-games'];
+ const lockSvg = '';
+ ADMIN_ONLY_TABS.forEach(id => {
+ const el = document.getElementById(id);
+ if (!el) return;
+ el.style.display = ''; // always visible now
+ if (!isAdmin) {
+ el.classList.add('locked');
+ el.title = 'Только для администраторов';
+ el.insertAdjacentHTML('beforeend', lockSvg);
+ }
+ });
+ // Система group: visible to everyone too
+ const sysGroup = document.getElementById('admin-nav-system-group');
+ if (sysGroup) sysGroup.style.display = '';
/* Collapsible nav groups — state persisted in localStorage */
window.toggleAdminGroup = function (slug) {
@@ -47,6 +55,10 @@
/* ─── 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) {
+ if (btn.classList.contains('locked')) {
+ LS.toast('Этот раздел доступен только администраторам', 'warn');
+ return;
+ }
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.admin-nav-item').forEach(b => b.classList.remove('active'));
const name = btn.dataset.tab;
@@ -119,7 +131,7 @@
`;
}).join('');
} catch (e) {
- document.getElementById('stats-grid').innerHTML = `
Ошибка: ${esc(e.message)}
`;
+ LS.state.error(document.getElementById('stats-grid'), e, loadStats);
}
}
@@ -669,7 +681,8 @@
`;
}).join('');
} catch (e) {
- document.getElementById('users-body').innerHTML = `| Ошибка: ${esc(e.message)} |
`;
+ document.getElementById('users-body').innerHTML = ` |
`;
+ LS.state.error(document.getElementById('users-body').querySelector('td'), e, loadUsers);
}
}
@@ -738,7 +751,7 @@
${s.score??'—'} / ${s.total}
`;
}).join('') + '';
- } catch (e) { document.getElementById('up-sessions').innerHTML = `Ошибка: ${esc(e.message)}
`; }
+ } catch (e) { LS.state.error(document.getElementById('up-sessions'), e); }
}
function closeUserPanel() {
diff --git a/js/api.js b/js/api.js
index 9acb2df..ae5623a 100644
--- a/js/api.js
+++ b/js/api.js
@@ -378,6 +378,63 @@ function lsToast(message, type = 'info', duration = 3500) {
el.querySelector('.ls-toast-close').addEventListener('click', () => clearTimeout(timer));
}
+/* ── State helpers: единый паттерн loading/empty/error ───────────────────
+ Usage:
+ LS.state.loading(el) // spinner
+ LS.state.empty(el, msg, icon?) // "пусто" с иконкой
+ LS.state.error(el, err, retry?) // ошибка + "Повторить"
+ ──────────────────────────────────────────────────────────────────────── */
+function _ensureStateStyles() {
+ if (document.getElementById('ls-state-style')) return;
+ const s = document.createElement('style');
+ s.id = 'ls-state-style';
+ s.textContent = `
+ .ls-state { padding: 40px 20px; text-align: center; color: #56687A; font-size: .9rem; }
+ .ls-state-icon { width: 36px; height: 36px; margin: 0 auto 10px; opacity: .55; display: block; stroke: currentColor; fill: none; stroke-width: 1.6; }
+ .ls-state-spin { width: 32px; height: 32px; border: 3px solid rgba(155,93,229,.15); border-top-color: #9B5DE5; border-radius: 50%; animation: ls-spin .8s linear infinite; margin: 8px auto; display: block; }
+ @keyframes ls-spin { to { transform: rotate(360deg); } }
+ .ls-state-title { font-family: 'Unbounded', sans-serif; font-weight: 700; color: #1F2937; margin-bottom: 4px; font-size: .92rem; }
+ .ls-state-msg { line-height: 1.5; }
+ .ls-state-btn {
+ margin-top: 14px; padding: 8px 18px; border-radius: 999px;
+ border: 1.5px solid #9B5DE5; background: transparent; color: #9B5DE5;
+ font-family: 'Manrope', sans-serif; font-size: .82rem; font-weight: 700;
+ cursor: pointer; transition: all .15s;
+ }
+ .ls-state-btn:hover { background: #9B5DE5; color: #fff; }
+ `;
+ document.head.appendChild(s);
+}
+
+const lsState = {
+ loading(el, msg) {
+ if (!el) return;
+ _ensureStateStyles();
+ el.innerHTML = `${msg ? `
${escapeHtml(msg)}
` : ''}
`;
+ },
+ empty(el, msg = 'Пусто', icon = 'inbox') {
+ if (!el) return;
+ _ensureStateStyles();
+ el.innerHTML = `${lsIcon(icon, 36) ? `
${lsIcon(icon, 36)}
` : ''}
${escapeHtml(msg)}
`;
+ },
+ error(el, err, retryFn) {
+ if (!el) return;
+ _ensureStateStyles();
+ const msg = (err && err.message) || String(err || 'Ошибка');
+ const retryId = 'lse-retry-' + Math.random().toString(36).slice(2, 8);
+ el.innerHTML = `
+
${lsIcon('alert-circle', 36)}
+
Не удалось загрузить
+
${escapeHtml(msg)}
+ ${retryFn ? `
` : ''}
+
`;
+ if (retryFn) {
+ const btn = el.querySelector('#' + retryId);
+ if (btn) btn.addEventListener('click', () => { lsState.loading(el); retryFn(); });
+ }
+ },
+};
+
/* ── Skeleton-заглушки ────────────────────────────────────────────────── */
function lsSkeleton(count = 3, variant = 'card') {
if (!document.getElementById('ls-skeleton-style')) {
@@ -919,6 +976,7 @@ window.LS = {
confirm: lsConfirm,
modal: lsModal,
toast: lsToast,
+ state: lsState,
skeleton: lsSkeleton,
api: apiFetch,
get: (path) => apiFetch(path, { method: 'GET' }),