let modulesList = []; let mPage = 1; const M_PAGE_SIZE = 25; let mSearchTimer = null; let modulePhotoDeleteFlag = false; let modulePhotoLimitMb = 50; function modulePhotoHint() { return `jpg, png, gif, webp, avif, heic и др., до ${modulePhotoLimitMb} МБ · или вставьте из буфера (Ctrl+V)`; } function mPlural(n, one, few, many) { n = Math.abs(n) % 100; const n1 = n % 10; if (n > 10 && n < 20) return many; if (n1 > 1 && n1 < 5) return few; if (n1 === 1) return one; return many; } async function loadModules() { const p = new URLSearchParams(); const q = document.getElementById('moduleSearch').value.trim(); if (q) p.set('search', q); p.set('limit', M_PAGE_SIZE); p.set('offset', (mPage - 1) * M_PAGE_SIZE); const res = await fetch(`${API}/api/modules?${p}`, { headers: hdr() }); if (!res.ok) return; const data = await res.json(); modulesList = data.modules || []; const totalPages = Math.max(1, Math.ceil(data.total / M_PAGE_SIZE)); if (mPage > totalPages) { mPage = totalPages; return loadModules(); } renderModules(data.total); } function renderModules(total) { const list = document.getElementById('moduleList'); if (!modulesList.length) { list.innerHTML = '
Модулей пока нет. Нажмите «Добавить модуль».
'; } else { list.innerHTML = modulesList.map(m => `
${m.photo_path ? `` : ''}
${esc(m.name)}${m.is_active ? '' : ' Отключён'}
${m.lessons_count} ${mPlural(m.lessons_count, 'занятие', 'занятия', 'занятий')} · ${m.entries_count} ${mPlural(m.entries_count, 'запись', 'записи', 'записей')}
${m.is_active ? ` ` : ` `}
`).join(''); } renderPager(document.getElementById('pager'), mPage, Math.max(1, Math.ceil(total / M_PAGE_SIZE)), p => { mPage = p; loadModules(); }); } function resetModulePhoto(m) { modulePhotoDeleteFlag = false; const fileInput = document.getElementById('modulePhotoFile'); const preview = document.getElementById('modulePhotoPreview'); const delBtn = document.getElementById('modulePhotoDeleteBtn'); const note = document.getElementById('modulePhotoNote'); fileInput.value = ''; if (m && m.photo_path) { preview.src = thumbSrc(m.photo_path); preview.style.display = 'block'; delBtn.style.display = 'inline-block'; note.textContent = 'Загрузка новой картинки заменит текущую'; } else { preview.removeAttribute('src'); preview.style.display = 'none'; delBtn.style.display = 'none'; note.textContent = modulePhotoHint(); } } function openModuleModal(id = null) { const modal = document.getElementById('moduleModal'); const form = document.getElementById('moduleForm'); const title = document.getElementById('moduleModalTitle'); const submitBtn = document.getElementById('moduleSubmitBtn'); form.reset(); document.getElementById('moduleId').value = ''; document.getElementById('moduleLessons').value = '0'; if (id) { const m = modulesList.find(x => x.id === id); if (!m) return; title.textContent = 'Редактировать модуль'; submitBtn.textContent = 'Сохранить'; form.name.value = m.name; document.getElementById('moduleLessons').value = String(m.lessons_count); document.getElementById('moduleId').value = m.id; resetModulePhoto(m); } else { title.textContent = 'Создать модуль'; submitBtn.textContent = 'Создать'; resetModulePhoto(null); } modal.classList.add('open'); setTimeout(() => form.name.focus(), 100); } function previewModulePhotoFile() { const file = document.getElementById('modulePhotoFile').files[0]; const preview = document.getElementById('modulePhotoPreview'); const delBtn = document.getElementById('modulePhotoDeleteBtn'); const note = document.getElementById('modulePhotoNote'); if (!file) return; modulePhotoDeleteFlag = false; preview.src = URL.createObjectURL(file); preview.style.display = 'block'; delBtn.style.display = 'none'; note.textContent = 'Новая картинка будет сохранена после нажатия «Сохранить»'; } function removeModulePhoto() { const preview = document.getElementById('modulePhotoPreview'); const delBtn = document.getElementById('modulePhotoDeleteBtn'); const note = document.getElementById('modulePhotoNote'); if (!confirm('Удалить картинку модуля?')) return; modulePhotoDeleteFlag = true; document.getElementById('modulePhotoFile').value = ''; preview.removeAttribute('src'); preview.style.display = 'none'; delBtn.style.display = 'none'; note.textContent = 'Картинка будет удалена после нажатия «Сохранить»'; } function pasteModulePhotoFile(e) { const modal = document.getElementById('moduleModal'); if (!modal.classList.contains('open')) return; const items = e.clipboardData && e.clipboardData.items; if (!items) return; for (const item of items) { if (item.type && item.type.startsWith('image/')) { const file = item.getAsFile(); if (!file) continue; e.preventDefault(); const dt = new DataTransfer(); dt.items.add(file); const input = document.getElementById('modulePhotoFile'); input.files = dt.files; previewModulePhotoFile(); return; } } } function closeModuleModal() { document.getElementById('moduleModal').classList.remove('open'); } async function disableModule(id) { const m = modulesList.find(x => x.id === id); const msg = m && m.entries_count > 0 ? `Отключить модуль «${m.name}»? Он станет недоступен для выбора, у ${m.entries_count} связанных записей тема сохранится.` : `Отключить модуль${m ? ` «${m.name}»` : ''}? Он станет недоступен для выбора.`; if (!confirm(msg)) return; const res = await fetch(`${API}/api/modules/${id}`, { method: 'DELETE', headers: hdr() }); if (res.ok) { showToast('Модуль отключён'); loadModules(); } else { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); } } async function enableModule(id) { const res = await fetch(`${API}/api/modules/${id}/restore`, { method: 'PUT', headers: hdr() }); if (res.ok) { showToast('Модуль включён'); loadModules(); } else { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); } } document.getElementById('moduleForm').addEventListener('submit', async (e) => { e.preventDefault(); const form = e.target; const id = document.getElementById('moduleId').value; const name = form.name.value.trim(); const lessons_count = parseInt(form.lessons_count.value, 10); if (!name) { alert('Название обязательно'); return; } if (!Number.isInteger(lessons_count) || lessons_count < 0) { alert('Количество занятий — целое неотрицательное число'); return; } const res = await fetch(id ? `${API}/api/modules/${id}` : `${API}/api/modules`, { method: id ? 'PUT' : 'POST', headers: hdrJson(), body: JSON.stringify({ name, lessons_count }) }); if (!res.ok) { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); return; } const moduleRow = await res.json(); const mid = moduleRow.id; const fileInput = document.getElementById('modulePhotoFile'); if (modulePhotoDeleteFlag) { const delRes = await fetch(`${API}/api/modules/${mid}/photo`, { method: 'DELETE', headers: hdr() }); if (!delRes.ok) { const err = await delRes.json().catch(() => ({})); alert(err.error || 'Ошибка удаления картинки'); } } const file = fileInput.files[0]; if (file) { const fd = new FormData(); fd.append('photo', file); const upRes = await fetch(`${API}/api/modules/${mid}/photo`, { method: 'POST', headers: hdr(), body: fd }); if (!upRes.ok) { const err = await upRes.json().catch(() => ({})); alert(err.error || 'Ошибка загрузки картинки'); } } showToast(id ? 'Модуль обновлён' : 'Модуль создан'); closeModuleModal(); loadModules(); }); function parseBatchModules(raw) { const items = []; const errors = []; const seen = new Set(); String(raw).split('\n').forEach((line, idx) => { const text = line.trim(); if (!text) return; const lineNo = idx + 1; let name = ''; let lessonsRaw = ''; const sepIndex = text.search(/[|;=\t]/); if (sepIndex !== -1) { name = text.slice(0, sepIndex).trim(); lessonsRaw = text.slice(sepIndex + 1).trim(); } else { const numMatch = text.match(/^(.*\S)\s+(\d+)$/); if (numMatch) { name = numMatch[1].trim(); lessonsRaw = numMatch[2]; } } if (!name && !lessonsRaw) { errors.push(`Строка ${lineNo}: «${text}» — укажите название и количество занятий, например: Основы Python | 10`); return; } if (!name) { errors.push(`Строка ${lineNo}: «${text}» — не указано название`); return; } if (!lessonsRaw) { errors.push(`Строка ${lineNo}: «${text}» — не указано количество занятий`); return; } const lessons = Number(lessonsRaw); if (!Number.isInteger(lessons) || lessons < 0 || lessons > 10000) { errors.push(`Строка ${lineNo}: «${text}» — количество занятий должно быть целым числом от 0 до 10000`); return; } const key = name.toLowerCase(); if (seen.has(key)) { errors.push(`Строка ${lineNo}: «${text}» — дубликат названия в списке`); return; } seen.add(key); items.push({ name, lessons_count: lessons }); }); return { items, errors }; } function openBatchModuleModal() { document.getElementById('batchModuleForm').reset(); document.getElementById('batchModuleModal').classList.add('open'); setTimeout(() => document.getElementById('batchModuleText').focus(), 100); } function closeBatchModuleModal() { document.getElementById('batchModuleModal').classList.remove('open'); } async function batchAddModules() { const submitBtn = document.getElementById('batchModuleSubmitBtn'); const { items, errors } = parseBatchModules(document.getElementById('batchModuleText').value); if (errors.length) { alert('Исправьте строки:\n\n' + errors.join('\n')); return; } if (!items.length) { alert('Добавьте хотя бы один модуль'); return; } submitBtn.disabled = true; let added = 0, dupes = 0, failed = 0; for (const item of items) { const res = await fetch(`${API}/api/modules`, { method: 'POST', headers: hdrJson(), body: JSON.stringify(item) }); if (res.ok) added++; else if (res.status === 409) dupes++; else failed++; } submitBtn.disabled = false; closeBatchModuleModal(); loadModules(); showToast(`Добавлено: ${added}${dupes ? `, дубликатов: ${dupes}` : ''}${failed ? `, ошибок: ${failed}` : ''}`); } document.getElementById('batchModuleForm').addEventListener('submit', e => { e.preventDefault(); batchAddModules(); }); document.getElementById('batchModuleBtn').addEventListener('click', openBatchModuleModal); document.getElementById('batchModuleCancelBtn').addEventListener('click', closeBatchModuleModal); document.getElementById('batchModuleModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeBatchModuleModal(); }); document.getElementById('addModuleBtn').addEventListener('click', () => openModuleModal()); document.getElementById('moduleCancelBtn').addEventListener('click', closeModuleModal); document.getElementById('moduleModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeModuleModal(); }); document.getElementById('modulePhotoFile').addEventListener('change', previewModulePhotoFile); document.getElementById('modulePhotoDeleteBtn').addEventListener('click', removeModulePhoto); document.addEventListener('paste', pasteModulePhotoFile); document.getElementById('moduleSearch').addEventListener('input', () => { clearTimeout(mSearchTimer); mSearchTimer = setTimeout(() => { mPage = 1; loadModules(); }, 300); }); document.getElementById('moduleList').addEventListener('click', e => { const ed = e.target.closest('[data-edit-module]'); if (ed) { openModuleModal(parseInt(ed.dataset.editModule, 10)); return; } const off = e.target.closest('[data-off-module]'); if (off) { disableModule(parseInt(off.dataset.offModule, 10)); return; } const on = e.target.closest('[data-on-module]'); if (on) enableModule(parseInt(on.dataset.onModule, 10)); }); (async () => { if (await requireAdminPage()) { try { const res = await fetch(`${API}/api/public-settings`); const f = await res.json(); const n = parseInt(f.upload_file_limit_mb, 10); if (Number.isFinite(n) && n >= 1) modulePhotoLimitMb = n; } catch (e) { /* ignore */ } buildSidebar(document.body.dataset.page); loadModules(); } })();