ux(admin): lock-icons на admin-only табах + LS.state helpers

1) LOCK-ICONS на admin-only табах
   Раньше: 7 табов (Магазин, Геймификация, Шаблоны, Симуляции, Игры,
   Доступные тесты, Права доступа) скрывались от учителей через
   display:none. Учитель не знал что они существуют — discoverability 0.

   Теперь:
   - Все табы видны всем, но для не-админа добавлен .locked класс
   - .locked: opacity .42, cursor not-allowed, lock-icon справа
   - title=\"Только для администраторов\" — нативный tooltip
   - switchTab() при клике на .locked показывает toast вместо
     переключения

   Эффект: учитель видит границы своих прав; знает что есть в системе,
   но не доступно ему — может попросить админа дать доступ.

2) LS.state — общий helper для loading/empty/error состояний
   api.js:381 — добавлен LS.state с тремя методами:

   LS.state.loading(el, msg?)           — спиннер + опц. текст
   LS.state.empty(el, msg, icon='inbox') — пустое состояние с иконкой
   LS.state.error(el, err, retryFn?)    — красная иконка + текст
                                          + опц. кнопка «Повторить»

   Все три используют один CSS (.ls-state*) с одним визуальным языком.
   inject стилей лениво (id=ls-state-style).

   Демо-миграция: 3 error-handler'а в admin.js (Stats / Users /
   Sessions) переписаны на LS.state.error с retry-функцией. Юзер
   теперь может нажать «Повторить» вместо перезагрузки страницы.

   Остальные 20+ inline error/empty/spinner'ов в admin.js — для
   постепенной миграции (паттерн установлен).
This commit is contained in:
Maxim Dolgolyov
2026-05-16 19:56:58 +03:00
parent ffd7bac0ac
commit 6b7d0355b6
3 changed files with 88 additions and 11 deletions
+24 -11
View File
@@ -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 = '<svg class="adm-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
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 @@
</div>`;
}).join('');
} catch (e) {
document.getElementById('stats-grid').innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
LS.state.error(document.getElementById('stats-grid'), e, loadStats);
}
}
@@ -669,7 +681,8 @@
</tr>`;
}).join('');
} catch (e) {
document.getElementById('users-body').innerHTML = `<tr><td colspan="7" class="error">Ошибка: ${esc(e.message)}</td></tr>`;
document.getElementById('users-body').innerHTML = `<tr><td colspan="7"></td></tr>`;
LS.state.error(document.getElementById('users-body').querySelector('td'), e, loadUsers);
}
}
@@ -738,7 +751,7 @@
<div class="sess-score">${s.score??'—'} / ${s.total}</div>
</div>`;
}).join('') + '</div>';
} catch (e) { document.getElementById('up-sessions').innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`; }
} catch (e) { LS.state.error(document.getElementById('up-sessions'), e); }
}
function closeUserPanel() {