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
+58
View File
@@ -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 = `<div class="ls-state"><div class="ls-state-spin"></div>${msg ? `<div class="ls-state-msg">${escapeHtml(msg)}</div>` : ''}</div>`;
},
empty(el, msg = 'Пусто', icon = 'inbox') {
if (!el) return;
_ensureStateStyles();
el.innerHTML = `<div class="ls-state">${lsIcon(icon, 36) ? `<div style="margin-bottom:10px;display:flex;justify-content:center;opacity:.55">${lsIcon(icon, 36)}</div>` : ''}<div class="ls-state-msg">${escapeHtml(msg)}</div></div>`;
},
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 = `<div class="ls-state">
<div style="margin-bottom:10px;display:flex;justify-content:center;opacity:.55;color:#F94144">${lsIcon('alert-circle', 36)}</div>
<div class="ls-state-title">Не удалось загрузить</div>
<div class="ls-state-msg">${escapeHtml(msg)}</div>
${retryFn ? `<button class="ls-state-btn" id="${retryId}">Повторить</button>` : ''}
</div>`;
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' }),