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:
@@ -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; }
|
||||
|
||||
|
||||
+24
-11
@@ -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() {
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
Reference in New Issue
Block a user