diff --git a/CLAUDE.md b/CLAUDE.md index 0a03c13..985c34c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,3 +105,12 @@ git push origin master - **Клиентская валидация зеркалит серверную** (Ф3 лимиты) и показывает дружелюбную модалку-список ДО запроса — экономит round-trip и даёт понятные ошибки вместо сырого 400. - **Сайдбар — аддитивно**: пункт `/sim-builder` в `js/sidebar.js` в группе `G('practice',...)` после `/lab`, паттерн `{ cls:'sb-teacher-only', hidden:!isTch }`. `isActive('/sim-builder')` подсвечивает на странице (clean URL без .html). НЕ ломает остальной сайдбар. - **Верификация без jsdom**: headless-смоук — `vm.createContext` + ручной DOM/window/Blob-стаб (canvas getContext-заглушка, setTimeout no-op чтобы debounce не стрелял), грузим `_sim_expr.js`+`sim-builder.js`, дёргаем `buildSpec()`/`validate()`/`loadFromSim()` напрямую (рендер не нужен для логики). 23/23. + +### Phase 5 — Learnings + +- **id-неймспейс custom: гочи LabRegistry**. `LabRegistry.get/has` обрезают часть после `:` (`_baseId`), т.к. встроенные используют `base:arg` (`emfield:E`, `stereo:cube`). Поэтому регистрировать `custom:42` НЕЛЬЗЯ — `has('custom:42')` искал бы `_byId['custom']`. Решение: в реестре id **без двоеточия** `customsim_`, а наружу (deep-link/клик/`data-open`) — `custom:`. Конвертация одной функцией `LabCustom.resolveId` через хук в начале `openSim` (lab-init.js, +7 строк). +- **Ленивый манифест-заглушка вместо ранней загрузки spec**. На старте /lab грузим только мету (`customSimsList`, без spec) и регистрируем заглушку с асинхронным `open()`. При первом открытии: `ensureSpec(dbid)` (`customSimGet`, кэш+дедуп) → `registerSpecSim(spec)` (Ф0-адаптер) **заменяет заглушку на месте** (`LabRegistry.register` сохраняет позицию по тому же id) → `setActive(real)` + `real.open(ctx)`. Дисп. в `openSim` уже умеет Promise-возврат `open()` (Ф3). Повторное открытие — синхронно (реальный манифест в реестре). Движок `_sim_*` уже eager (Ф0) → ленивый файл не нужен, `_sim_deps.js` не трогаем. +- **Аддитивность в чужих файлах**: вся логика — в новом IIFE `window.LabCustom` в КОНЦЕ lab-glue.js; в существующий код добавлены только хуки: `renderSims()` merge +`&& !m._custom` (1 терм) + вызов `renderSection`; init зовёт `init()`. Секция «Мои симуляции» (`#custom-sim-section`) создаётся **динамически** в `#lab-home` — без правок lab.html/CSS (тот же приём, что `_loadRelated` в Ф-каталоге). Карточки переиспользуют `.sim-card/.sim-cat/.sim-preview`; бейджи/кнопки — inline-стиль + SVG `.ic` (без эмодзи). +- **Owner-only действия**: `owner_id === user.id` (user из `LS.initPage()`, поле `id` — канон всего фронта, ср. `t.createdBy === user.id` в theory.html). Edit → `location.href='/sim-builder?id='+dbid`; Delete → `LS.customSimDelete` + убрать карточку. Делегированный клик по контейнеру секции: `data-act` (edit/del, `stopPropagation`) vs `data-open` (открыть). Видимость draft/published обеспечивает сервер Ф3 (список = свои+чужие published). +- **Embed/Ф7 заметка**: для `?sim=custom:*` открытие отложено до `LabCustom.init()` (и в обычном, и в embed-режиме). `_loadRelated('customsim_')` дергает `/api/lab/sims/.../related` (404, тихо). LabRegistry не имеет unregister → удалённая custom остаётся заглушкой в реестре (карточки нет, ensureSpec вернёт 404). Источник spec для доски (Ф7): `LabCustom.ensureSpec(dbid)`. +- **Смоук на РЕАЛЬНОМ registry/adapter**: harness грузит настоящие `_registry.js`+`_sim_adapter.js` в `vm`-контекст, стабит только SimEngine/LS/DOM, извлекает IIFE `LabCustom` из lab-glue по маркеру и прогоняет init→open→del. Гочи стаба: реальный код проверяет `window.LS` (api.js ставит и `window.LS`, и глобал `LS`) — в стабе надо ставить ОБА; `document.getElementById` стаба должен находить и динамически `appendChild`-нутые элементы (регистрировать по id в appendChild). 22/22. diff --git a/frontend/js/labs/lab-glue.js b/frontend/js/labs/lab-glue.js index 391dcd1..03dcc09 100644 --- a/frontend/js/labs/lab-glue.js +++ b/frontend/js/labs/lab-glue.js @@ -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 @@ ${!s.id ? '
Скоро
' : ''} `).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 = [ `; } 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:'; в LabRegistry — 'customsim_' + (реестр обрезает часть после ':' в 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:' -> реестровый 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 ' Химия'; + if (cat === 'bio') return ' Биология'; + if (cat === 'game') return ' Игры'; + 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 = ''; + var _DEL_ICON = ''; + + function _cardHtml(m) { + var owner = _isOwner(m); + var published = m.status === 'published'; + var rid = _regId(m.id); + var badges = ''; + if (owner) badges += 'Моя'; + if (published) badges += 'Опубликована'; + else if (owner) badges += 'Черновик'; + var actions = ''; + if (owner) { + actions = + '
' + + '' + + '' + + '
'; + } + var preview = '' + + '' + + '' + + '' + + '' + + ''; + return '' + + '
' + + preview + + '
' + + '
' + + '' + _catLabel(m.cat) + '' + badges + + '
' + + '
' + _esc(m.title || ('Симуляция #' + m.id)) + '
' + + '
' + _esc(m.description || 'Пользовательская симуляция') + + (m.grade != null && m.grade !== '' ? ' · ' + _esc(m.grade) + ' класс' : '') + '
' + + actions + + '
' + + '
'; + } + + // Видимые в данной вкладке записи (фильтр категорий применяем и к 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 = '
' + + '' + + 'Мои симуляции' + + 'собранные в конструкторе
'; + host.innerHTML = head + '
' + list.map(_cardHtml).join('') + '
'; + 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 + }; + })(); diff --git a/frontend/js/labs/lab-init.js b/frontend/js/labs/lab-init.js index b48303a..ae76eb0 100644 --- a/frontend/js/labs/lab-init.js +++ b/frontend/js/labs/lab-init.js @@ -105,6 +105,13 @@ } function openSim(id) { + // Конструктор симуляций (Фаза 5): custom-sims регистрируются в LabRegistry под + // id без двоеточия (LabRegistry.get/has обрезают часть после ':'). Хук resolveId + // переводит deep-link/клик 'custom:' в реестровый 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'); diff --git a/plans/sim-builder/CONTEXT.md b/plans/sim-builder/CONTEXT.md index b6ea7c1..fa1a40c 100644 --- a/plans/sim-builder/CONTEXT.md +++ b/plans/sim-builder/CONTEXT.md @@ -1,6 +1,33 @@ # Feature Context: Конструктор симуляций (SimForge) ## Current State +- **Фаза 5 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Только + **аддитивные** правки двух файлов параллельной сессии (без рефактора их кода): рабочее дерево + по ним было ЧИСТЫМ до начала. classroom.html / backend / `_sim_deps.js` НЕ тронуты. + - **`frontend/js/labs/lab-init.js`** (+7 строк): в начало `openSim(id)` добавлен хук + `if (window.LabCustom && LabCustom.resolveId) id = LabCustom.resolveId(id) || id;` — + переводит deep-link/клик `custom:` в реестровый id `customsim_` (LabRegistry.get/has + обрезают часть после `:`, поэтому в реестре двоеточие недопустимо). Для встроенных id — no-op. + - **`frontend/js/labs/lab-glue.js`**: (а) `renderSims()` merge +`&& !m._custom` (custom не в + основной сетке) и вызов `LabCustom.renderSection(_catFilter)`; (б) init-блок (non-embed и embed) + зовёт `LabCustom.init()`, отложенное открытие `?sim=custom:*` до загрузки списка; (в) новый + IIFE **`window.LabCustom`** в конце файла. + - **Поток**: `LS.customSimsList()` (мета без spec) → `_registerLazy` кладёт в LabRegistry + манифест-заглушку `customsim_` (`_custom:true`) с ленивым `open()`. Секция «Мои симуляции» + `#custom-sim-section` (создаётся динамически в `#lab-home`, без правок lab.html/CSS) рендерит + карточки из `_meta`. Открытие: `resolveId` → дисп. реестра → `open()` заглушки → + `ensureSpec(dbid)` (`LS.customSimGet`, кэш+дедуп) → `spec.id=regId` → `registerSpecSim(spec)` + (Ф0-адаптер, заменяет заглушку на месте) → `setActive(real)`+`real.open(ctx)` (монтирует SimEngine). + **spec лениво** — на старте /lab не грузится. Движок (`_sim_*`) уже eager (Ф0), ленивый файл не нужен. + - **Карточка**: preview-SVG + cat-бейдж + бейджи «Моя»(owner)/«Опубликована»(status)/«Черновик» + + кнопки «Редактировать»→`/sim-builder?id=` / «Удалить»→`LS.customSimDelete` (владельцу, + `owner_id===user.id`). Делегированный клик по `#custom-sim-section`. Иконки — inline SVG `.ic`. + - Верификация: `node --check` обоих изменённых файлов OK; эмодзи нет (скан кодпойнтов — только + math/box-drawing глифы ∑/═/─/→, как в существующем коде); eval/Function нет; headless-смоук + (vm + DOM/SimEngine/LS-стабы, РЕАЛЬНЫЕ `_registry.js`+`_sim_adapter.js`) 22/22: resolveId, + регистрация ленивых манифестов+флаг `_custom`, секция/карточки, бейджи, owner-only edit/del, + deep-link `data-open`, lazy spec→registerSpecSim→mount, reopen синхронно, delete, встроенные не сломаны. + git status: изменены только lab-init.js/lab-glue.js (+ плановые .md); classroom.html/backend чисты. - **Фаза 4 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Только новые файлы `frontend/sim-builder.html` + `frontend/js/sim-builder.js` + аддитивная правка `js/sidebar.js` (lab.html/lab-glue.js НЕ тронуты — зона параллельной сессии). @@ -84,8 +111,13 @@ - Reuse > переписывание: сначала смотреть `_fx_motion`, `_graph_panel`, `graph.js`. ## RESUME STATE -- Последний коммит фичи: — (Ф0 + Ф1 + Ф2 + Ф3 + Ф4 реализованы, ещё не закоммичены — ждут оркестратора) -- Текущая фаза: Phase 4 — Builder UI (✅ Implemented, pending commit) → дальше Phase 5 — Каталог (custom-sims в /lab) +- Последний коммит фичи: — (Ф0 + Ф1 + Ф2 + Ф3 + Ф4 + Ф5 реализованы, ещё не закоммичены — ждут оркестратора) +- Текущая фаза: Phase 5 — Каталог (✅ Implemented, pending commit) → дальше Phase 6 — Раздача / шаблоны / клон / программа +- Файлы Ф5 (аддитивные правки зоны параллельной сессии — БЕЗ рефактора): `frontend/js/labs/lab-init.js` + (+7 строк: хук `LabCustom.resolveId` в `openSim`), `frontend/js/labs/lab-glue.js` (renderSims +`!m._custom` + и вызов renderSection; init зовёт `LabCustom.init()`; новый IIFE `window.LabCustom`). `_sim_deps.js`, + classroom.html, backend — НЕ тронуты. Публичное API: `window.LabCustom.{init,resolveId,renderSection,ensureSpec,del}`. +- id-неймспейс custom: deep-link/клик/`data-open` = `custom:`; LabRegistry/host = `customsim_`. - Режим: Automated / Orchestrator / Incremental - Файлы Ф4 (несведённые с параллельной сессией): `frontend/sim-builder.html` (new), `frontend/js/sim-builder.js` (new), `js/sidebar.js` (modify, аддитивный пункт `/sim-builder`). diff --git a/plans/sim-builder/PLAN.md b/plans/sim-builder/PLAN.md index 285f37f..d3edb33 100644 --- a/plans/sim-builder/PLAN.md +++ b/plans/sim-builder/PLAN.md @@ -44,7 +44,7 @@ - [x] Phase 2: Физический интегратор [domain: frontend] → [subplan](./phase-2-physics.md) - [x] Phase 3: БД + API (custom_sims) [domain: backend] → [subplan](./phase-3-persistence-api.md) - [x] Phase 4: Билдер (редактор) [domain: frontend] → [subplan](./phase-4-builder-ui.md) -- [ ] Phase 5: Каталог (custom-sims в /lab) [domain: fullstack] → [subplan](./phase-5-catalog.md) +- [x] Phase 5: Каталог (custom-sims в /lab) [domain: fullstack] → [subplan](./phase-5-catalog.md) - [ ] Phase 6: Раздача / шаблоны / клон / программа [domain: fullstack] → [subplan](./phase-6-sharing.md) - [ ] Phase 7: Доска онлайн-урока [domain: fullstack] → [subplan](./phase-7-classroom.md) @@ -57,7 +57,7 @@ | Phase 2: Physics | frontend | ✅ Done | ✅ | ✅ | ✅ | | Phase 3: Persistence + API | backend | ✅ Done | ✅ | ✅ | ✅ | | Phase 4: Builder UI | frontend | ✅ Done | ✅ | ✅ | ✅ | -| Phase 5: Catalog | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 5: Catalog | fullstack | ✅ Done | ✅ | ✅ | ✅ | | Phase 6: Sharing | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 7: Classroom | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | diff --git a/plans/sim-builder/phase-5-catalog.md b/plans/sim-builder/phase-5-catalog.md index 5c1f801..67088f7 100644 --- a/plans/sim-builder/phase-5-catalog.md +++ b/plans/sim-builder/phase-5-catalog.md @@ -1,6 +1,6 @@ # Phase 5: Каталог (custom-sims в /lab) -**Status:** ⬜ Not Started +**Status:** ✅ Implemented (в рабочем дереве, не закоммичено — коммит за оркестратором) **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** fullstack @@ -9,13 +9,21 @@ раздел «Мои симуляции», редактирование/удаление из каталога, deep-link. ## Tasks -- [ ] При загрузке /lab подтягивать custom-sims (свои + published) из `GET /api/custom-sims` - и регистрировать через `registerSpecSim` (Ф0-адаптер) с id `custom:`. -- [ ] Карточки в каталоге: категория/предмет/класс из меты; бейдж «Моя»/«Опубликована». -- [ ] Раздел/фильтр «Мои симуляции» в /lab. -- [ ] Кнопки на карточке custom-sim: «Редактировать» → `sim-builder.html?id=`, «Удалить» (владельцу). -- [ ] Deep-link `/lab?sim=custom:` открывает напрямую (расширить существующий `LAB_SIM_ALIASES`/openSim). -- [ ] Ленивая загрузка движка (`_sim_*.js`) — только когда открыта custom-sim (через `_loader`/`_sim_deps`). +- [x] При загрузке /lab подтягивать custom-sims (свои + published) из `GET /api/custom-sims` + (`LS.customSimsList()`) и регистрировать ЛЕНИВЫЕ манифесты в LabRegistry. spec НЕ грузится + на старте — тянется при первом открытии (`LS.customSimGet` → `registerSpecSim` Ф0-адаптера). +- [x] Карточки в каталоге: категория/предмет(grade)/из меты; бейдж «Моя» (owner) / «Опубликована» + (status) / «Черновик» (own draft). +- [x] Раздел «Мои симуляции» в /lab — отдельная секция `#custom-sim-section` в `#lab-home` + (создаётся динамически, без правок lab.html/CSS); уважает текущий фильтр категорий. +- [x] Кнопки на карточке custom-sim: «Редактировать» → `/sim-builder?id=`, «Удалить» + (`LS.customSimDelete`) — только владельцу (`owner_id === user.id`). +- [x] Deep-link `/lab?sim=custom:` открывает напрямую: хук `LabCustom.resolveId` в `openSim` + (lab-init.js) переводит `custom:` → реестровый id `customsim_`; для custom deep-link + открытие отложено до загрузки списка (и в обычном, и в embed-режиме). +- [x] Ленивая загрузка: движок (`_sim_expr/_sim_engine/_sim_adapter`) уже eager в lab.html (Ф0), + поэтому отдельный ленивый файл не нужен; лениво грузится только spec (тяжёлый JSON) при открытии. + `_sim_deps.js` НЕ тронут. ## Files to Modify/Create - `frontend/js/labs/lab-glue.js` и/или `lab-init.js` — загрузка+регистрация custom-sims, карточки, фильтр (modify) @@ -33,10 +41,57 @@ - id-неймспейс `custom:` чтобы не конфликтовать со встроенными. ## Review Checklist -- [ ] Все задачи выполнены -- [ ] Встроенные симуляции и старт /lab не регрессировали -- [ ] Draft видит только владелец; published — все -- [ ] Ленивая загрузка движка работает +- [x] Все задачи выполнены +- [x] Встроенные симуляции и старт /lab не регрессировали (custom исключены из основной сетки + по флагу `_custom`; падение загрузки списка не ломает каталог — try/catch + мягкий warn) +- [x] Draft видит только владелец; published — все (видимость обеспечивает сервер Ф3: + `customSimsList` отдаёт свои любого статуса + чужие published; бейджи/кнопки — по `owner_id`) +- [x] Ленивая загрузка spec работает (кэш + дедуп; на старте спеки не грузятся) ## Handoff to Next Phase - + +### Что реализовано (Phase 5) +Только аддитивные правки двух файлов параллельной сессии — без рефактора их кода: +- **`frontend/js/labs/lab-init.js`** (+7 строк): в начало `openSim(id)` добавлен хук — + `if (window.LabCustom && LabCustom.resolveId) id = LabCustom.resolveId(id) || id;` + Переводит `custom:` → реестровый id `customsim_` (LabRegistry.get/has обрезают + часть после `:`, поэтому в реестре двоеточие недопустимо). Для встроенных id — no-op. +- **`frontend/js/labs/lab-glue.js`**: + - `renderSims()`: 1 строка в merge — `&& !m._custom` (custom-манифесты не попадают в основную + сетку встроенных) + вызов `LabCustom.renderSection(_catFilter)` после рендера грида. + - init-блок (non-embed): после `renderSims()` зовёт `LabCustom.init()`; для `?sim=custom:*` + открытие отложено до резолва списка. Аналогично в embed-ветке (на `load`). + - **`window.LabCustom`** (новый IIFE в конце файла): `init()` (fetch списка, регистрация + ленивых манифестов, рендер секции), `resolveId`, `renderSection`, `ensureSpec`, `del`. + +### Как custom-sims попадают в каталог и открываются +1. `LS.customSimsList()` → мета (без spec). Для каждой — `_registerLazy(meta)`: в LabRegistry + кладётся манифест-заглушка `id='customsim_'`, `_custom:true`, мета-поля, и `open()`, + который при первом вызове лениво тянет spec. +2. Секция «Мои симуляции» (`#custom-sim-section`) рендерится из `_meta` (НЕ из реестра): карточки + `.sim-card` с `data-open="custom:"`, бейджами и (владельцу) кнопками edit/del. Делегат + кликов на секции: открыть / `/sim-builder?id=` / `LS.customSimDelete`. +3. Открытие: `openSim('custom:')` → `resolveId` → `customsim_` → дисп. реестра → + `open()` заглушки → `ensureSpec(dbid)` (`LS.customSimGet`, кэш+дедуп) → `spec.id=` → + `registerSpecSim(spec)` (Ф0-адаптер строит реальный SimEngine-манифест, ЗАМЕНЯЕТ заглушку на + месте) → `setActive(real)` + `real.open(ctx)` (монтирует SimEngine). Повторное открытие — + синхронно, реальный манифест уже в реестре, spec из кэша. + +### id-неймспейс +- Deep-link / клик / `data-open`: **`custom:`**. +- LabRegistry / host: **`customsim_`** (без `:`). Конвертация — только в `resolveId`. + +### Формат карточки +preview-SVG (плейсхолдер) + cat-бейдж (`.sim-cat`) + бейджи «Моя»/«Опубликована»/«Черновик» ++ title + desc (+ «N класс») + (владельцу) ряд кнопок «Редактировать»/«Удалить» (inline SVG `.ic`). + +### Риски / заметки для Ф6 +- `_loadRelated('customsim_')` дергает `/api/lab/sims/.../related` (404 для custom) — тихо + глотается. Если Ф6 заведёт связи custom-sim с программой — учесть этот id. +- Удаление: после `customSimDelete` карточка убирается из секции, но манифест-заглушка остаётся + в LabRegistry (LabRegistry не имеет unregister). Не критично (карточки нет, deep-link на + удалённую вернёт 404 при ensureSpec). Если Ф6/Ф7 потребуют чистку — добавить unregister в реестр. +- Ф6 (раздача/публикация/клон/шаблоны): кнопку «Поделиться/Раздать классу» добавлять в `_cardHtml` + (ещё один `data-act`); публикацию toggle — там же. Клон — новый `LS.customSimCreate` со spec + из `ensureSpec`. Источник spec для доски (Ф7) — `LabCustom.ensureSpec(dbid)` или живой + `LabRegistry.get('customsim_').instance()`.