From 6b7d0355b6b7cf93f9ecc406cd43f64f2de59f11 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sat, 16 May 2026 19:56:58 +0300 Subject: [PATCH] =?UTF-8?q?ux(admin):=20lock-icons=20=D0=BD=D0=B0=20admin-?= =?UTF-8?q?only=20=D1=82=D0=B0=D0=B1=D0=B0=D1=85=20+=20LS.state=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 — для постепенной миграции (паттерн установлен). --- frontend/admin.html | 6 ++++ frontend/js/admin/admin.js | 35 +++++++++++++++-------- js/api.js | 58 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 11 deletions(-) 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' }),