fix(features): пустой блок флешкарт, лаба в сайдбаре, мигание (FOUC)

Три проблемы UX отключения модулей:
1) Пустой блок флешкарт на дашборде: виджет #w-flashcard — <div>, а скрытие шло
   только по [href]. Добавлена карта FEATURE_WIDGETS (флешкарты→#w-flashcard) —
   контейнер прячется целиком.
2) Лаборатория не уходила из сайдбара: не было ГЛОБАЛЬНОГО тумблера lab (только
   free-student). Добавлен в whitelist updateFeatures + GAME_FEATURES; map уже знал
   lab→/lab. Теперь выключение скрывает пункт и редиректит со страницы.
3) Мигание выключенных модулей (FOUC): hideDisabledFeatures асинхронный. Теперь
   loadFeatures кэширует /api/features в localStorage, а _applyFeatureCss инъектит
   <style id=ls-feat-hide> синхронно из кэша на ранней загрузке (api.js идёт до
   sidebar.js) — сайдбар/виджеты строятся уже скрытыми. Геймификация: класс
   no-gamification ставится на <html> (раньше body), ls.css правило body.no-gamification
   → .no-gamification (работает и для html, и для body).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-22 17:41:11 +03:00
parent d5fbd0168e
commit 83f0ba9c04
4 changed files with 87 additions and 51 deletions
+1 -1
View File
@@ -525,7 +525,7 @@ function getFeatures(_req, res) {
function updateFeatures(req, res) { function updateFeatures(req, res) {
const allowed = ['crossword', 'hangman', 'pet', 'red_book', 'collection', const allowed = ['crossword', 'hangman', 'pet', 'red_book', 'collection',
'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz', 'classroom', '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 updates = req.body;
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)"); const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
const getOld = db.prepare("SELECT value FROM app_settings WHERE key = ?"); const getOld = db.prepare("SELECT value FROM app_settings WHERE key = ?");
+24 -24
View File
@@ -1039,7 +1039,7 @@ body {
body.no-class #lb-section { display: none !important; } body.no-class #lb-section { display: none !important; }
/* Gamification kill-switch. /* 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 / api.js/hideDisabledFeatures and EVERY XP / coin / streak / shop /
achievement / frame element must vanish — across the whole app, achievement / frame element must vanish — across the whole app,
not just the dashboard. The rules below cover: 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 — • a catch-all [data-gamified] hook that wraps any future block —
authors of new pages should wrap XP UI in a <div data-gamified> authors of new pages should wrap XP UI in a <div data-gamified>
instead of inventing new classes. */ instead of inventing new classes. */
body.no-gamification .gam-bar, .no-gamification .gam-bar,
body.no-gamification .lb-widget, .no-gamification .lb-widget,
body.no-gamification .achievements-section, .no-gamification .achievements-section,
body.no-gamification #tab-btn-achievements, .no-gamification #tab-btn-achievements,
body.no-gamification #tab-btn-shop, .no-gamification #tab-btn-shop,
body.no-gamification #tab-achievements, .no-gamification #tab-achievements,
body.no-gamification #tab-shop, .no-gamification #tab-shop,
body.no-gamification #frames-section, .no-gamification #frames-section,
body.no-gamification .hero-xp-badge, .no-gamification .hero-xp-badge,
body.no-gamification .po-xp, .no-gamification .po-xp,
body.no-gamification .xp-card, .no-gamification .xp-card,
body.no-gamification .xp-bar, .no-gamification .xp-bar,
body.no-gamification .xp-pill, .no-gamification .xp-pill,
body.no-gamification .xp-badge, .no-gamification .xp-badge,
/* challenges / еженедельные испытания (dashboard) */ /* challenges / еженедельные испытания (dashboard) */
body.no-gamification .ch-widget, .no-gamification .ch-widget,
body.no-gamification #ch-section, .no-gamification #ch-section,
/* серия/стрик: календарь, стат-кольцо, чипы на карточке питомца */ /* серия/стрик: календарь, стат-кольцо, чипы на карточке питомца */
body.no-gamification .streak-cal, .no-gamification .streak-cal,
body.no-gamification #sr-streak, .no-gamification #sr-streak,
body.no-gamification .hc-pet .chip-streak, .no-gamification .hc-pet .chip-streak,
body.no-gamification .hc-pet .chip-goal, .no-gamification .hc-pet .chip-goal,
/* монеты (профиль) и xp-прогресс */ /* монеты (профиль) и xp-прогресс */
body.no-gamification #p-coins-row, .no-gamification #p-coins-row,
body.no-gamification .gam-progress, .no-gamification .gam-progress,
body.no-gamification [data-gamified] { display: none !important; } .no-gamification [data-gamified] { display: none !important; }
/* ══════════════════════════════════════════ /* ══════════════════════════════════════════
RESPONSIVE — SMALL PHONES (≤ 480px) RESPONSIVE — SMALL PHONES (≤ 480px)
+1
View File
@@ -14,6 +14,7 @@
{ key: 'flashcards', label: 'Флеш-карточки', desc: 'Карточки для запоминания терминов и понятий методом интервальных повторений', icon: 'square-stack' }, { key: 'flashcards', label: 'Флеш-карточки', desc: 'Карточки для запоминания терминов и понятий методом интервальных повторений', icon: 'square-stack' },
{ key: 'imggen', label: 'Генерация картинок (ИИ)', desc: 'ИИ-генерация изображений в ассистенте, флэшкартах, уроках, питомце, аватаре, доске', icon: 'image' }, { key: 'imggen', label: 'Генерация картинок (ИИ)', desc: 'ИИ-генерация изображений в ассистенте, флэшкартах, уроках, питомце, аватаре, доске', icon: 'image' },
{ key: 'knowledge_map', label: 'Карта знаний', desc: 'Визуальная карта тем и связей между биологическими понятиями', icon: 'share-2' }, { key: 'knowledge_map', label: 'Карта знаний', desc: 'Визуальная карта тем и связей между биологическими понятиями', icon: 'share-2' },
{ key: 'lab', label: 'Лаборатория', desc: 'Раздел «Лаборатория»: виртуальные симуляции и интерактивные опыты', icon: 'atom' },
{ key: 'theory', label: 'Теория', desc: 'Раздел «Теория»: учебные курсы и уроки для учеников', icon: 'brain' }, { key: 'theory', label: 'Теория', desc: 'Раздел «Теория»: учебные курсы и уроки для учеников', icon: 'brain' },
{ key: 'board', label: 'Доска', desc: 'Классная доска с объявлениями, постами и обсуждениями', icon: 'layout-dashboard'}, { key: 'board', label: 'Доска', desc: 'Классная доска с объявлениями, постами и обсуждениями', icon: 'layout-dashboard'},
{ key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' }, { key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' },
+61 -26
View File
@@ -826,10 +826,67 @@ async function loadFeatures() {
_featuresCache = await apiFetch('/api/features'); _featuresCache = await apiFetch('/api/features');
} catch { _featuresCache = {}; } } catch { _featuresCache = {}; }
_gamificationEnabled = _featuresCache.gamification !== false; _gamificationEnabled = _featuresCache.gamification !== false;
try { localStorage.setItem('ls_feat_cache', JSON.stringify(_featuresCache)); } catch {}
_applyFeatureCss(_featuresCache); // авторитетное скрытие по свежим данным
return _featuresCache; return _featuresCache;
} }
function clearFeaturesCache() { _featuresCache = null; _gamificationEnabled = null; } 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;
// Геймификация: класс на <html> (доступен раньше 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. * Show board sidebar link only for teachers/admins and students in a class.
* Call after LS.initPage(). Uses features cache (_no_class flag). * Call after LS.initPage(). Uses features cache (_no_class flag).
@@ -850,32 +907,10 @@ async function showBoardIfAllowed() {
} }
async function hideDisabledFeatures() { async function hideDisabledFeatures() {
const feats = await loadFeatures(); const feats = await loadFeatures(); // loadFeatures уже вызвал _applyFeatureCss (визуальное скрытие)
const map = { // Редирект со страницы отключённой фичи (CSS прячет ссылки, а тут уводим со страницы).
hangman: ['/hangman'], for (const [key, hrefs] of Object.entries(FEATURE_HREFS)) {
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)) {
if (feats[key] === false) { 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; const cur = window.location.pathname;
if (hrefs.some(h => cur === h || cur === h.replace('.html', ''))) { if (hrefs.some(h => cur === h || cur === h.replace('.html', ''))) {
window.location.href = '/dashboard.html'; window.location.href = '/dashboard.html';
@@ -883,7 +918,7 @@ async function hideDisabledFeatures() {
} }
} }
if (feats.gamification === false) { 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 // If student is already viewing achievements or shop tab, redirect to account tab
const active = document.querySelector('#tab-achievements.active, #tab-shop.active'); const active = document.querySelector('#tab-achievements.active, #tab-shop.active');
if (active) { if (active) {