feat(sim-builder): фаза 5 — каталог custom-sims в /lab (LabCustom: ленивая регистрация, секция, deep-link)
This commit is contained in:
@@ -32,7 +32,9 @@
|
||||
_merged.push(s.id && _regById[s.id] ? _regById[s.id] : s);
|
||||
if (s.id) _seen[s.id] = 1;
|
||||
});
|
||||
_reg.forEach(m => { if (!_seen[m.id]) _merged.push(m); });
|
||||
// Конструктор симуляций (Фаза 5): custom-sims рендерятся отдельной секцией
|
||||
// «Мои симуляции» (см. LabCustom) — исключаем их из основной сетки встроенных.
|
||||
_reg.forEach(m => { if (!_seen[m.id] && !m._custom) _merged.push(m); });
|
||||
|
||||
const base = _catFilter === 'all' ? _merged : _merged.filter(s => s.cat === _catFilter);
|
||||
const list = base.filter(s => !s.id || !_disabledSimIds.has(s.id));
|
||||
@@ -46,6 +48,10 @@
|
||||
</div>
|
||||
${!s.id ? '<div class="sim-soon-badge">Скоро</div>' : ''}
|
||||
</div>`).join('');
|
||||
// Конструктор симуляций (Фаза 5): дорисовать секцию «Мои симуляции».
|
||||
if (window.LabCustom && typeof window.LabCustom.renderSection === 'function') {
|
||||
try { window.LabCustom.renderSection(_catFilter); } catch (e) {}
|
||||
}
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
@@ -412,7 +418,14 @@ const SIMS = [
|
||||
document.getElementById('lab-sim').classList.add('open');
|
||||
document.querySelector('.sim-topbar').style.display = 'none';
|
||||
// defer until all external scripts are loaded
|
||||
window.addEventListener('load', () => openSim(_autoSim));
|
||||
// Конструктор симуляций (Фаза 5): custom-симуляции требуют предзагрузки спеки.
|
||||
window.addEventListener('load', function () {
|
||||
if (/^custom:/i.test(_autoSim) && window.LabCustom && window.LabCustom.init) {
|
||||
window.LabCustom.init().then(function () { openSim(_autoSim); });
|
||||
} else {
|
||||
openSim(_autoSim);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
/* init — fetch sim settings + permissions in parallel, then render */
|
||||
@@ -455,7 +468,14 @@ const SIMS = [
|
||||
</div>`;
|
||||
} else {
|
||||
renderSims();
|
||||
if (_autoSim) openSim(_autoSim);
|
||||
// Конструктор симуляций (Фаза 5): подтянуть custom-sims (свои + published),
|
||||
// зарегистрировать ленивые манифесты и дорисовать секцию «Мои симуляции».
|
||||
// Если deep-link ведёт на custom-симуляцию — открыть её ПОСЛЕ загрузки списка.
|
||||
var _customAuto = _autoSim && /^custom:/i.test(_autoSim);
|
||||
var _customReady = (window.LabCustom && window.LabCustom.init)
|
||||
? window.LabCustom.init() : Promise.resolve();
|
||||
if (_autoSim && !_customAuto) openSim(_autoSim);
|
||||
else if (_customAuto) _customReady.then(function () { openSim(_autoSim); });
|
||||
// hash-router: activate sim from URL fragment after catalogue renders
|
||||
else _activateFromHash();
|
||||
}
|
||||
@@ -560,3 +580,270 @@ const SIMS = [
|
||||
_origCloseSim();
|
||||
}
|
||||
});
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
LabCustom — каталог пользовательских симуляций (Конструктор симуляций, Фаза 5)
|
||||
|
||||
Подтягивает сохранённые custom-sims (свои любого статуса + чужие published)
|
||||
через LS.customSimsList(), регистрирует ЛЕНИВЫЕ манифесты в LabRegistry и
|
||||
рисует отдельную секцию «Мои симуляции» в #lab-home (карточки переиспользуют
|
||||
стили .sim-card). Спека (тяжёлый JSON) тянется лениво при ПЕРВОМ открытии
|
||||
(LS.customSimGet -> registerSpecSim из Ф0-адаптера), а не на старте /lab.
|
||||
|
||||
id-неймспейс: deep-link/клик — 'custom:<dbid>'; в LabRegistry — 'customsim_<dbid>'
|
||||
(реестр обрезает часть после ':' в get/has, поэтому двоеточие там недопустимо).
|
||||
openSim() переводит одно в другое через LabCustom.resolveId (хук в lab-init.js).
|
||||
|
||||
Самодостаточно: создаёт контейнер секции динамически, без правок lab.html/CSS —
|
||||
меньше риск конфликта с параллельными сессиями. Падение загрузки (нет сети/404)
|
||||
не ломает каталог встроенных — секция просто не появляется (try/catch).
|
||||
════════════════════════════════════════════════════════════════════════ */
|
||||
(function () {
|
||||
var REG_PREFIX = 'customsim_';
|
||||
var _meta = {}; // dbid -> мета-запись из списка (без spec)
|
||||
var _order = []; // dbid в порядке выдачи списка
|
||||
var _specCache = {}; // dbid -> распарсенная spec (кэш ленивой загрузки)
|
||||
var _specPromise = {}; // dbid -> Promise загрузки spec (дедуп)
|
||||
var _initPromise = null;
|
||||
|
||||
function _uid() { try { return (typeof user !== 'undefined' && user) ? user.id : null; } catch (e) { return null; } }
|
||||
function _regId(dbid) { return REG_PREFIX + dbid; }
|
||||
function _dbIdOf(id) {
|
||||
if (id == null) return null;
|
||||
var s = String(id);
|
||||
if (s.indexOf('custom:') === 0) return s.slice(7).split(':')[0];
|
||||
if (s.indexOf(REG_PREFIX) === 0) return s.slice(REG_PREFIX.length);
|
||||
return null;
|
||||
}
|
||||
function _esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
function _isOwner(m) {
|
||||
var uid = _uid();
|
||||
return m && uid != null && String(m.owner_id) === String(uid);
|
||||
}
|
||||
|
||||
// deep-link/клик 'custom:<dbid>' -> реестровый id; встроенные id не трогаем.
|
||||
function resolveId(id) {
|
||||
var dbid = _dbIdOf(id);
|
||||
return dbid != null ? _regId(dbid) : id;
|
||||
}
|
||||
|
||||
// Лениво получить spec симуляции (кэш + дедуп параллельных запросов).
|
||||
function ensureSpec(dbid) {
|
||||
if (_specCache[dbid]) return Promise.resolve(_specCache[dbid]);
|
||||
if (_specPromise[dbid]) return _specPromise[dbid];
|
||||
if (!window.LS || !LS.customSimGet) return Promise.resolve(null);
|
||||
_specPromise[dbid] = LS.customSimGet(dbid).then(function (data) {
|
||||
var sim = data && data.sim;
|
||||
var spec = sim && sim.spec;
|
||||
if (spec) { _specCache[dbid] = spec; }
|
||||
delete _specPromise[dbid];
|
||||
return spec || null;
|
||||
}).catch(function (e) {
|
||||
delete _specPromise[dbid];
|
||||
if (window.console) console.warn('[LabCustom] не удалось загрузить спеку', dbid, e);
|
||||
return null;
|
||||
});
|
||||
return _specPromise[dbid];
|
||||
}
|
||||
|
||||
// Зарегистрировать ЛЕНИВЫЙ манифест-заглушку для одной custom-sim.
|
||||
// При первом open() — подтянуть spec и заменить заглушку реальным манифестом
|
||||
// (registerSpecSim из Ф0-адаптера строит полноценный SimEngine-манифест).
|
||||
function _registerLazy(m) {
|
||||
if (!window.LabRegistry) return;
|
||||
var dbid = m.id;
|
||||
var rid = _regId(dbid);
|
||||
var manifest = {
|
||||
id: rid,
|
||||
cat: m.cat || 'phys',
|
||||
title: m.title || ('Симуляция #' + dbid),
|
||||
desc: m.description || '',
|
||||
subject: m.subject,
|
||||
grade: m.grade,
|
||||
_custom: true, // секция рисует их отдельно (см. renderSims)
|
||||
_customId: dbid,
|
||||
open: function (ctx) {
|
||||
return ensureSpec(dbid).then(function (spec) {
|
||||
if (!spec) {
|
||||
if (window.console) console.warn('[LabCustom] спека пуста для', dbid);
|
||||
return;
|
||||
}
|
||||
spec.id = rid; // реестровый id без двоеточия
|
||||
if (!spec.cat) spec.cat = m.cat || 'phys';
|
||||
if (!spec.subject && m.subject) spec.subject = m.subject;
|
||||
if (!spec.grade && m.grade != null) spec.grade = m.grade;
|
||||
var real = window.registerSpecSim
|
||||
? window.registerSpecSim(spec) // заменит заглушку на месте (тот же id)
|
||||
: null;
|
||||
if (real) {
|
||||
real._custom = true;
|
||||
real._customId = dbid;
|
||||
if (window.LabRegistry) window.LabRegistry.setActive(real);
|
||||
return real.open(ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
window.LabRegistry.register(manifest);
|
||||
}
|
||||
|
||||
function _catLabel(cat) {
|
||||
if (cat === 'math') return '∑ Математика';
|
||||
if (cat === 'chem') return '<svg class="ic" viewBox="0 0 24 24"><path d="M9 3h6m-4.5 0v5.5l-4 7.5a1 1 0 0 0 .9 1.5h8.2a1 1 0 0 0 .9-1.5l-4-7.5V3"/></svg> Химия';
|
||||
if (cat === 'bio') return '<svg class="ic" viewBox="0 0 24 24"><path d="M2 15c6.667-6 13.333 0 20-6"/></svg> Биология';
|
||||
if (cat === 'game') return '<svg class="ic" viewBox="0 0 24 24"><rect x="2" y="6" width="20" height="12" rx="2"/></svg> Игры';
|
||||
return (typeof LS !== 'undefined' && LS.icon ? LS.icon('zap', 14) : '') + ' Физика';
|
||||
}
|
||||
|
||||
function _ensureSectionHost() {
|
||||
var host = document.getElementById('custom-sim-section');
|
||||
if (host) return host;
|
||||
var home = document.getElementById('lab-home');
|
||||
var grid = document.getElementById('sim-grid');
|
||||
if (!home) return null;
|
||||
host = document.createElement('div');
|
||||
host.id = 'custom-sim-section';
|
||||
host.style.cssText = 'margin-top:34px';
|
||||
if (grid && grid.parentNode === home) home.appendChild(host);
|
||||
else home.appendChild(host);
|
||||
return host;
|
||||
}
|
||||
|
||||
var _EDIT_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4Z"/></svg>';
|
||||
var _DEL_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
|
||||
|
||||
function _cardHtml(m) {
|
||||
var owner = _isOwner(m);
|
||||
var published = m.status === 'published';
|
||||
var rid = _regId(m.id);
|
||||
var badges = '';
|
||||
if (owner) badges += '<span style="display:inline-flex;align-items:center;gap:4px;font-size:.62rem;font-weight:800;text-transform:uppercase;letter-spacing:.05em;padding:3px 9px;border-radius:99px;background:rgba(155,93,229,.16);color:var(--violet);border:1px solid rgba(155,93,229,.34)">Моя</span>';
|
||||
if (published) badges += '<span style="display:inline-flex;align-items:center;gap:4px;font-size:.62rem;font-weight:800;text-transform:uppercase;letter-spacing:.05em;padding:3px 9px;border-radius:99px;background:rgba(52,211,153,.14);color:#34d399;border:1px solid rgba(52,211,153,.32)">Опубликована</span>';
|
||||
else if (owner) badges += '<span style="display:inline-flex;align-items:center;gap:4px;font-size:.62rem;font-weight:800;text-transform:uppercase;letter-spacing:.05em;padding:3px 9px;border-radius:99px;background:rgba(255,255,255,.06);color:var(--text-3);border:1px solid rgba(255,255,255,.14)">Черновик</span>';
|
||||
var actions = '';
|
||||
if (owner) {
|
||||
actions =
|
||||
'<div style="display:flex;gap:8px;margin-top:12px">' +
|
||||
'<button type="button" data-act="edit" data-id="' + _esc(m.id) + '" ' +
|
||||
'style="flex:1;display:inline-flex;align-items:center;justify-content:center;gap:6px;font-size:.78rem;font-weight:700;padding:7px 10px;border-radius:10px;cursor:pointer;background:rgba(155,93,229,.12);color:var(--violet);border:1px solid rgba(155,93,229,.3)">' +
|
||||
_EDIT_ICON + 'Редактировать</button>' +
|
||||
'<button type="button" data-act="del" data-id="' + _esc(m.id) + '" ' +
|
||||
'style="display:inline-flex;align-items:center;justify-content:center;padding:7px 11px;border-radius:10px;cursor:pointer;background:rgba(244,91,105,.1);color:#f45b69;border:1px solid rgba(244,91,105,.28)" title="Удалить">' +
|
||||
_DEL_ICON + '</button>' +
|
||||
'</div>';
|
||||
}
|
||||
var preview = '<svg class="sim-preview" viewBox="0 0 300 140" preserveAspectRatio="xMidYMid slice" xmlns="http://www.w3.org/2000/svg">' +
|
||||
'<rect width="300" height="140" fill="#0D0D1A"/>' +
|
||||
'<line x1="20" y1="120" x2="280" y2="120" stroke="rgba(255,255,255,0.25)" stroke-width="1.5"/>' +
|
||||
'<line x1="30" y1="20" x2="30" y2="130" stroke="rgba(255,255,255,0.25)" stroke-width="1.5"/>' +
|
||||
'<path d="M30 120 Q120 30 270 110" fill="none" stroke="#06D6E0" stroke-width="2.5"/>' +
|
||||
'<circle cx="150" cy="64" r="5" fill="#9B5DE5"/></svg>';
|
||||
return '' +
|
||||
'<div class="sim-card" data-open="' + _esc('custom:' + m.id) + '">' +
|
||||
preview +
|
||||
'<div class="sim-body">' +
|
||||
'<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px">' +
|
||||
'<span class="sim-cat ' + _esc(m.cat || 'phys') + '">' + _catLabel(m.cat) + '</span>' + badges +
|
||||
'</div>' +
|
||||
'<div class="sim-title">' + _esc(m.title || ('Симуляция #' + m.id)) + '</div>' +
|
||||
'<div class="sim-desc">' + _esc(m.description || 'Пользовательская симуляция') +
|
||||
(m.grade != null && m.grade !== '' ? ' · ' + _esc(m.grade) + ' класс' : '') + '</div>' +
|
||||
actions +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Видимые в данной вкладке записи (фильтр категорий применяем и к custom).
|
||||
function _visible(catFilter) {
|
||||
return _order
|
||||
.map(function (id) { return _meta[id]; })
|
||||
.filter(function (m) { return m && (catFilter === 'all' || (m.cat || 'phys') === catFilter); });
|
||||
}
|
||||
|
||||
function renderSection(catFilter) {
|
||||
var host = _ensureSectionHost();
|
||||
if (!host) return;
|
||||
var list = _visible(catFilter || _catFilter);
|
||||
if (!list.length) { host.innerHTML = ''; host.style.display = 'none'; return; }
|
||||
host.style.display = '';
|
||||
var head = '<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px">' +
|
||||
'<svg class="ic" viewBox="0 0 24 24" style="width:18px;height:18px;stroke:var(--violet)"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>' +
|
||||
'<span style="font-family:\'Unbounded\',sans-serif;font-size:1rem;font-weight:800">Мои симуляции</span>' +
|
||||
'<span style="font-size:.78rem;color:var(--text-3)">собранные в конструкторе</span></div>';
|
||||
host.innerHTML = head + '<div class="sim-grid">' + list.map(_cardHtml).join('') + '</div>';
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
// Делегированные клики по секции: открыть / редактировать / удалить.
|
||||
document.addEventListener('click', function (ev) {
|
||||
var host = document.getElementById('custom-sim-section');
|
||||
if (!host || !host.contains(ev.target)) return;
|
||||
var actBtn = ev.target.closest ? ev.target.closest('[data-act]') : null;
|
||||
if (actBtn && host.contains(actBtn)) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
var act = actBtn.getAttribute('data-act');
|
||||
var id = actBtn.getAttribute('data-id');
|
||||
if (act === 'edit') {
|
||||
location.href = '/sim-builder?id=' + encodeURIComponent(id);
|
||||
} else if (act === 'del') {
|
||||
del(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
var card = ev.target.closest ? ev.target.closest('[data-open]') : null;
|
||||
if (card && host.contains(card)) {
|
||||
var openId = card.getAttribute('data-open');
|
||||
if (openId) openSim(openId);
|
||||
}
|
||||
});
|
||||
|
||||
function del(dbid) {
|
||||
var m = _meta[dbid];
|
||||
var name = (m && m.title) || ('симуляцию #' + dbid);
|
||||
if (!window.confirm('Удалить «' + name + '»? Это действие необратимо.')) return;
|
||||
if (!window.LS || !LS.customSimDelete) return;
|
||||
LS.customSimDelete(dbid).then(function () {
|
||||
delete _meta[dbid];
|
||||
delete _specCache[dbid];
|
||||
_order = _order.filter(function (x) { return String(x) !== String(dbid); });
|
||||
renderSection(_catFilter);
|
||||
}).catch(function (e) {
|
||||
if (window.LS && LS.toast) LS.toast('Не удалось удалить симуляцию', 'error');
|
||||
else if (window.console) console.warn('[LabCustom] delete failed', dbid, e);
|
||||
});
|
||||
}
|
||||
|
||||
// Загрузить список custom-sims, зарегистрировать ленивые манифесты, нарисовать секцию.
|
||||
function init() {
|
||||
if (_initPromise) return _initPromise;
|
||||
if (!window.LS || !LS.customSimsList) { _initPromise = Promise.resolve(); return _initPromise; }
|
||||
_initPromise = LS.customSimsList().then(function (data) {
|
||||
var sims = (data && data.sims) || [];
|
||||
_order = [];
|
||||
sims.forEach(function (s) {
|
||||
if (s == null || s.id == null) return;
|
||||
_meta[s.id] = s;
|
||||
_order.push(s.id);
|
||||
_registerLazy(s);
|
||||
});
|
||||
renderSection(_catFilter);
|
||||
}).catch(function (e) {
|
||||
// мягко: нет сети/прав — секция просто не появится, встроенные работают
|
||||
if (window.console) console.warn('[LabCustom] список custom-sims недоступен', e);
|
||||
});
|
||||
return _initPromise;
|
||||
}
|
||||
|
||||
window.LabCustom = {
|
||||
init: init,
|
||||
resolveId: resolveId,
|
||||
renderSection: renderSection,
|
||||
ensureSpec: ensureSpec,
|
||||
del: del
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -105,6 +105,13 @@
|
||||
}
|
||||
|
||||
function openSim(id) {
|
||||
// Конструктор симуляций (Фаза 5): custom-sims регистрируются в LabRegistry под
|
||||
// id без двоеточия (LabRegistry.get/has обрезают часть после ':'). Хук resolveId
|
||||
// переводит deep-link/клик 'custom:<dbid>' в реестровый id и лениво подтягивает
|
||||
// спеку при первом открытии. Для встроенных симуляций id не меняется.
|
||||
if (window.LabCustom && typeof window.LabCustom.resolveId === 'function') {
|
||||
id = window.LabCustom.resolveId(id) || id;
|
||||
}
|
||||
if (_disabledSimIds.has(id.split(':')[0])) return;
|
||||
document.getElementById('lab-home').style.display = 'none';
|
||||
document.getElementById('lab-sim').classList.add('open');
|
||||
|
||||
Reference in New Issue
Block a user