Files
Maxim Dolgolyov 3898080f04 fix(features): админ открывает отключённые модули — пейдж-гейты уважают admin-override
Причина бага «из админа конструктор симуляций редиректит на дашборд»: у sim-builder.html
свой пейдж-гейт, который при feature_sim_builder=false уводил на /dashboard НЕЗАВИСИМО от
роли (мой прошлый admin-override был только в hideDisabledFeatures, а этот гейт его не знал).

Тот же недочёт нашёлся ещё у 3 страниц с собственным фича-редиректом (на /403):
collection.html, knowledge-map.html, red-book.html. Во все 4 добавил обход для админа
(админ управляет модулями → видит и открывает всё, даже отключённое) — согласно правилу
admin-override. Поведение для ученика/учителя не изменилось.

node --check инлайна всех 4 страниц — OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:59:51 +03:00

242 lines
15 KiB
HTML

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Конструктор симуляций — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml"/>
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/css/ls.css"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
<script src="https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<style>
/* ── Раскладка редактора: панели слева + превью справа ── */
.sbu-wrap { display: flex; flex-direction: column; height: 100vh; min-height: 0; }
.sbu-toolbar {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 12px 20px; border-bottom: 1px solid var(--border); background: var(--surface);
flex-shrink: 0; backdrop-filter: blur(8px);
}
.sbu-tb-left { display: flex; align-items: center; gap: 12px; min-width: 0; }
.sbu-tb-title { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 1.05rem; color: var(--text); white-space: nowrap; }
.sbu-tb-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.sbu-tb-btn { display: inline-flex; align-items: center; gap: 6px; font-size: .82rem; padding: 8px 14px; }
.sbu-tb-btn svg { flex-shrink: 0; }
.sbu-badge { font-size: .66rem; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; padding: 3px 9px; border-radius: 99px; background: rgba(15,23,42,0.08); color: var(--text-3); }
.sbu-badge-pub { background: rgba(16,185,129,0.14); color: #0f9d6e; }
.sbu-body { display: flex; flex: 1; min-height: 0; }
/* панели */
.sbu-panels { width: 360px; flex-shrink: 0; overflow-y: auto; padding: 14px; border-right: 1px solid var(--border); background: #fafbfd; display: flex; flex-direction: column; gap: 10px; }
/* превью */
.sbu-preview-col { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.sbu-preview-hint { font-size: .76rem; color: var(--text-3); padding: 7px 16px; border-bottom: 1px dashed var(--border); background: #fff; }
.sbu-preview { flex: 1; min-height: 0; position: relative; background: #0D0D1A; }
.sbu-preview .sim-spec-root { position: absolute; inset: 0; }
/* ── секция-аккордеон ── */
.sbu-sec { border: 1px solid var(--border); border-radius: 12px; background: var(--surface); overflow: hidden; }
.sbu-sec-hdr { width: 100%; display: flex; align-items: center; gap: 8px; padding: 11px 13px; border: none; background: none; cursor: pointer; font-family: 'Manrope', sans-serif; }
.sbu-sec-title { font-weight: 800; font-size: .82rem; color: var(--text); flex: 1; text-align: left; }
.sbu-sec-count { font-size: .68rem; font-weight: 700; color: var(--violet); background: rgba(155,93,229,0.12); padding: 2px 8px; border-radius: 99px; }
.sbu-sec-chev { display: inline-flex; color: var(--text-3); transition: transform .18s; }
.sbu-sec.open .sbu-sec-chev { transform: rotate(180deg); }
.sbu-sec-body { display: none; flex-direction: column; gap: 10px; padding: 0 13px 13px; }
.sbu-sec.open .sbu-sec-body { display: flex; }
/* ── поля ── */
.sbu-field { display: flex; flex-direction: column; gap: 4px; }
.sbu-field-lbl { font-size: .72rem; font-weight: 600; color: var(--text-3); }
.sbu-in { width: 100%; box-sizing: border-box; padding: 8px 10px; border: 1px solid var(--border); border-radius: 9px; font: inherit; font-size: .82rem; background: #fff; color: var(--text); }
.sbu-in:focus { outline: none; border-color: var(--violet); box-shadow: 0 0 0 3px rgba(155,93,229,0.12); }
.sbu-in-sm { padding: 6px 9px; font-size: .78rem; }
.sbu-in-expr { font-family: 'Menlo', 'Consolas', monospace; font-size: .78rem; }
.sbu-in-color { font-family: 'Menlo', 'Consolas', monospace; }
textarea.sbu-in { resize: vertical; }
.sbu-row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.sbu-row4 { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 6px; }
.sbu-mini { display: flex; flex-direction: column; gap: 2px; }
.sbu-mini-lbl { font-size: .66rem; color: var(--text-3); }
.sbu-divider { height: 1px; background: var(--border); margin: 4px 0; }
.sbu-sub { font-size: .7rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-3); margin-top: 4px; }
.sbu-checks { display: flex; flex-wrap: wrap; gap: 10px; }
.sbu-chk, .sbu-of-check { display: inline-flex; align-items: center; gap: 5px; font-size: .76rem; color: var(--text-2); cursor: pointer; }
.sbu-chk input, .sbu-of-check input { accent-color: var(--violet); }
/* ── кнопки ── */
.sbu-add { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 12px; border: 1px dashed var(--border-h); border-radius: 9px; background: #fff; cursor: pointer; font: inherit; font-size: .78rem; font-weight: 600; color: var(--text-2); transition: border-color .12s, color .12s; }
.sbu-add:hover { border-color: var(--violet); color: var(--violet); }
.sbu-add-sm { padding: 6px 10px; font-size: .74rem; }
.sbu-add-row { display: flex; gap: 8px; align-items: stretch; }
.sbu-add-row .sbu-add { flex: 1; }
.sbu-add-row select { flex: 0 0 130px; }
.sbu-icon-btn { width: 28px; height: 28px; flex-shrink: 0; border: 1px solid var(--border); border-radius: 8px; background: #fff; cursor: pointer; color: var(--text-3); display: inline-flex; align-items: center; justify-content: center; transition: border-color .12s, color .12s; }
.sbu-icon-btn:hover { border-color: var(--violet); color: var(--violet); }
.sbu-del:hover { border-color: #ef4444; color: #ef4444; }
.sbu-place.active { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,0.1); }
/* ── параметр ── */
.sbu-param, .sbu-obj, .sbu-plot, .sbu-wall, .sbu-spring { border: 1px solid var(--border); border-radius: 10px; padding: 9px; background: #fff; display: flex; flex-direction: column; gap: 7px; }
.sbu-param-top { display: flex; gap: 6px; align-items: center; }
.sbu-param-top .sbu-in { flex: 1; }
/* ── объект ── */
.sbu-obj.sel { border-color: var(--violet); box-shadow: 0 0 0 2px rgba(155,93,229,0.16); }
.sbu-obj.is-hidden, .sbu-plot.is-hidden { opacity: .62; }
.sbu-obj.is-hidden .sbu-obj-fields, .sbu-obj.is-hidden .sbu-obj-style { opacity: .7; }
.sbu-obj-hdr { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
.sbu-obj-type { font-size: .72rem; font-weight: 800; color: var(--violet); flex-shrink: 0; }
.sbu-in-id { flex: 1; min-width: 64px; max-width: 110px; }
.sbu-obj-hdr .sbu-icon-btn { width: 26px; height: 26px; }
.sbu-icon-btn:disabled { opacity: .32; cursor: default; pointer-events: none; }
.sbu-icon-btn.active { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,0.1); }
.sbu-zord { color: var(--text-3); }
.sbu-obj-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
/* ── блок «Стиль» объекта (P4) ── */
.sbu-obj-style { border-top: 1px dashed var(--border); padding-top: 7px; margin-top: 1px; display: flex; flex-direction: column; gap: 7px; }
.sbu-obj-style .sbu-sub { margin-top: 0; }
.sbu-style-row { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; align-items: end; }
.sbu-style-row > * { min-width: 0; }
.sbu-grad-row { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
/* ── color-picker контрол (нативный пикер + текст + очистка) ── */
.sbu-color-mini { min-width: 0; }
.sbu-color-wrap { display: flex; align-items: center; gap: 5px; }
.sbu-color-pick { width: 30px; height: 30px; flex-shrink: 0; padding: 0; border: 1px solid var(--border); border-radius: 8px; background: #fff; cursor: pointer; }
.sbu-color-pick::-webkit-color-swatch-wrapper { padding: 3px; }
.sbu-color-pick::-webkit-color-swatch { border: none; border-radius: 5px; }
.sbu-color-wrap .sbu-in-color { flex: 1; min-width: 0; }
.sbu-color-clr { width: 26px; height: 26px; flex-shrink: 0; border: 1px solid var(--border); border-radius: 7px; background: #fff; color: var(--text-3); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
.sbu-color-clr:hover { border-color: #ef4444; color: #ef4444; }
/* ── range (opacity) ── */
.sbu-range-mini { min-width: 0; }
.sbu-range-val { color: var(--violet); font-variant-numeric: tabular-nums; }
.sbu-range { width: 100%; accent-color: var(--violet); height: 30px; box-sizing: border-box; }
/* ── кривые графика ── */
.sbu-curves { display: flex; flex-direction: column; gap: 8px; }
.sbu-curve { border: 1px solid var(--border); border-radius: 9px; padding: 8px; background: #fafbfd; display: flex; flex-direction: column; gap: 7px; }
.sbu-curve-del { width: 24px; height: 24px; }
.sbu-of { display: flex; flex-direction: column; gap: 2px; }
.sbu-of-lbl { font-size: .66rem; color: var(--text-3); display: flex; align-items: center; justify-content: space-between; gap: 4px; }
.sbu-fx { font-size: .62rem; font-weight: 800; font-style: italic; color: var(--violet); background: rgba(155,93,229,0.1); border: none; border-radius: 5px; padding: 1px 6px; cursor: pointer; }
.sbu-fx:hover { background: rgba(155,93,229,0.2); }
.sbu-of.has-err .sbu-in { border-color: #ef4444; }
.sbu-of-err { font-size: .68rem; color: #ef4444; }
.sbu-of-check { grid-column: 1 / -1; }
.sbu-latex { grid-column: 1 / -1; padding: 8px; background: #f1f5f9; border-radius: 8px; font-size: .9rem; min-height: 20px; text-align: center; color: var(--text); }
.sbu-empty-sm { font-size: .76rem; color: var(--text-3); padding: 6px 2px; }
/* ── физика ── */
.sbu-phys-toggle { font-weight: 700; font-size: .82rem; }
.sbu-phys-fields { display: flex; flex-direction: column; gap: 8px; }
.sbu-wall { display: flex; flex-direction: column; gap: 6px; }
/* ── игровой уровень (P5-Квантик): цель + звёзды ── */
.sbu-game-fields { display: flex; flex-direction: column; gap: 8px; }
.sbu-stars-list { display: flex; flex-direction: column; gap: 8px; }
.sbu-star { border: 1px solid var(--border); border-radius: 10px; padding: 9px; background: #fafbfd; display: flex; flex-direction: column; gap: 7px; }
.sbu-star-hdr { display: flex; align-items: center; gap: 5px; }
/* ── палитра ── */
.sbu-pal { display: flex; flex-direction: column; gap: 12px; max-height: 60vh; overflow-y: auto; }
.sbu-pal-title { font-size: .72rem; font-weight: 700; color: var(--text-3); margin-bottom: 5px; }
.sbu-pal-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.sbu-pal-chip { padding: 4px 10px; border: 1px solid var(--border); border-radius: 8px; background: #fff; font-family: 'Menlo', 'Consolas', monospace; font-size: .78rem; cursor: pointer; color: var(--text-2); }
.sbu-pal-chip:hover { border-color: var(--violet); color: var(--violet); background: rgba(155,93,229,0.06); }
@media (max-width: 920px) {
.sbu-body { flex-direction: column; }
.sbu-panels { width: auto; max-height: 50vh; border-right: none; border-bottom: 1px solid var(--border); }
.sbu-preview { min-height: 320px; }
}
@media (max-width: 560px) {
.sbu-obj-fields { grid-template-columns: 1fr; }
.sbu-style-row, .sbu-grad-row { grid-template-columns: 1fr; }
.sbu-row4 { grid-template-columns: 1fr 1fr; }
}
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<main class="sb-content">
<div class="sbu-wrap">
<div class="sbu-toolbar" id="sbu-toolbar"></div>
<div class="sbu-body">
<div class="sbu-panels" id="sbu-panels"></div>
<div class="sbu-preview-col">
<div class="sbu-preview-hint">
Живое превью обновляется при правках. Включите «прицел» у объекта и кликните по сцене, чтобы задать его координаты. Кнопка «Тест» запускает анимацию.
</div>
<div class="sbu-preview" id="sbu-preview"></div>
</div>
</div>
</div>
</main>
</div>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/mobile.js"></script>
<!-- движок спек-симуляций (Фазы 0–2) -->
<script src="/js/labs/_sim_expr.js"></script>
<script src="/js/labs/_sim_engine.js"></script>
<!-- KaTeX для превью LaTeX-подписей -->
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
<!-- логика редактора -->
<script src="/js/sim-builder.js"></script>
<script>
(function () {
// Гейт: только teacher/admin
var ip = LS.initPage() || {};
if (!(ip.isTeacher || ip.isAdmin)) { location.href = '/dashboard'; return; }
// Фича-гейт: «Конструктор симуляций» можно отключить в админке (feature_sim_builder_enabled).
// Админ имеет доступ всегда (он управляет модулями) — для него гейт не срабатывает.
if (LS.loadFeatures && !ip.isAdmin) {
LS.loadFeatures().then(function (feats) {
if (feats && feats.sim_builder === false) { LS.toast && LS.toast('Конструктор симуляций отключён', 'warn'); location.href = '/dashboard'; }
}).catch(function () {});
}
if (!window.SimEngine || !window.SimExpr || !window.SimBuilder) {
document.getElementById('sbu-preview').innerHTML =
'<div style="padding:40px;color:#fff">Движок симуляций не загрузился. Обновите страницу.</div>';
return;
}
var builder = SimBuilder.create({
host: document.querySelector('.sbu-wrap'),
previewHost: document.getElementById('sbu-preview'),
panelHost: document.getElementById('sbu-panels'),
toolbarHost: document.getElementById('sbu-toolbar')
});
// ?id= -> загрузить существующую симуляцию
var params = new URLSearchParams(location.search);
var id = params.get('id');
if (id) {
builder.init();
LS.customSimGet(id).then(function (res) {
if (res && res.sim) {
builder.loadFromSim(res.sim);
} else {
LS.toast('Симуляция не найдена', 'error');
}
}).catch(function (e) {
LS.toast((e && e.message) || 'Не удалось загрузить симуляцию', 'error');
});
} else {
builder.init();
}
window.__simBuilder = builder; // для отладки
})();
</script>
</body>
</html>