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 = ` +
+
+ +
${classesHtml}
+
+
+ + +
+
`; - 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,