diff --git a/frontend/exam9.html b/frontend/exam9.html
index fe6ae7c..dfb2d1d 100644
--- a/frontend/exam9.html
+++ b/frontend/exam9.html
@@ -225,32 +225,6 @@
font-family:'Manrope',sans-serif; font-size:.9rem;
}
.ax-input:focus { outline:none; border-color:var(--violet); }
- .ax-actions {
- display:flex; gap:10px; justify-content:flex-end; margin-top:6px;
- }
- .ax-btn {
- padding:9px 18px; border-radius:10px; border:1.5px solid var(--border-h);
- background:transparent; color:var(--text);
- font-family:'Manrope',sans-serif; font-size:.88rem; font-weight:700;
- cursor:pointer; transition:all .15s;
- }
- .ax-btn:hover { border-color:var(--text-2); }
- .ax-btn-primary { background:var(--violet); border-color:var(--violet); color:#fff; }
- .ax-btn-primary:hover { background:#7e3eca; border-color:#7e3eca; }
- .ax-btn-primary:disabled { opacity:.5; cursor:not-allowed; }
- .ax-error {
- padding:9px 12px; border-radius:8px; background:rgba(241,91,68,.1);
- border:1px solid rgba(241,91,68,.3); color:#F94144;
- font-size:.84rem; display:none;
- }
- .ax-error.visible { display:block; }
- .ax-success {
- padding:9px 12px; border-radius:8px; background:rgba(6,214,160,.1);
- border:1px solid rgba(6,214,160,.3); color:#06D6A0;
- font-size:.84rem; display:none;
- }
- .ax-success.visible { display:block; }
-
@media (max-width: 600px) {
.ex-wrap { padding:20px 16px 60px; }
.ex-title { font-size:1.15rem; }
@@ -311,33 +285,6 @@
-
-
-
-
Назначить вариант
-
-
-
-
-
-
diff --git a/frontend/js/exam9/app.js b/frontend/js/exam9/app.js
index 89c11b1..1c45af3 100644
--- a/frontend/js/exam9/app.js
+++ b/frontend/js/exam9/app.js
@@ -176,7 +176,6 @@ function selectVariant(num) {
}
/* ── Assignment modal ───────────────────────────────────────────── */
-let assignVariantNum = null;
async function loadTeacherClasses() {
if (teacherClasses) return teacherClasses;
@@ -189,94 +188,76 @@ async function loadTeacherClasses() {
return teacherClasses;
}
-async function openAssignModal(variantNum) {
- if (!variantTests[variantNum]) return;
- assignVariantNum = variantNum;
- document.getElementById('assign-title').textContent = `Назначить «Вариант ${variantNum}» как ДЗ`;
- document.getElementById('ax-error').classList.remove('visible');
- document.getElementById('ax-success').classList.remove('visible');
- document.getElementById('ax-deadline').value = '';
- document.getElementById('ax-submit').disabled = false;
- document.getElementById('ax-submit').textContent = 'Назначить';
-
- const listEl = document.getElementById('ax-classes-list');
- listEl.textContent = 'Загрузка…';
- const classes = await loadTeacherClasses();
- if (!classes.length) {
- listEl.innerHTML = 'У вас пока нет классов. Создайте класс на странице «Классы».
';
- } else {
- listEl.innerHTML = classes.map(c => `
- `).join('');
- }
-
- document.getElementById('assign-overlay').classList.add('visible');
- document.addEventListener('keydown', onAssignEsc);
-}
-
-function closeAssignModal() {
- document.getElementById('assign-overlay').classList.remove('visible');
- document.removeEventListener('keydown', onAssignEsc);
- assignVariantNum = null;
-}
-
-function onAssignOverlayClick(e) {
- if (e.target === document.getElementById('assign-overlay')) closeAssignModal();
-}
-function onAssignEsc(e) { if (e.key === 'Escape') closeAssignModal(); }
-
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
}
-async function submitAssign() {
- const errorEl = document.getElementById('ax-error');
- const successEl = document.getElementById('ax-success');
- const submitBtn = document.getElementById('ax-submit');
- errorEl.classList.remove('visible');
- successEl.classList.remove('visible');
+async function openAssignModal(variantNum) {
+ if (!variantTests[variantNum]) return;
+ const testId = variantTests[variantNum];
- const checked = Array.from(document.querySelectorAll('#ax-classes-list input[name="cls"]:checked'))
- .map(el => Number(el.value));
- if (!checked.length) {
- errorEl.textContent = 'Выберите хотя бы один класс';
- errorEl.classList.add('visible');
- return;
- }
+ // Build body with shared LS.modal helper
+ const classes = await loadTeacherClasses();
+ const classesHtml = classes.length
+ ? classes.map(c => `
+ `).join('')
+ : 'У вас пока нет классов. Создайте класс на странице «Классы».
';
- const testId = variantTests[assignVariantNum];
- const deadline = document.getElementById('ax-deadline').value || null;
- if (!testId) { errorEl.textContent = 'Вариант не в банке вопросов'; errorEl.classList.add('visible'); return; }
+ const body = `
+ `;
- submitBtn.disabled = true;
- submitBtn.textContent = 'Назначаю…';
+ const m = LS.modal({
+ title: `Назначить «Вариант ${variantNum}» как ДЗ`,
+ content: body,
+ size: 'sm',
+ actions: [
+ { label: 'Отмена', onClick: () => m.close() },
+ {
+ label: 'Назначить', primary: true,
+ onClick: async () => {
+ const checked = Array.from(m.body.querySelectorAll('input[name="cls"]:checked')).map(el => Number(el.value));
+ if (!checked.length) { m.setError('Выберите хотя бы один класс'); return; }
- try {
- const r = await LS.api('/api/assignments/bulk', {
- method: 'POST',
- body: {
- title: `Экзамен 9 — Вариант ${assignVariantNum}`,
- class_ids: checked,
- mode: 'exam',
- count: 10,
- test_id: testId,
- deadline: deadline,
- is_homework: 1,
+ const btns = m.root.querySelectorAll('.ls-mod-btn');
+ btns.forEach(b => b.disabled = true);
+ btns[1].textContent = 'Назначаю…';
+
+ try {
+ const r = await LS.api('/api/assignments/bulk', {
+ method: 'POST',
+ body: {
+ title: `Экзамен 9 — Вариант ${variantNum}`,
+ class_ids: checked,
+ mode: 'exam', count: 10,
+ test_id: testId,
+ deadline: m.body.querySelector('#ax-deadline').value || null,
+ is_homework: 1,
+ },
+ });
+ LS.toast(`Назначено в ${r.count || checked.length} класс(ах)`, 'success');
+ m.close();
+ } catch (e) {
+ m.setError(e.message || 'Не удалось создать задание');
+ btns.forEach(b => b.disabled = false);
+ btns[1].textContent = 'Назначить';
+ }
+ },
},
- });
- successEl.textContent = `Назначено в ${r.count || checked.length} классе(ах)`;
- successEl.classList.add('visible');
- submitBtn.textContent = 'Готово';
- setTimeout(closeAssignModal, 1500);
- } catch (e) {
- errorEl.textContent = e.message || 'Не удалось создать задание';
- errorEl.classList.add('visible');
- submitBtn.disabled = false;
- submitBtn.textContent = 'Назначить';
- }
+ ],
+ });
}
/* ── Boot ───────────────────────────────────────────────────────── */
diff --git a/frontend/my-students.html b/frontend/my-students.html
index 47214ec..2a0ac4d 100644
--- a/frontend/my-students.html
+++ b/frontend/my-students.html
@@ -277,13 +277,18 @@
};
window.removeStudent = async function (id, name) {
- if (!confirm(`Убрать «${name}» из списка «Мои ученики»?\n\nСозданные задания не удалятся.`)) return;
+ const ok = await LS.confirm(
+ `Убрать «${name}» из списка?\nСозданные задания не удалятся — ученик продолжит их видеть.`,
+ { title: 'Убрать ученика', confirmText: 'Убрать', danger: true }
+ );
+ if (!ok) return;
try {
await LS.api('/api/teacher-students/' + id, { method: 'DELETE' });
students = students.filter(s => s.id !== id);
render();
+ LS.toast(`«${name}» убран из списка`, 'success');
} catch (e) {
- alert('Ошибка: ' + e.message);
+ LS.toast('Ошибка: ' + e.message, 'error');
}
};
diff --git a/js/api.js b/js/api.js
index 6cb9380..9acb2df 100644
--- a/js/api.js
+++ b/js/api.js
@@ -498,6 +498,148 @@ function lsConfirm(message, { title = 'Подтверждение', confirmText
});
}
+/* ────────────────────────────────────────────────────────────────────────
+ LS.modal — universal form/content modal
+ Companion to LS.confirm. Use for forms, pickers, editors — anything
+ that's not a simple yes/no confirmation.
+
+ Usage:
+ const m = LS.modal({
+ title: 'Назначить чтение',
+ content: htmlString | DOMElement,
+ size: 'sm' | 'md' | 'lg', // 420 / 560 / 720 px
+ actions: [
+ { label: 'Отмена', onClick: () => m.close() },
+ { label: 'Назначить', primary: true, onClick: async () => { ... } },
+ ],
+ onClose: () => { ... },
+ });
+ // Returns { close, root, setBody, setActions, setError }
+ ──────────────────────────────────────────────────────────────────────── */
+function lsModal({ title = '', content = '', size = 'md', actions = [], onClose, dismissible = true } = {}) {
+ if (!document.getElementById('ls-modal-style')) {
+ const s = document.createElement('style');
+ s.id = 'ls-modal-style';
+ s.textContent = `
+ .ls-mov{position:fixed;inset:0;z-index:9000;display:flex;align-items:flex-start;justify-content:center;
+ padding:60px 20px 20px;background:rgba(15,23,42,0.55);backdrop-filter:blur(8px);
+ opacity:0;transition:opacity .18s ease;overflow-y:auto;}
+ .ls-mov.open{opacity:1;}
+ .ls-mod{background:#fff;border-radius:18px;width:100%;
+ box-shadow:0 24px 80px rgba(15,23,42,0.28);
+ transform:scale(.96) translateY(-12px);transition:transform .22s ease;
+ display:flex;flex-direction:column;max-height:calc(100vh - 80px);}
+ .ls-mod.sm{max-width:420px;} .ls-mod.md{max-width:560px;} .ls-mod.lg{max-width:720px;}
+ .ls-mov.open .ls-mod{transform:scale(1) translateY(0);}
+ .ls-mod-hdr{display:flex;align-items:center;justify-content:space-between;
+ padding:18px 22px 14px;border-bottom:1px solid rgba(15,23,42,.08);flex-shrink:0;}
+ .ls-mod-title{font-family:'Unbounded',sans-serif;font-size:1rem;font-weight:800;color:#0F172A;}
+ .ls-mod-x{width:32px;height:32px;border:none;background:transparent;color:#56687A;
+ cursor:pointer;border-radius:8px;display:flex;align-items:center;justify-content:center;
+ transition:background .12s;flex-shrink:0;margin-left:12px;}
+ .ls-mod-x:hover{background:rgba(15,23,42,.06);color:#0F172A;}
+ .ls-mod-x svg{width:18px;height:18px;}
+ .ls-mod-body{padding:18px 22px;overflow-y:auto;flex:1;}
+ .ls-mod-err{margin:0 22px 14px;padding:9px 12px;border-radius:8px;font-size:.84rem;
+ background:rgba(241,91,68,.1);border:1px solid rgba(241,91,68,.3);color:#F94144;display:none;}
+ .ls-mod-err.visible{display:block;}
+ .ls-mod-act{display:flex;gap:10px;justify-content:flex-end;
+ padding:14px 22px 18px;border-top:1px solid rgba(15,23,42,.08);flex-shrink:0;}
+ .ls-mod-btn{padding:9px 18px;border-radius:10px;border:1.5px solid rgba(15,23,42,0.18);
+ background:transparent;color:#0F172A;font-family:'Manrope',sans-serif;
+ font-size:.88rem;font-weight:700;cursor:pointer;transition:all .15s;}
+ .ls-mod-btn:hover{border-color:#9B5DE5;color:#9B5DE5;}
+ .ls-mod-btn.primary{background:#9B5DE5;border-color:#9B5DE5;color:#fff;}
+ .ls-mod-btn.primary:hover{background:#7e3eca;border-color:#7e3eca;color:#fff;}
+ .ls-mod-btn.danger{background:#F94144;border-color:#F94144;color:#fff;}
+ .ls-mod-btn.danger:hover{background:#d62a2d;border-color:#d62a2d;}
+ .ls-mod-btn:disabled{opacity:.55;cursor:not-allowed;}
+ @media (max-width:540px){.ls-mov{padding:20px 12px;}.ls-mod-hdr,.ls-mod-body,.ls-mod-act{padding-left:16px;padding-right:16px;}}
+ `;
+ document.head.appendChild(s);
+ }
+
+ const prevFocus = document.activeElement;
+ const ov = document.createElement('div');
+ ov.className = 'ls-mov';
+ ov.setAttribute('role', 'dialog');
+ ov.setAttribute('aria-modal', 'true');
+ ov.innerHTML = `
+
+
+
+
+
+
+
+
+
`;
+ ov.querySelector('.ls-mod-title').textContent = title;
+ const bodyEl = ov.querySelector('.ls-mod-body');
+ const errEl = ov.querySelector('.ls-mod-err');
+ const actEl = ov.querySelector('.ls-mod-act');
+
+ function setBody(c) {
+ bodyEl.innerHTML = '';
+ if (typeof c === 'string') bodyEl.innerHTML = c;
+ else if (c instanceof Node) bodyEl.appendChild(c);
+ }
+ function setError(msg) {
+ if (!msg) { errEl.classList.remove('visible'); return; }
+ errEl.textContent = msg;
+ errEl.classList.add('visible');
+ }
+ function setActions(arr) {
+ actEl.innerHTML = '';
+ (arr || []).forEach((a, i) => {
+ const b = document.createElement('button');
+ b.className = 'ls-mod-btn' + (a.primary ? ' primary' : '') + (a.danger ? ' danger' : '');
+ b.textContent = a.label || (a.primary ? 'OK' : 'Отмена');
+ if (a.id) b.id = a.id;
+ b.addEventListener('click', e => {
+ e.preventDefault();
+ if (typeof a.onClick === 'function') a.onClick();
+ else if (a.close !== false) close();
+ });
+ actEl.appendChild(b);
+ });
+ actEl.style.display = (arr && arr.length) ? '' : 'none';
+ }
+
+ setBody(content);
+ setActions(actions);
+ document.body.appendChild(ov);
+ requestAnimationFrame(() => ov.classList.add('open'));
+
+ function close() {
+ ov.classList.remove('open');
+ setTimeout(() => {
+ ov.remove();
+ prevFocus?.focus?.();
+ if (typeof onClose === 'function') onClose();
+ }, 230);
+ }
+
+ ov.querySelector('.ls-mod-x').onclick = () => { if (dismissible) close(); };
+ if (dismissible) {
+ ov.addEventListener('click', e => { if (e.target === ov) close(); });
+ }
+ const onKey = e => {
+ if (e.key === 'Escape' && dismissible) { e.preventDefault(); close(); }
+ };
+ document.addEventListener('keydown', onKey);
+ const _close = close;
+ close = () => { document.removeEventListener('keydown', onKey); _close(); };
+
+ // Focus first focusable element inside body, or close button
+ setTimeout(() => {
+ const focusable = bodyEl.querySelector('input,select,textarea,button') || ov.querySelector('.ls-mod-x');
+ focusable?.focus?.();
+ }, 50);
+
+ return { close, root: ov, body: bodyEl, setBody, setActions, setError };
+}
+
/* ── applyRoleSidebar — reveal teacher/admin sidebar items ───────────── */
function applyRoleSidebar(user) {
if (!user) return;
@@ -775,6 +917,7 @@ window.LS = {
applyRoleSidebar,
icon: lsIcon,
confirm: lsConfirm,
+ modal: lsModal,
toast: lsToast,
skeleton: lsSkeleton,
api: apiFetch,