diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index f6daa45..3fcbe11 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -525,7 +525,7 @@ function getFeatures(_req, res) { function updateFeatures(req, res) { const allowed = ['crossword', 'hangman', 'pet', 'red_book', 'collection', 'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz', 'classroom', - 'gamification', 'assistant', 'sim_builder', 'quantik', 'theory']; + 'gamification', 'assistant', 'sim_builder', 'quantik', 'theory', 'lab']; const updates = req.body; const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)"); const getOld = db.prepare("SELECT value FROM app_settings WHERE key = ?"); diff --git a/frontend/css/ls.css b/frontend/css/ls.css index 187ae9c..032e592 100644 --- a/frontend/css/ls.css +++ b/frontend/css/ls.css @@ -1039,7 +1039,7 @@ body { body.no-class #lb-section { display: none !important; } /* Gamification kill-switch. - When admin turns off the feature, body.no-gamification is set by + When admin turns off the feature, .no-gamification is set by api.js/hideDisabledFeatures and EVERY XP / coin / streak / shop / achievement / frame element must vanish — across the whole app, not just the dashboard. The rules below cover: @@ -1050,32 +1050,32 @@ body.no-class #lb-section { display: none !important; } • a catch-all [data-gamified] hook that wraps any future block — authors of new pages should wrap XP UI in a
instead of inventing new classes. */ -body.no-gamification .gam-bar, -body.no-gamification .lb-widget, -body.no-gamification .achievements-section, -body.no-gamification #tab-btn-achievements, -body.no-gamification #tab-btn-shop, -body.no-gamification #tab-achievements, -body.no-gamification #tab-shop, -body.no-gamification #frames-section, -body.no-gamification .hero-xp-badge, -body.no-gamification .po-xp, -body.no-gamification .xp-card, -body.no-gamification .xp-bar, -body.no-gamification .xp-pill, -body.no-gamification .xp-badge, +.no-gamification .gam-bar, +.no-gamification .lb-widget, +.no-gamification .achievements-section, +.no-gamification #tab-btn-achievements, +.no-gamification #tab-btn-shop, +.no-gamification #tab-achievements, +.no-gamification #tab-shop, +.no-gamification #frames-section, +.no-gamification .hero-xp-badge, +.no-gamification .po-xp, +.no-gamification .xp-card, +.no-gamification .xp-bar, +.no-gamification .xp-pill, +.no-gamification .xp-badge, /* challenges / еженедельные испытания (dashboard) */ -body.no-gamification .ch-widget, -body.no-gamification #ch-section, +.no-gamification .ch-widget, +.no-gamification #ch-section, /* серия/стрик: календарь, стат-кольцо, чипы на карточке питомца */ -body.no-gamification .streak-cal, -body.no-gamification #sr-streak, -body.no-gamification .hc-pet .chip-streak, -body.no-gamification .hc-pet .chip-goal, +.no-gamification .streak-cal, +.no-gamification #sr-streak, +.no-gamification .hc-pet .chip-streak, +.no-gamification .hc-pet .chip-goal, /* монеты (профиль) и xp-прогресс */ -body.no-gamification #p-coins-row, -body.no-gamification .gam-progress, -body.no-gamification [data-gamified] { display: none !important; } +.no-gamification #p-coins-row, +.no-gamification .gam-progress, +.no-gamification [data-gamified] { display: none !important; } /* ══════════════════════════════════════════ RESPONSIVE — SMALL PHONES (≤ 480px) diff --git a/frontend/js/admin/sections/games.js b/frontend/js/admin/sections/games.js index 8c7852f..dfa0010 100644 --- a/frontend/js/admin/sections/games.js +++ b/frontend/js/admin/sections/games.js @@ -14,6 +14,7 @@ { key: 'flashcards', label: 'Флеш-карточки', desc: 'Карточки для запоминания терминов и понятий методом интервальных повторений', icon: 'square-stack' }, { key: 'imggen', label: 'Генерация картинок (ИИ)', desc: 'ИИ-генерация изображений в ассистенте, флэшкартах, уроках, питомце, аватаре, доске', icon: 'image' }, { key: 'knowledge_map', label: 'Карта знаний', desc: 'Визуальная карта тем и связей между биологическими понятиями', icon: 'share-2' }, + { key: 'lab', label: 'Лаборатория', desc: 'Раздел «Лаборатория»: виртуальные симуляции и интерактивные опыты', icon: 'atom' }, { key: 'theory', label: 'Теория', desc: 'Раздел «Теория»: учебные курсы и уроки для учеников', icon: 'brain' }, { key: 'board', label: 'Доска', desc: 'Классная доска с объявлениями, постами и обсуждениями', icon: 'layout-dashboard'}, { key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' }, diff --git a/js/api.js b/js/api.js index 020bc02..09ab2df 100644 --- a/js/api.js +++ b/js/api.js @@ -826,10 +826,67 @@ async function loadFeatures() { _featuresCache = await apiFetch('/api/features'); } catch { _featuresCache = {}; } _gamificationEnabled = _featuresCache.gamification !== false; + try { localStorage.setItem('ls_feat_cache', JSON.stringify(_featuresCache)); } catch {} + _applyFeatureCss(_featuresCache); // авторитетное скрытие по свежим данным return _featuresCache; } function clearFeaturesCache() { _featuresCache = null; _gamificationEnabled = null; } +/* Карта «фича → href пунктов меню» (скрытие из сайдбара + редирект со страницы). */ +const FEATURE_HREFS = { + hangman: ['/hangman'], + crossword: ['/crossword'], + pet: ['/pet'], + red_book: ['/red-book', '/red-book.html', '/red-book-ecosystem.html', '/red-book-biomes.html'], + collection: ['/collection.html', '/collection'], + lab: ['/lab'], + knowledge_map: ['/knowledge-map'], + flashcards: ['/flashcards'], + board: ['/board'], + biochem: ['/biochem', '/biochem-library', '/biochem-reactions'], + live_quiz: ['/live-quiz'], + classroom: ['/classroom'], + sim_builder: ['/sim-builder', '/sim-builder.html'], + exam9: ['/exam9', '/exam9.html'], + textbooks: ['/textbooks', '/textbooks.html', '/textbook'], + quantik: ['/quantik', '/quantik.html'], + theory: ['/theory', '/theory.html'], +}; +/* Контейнеры виджетов-модулей (дашборд и т.п.) — прячем блок целиком, а не только + ссылку, иначе остаётся пустой блок (напр. виджет флеш-карт #w-flashcard). */ +const FEATURE_WIDGETS = { + flashcards: ['#w-flashcard'], +}; +/* Инъекция CSS, прячущего отключённые фичи. Ставится синхронно из localStorage-кэша + на ранней загрузке (ДО построения сайдбара/виджетов) — против мигания (FOUC), + затем обновляется по свежему /api/features. */ +function _applyFeatureCss(feats) { + if (!feats) return; + const sels = []; + for (const [key, hrefs] of Object.entries(FEATURE_HREFS)) { + if (feats[key] === false) { + hrefs.forEach(h => sels.push(`[href="${h}"]`)); + (FEATURE_WIDGETS[key] || []).forEach(s => sels.push(s)); + } + } + const css = sels.length ? sels.join(',') + '{display:none !important}' : ''; + let el = document.getElementById('ls-feat-hide'); + if (!el) { + el = document.createElement('style'); + el.id = 'ls-feat-hide'; + (document.head || document.documentElement).appendChild(el); + } + el.textContent = css; + // Геймификация: класс на (доступен раньше body) → kill-switch без мигания. + document.documentElement.classList.toggle('no-gamification', feats.gamification === false); +} +/* Ранняя синхронная попытка из кэша прошлой загрузки — нет мигания на повторных заходах. + (FEATURE_HREFS — const, поэтому этот вызов идёт ПОСЛЕ его объявления.) */ +try { + const _cachedFeats = JSON.parse(localStorage.getItem('ls_feat_cache') || 'null'); + if (_cachedFeats) _applyFeatureCss(_cachedFeats); +} catch { /* нет кэша / приватный режим — просто ждём async */ } + /** * Show board sidebar link only for teachers/admins and students in a class. * Call after LS.initPage(). Uses features cache (_no_class flag). @@ -850,32 +907,10 @@ async function showBoardIfAllowed() { } async function hideDisabledFeatures() { - const feats = await loadFeatures(); - const map = { - hangman: ['/hangman'], - crossword: ['/crossword'], - pet: ['/pet'], - red_book: ['/red-book', '/red-book.html', '/red-book-ecosystem.html', '/red-book-biomes.html'], - collection: ['/collection.html', '/collection'], - lab: ['/lab'], - knowledge_map: ['/knowledge-map'], - flashcards: ['/flashcards'], - board: ['/board'], - biochem: ['/biochem', '/biochem-library', '/biochem-reactions'], - live_quiz: ['/live-quiz'], - classroom: ['/classroom'], - sim_builder: ['/sim-builder', '/sim-builder.html'], - exam9: ['/exam9', '/exam9.html'], - textbooks: ['/textbooks', '/textbooks.html', '/textbook'], - quantik: ['/quantik', '/quantik.html'], - theory: ['/theory', '/theory.html'], - }; - for (const [key, hrefs] of Object.entries(map)) { + const feats = await loadFeatures(); // loadFeatures уже вызвал _applyFeatureCss (визуальное скрытие) + // Редирект со страницы отключённой фичи (CSS прячет ссылки, а тут уводим со страницы). + for (const [key, hrefs] of Object.entries(FEATURE_HREFS)) { if (feats[key] === false) { - hrefs.forEach(href => { - document.querySelectorAll(`[href="${href}"]`).forEach(el => el.style.display = 'none'); - }); - // Redirect away if currently on a disabled page const cur = window.location.pathname; if (hrefs.some(h => cur === h || cur === h.replace('.html', ''))) { window.location.href = '/dashboard.html'; @@ -883,7 +918,7 @@ async function hideDisabledFeatures() { } } if (feats.gamification === false) { - document.body.classList.add('no-gamification'); + document.body.classList.add('no-gamification'); // дубль на body (html-класс ставит _applyFeatureCss) // If student is already viewing achievements or shop tab, redirect to account tab const active = document.querySelector('#tab-achievements.active, #tab-shop.active'); if (active) {