Files
Learn_System/frontend/trainer-builder.html
T
Maxim Dolgolyov 6d600ad576 feat(trainer): P13 — конструктор параметрических генераторов
- custom_generators (мигр.084, spec_json + draft/published); customGeneratorController: validateGenSpec без исполнения (лимиты/типы), CRUD own+published + ownership
- /api/practice/generators[/:id]; клиент LS.practiceGen*
- страница /trainer-builder (учитель): форма (pick/derive/lhs/rhs/display/answer/solution) + живое превью через TE.instantiate(strict) (материализация + проверка ответа подстановкой) + список своих (правка/удаление/публикация)
- тренажёр грузит свои+опубликованные генераторы в тему «Мои генераторы» (пошаговый режим работает); пункт сайдбара /trainer-builder (teacher-only)
- тесты custom-generators.test.js 12/12; смоук движка 402/402 (T17 кастомный спек + strict-валидация); страница 33/33; ROADMAP_V2 P13 → DONE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:30:08 +03:00

372 lines
23 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=Manrope:wght@400;500;600;700;800&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>
:root {
--ink:#1b1f38; --ink-soft:#5b6378; --ink-faint:#98a1b8;
--g1:#6366f1; --g2:#8b5cf6; --accent-ink:#4338ca; --accent-soft:#eef0ff;
--ok:#10b981; --ok-ink:#047857; --bad:#ef4444;
--sh:0 16px 40px rgba(27,31,56,.09), 0 2px 6px rgba(27,31,56,.04);
--ease:cubic-bezier(.22,.61,.36,1);
}
.sb-content {
background-color:#f5f6fb;
background-image:
radial-gradient(1000px 600px at 86% -10%, rgba(139,92,246,.10), transparent 60%),
radial-gradient(820px 560px at 2% -6%, rgba(99,102,241,.09), transparent 55%),
linear-gradient(rgba(99,102,241,.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(99,102,241,.05) 1px, transparent 1px);
background-size:100% 100%,100% 100%,26px 26px,26px 26px; background-attachment:fixed;
}
.gb-wrap { max-width:1080px; margin:0 auto; padding:30px 20px 90px; }
.gb-h1 { font-family:'Manrope',sans-serif; font-weight:800; font-size:clamp(1.5rem,4vw,2rem); letter-spacing:-.02em; color:var(--ink); margin:0 0 4px; }
.gb-sub { color:var(--ink-soft); font-size:.95rem; margin-bottom:22px; }
.gb-grid { display:grid; grid-template-columns:300px 1fr; gap:20px; align-items:start; }
@media (max-width:880px){ .gb-grid { grid-template-columns:1fr; } }
.gb-card { background:#fff; border:1px solid rgba(99,102,241,.1); border-radius:18px; box-shadow:var(--sh); padding:20px; }
.gb-card h2 { font-family:'Manrope',sans-serif; font-size:1rem; font-weight:800; color:var(--ink); margin:0 0 14px; }
.gb-list-item { display:flex; align-items:center; gap:8px; padding:10px 12px; border-radius:12px; border:1px solid rgba(99,102,241,.12); margin-bottom:8px; background:#fbfbff; }
.gb-li-main { flex:1; min-width:0; cursor:pointer; }
.gb-li-title { font-weight:700; color:var(--ink); font-size:.9rem; }
.gb-li-meta { font-size:.74rem; color:var(--ink-faint); }
.gb-li-pub { font-size:.66rem; font-weight:800; text-transform:uppercase; letter-spacing:.04em; padding:2px 7px; border-radius:99px; }
.gb-li-pub.draft { background:rgba(148,163,184,.16); color:#64748b; }
.gb-li-pub.published { background:var(--ok); color:#fff; }
.gb-icon-btn { background:none; border:none; cursor:pointer; color:var(--ink-faint); padding:4px; border-radius:8px; }
.gb-icon-btn:hover { background:rgba(99,102,241,.1); color:var(--accent-ink); }
.gb-icon-btn .ic { width:16px; height:16px; }
.gb-empty { color:var(--ink-faint); font-size:.85rem; text-align:center; padding:14px; }
.gb-field { margin-bottom:14px; }
.gb-field label { display:block; font-size:.82rem; font-weight:700; color:var(--ink-soft); margin-bottom:5px; }
.gb-field input, .gb-field textarea, .gb-field select {
width:100%; font:inherit; padding:9px 12px; border:1px solid rgba(99,102,241,.22); border-radius:10px; outline:none; color:var(--ink); box-sizing:border-box; transition:.15s;
}
.gb-field input:focus, .gb-field textarea:focus, .gb-field select:focus { border-color:var(--g1); box-shadow:0 0 0 3px rgba(99,102,241,.14); }
.gb-field .hint { font-size:.75rem; color:var(--ink-faint); margin-top:4px; font-family:'Cambria Math',serif; }
.gb-row2 { display:flex; gap:12px; flex-wrap:wrap; }
.gb-row2 > * { flex:1; min-width:140px; }
.gb-check { display:flex; align-items:center; gap:8px; font-size:.85rem; font-weight:600; color:var(--ink-soft); }
.gb-check input { width:auto; }
.gb-rows { display:flex; flex-direction:column; gap:7px; }
.gb-rrow { display:flex; gap:7px; align-items:center; }
.gb-rrow input { font:inherit; padding:7px 10px; border:1px solid rgba(99,102,241,.2); border-radius:9px; outline:none; min-width:0; }
.gb-rrow .nm { width:64px; flex:0 0 auto; font-family:'Cambria Math',serif; }
.gb-rrow .num { width:60px; flex:0 0 auto; }
.gb-rrow .grow { flex:1; font-family:'Cambria Math',serif; }
.gb-add { font:inherit; font-size:.82rem; font-weight:700; cursor:pointer; color:var(--accent-ink); background:rgba(99,102,241,.08); border:1px dashed rgba(99,102,241,.3); border-radius:9px; padding:6px 12px; margin-top:7px; }
.gb-add:hover { background:var(--accent-soft); }
.gb-btn { font:inherit; font-weight:700; cursor:pointer; border:none; border-radius:12px; padding:11px 20px; transition:.16s var(--ease); display:inline-flex; align-items:center; gap:7px; }
.gb-btn .ic { width:16px; height:16px; }
.gb-primary { color:#fff; background:linear-gradient(135deg,var(--g1),var(--g2)); box-shadow:0 8px 20px rgba(99,102,241,.3); }
.gb-primary:hover { transform:translateY(-2px); }
.gb-ghost { background:rgba(99,102,241,.08); color:var(--accent-ink); }
.gb-ghost:hover { background:rgba(99,102,241,.16); }
.gb-actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:16px; }
.gb-preview { margin-top:18px; padding:18px; border-radius:14px; background:linear-gradient(180deg,#fbfbff,#f4f5fd); border:1px solid rgba(99,102,241,.14); }
.gb-preview h3 { font-size:.74rem; text-transform:uppercase; letter-spacing:.07em; color:var(--accent-ink); font-weight:800; margin:0 0 10px; }
.gb-pv-eq { font-family:'Cambria Math',serif; font-size:1.5rem; color:var(--ink); text-align:center; padding:6px 0 14px; }
.gb-pv-ans { text-align:center; color:var(--ok-ink); font-weight:700; margin-bottom:10px; }
.gb-pv-step { padding:7px 0; border-top:1px dashed rgba(99,102,241,.2); font-size:.9rem; color:#334155; }
.gb-pv-step:first-child { border-top:none; }
.gb-pv-step .stx { font-family:'Cambria Math',serif; display:block; margin-top:3px; }
.gb-err { background:#fee2e2; color:#b91c1c; border-radius:10px; padding:10px 14px; font-size:.86rem; font-weight:600; margin-top:14px; }
.gb-err:empty { display:none; }
</style>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<main class="sb-content">
<div class="gb-wrap">
<h1 class="gb-h1">Конструктор генераторов</h1>
<div class="gb-sub">Создайте параметрический генератор задач: диапазоны → формулы → шаблон → ответ. Сервер проверит, что ответ согласован с условием.</div>
<div class="gb-grid">
<div class="gb-card">
<h2>Мои генераторы</h2>
<div id="gb-list"><div class="gb-empty">Загрузка…</div></div>
<button class="gb-btn gb-ghost" id="gb-new" type="button" style="margin-top:8px;width:100%;justify-content:center">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
Новый генератор
</button>
</div>
<div class="gb-card">
<h2 id="gb-form-title">Новый генератор</h2>
<div class="gb-field"><label>Заголовок</label><input id="f-title" placeholder="напр. Линейное: ax + b = c"/></div>
<div class="gb-row2">
<div class="gb-field"><label>Тема</label><input id="f-topic" placeholder="custom" value="custom"/></div>
<div class="gb-field"><label>Тип</label>
<select id="f-kind">
<option value="solve">Уравнение (solve)</option>
<option value="compute">Вычисление (compute)</option>
</select>
</div>
</div>
<div class="gb-field">
<label>Параметры (диапазоны целых)</label>
<div class="gb-rows" id="f-pick"></div>
<button class="gb-add" id="add-pick" type="button">+ параметр</button>
<div class="hint">имя, от, до — напр. a: 2…9. Зарезервированы: x, e, pi, tau.</div>
</div>
<div class="gb-field">
<label>Производные (формулы от параметров)</label>
<div class="gb-rows" id="f-derive"></div>
<button class="gb-add" id="add-derive" type="button">+ формула</button>
<div class="hint">напр. c = a*root + b. Приём «корень-вперёд»: задайте root и выведите c.</div>
</div>
<div class="gb-row2">
<div class="gb-field"><label>Левая часть</label><input id="f-lhs" placeholder="{a}*x + {b}"/></div>
<div class="gb-field"><label>Правая часть</label><input id="f-rhs" placeholder="{c}"/></div>
</div>
<div class="gb-field"><label>Условие текстом (для «вычисления»)</label><input id="f-display" placeholder="напр. Найдите {p}% от {a}"/></div>
<div class="gb-row2">
<div class="gb-field"><label>Ответ (формула)</label><input id="f-answer" placeholder="root"/></div>
<div class="gb-field"><label>Ограничения (опц.)</label><input id="f-require" placeholder="root != 0"/></div>
</div>
<div class="gb-field"><label class="gb-check"><input type="checkbox" id="f-int" checked/> Ответ — целое число</label></div>
<div class="gb-field">
<label>Шаги решения</label>
<div class="gb-rows" id="f-sol"></div>
<button class="gb-add" id="add-sol" type="button">+ шаг</button>
<div class="hint">пояснение словами + формула шага (одно равенство), напр. x = {cmb} / {a}.</div>
</div>
<div class="gb-actions">
<button class="gb-btn gb-ghost" id="gb-preview" type="button">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>
Превью
</button>
<button class="gb-btn gb-primary" id="gb-save" type="button">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><path d="M17 21v-8H7v8M7 3v5h8"/></svg>
Сохранить
</button>
<label class="gb-check" style="margin-left:auto"><input type="checkbox" id="f-pub"/> Опубликовать ученикам</label>
</div>
<div class="gb-err" id="gb-err"></div>
<div class="gb-preview" id="gb-pv" style="display:none">
<h3>Превью задачи</h3>
<div class="gb-pv-eq" id="pv-eq"></div>
<div class="gb-pv-ans" id="pv-ans"></div>
<div id="pv-sol"></div>
</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>
<script src="/js/labs/_sim_expr.js"></script>
<script src="/js/trainer/_trainer_engine.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script>
(function () {
'use strict';
if (typeof LS === 'undefined') return;
var ip = LS.initPage();
if (!ip) return;
if (!(ip.isTeacher || ip.isAdmin)) { location.href = '/dashboard'; return; }
var TE = window.TrainerEngine;
var $ = function (id) { return document.getElementById(id); };
function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
function kat(latex, disp) { if (window.katex && latex) { try { return window.katex.renderToString(latex, { displayMode: !!disp, throwOnError: false }); } catch (e) {} } return null; }
var editingId = null;
// ── динамические строки ──
function pickRow(name, lo, hi) {
var d = document.createElement('div'); d.className = 'gb-rrow';
d.innerHTML = '<input class="nm" placeholder="имя" value="' + esc(name || '') + '"/>' +
'<input class="num" type="number" placeholder="от" value="' + (lo == null ? '' : lo) + '"/>' +
'<input class="num" type="number" placeholder="до" value="' + (hi == null ? '' : hi) + '"/>' +
'<button class="gb-icon-btn" type="button" title="Удалить"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>';
d.querySelector('.gb-icon-btn').addEventListener('click', function () { d.remove(); });
return d;
}
function kvRow(name, val, nmPh, valPh) {
var d = document.createElement('div'); d.className = 'gb-rrow';
d.innerHTML = '<input class="nm" placeholder="' + nmPh + '" value="' + esc(name || '') + '"/>' +
'<input class="grow" placeholder="' + valPh + '" value="' + esc(val || '') + '"/>' +
'<button class="gb-icon-btn" type="button" title="Удалить"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>';
d.querySelector('.gb-icon-btn').addEventListener('click', function () { d.remove(); });
return d;
}
function solRow(note, tex) {
var d = document.createElement('div'); d.className = 'gb-rrow';
d.innerHTML = '<input class="grow" placeholder="пояснение" value="' + esc(note || '') + '"/>' +
'<input class="grow" placeholder="формула шага" value="' + esc(tex || '') + '" style="font-family:\'Cambria Math\',serif"/>' +
'<button class="gb-icon-btn" type="button" title="Удалить"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>';
d.querySelector('.gb-icon-btn').addEventListener('click', function () { d.remove(); });
return d;
}
$('add-pick').addEventListener('click', function () { $('f-pick').appendChild(pickRow()); });
$('add-derive').addEventListener('click', function () { $('f-derive').appendChild(kvRow('', '', 'имя', 'формула')); });
$('add-sol').addEventListener('click', function () { $('f-sol').appendChild(solRow()); });
function clearForm() {
editingId = null;
$('gb-form-title').textContent = 'Новый генератор';
$('f-title').value = ''; $('f-topic').value = 'custom'; $('f-kind').value = 'solve';
$('f-lhs').value = ''; $('f-rhs').value = ''; $('f-display').value = '';
$('f-answer').value = ''; $('f-require').value = ''; $('f-int').checked = true; $('f-pub').checked = false;
$('f-pick').innerHTML = ''; $('f-derive').innerHTML = ''; $('f-sol').innerHTML = '';
$('f-pick').appendChild(pickRow('a', 2, 9));
$('f-derive').appendChild(kvRow('', '', 'имя', 'формула'));
$('f-sol').appendChild(solRow());
$('gb-err').textContent = ''; $('gb-pv').style.display = 'none';
}
function readRows(container, mapper) {
var out = [];
container.querySelectorAll('.gb-rrow').forEach(function (r) {
var inputs = r.querySelectorAll('input');
var v = mapper(inputs);
if (v) out.push(v);
});
return out;
}
function buildSpec() {
var spec = { title: $('f-title').value.trim(), topic: $('f-topic').value.trim() || 'custom', kind: $('f-kind').value };
var pick = {};
readRows($('f-pick'), function (i) {
var nm = i[0].value.trim(); if (!nm) return null;
var lo = parseInt(i[1].value, 10), hi = parseInt(i[2].value, 10);
if (!isNaN(lo) && !isNaN(hi)) pick[nm] = [lo, hi];
return null;
});
spec.pick = pick;
var derive = {};
readRows($('f-derive'), function (i) { var nm = i[0].value.trim(); if (nm && i[1].value.trim()) derive[nm] = i[1].value.trim(); return null; });
if (Object.keys(derive).length) spec.derive = derive;
if ($('f-lhs').value.trim()) spec.lhs = $('f-lhs').value.trim();
if ($('f-rhs').value.trim()) spec.rhs = $('f-rhs').value.trim();
if ($('f-display').value.trim()) spec.display = $('f-display').value.trim();
if ($('f-answer').value.trim()) spec.answer = $('f-answer').value.trim();
if ($('f-require').value.trim()) spec.require = $('f-require').value.trim();
spec.integerAnswer = $('f-int').checked;
spec.solution = readRows($('f-sol'), function (i) {
var note = i[0].value.trim(), tex = i[1].value.trim();
if (!note && !tex) return null;
return { note: note, tex: tex };
});
return spec;
}
// материализуем спек локально (тот же движок, что у ученика) для превью/валидации
function tryInstantiate(spec) {
if (!spec.title) return { err: 'Укажите заголовок.' };
if (!Object.keys(spec.pick || {}).length) return { err: 'Добавьте хотя бы один параметр.' };
try {
var p = TE.instantiate(spec, { seed: (Math.random() * 1e9) | 0, strict: true });
if (!p) return { err: 'Не удалось сгенерировать задачу — проверьте диапазоны и ограничения.' };
return { p: p };
} catch (e) {
return { err: 'Проверка не прошла: ' + (e && e.message ? e.message : 'ответ не согласован с условием') };
}
}
function renderPreview() {
var r = tryInstantiate(buildSpec());
if (r.err) { $('gb-err').textContent = r.err; $('gb-pv').style.display = 'none'; return false; }
$('gb-err').textContent = '';
var p = r.p;
var eq = $('pv-eq'); var h = kat(p.latex, true); if (h) eq.innerHTML = h; else eq.textContent = p.display || '—';
$('pv-ans').textContent = 'Ответ: ' + (p.answers ? p.answers.join('; ') : ('x = ' + p.answer));
$('pv-sol').innerHTML = (p.solution || []).map(function (s, i) {
var m = s.latex ? (kat(s.latex, false) || esc(s.tex || '')) : esc(s.tex || '');
return '<div class="gb-pv-step">' + (i + 1) + '. ' + esc(s.note || '') + (m ? '<span class="stx">' + m + '</span>' : '') + '</div>';
}).join('');
$('gb-pv').style.display = 'block';
return true;
}
$('gb-preview').addEventListener('click', renderPreview);
function save() {
var spec = buildSpec();
var r = tryInstantiate(spec);
if (r.err) { $('gb-err').textContent = r.err; return; }
var status = $('f-pub').checked ? 'published' : 'draft';
var pr = editingId ? LS.practiceGenUpdate(editingId, spec, status) : LS.practiceGenCreate(spec, status);
pr.then(function (res) {
if (res && res.ok) { if (LS.toast) LS.toast(editingId ? 'Генератор обновлён' : 'Генератор создан', 'success'); editingId = res.generator.dbid; $('gb-form-title').textContent = 'Редактирование'; loadList(); }
else { $('gb-err').textContent = 'Не удалось сохранить.'; }
}).catch(function (e) { $('gb-err').textContent = 'Ошибка сохранения: ' + (e && e.message || ''); });
}
$('gb-save').addEventListener('click', save);
$('gb-new').addEventListener('click', clearForm);
function fillForm(g) {
editingId = g.dbid;
$('gb-form-title').textContent = 'Редактирование: ' + (g.title || '');
$('f-title').value = g.title || ''; $('f-topic').value = g.topic || 'custom';
$('f-kind').value = (g.kind === 'compute') ? 'compute' : 'solve';
$('f-lhs').value = g.lhs || ''; $('f-rhs').value = g.rhs || ''; $('f-display').value = g.display || '';
$('f-answer').value = g.answer || ''; $('f-require').value = g.require || ''; $('f-int').checked = !!g.integerAnswer;
$('f-pub').checked = g.status === 'published';
$('f-pick').innerHTML = ''; var pk = g.pick || {};
Object.keys(pk).forEach(function (k) { $('f-pick').appendChild(pickRow(k, pk[k][0], pk[k][1])); });
if (!Object.keys(pk).length) $('f-pick').appendChild(pickRow());
$('f-derive').innerHTML = ''; var dv = g.derive || {};
Object.keys(dv).forEach(function (k) { $('f-derive').appendChild(kvRow(k, dv[k], 'имя', 'формула')); });
if (!Object.keys(dv).length) $('f-derive').appendChild(kvRow('', '', 'имя', 'формула'));
$('f-sol').innerHTML = ''; (g.solution || []).forEach(function (s) { $('f-sol').appendChild(solRow(s.note, s.tex)); });
if (!(g.solution || []).length) $('f-sol').appendChild(solRow());
$('gb-err').textContent = ''; $('gb-pv').style.display = 'none';
window.scrollTo(0, 0);
}
var myId = (LS.getUser && LS.getUser()) ? LS.getUser().id : null;
function loadList() {
LS.practiceGenList().then(function (r) {
var mine = (r.generators || []).filter(function (g) { return g.owner_id === myId; });
var box = $('gb-list');
if (!mine.length) { box.innerHTML = '<div class="gb-empty">Пока нет генераторов. Создайте первый.</div>'; return; }
box.innerHTML = '';
mine.forEach(function (g) {
var d = document.createElement('div'); d.className = 'gb-list-item';
d.innerHTML = '<div class="gb-li-main"><div class="gb-li-title">' + esc(g.title) + '</div><div class="gb-li-meta">' + esc(g.topic) + ' · ' + esc(g.kind || 'solve') + '</div></div>' +
'<span class="gb-li-pub ' + (g.status === 'published' ? 'published' : 'draft') + '">' + (g.status === 'published' ? 'опубл.' : 'черновик') + '</span>' +
'<button class="gb-icon-btn gb-del" type="button" title="Удалить"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg></button>';
d.querySelector('.gb-li-main').addEventListener('click', function () { fillForm(g); });
d.querySelector('.gb-del').addEventListener('click', function () {
LS.practiceGenDelete(g.dbid).then(function () { if (LS.toast) LS.toast('Удалено', 'success'); if (editingId === g.dbid) clearForm(); loadList(); }).catch(function () {});
});
box.appendChild(d);
});
if (window.lucide) lucide.createIcons();
}).catch(function () { $('gb-list').innerHTML = '<div class="gb-empty">Не удалось загрузить.</div>'; });
}
clearForm();
loadList();
})();
</script>
</body>
</html>