refactor: textbooks assign modal → LS.modal (−120 строк)

Та же миграция что и в exam9: убран inline-overlay HTML, дубликаты
CSS (.ex-overlay/.ex-panel/.ex-panel-* + .ax-error/.ax-success/
.ax-actions/.ax-btn) — всё это теперь .ls-mod-* из LS.modal.

Глобальные window.openAssignModal/closeAssignModal/onAssignOverlayClick/
onAssignEsc/setAssignTab/submitAssign и assignSlug/assignTitle/assignTab
переменные заменены на одну window.openAssignModal с локальным
closure по slug/title/currentTab.

Сохранены внутренние form-классы (.ax-form/.ax-classes/.ax-class/
.ax-tabs/.ax-tab/.ax-student-results/.ax-input/.ax-hint) — они
используются в body модалки.

Student search и tab-switching теперь обработчики на элементах
модалки (m.body.querySelector), а не глобальные document-listener'ы —
автоматически очищаются вместе с модалкой при close().

textbooks.html: 945 → 824 строки
This commit is contained in:
Maxim Dolgolyov
2026-05-16 18:51:58 +03:00
parent bc22715734
commit 91696ba089
+142 -240
View File
@@ -255,30 +255,6 @@
.am-saved.show { opacity:1; }
/* ── Assign modal (reused styling from exam9) ── */
.ex-overlay {
display:none; position:fixed; inset:0;
background:rgba(15,23,42,.55); z-index:300;
align-items:flex-start; justify-content:center; padding-top:80px;
backdrop-filter:blur(2px);
}
.ex-overlay.visible { display:flex; }
.ex-panel {
background:var(--surface); border:1.5px solid var(--border);
border-radius:16px; box-shadow:0 24px 64px rgba(0,0,0,.32);
width:min(520px, 94vw); max-height:calc(100vh - 120px);
overflow-y:auto; padding:22px 22px 26px;
}
.ex-panel-head {
display:flex; align-items:center; justify-content:space-between; margin-bottom:18px;
}
.ex-panel-head h2 { font-family:'Unbounded',sans-serif; font-size:1rem; font-weight:800; }
.ex-panel-close {
width:32px; height:32px; border:none; background:none;
color:var(--text-2); cursor:pointer; border-radius:8px;
display:flex; align-items:center; justify-content:center; transition:background .15s;
}
.ex-panel-close:hover { background:var(--border); color:var(--text); }
.ex-panel-close svg { width:18px; height:18px; }
.ax-form { display:flex; flex-direction:column; gap:14px; }
.ax-field label {
display:block; font-size:.78rem; font-weight:700; color:var(--text-2);
@@ -304,22 +280,6 @@
}
.ax-input:focus { outline:none; border-color:var(--violet); }
.ax-hint { font-size:.74rem; color:var(--text-3); margin-top:4px; }
.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, .ax-success {
padding:9px 12px; border-radius:8px; font-size:.84rem; display:none;
}
.ax-error.visible { display:block; background:rgba(241,91,68,.1); border:1px solid rgba(241,91,68,.3); color:#F94144; }
.ax-success.visible { display:block; background:rgba(6,214,160,.1); border:1px solid rgba(6,214,160,.3); color:#06D6A0; }
.ax-tabs { display:flex; gap:6px; background:var(--border); padding:4px; border-radius:10px; }
.ax-tab {
@@ -419,51 +379,6 @@
</div>
</div>
<div class="ex-overlay" id="assign-overlay" onclick="onAssignOverlayClick(event)">
<div class="ex-panel" onclick="event.stopPropagation()">
<div class="ex-panel-head">
<h2 id="assign-title">Назначить чтение</h2>
<button class="ex-panel-close" onclick="closeAssignModal()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<form class="ax-form" id="assign-form" onsubmit="event.preventDefault(); submitAssign()">
<div class="ax-field">
<label>Кому</label>
<div class="ax-tabs">
<button type="button" class="ax-tab active" data-tab="class" onclick="setAssignTab('class')">Классу</button>
<button type="button" class="ax-tab" data-tab="student" onclick="setAssignTab('student')">Ученику</button>
</div>
</div>
<div class="ax-field" id="ax-class-field">
<label>Классы</label>
<div class="ax-classes" id="ax-classes-list">Загрузка…</div>
</div>
<div class="ax-field" id="ax-student-field" style="display:none">
<label>Ученик</label>
<input type="text" class="ax-input" id="ax-student-search" placeholder="Поиск по имени или email…" autocomplete="off" />
<div class="ax-student-results" id="ax-student-results"></div>
<input type="hidden" id="ax-student-id" />
</div>
<div class="ax-field">
<label>Параграфы</label>
<input type="text" class="ax-input" id="ax-paragraphs" placeholder="например: 1-5 или 1,3,7" />
<div class="ax-hint">Диапазон («15-18») или список через запятую («1,3,5»). Пустое = весь учебник.</div>
</div>
<div class="ax-field">
<label>Срок сдачи</label>
<input type="datetime-local" class="ax-input" id="ax-deadline" />
</div>
<div class="ax-error" id="ax-error"></div>
<div class="ax-success" id="ax-success"></div>
<div class="ax-actions">
<button type="button" class="ax-btn" onclick="closeAssignModal()">Отмена</button>
<button type="submit" class="ax-btn ax-btn-primary" id="ax-submit">Назначить</button>
</div>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script src="/js/api.js"></script>
<script src="/js/sidebar.js"></script>
@@ -573,178 +488,165 @@
if (window.lucide) lucide.createIcons();
}
/* ── Assign modal ── */
let assignSlug = null;
let assignTitle = null;
let assignTab = 'class'; // 'class' or 'student'
let teacherStudents = null; // cached list of students-in-teacher's-classes
/* ── Assign modal (via LS.modal) ── */
let teacherStudents = null;
async function loadTeacherClasses() {
if (teacherClasses) return teacherClasses;
try {
const list = await LS.api('/api/classes');
teacherClasses = Array.isArray(list) ? list : [];
} catch { teacherClasses = []; }
try { const list = await LS.api('/api/classes'); teacherClasses = Array.isArray(list) ? list : []; }
catch { teacherClasses = []; }
return teacherClasses;
}
async function loadTeacherStudents() {
if (teacherStudents) return teacherStudents;
try {
const r = await LS.api('/api/classes/students');
teacherStudents = Array.isArray(r) ? r : (r.students || []);
} catch { teacherStudents = []; }
try { const r = await LS.api('/api/classes/students'); teacherStudents = Array.isArray(r) ? r : (r.students || []); }
catch { teacherStudents = []; }
return teacherStudents;
}
window.setAssignTab = function (tab) {
assignTab = tab;
document.querySelectorAll('.ax-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
document.getElementById('ax-class-field').style.display = tab === 'class' ? '' : 'none';
document.getElementById('ax-student-field').style.display = tab === 'student' ? '' : 'none';
document.getElementById('ax-student-id').value = '';
document.getElementById('ax-student-search').value = '';
document.getElementById('ax-student-results').classList.remove('visible');
};
window.openAssignModal = async function (slug, title) {
assignSlug = slug;
assignTitle = title;
document.getElementById('assign-title').textContent = `Назначить чтение: «${title}»`;
['ax-error', 'ax-success'].forEach(id => document.getElementById(id).classList.remove('visible'));
document.getElementById('ax-paragraphs').value = '';
document.getElementById('ax-deadline').value = '';
document.getElementById('ax-submit').disabled = false;
document.getElementById('ax-submit').textContent = 'Назначить';
setAssignTab('class');
const listEl = document.getElementById('ax-classes-list');
listEl.textContent = 'Загрузка…';
const classes = await loadTeacherClasses();
if (!classes.length) {
listEl.innerHTML = '<div style="padding:14px;color:var(--text-3);font-size:.85rem">У вас пока нет классов</div>';
} else {
listEl.innerHTML = classes.map(c => `
<label class="ax-class">
<input type="checkbox" name="cls" value="${c.id}" />
<span class="ax-cname">${esc(c.name)}</span>
<span class="ax-cmeta">${c.member_count || 0} учеников</span>
</label>`).join('');
}
const classesHtml = classes.length
? classes.map(c => `
<label class="ax-class">
<input type="checkbox" name="cls" value="${c.id}" />
<span class="ax-cname">${esc(c.name)}</span>
<span class="ax-cmeta">${c.member_count || 0} учеников</span>
</label>`).join('')
: '<div style="padding:14px;color:var(--text-3);font-size:.85rem">У вас пока нет классов</div>';
document.getElementById('assign-overlay').classList.add('visible');
document.addEventListener('keydown', onAssignEsc);
};
const body = `
<form class="ax-form" onsubmit="event.preventDefault()">
<div class="ax-field">
<label>Кому</label>
<div class="ax-tabs">
<button type="button" class="ax-tab active" data-tab="class">Классу</button>
<button type="button" class="ax-tab" data-tab="student">Ученику</button>
</div>
</div>
<div class="ax-field" data-pane="class">
<label>Классы</label>
<div class="ax-classes">${classesHtml}</div>
</div>
<div class="ax-field" data-pane="student" style="display:none">
<label>Ученик</label>
<input type="text" class="ax-input" name="student-search" placeholder="Поиск по имени или email…" autocomplete="off" />
<div class="ax-student-results"></div>
<input type="hidden" name="student-id" />
</div>
<div class="ax-field">
<label>Параграфы</label>
<input type="text" class="ax-input" name="paragraphs" placeholder="например: 1-5 или 1,3,7" />
<div class="ax-hint">Диапазон («15-18») или список через запятую («1,3,5»). Пустое = весь учебник.</div>
</div>
<div class="ax-field">
<label>Срок сдачи</label>
<input type="datetime-local" class="ax-input" name="deadline" />
</div>
</form>`;
window.closeAssignModal = function () {
document.getElementById('assign-overlay').classList.remove('visible');
document.removeEventListener('keydown', onAssignEsc);
};
window.onAssignOverlayClick = function (e) {
if (e.target === document.getElementById('assign-overlay')) closeAssignModal();
};
function onAssignEsc(e) { if (e.key === 'Escape') closeAssignModal(); }
/* Student search (debounced) */
let stSearchTimer = null;
document.addEventListener('input', e => {
if (e.target?.id !== 'ax-student-search') return;
clearTimeout(stSearchTimer);
stSearchTimer = setTimeout(() => filterStudents(e.target.value), 200);
});
async function filterStudents(q) {
const resultsEl = document.getElementById('ax-student-results');
q = q.trim().toLowerCase();
if (q.length < 2) { resultsEl.classList.remove('visible'); return; }
const students = await loadTeacherStudents();
const matches = students.filter(s =>
(s.name && s.name.toLowerCase().includes(q)) ||
(s.email && s.email.toLowerCase().includes(q))
).slice(0, 12);
if (!matches.length) {
resultsEl.innerHTML = '<div class="ax-student-row" style="color:var(--text-3);cursor:default">Не найдено</div>';
} else {
resultsEl.innerHTML = matches.map(s => `
<div class="ax-student-row" data-id="${s.id}" data-name="${esc(s.name)}">
<span>${esc(s.name)}</span>
<span class="ax-student-email">${esc(s.email || '')}</span>
</div>`).join('');
}
resultsEl.classList.add('visible');
}
document.addEventListener('click', e => {
const row = e.target.closest('.ax-student-row');
if (!row || !row.dataset.id) return;
document.querySelectorAll('.ax-student-row').forEach(r => r.classList.remove('selected'));
row.classList.add('selected');
document.getElementById('ax-student-id').value = row.dataset.id;
document.getElementById('ax-student-search').value = row.dataset.name;
document.getElementById('ax-student-results').classList.remove('visible');
});
window.submitAssign = async function () {
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');
const paragraphs = document.getElementById('ax-paragraphs').value.trim();
const deadline = document.getElementById('ax-deadline').value || null;
const titleSuffix = paragraphs ? `${paragraphs})` : '';
submitBtn.disabled = true;
submitBtn.textContent = 'Назначаю…';
try {
let resultMsg;
if (assignTab === 'class') {
const checked = [...document.querySelectorAll('#ax-classes-list input[name="cls"]:checked')]
.map(el => Number(el.value));
if (!checked.length) throw new Error('Выберите хотя бы один класс');
const r = await LS.api('/api/assignments/bulk', {
method: 'POST',
body: {
title: `Учебник: ${assignTitle}${titleSuffix}`,
class_ids: checked,
mode: 'exam', count: 1, subject_slug: 'other', is_homework: 1,
deadline,
textbook_slug: assignSlug,
textbook_paragraphs: paragraphs || null,
let currentTab = 'class';
const m = LS.modal({
title: `Назначить чтение: «${title}»`,
content: body,
size: 'sm',
actions: [
{ label: 'Отмена', onClick: () => m.close() },
{
label: 'Назначить', primary: true,
onClick: async () => {
const f = m.body.querySelector('form');
const paragraphs = f['paragraphs'].value.trim();
const deadline = f['deadline'].value || null;
const titleSuffix = paragraphs ? `${paragraphs})` : '';
const btns = m.root.querySelectorAll('.ls-mod-btn');
btns.forEach(b => b.disabled = true);
btns[1].textContent = 'Назначаю…';
try {
if (currentTab === 'class') {
const checked = [...f.querySelectorAll('input[name="cls"]:checked')].map(el => Number(el.value));
if (!checked.length) throw new Error('Выберите хотя бы один класс');
const r = await LS.api('/api/assignments/bulk', {
method: 'POST',
body: {
title: `Учебник: ${title}${titleSuffix}`,
class_ids: checked,
mode: 'exam', count: 1, subject_slug: 'other', is_homework: 1,
deadline, textbook_slug: slug, textbook_paragraphs: paragraphs || null,
},
});
LS.toast(`Назначено в ${r.count || checked.length} класс(ах)`, 'success');
} else {
const studentId = Number(f['student-id'].value);
if (!studentId) throw new Error('Выберите ученика');
await LS.api('/api/assignments', {
method: 'POST',
body: {
title: `Учебник: ${title}${titleSuffix}`,
student_id: studentId,
mode: 'exam', count: 1, subject_slug: 'other', is_homework: 1,
deadline, textbook_slug: slug, textbook_paragraphs: paragraphs || null,
},
});
LS.toast('Личное задание создано', 'success');
}
m.close();
} catch (e) {
m.setError(e.message || 'Не удалось создать задание');
btns.forEach(b => b.disabled = false);
btns[1].textContent = 'Назначить';
}
},
});
resultMsg = `Назначено в ${r.count || checked.length} класс(е/ах)`;
} else {
const studentId = Number(document.getElementById('ax-student-id').value);
if (!studentId) throw new Error('Выберите ученика');
},
],
});
await LS.api('/api/assignments', {
method: 'POST',
body: {
title: `Учебник: ${assignTitle}${titleSuffix}`,
student_id: studentId,
mode: 'exam', count: 1, subject_slug: 'other', is_homework: 1,
deadline,
textbook_slug: assignSlug,
textbook_paragraphs: paragraphs || null,
},
});
resultMsg = 'Личное задание создано';
}
successEl.textContent = resultMsg;
successEl.classList.add('visible');
submitBtn.textContent = 'Готово';
setTimeout(closeAssignModal, 1500);
} catch (e) {
errorEl.textContent = e.message || 'Не удалось создать задание';
errorEl.classList.add('visible');
submitBtn.disabled = false;
submitBtn.textContent = 'Назначить';
}
// Tab switching within modal
m.body.querySelectorAll('.ax-tab').forEach(tab => {
tab.addEventListener('click', () => {
currentTab = tab.dataset.tab;
m.body.querySelectorAll('.ax-tab').forEach(t => t.classList.toggle('active', t === tab));
m.body.querySelectorAll('[data-pane]').forEach(p => p.style.display = p.dataset.pane === currentTab ? '' : 'none');
});
});
// Student search (debounced, scoped to this modal)
let stTimer = null;
const searchInput = m.body.querySelector('input[name="student-search"]');
const resultsEl = m.body.querySelector('.ax-student-results');
const idInput = m.body.querySelector('input[name="student-id"]');
searchInput.addEventListener('input', () => {
clearTimeout(stTimer);
stTimer = setTimeout(async () => {
const q = searchInput.value.trim().toLowerCase();
if (q.length < 2) { resultsEl.classList.remove('visible'); return; }
const students = await loadTeacherStudents();
const matches = students.filter(s =>
(s.name && s.name.toLowerCase().includes(q)) ||
(s.email && s.email.toLowerCase().includes(q))
).slice(0, 12);
resultsEl.innerHTML = matches.length
? matches.map(s => `
<div class="ax-student-row" data-id="${s.id}" data-name="${esc(s.name)}">
<span>${esc(s.name)}</span>
<span class="ax-student-email">${esc(s.email || '')}</span>
</div>`).join('')
: '<div class="ax-student-row" style="color:var(--text-3);cursor:default">Не найдено</div>';
resultsEl.classList.add('visible');
}, 200);
});
resultsEl.addEventListener('click', e => {
const row = e.target.closest('.ax-student-row');
if (!row || !row.dataset.id) return;
m.body.querySelectorAll('.ax-student-row').forEach(r => r.classList.remove('selected'));
row.classList.add('selected');
idInput.value = row.dataset.id;
searchInput.value = row.dataset.name;
resultsEl.classList.remove('visible');
});
};
/* ════════════════════════════════════════════════