Форма архива файлов группы на /groups.html получила галочки выгрузки: - «Работы (файлы проектов)» — включена по умолчанию - «Фото записи» — выключена по умолчанию - режим фото «Последние» / «Все», по умолчанию «Последние»; радиокнопки блокируются, когда «Фото записи» выключена Дефолты периода: дата «с» пустая, дата «по» — сегодня. GET /api/groups/:id/export/files принимает include_files, include_photos и photos_mode: - latest — только entries.photo_path, то главное обработанное фото записи - all — плюс вся галерея записи из entry_photos - 400, если обе категории выключены Запросы к БД для отключённых категорий не выполняются; в data.json и в аудит добавлены options и раздельные счётчики works/photos. В аудите добавлена метка export.group_files. Проверено на стенде: группа с многофото-записями даёт 4 фото в режиме «последние» и 6 в режиме «все» (совпадает с БД: 4 главных + 2 галереи), категории комбинируются независимо. api.smoketest.js 57/57, diff.selftest.js 16/16.
534 lines
25 KiB
JavaScript
534 lines
25 KiB
JavaScript
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
|
||
let branchesCache = [];
|
||
let tutorsCache = [];
|
||
let allGroups = [];
|
||
let currentCover = null;
|
||
|
||
async function loadBranchesCache() {
|
||
const res = await fetch(`${API}/api/branches`, { headers: hdr() });
|
||
if (res.ok) branchesCache = await res.json();
|
||
}
|
||
|
||
async function loadTutorsCache() {
|
||
const res = await fetch(`${API}/api/users/tutors`, { headers: hdr() });
|
||
if (res.ok) tutorsCache = await res.json();
|
||
}
|
||
|
||
function tutorSelectHTML(selectedId) {
|
||
const opts = tutorsCache.map(t => `<option value="${t.id}" ${t.id === selectedId ? 'selected' : ''}>${esc(t.name || t.username)}</option>`).join('');
|
||
return `<div><label>Тутор</label><select class="tutor-select" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-size:.8rem"><option value="">— не назначен —</option>${opts}</select></div>`;
|
||
}
|
||
|
||
function branchSelectHTML(selectedId) {
|
||
const opts = branchesCache.map(b => `<option value="${b.id}" ${b.id === selectedId ? 'selected' : ''}>${esc(b.name)}</option>`).join('');
|
||
return `<div><label>Филиал</label><select class="branch-select" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-size:.8rem"><option value="">— без филиала —</option>${opts}</select></div>`;
|
||
}
|
||
|
||
async function loadGroups() {
|
||
await loadBranchesCache();
|
||
await loadTutorsCache();
|
||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||
const groups = await res.json();
|
||
allGroups = groups;
|
||
const list = document.getElementById('groupList');
|
||
if (!groups.length) { list.innerHTML = '<div style="color:var(--muted);text-align:center;padding:32px">Нет групп</div>'; return; }
|
||
list.innerHTML = `<div class="group-grid">` + groups.map(g => {
|
||
const schedSet = g.day_of_week != null && g.time_start && g.time_end;
|
||
return `
|
||
<div class="group-card" data-group-id="${g.id}">
|
||
${g.cover_path
|
||
? `<img class="group-cover" src="${thumbSrc(g.cover_path)}" loading="lazy" decoding="async" alt="">`
|
||
: `<div class="group-cover empty-cover">📷</div>`}
|
||
<div class="group-head">
|
||
<input class="name-input" id="name-${g.id}" value="${esc(g.name)}">
|
||
<button class="del" data-del-group="${g.id}" title="Удалить">×</button>
|
||
</div>
|
||
<div class="group-schedule">
|
||
${branchSelectHTML(g.branch_id)}
|
||
${tutorSelectHTML(g.tutor_id)}
|
||
<div><label>День</label>
|
||
<select id="dow-${g.id}">
|
||
<option value="">—</option>
|
||
${DAYS.map((d, i) => `<option value="${i}" ${g.day_of_week === i ? 'selected' : ''}>${d}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div><label>С</label>
|
||
<input type="time" id="ts-${g.id}" value="${g.time_start ? g.time_start.slice(0,5) : ''}">
|
||
</div>
|
||
<div><label>По</label>
|
||
<input type="time" id="te-${g.id}" value="${g.time_end ? g.time_end.slice(0,5) : ''}">
|
||
</div>
|
||
</div>
|
||
<div class="group-foot">
|
||
<div style="display:flex;gap:8px">
|
||
<button class="save-sched" data-save-group="${g.id}">Сохранить</button>
|
||
<button class="photo-btn" data-group-id="${g.id}" data-group-name="${esc(g.name)}" title="Фото группы (хронология)">Фото</button>
|
||
<button class="btn-link" data-export-group="${g.id}" data-group-name="${esc(g.name)}" title="Экспортировать записи группы">CSV</button>
|
||
<button class="btn-link" data-export-urls="${g.id}" data-group-name="${esc(g.name)}" title="Скачать .url файлы учеников группы">URL</button>
|
||
<button class="btn-link" data-export-files="${g.id}" data-group-name="${esc(g.name)}" title="Скачать ZIP-архив файлов группы за период">ZIP</button>
|
||
</div>
|
||
<div class="sched-status">${schedSet ? `${DAYS[g.day_of_week]} ${g.time_start.slice(0,5)}–${g.time_end.slice(0,5)}` : 'Не настроено'}</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('') + `</div>`;
|
||
}
|
||
|
||
async function saveGroup(id) {
|
||
const name = document.getElementById(`name-${id}`).value.trim();
|
||
if (!name) return;
|
||
const nameInput = document.getElementById(`name-${id}`);
|
||
const groupCard = nameInput.closest('.group-card');
|
||
const branchSelect = groupCard ? groupCard.querySelector('.branch-select') : null;
|
||
const branch_id = branchSelect ? branchSelect.value : '';
|
||
const tutorSelect = groupCard ? groupCard.querySelector('.tutor-select') : null;
|
||
const tutor_id = tutorSelect ? (tutorSelect.value || null) : undefined;
|
||
const dow = document.getElementById(`dow-${id}`).value;
|
||
const ts = document.getElementById(`ts-${id}`).value;
|
||
const te = document.getElementById(`te-${id}`).value;
|
||
const res = await fetch(`${API}/api/groups/${id}`, {
|
||
method: 'PUT',
|
||
headers: hdrJson(),
|
||
body: JSON.stringify({ name, branch_id: branch_id || null, tutor_id, day_of_week: dow !== '' ? parseInt(dow) : null, time_start: ts || null, time_end: te || null })
|
||
});
|
||
if (res.ok) {
|
||
loadGroups();
|
||
const dayLabel = dow !== '' ? DAYS[dow] : 'без расписания';
|
||
const timeLabel = ts && te ? `, ${ts}–${te}` : '';
|
||
showToast(`Сохранено: «${name}» — ${dayLabel}${timeLabel}`);
|
||
} else { const e = await res.json(); alert(e.error); }
|
||
}
|
||
|
||
async function addGroup() {
|
||
const input = document.getElementById('newGroup');
|
||
const name = input.value.trim();
|
||
if (!name) return;
|
||
await loadBranchesCache();
|
||
await loadTutorsCache();
|
||
const branchSelect = document.getElementById('newGroupBranch');
|
||
const branch_id = branchSelect ? branchSelect.value : '';
|
||
const tutorSelect = document.getElementById('newGroupTutor');
|
||
const tutor_id = tutorSelect && tutorSelect.value ? tutorSelect.value : null;
|
||
const res = await fetch(`${API}/api/groups`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name, branch_id: branch_id || null, tutor_id }) });
|
||
if (res.ok) { input.value = ''; loadGroups(); }
|
||
else { const e = await res.json(); alert(e.error); }
|
||
}
|
||
|
||
async function delGroup(id) {
|
||
if (!confirm('Удалить группу? Группа попадёт в корзину, восстановить её можно будет позже.')) return;
|
||
const res = await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() });
|
||
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); }
|
||
loadGroups();
|
||
}
|
||
|
||
const PHOTO_PAGE_SIZE = 24;
|
||
let currentGroupId = null;
|
||
let currentGroupName = '';
|
||
let photos = [];
|
||
let photoPage = 1;
|
||
let totalPhotos = 0;
|
||
let editingPhotoId = null;
|
||
|
||
async function openPhotos(groupId, groupName) {
|
||
currentGroupId = groupId;
|
||
currentGroupName = groupName;
|
||
const g = allGroups.find(x => String(x.id) === String(groupId));
|
||
currentCover = g ? g.cover_path : null;
|
||
photoPage = 1;
|
||
editingPhotoId = null;
|
||
document.getElementById('photosTitle').textContent = `Фото — ${groupName}`;
|
||
document.getElementById('photosModal').classList.add('open');
|
||
await loadGroupPhotos();
|
||
}
|
||
|
||
function closePhotos() {
|
||
document.getElementById('photosModal').classList.remove('open');
|
||
currentGroupId = null;
|
||
}
|
||
|
||
async function loadGroupPhotos() {
|
||
const p = new URLSearchParams();
|
||
p.set('limit', PHOTO_PAGE_SIZE);
|
||
p.set('offset', (photoPage - 1) * PHOTO_PAGE_SIZE);
|
||
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos?${p}`, { headers: hdr() });
|
||
const data = await res.json();
|
||
photos = data.photos;
|
||
totalPhotos = data.total;
|
||
document.getElementById('photoGrid').className = 'photo-grid';
|
||
document.getElementById('photoGrid').innerHTML = photos.length
|
||
? photos.map(photoHTML).join('')
|
||
: '<div class="empty" style="grid-column:1/-1">Фото пока нет. Загрузите первое фото.</div>';
|
||
renderPager(document.getElementById('photosPager'), photoPage, Math.max(1, Math.ceil(totalPhotos / PHOTO_PAGE_SIZE)), goPhotoPage);
|
||
}
|
||
|
||
function goPhotoPage(p) { photoPage = p; loadGroupPhotos(); }
|
||
|
||
function fmtPhotoDate(d) { return d ? d.split('-').reverse().join('.') : ''; }
|
||
|
||
function photoHTML(p) {
|
||
const taken = p.taken_at ? `Дата: ${fmtPhotoDate(p.taken_at)}` : '';
|
||
const uploaded = `Загружено: ${new Date(p.created_at).toLocaleDateString('ru')}`;
|
||
const isCover = currentCover && p.photo_path === currentCover;
|
||
return `
|
||
<div class="photo-item" data-photo-id="${p.id}" data-photo-path="${esc(p.photo_path)}" draggable="true">
|
||
<div class="drag-hint" title="Перетащите, чтобы изменить порядок">⠿</div>
|
||
<img src="${thumbSrc(p.photo_path)}" data-img="${API}${p.photo_path}" loading="lazy" decoding="async" alt="">
|
||
<div class="p-cap">${esc(p.caption || '')}</div>
|
||
<div class="p-date">${taken ? taken + ' · ' : ''}${uploaded}</div>
|
||
<div class="p-actions">
|
||
<button class="p-cover${isCover ? ' active' : ''}" data-photo-id="${p.id}" data-photo-path="${esc(p.photo_path)}" title="${isCover ? 'Текущая обложка' : 'Сделать обложкой'}" ${isCover ? 'disabled' : ''}>🖼️</button>
|
||
<button class="p-edit" data-edit-photo="${p.id}" title="Редактировать">✎</button>
|
||
<button class="p-del" data-del-photo="${p.id}" title="Удалить">×</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function openAddPhoto() {
|
||
editingPhotoId = null;
|
||
document.getElementById('photoFormTitle').textContent = `Добавить фото — ${currentGroupName}`;
|
||
document.getElementById('photoFile').value = '';
|
||
document.getElementById('photoCaption').value = '';
|
||
document.getElementById('photoTakenAt').value = '';
|
||
document.getElementById('photoFormPreview').style.display = 'none';
|
||
document.getElementById('photoFormModal').classList.add('open');
|
||
}
|
||
|
||
async function openEditPhoto(id) {
|
||
const p = photos.find(x => x.id === id);
|
||
if (!p) return;
|
||
editingPhotoId = id;
|
||
document.getElementById('photoFormTitle').textContent = 'Редактировать фото';
|
||
document.getElementById('photoFile').value = '';
|
||
document.getElementById('photoCaption').value = p.caption || '';
|
||
document.getElementById('photoTakenAt').value = p.taken_at || '';
|
||
const prev = document.getElementById('photoFormPreview');
|
||
const img = document.getElementById('photoPreviewImg');
|
||
img.src = `${API}${p.photo_path}`;
|
||
prev.style.display = 'block';
|
||
document.getElementById('photoFormModal').classList.add('open');
|
||
}
|
||
|
||
function closePhotoForm() {
|
||
document.getElementById('photoFormModal').classList.remove('open');
|
||
editingPhotoId = null;
|
||
}
|
||
|
||
function previewPhotoFile() {
|
||
const file = document.getElementById('photoFile').files[0];
|
||
const prev = document.getElementById('photoFormPreview');
|
||
if (!file) { prev.style.display = 'none'; return; }
|
||
const img = document.getElementById('photoPreviewImg');
|
||
img.src = URL.createObjectURL(file);
|
||
prev.style.display = 'block';
|
||
}
|
||
|
||
async function savePhotoForm() {
|
||
const caption = document.getElementById('photoCaption').value.trim();
|
||
const taken_at = document.getElementById('photoTakenAt').value;
|
||
if (editingPhotoId) {
|
||
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos/${editingPhotoId}`, {
|
||
method: 'PUT',
|
||
headers: hdrJson(),
|
||
body: JSON.stringify({ caption: caption || null, taken_at: taken_at || null })
|
||
});
|
||
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); return; }
|
||
showToast('Фото обновлено');
|
||
} else {
|
||
const file = document.getElementById('photoFile').files[0];
|
||
if (!file) { alert('Выберите файл'); return; }
|
||
const fd = new FormData();
|
||
fd.append('photo', file);
|
||
fd.append('caption', caption);
|
||
fd.append('taken_at', taken_at);
|
||
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos`, {
|
||
method: 'POST',
|
||
headers: hdr(),
|
||
body: fd
|
||
});
|
||
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); return; }
|
||
showToast('Фото добавлено в хронологию');
|
||
}
|
||
closePhotoForm();
|
||
loadGroupPhotos();
|
||
loadGroups();
|
||
}
|
||
|
||
async function deletePhoto(id) {
|
||
if (!confirm('Удалить это фото безвозвратно?')) return;
|
||
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos/${id}`, { method: 'DELETE', headers: hdr() });
|
||
if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); return; }
|
||
const p = photos.find(x => x.id === id);
|
||
if (p && currentCover === p.photo_path) currentCover = null;
|
||
showToast('Фото удалено');
|
||
loadGroupPhotos();
|
||
loadGroups();
|
||
}
|
||
|
||
async function exportGroup(id, name) {
|
||
const res = await fetch(`${API}/api/entries?group_id=${id}`, { headers: hdr() });
|
||
const { entries } = await res.json();
|
||
if (!entries.length) { alert('Нет данных для экспорта'); return; }
|
||
const rows = [['Фамилия Имя', 'Группа', 'Описание', 'Дата', 'Ссылка на страницу']];
|
||
entries.forEach(e => {
|
||
rows.push([
|
||
e.student_name,
|
||
e.group_name,
|
||
e.description.replace(/\n/g, ' '),
|
||
new Date(e.created_at).toLocaleString('ru'),
|
||
`${location.origin}/?nameInput=${encodeURIComponent(e.student_name)}`
|
||
]);
|
||
});
|
||
const csv = rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(';')).join('\n');
|
||
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `group_${name.replace(/[\\/:*?"<>|]/g, '_')}.csv`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
showToast(`Экспортировано записей: ${entries.length}`);
|
||
}
|
||
|
||
async function exportGroupUrls(groupId, groupName) {
|
||
const res = await fetch(`${API}/api/students`, { headers: hdr() });
|
||
const students = await res.json();
|
||
const list = students.filter(s => s.group_id === groupId);
|
||
if (!list.length) { alert('В группе нет учеников'); return; }
|
||
await exportStudentUrlZip(list, `group_${groupName.replace(/[\\/:*?"<>|]/g, '_')}.zip`);
|
||
showToast(`Экспортировано: ${list.length}`);
|
||
}
|
||
|
||
function openGroupFilesModal(groupId, groupName) {
|
||
const sel = document.getElementById('groupFilesGroup');
|
||
sel.innerHTML = allGroups.map(g => `<option value="${g.id}" ${String(g.id) === String(groupId) ? 'selected' : ''}>${esc(g.name)}</option>`).join('');
|
||
document.getElementById('groupFilesTitle').textContent = `Архив файлов — ${groupName}`;
|
||
document.getElementById('groupFilesFrom').value = '';
|
||
document.getElementById('groupFilesTo').value = todayISOLocal();
|
||
document.getElementById('groupFilesIncludeWorks').checked = true;
|
||
document.getElementById('groupFilesIncludePhotos').checked = false;
|
||
const latest = document.querySelector('input[name="groupFilesMode"][value="latest"]');
|
||
if (latest) latest.checked = true;
|
||
onGroupFilesPhotosToggle();
|
||
document.getElementById('groupFilesModal').classList.add('open');
|
||
}
|
||
|
||
function closeGroupFilesModal() {
|
||
document.getElementById('groupFilesModal').classList.remove('open');
|
||
}
|
||
|
||
function todayISOLocal() {
|
||
const d = new Date();
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||
}
|
||
|
||
function onGroupFilesPhotosToggle() {
|
||
const on = document.getElementById('groupFilesIncludePhotos').checked;
|
||
document.querySelectorAll('input[name="groupFilesMode"]').forEach(el => { el.disabled = !on; });
|
||
}
|
||
|
||
function groupFilesMode() {
|
||
const checked = document.querySelector('input[name="groupFilesMode"]:checked');
|
||
return checked && checked.value === 'all' ? 'all' : 'latest';
|
||
}
|
||
|
||
async function downloadGroupFiles() {
|
||
const groupId = document.getElementById('groupFilesGroup').value;
|
||
const dateFrom = document.getElementById('groupFilesFrom').value;
|
||
const dateTo = document.getElementById('groupFilesTo').value;
|
||
const includeWorks = document.getElementById('groupFilesIncludeWorks').checked;
|
||
const includePhotos = document.getElementById('groupFilesIncludePhotos').checked;
|
||
if (!groupId) { alert('Выберите группу'); return; }
|
||
if (!includeWorks && !includePhotos) { alert('Выберите хотя бы одну категорию: работы или фото записи'); return; }
|
||
if (dateFrom && dateTo && dateFrom > dateTo) { alert('Дата «С» позже даты «По»'); return; }
|
||
const btn = document.getElementById('downloadGroupFilesBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Формируем...';
|
||
try {
|
||
const q = new URLSearchParams();
|
||
if (dateFrom) q.set('date_from', dateFrom);
|
||
if (dateTo) q.set('date_to', dateTo);
|
||
q.set('include_files', includeWorks ? '1' : '0');
|
||
q.set('include_photos', includePhotos ? '1' : '0');
|
||
q.set('photos_mode', groupFilesMode());
|
||
const res = await fetch(`${API}/api/groups/${groupId}/export/files?${q}`, { headers: hdr() });
|
||
if (!res.ok) {
|
||
let msg = 'Не удалось сформировать архив';
|
||
try { const e = await res.json(); msg = e.error || msg; } catch {}
|
||
alert(msg);
|
||
return;
|
||
}
|
||
const blob = await res.blob();
|
||
let disp = res.headers.get('Content-Disposition') || '';
|
||
let fname = 'group_files.zip';
|
||
const star = disp.match(/filename\*=UTF-8''([^;]+)/i);
|
||
const plain = disp.match(/filename="?([^";]+)"?/i);
|
||
if (star) { try { fname = decodeURIComponent(star[1]); } catch { fname = plain ? plain[1] : fname; } }
|
||
else if (plain) fname = plain[1];
|
||
downloadBlob(blob, fname);
|
||
showToast('Архив сформирован');
|
||
closeGroupFilesModal();
|
||
} catch (err) {
|
||
alert('Ошибка при формировании архива: ' + err.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = 'Скачать ZIP';
|
||
}
|
||
}
|
||
|
||
document.getElementById('newGroup').addEventListener('keydown', e => { if (e.key === 'Enter') addGroup(); });
|
||
document.getElementById('addGroupBtn').addEventListener('click', addGroup);
|
||
document.getElementById('photoFile').addEventListener('change', previewPhotoFile);
|
||
document.getElementById('photoForm').addEventListener('submit', e => {
|
||
e.preventDefault();
|
||
savePhotoForm();
|
||
});
|
||
|
||
document.getElementById('photosModal').addEventListener('click', e => { if (e.target === e.currentTarget) closePhotos(); });
|
||
document.getElementById('photoFormModal').addEventListener('click', e => { if (e.target === e.currentTarget) closePhotoForm(); });
|
||
document.getElementById('groupFilesModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeGroupFilesModal(); });
|
||
document.getElementById('closeGroupFilesBtn').addEventListener('click', closeGroupFilesModal);
|
||
document.getElementById('groupFilesForm').addEventListener('submit', e => { e.preventDefault(); downloadGroupFiles(); });
|
||
document.getElementById('groupFilesIncludePhotos').addEventListener('change', onGroupFilesPhotosToggle);
|
||
document.getElementById('openPhotoBtn').addEventListener('click', openAddPhoto);
|
||
document.getElementById('closePhotosBtn').addEventListener('click', closePhotos);
|
||
document.getElementById('closePhotoFormBtn').addEventListener('click', closePhotoForm);
|
||
|
||
document.getElementById('groupList').addEventListener('click', e => {
|
||
const sg = e.target.closest('[data-save-group]');
|
||
if (sg) { saveGroup(parseInt(sg.dataset.saveGroup, 10)); return; }
|
||
const dg = e.target.closest('[data-del-group]');
|
||
if (dg) { delGroup(parseInt(dg.dataset.delGroup, 10)); return; }
|
||
const eg = e.target.closest('[data-export-group]');
|
||
if (eg) { exportGroup(parseInt(eg.dataset.exportGroup, 10), eg.dataset.groupName); return; }
|
||
const eu = e.target.closest('[data-export-urls]');
|
||
if (eu) { exportGroupUrls(parseInt(eu.dataset.exportUrls, 10), eu.dataset.groupName); return; }
|
||
const ef = e.target.closest('[data-export-files]');
|
||
if (ef) { openGroupFilesModal(parseInt(ef.dataset.exportFiles, 10), ef.dataset.groupName); return; }
|
||
const photoBtn = e.target.closest('.photo-btn');
|
||
if (photoBtn) {
|
||
const id = parseInt(photoBtn.dataset.groupId, 10);
|
||
const name = photoBtn.dataset.groupName;
|
||
if (!isNaN(id)) openPhotos(id, name);
|
||
}
|
||
});
|
||
|
||
function updateGroupCoverUI(coverPath) {
|
||
const card = document.querySelector(`.group-card[data-group-id="${currentGroupId}"]`);
|
||
if (!card) return;
|
||
const g = allGroups.find(x => String(x.id) === String(currentGroupId));
|
||
const existing = card.querySelector('.group-cover');
|
||
if (existing && existing.tagName === 'IMG') { existing.src = API + coverPath; return; }
|
||
const el = document.createElement('img');
|
||
el.className = 'group-cover';
|
||
el.src = API + coverPath;
|
||
el.alt = '';
|
||
if (existing) existing.replaceWith(el);
|
||
else card.insertBefore(el, card.firstChild);
|
||
}
|
||
|
||
let dragPhotoId = null;
|
||
|
||
async function persistPhotoOrder() {
|
||
const items = [...document.querySelectorAll('#photoGrid .photo-item')];
|
||
const order = items.map(el => parseInt(el.dataset.photoId, 10));
|
||
if (JSON.stringify(order) === JSON.stringify(photos.map(p => p.id))) return;
|
||
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos/reorder`, {
|
||
method: 'PUT',
|
||
headers: hdrJson(),
|
||
body: JSON.stringify({ order })
|
||
});
|
||
if (res.ok) showToast('Порядок фото сохранён');
|
||
else {
|
||
try { const err = await res.json(); alert(err.error || 'Ошибка'); } catch { alert('Ошибка'); }
|
||
loadGroupPhotos();
|
||
}
|
||
}
|
||
|
||
document.getElementById('photoGrid').addEventListener('dragstart', e => {
|
||
const item = e.target.closest('.photo-item');
|
||
if (!item) return;
|
||
e.stopPropagation();
|
||
dragPhotoId = parseInt(item.dataset.photoId, 10);
|
||
item.classList.add('dragging');
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
e.dataTransfer.setData('text/plain', String(dragPhotoId));
|
||
});
|
||
|
||
document.getElementById('photoGrid').addEventListener('dragover', e => {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'move';
|
||
const target = e.target.closest('.photo-item');
|
||
const dragged = dragPhotoId != null ? document.querySelector(`.photo-item[data-photo-id="${dragPhotoId}"]`) : null;
|
||
if (!target || !dragged || target === dragged) return;
|
||
if (target.contains(dragged)) return;
|
||
const rect = target.getBoundingClientRect();
|
||
const grid = document.getElementById('photoGrid');
|
||
grid.insertBefore(dragged, e.clientX > rect.left + rect.width / 2 ? target.nextSibling : target);
|
||
});
|
||
|
||
document.getElementById('photoGrid').addEventListener('dragend', e => {
|
||
const item = e.target.closest('.photo-item');
|
||
if (item) item.classList.remove('dragging');
|
||
if (dragPhotoId != null) persistPhotoOrder();
|
||
dragPhotoId = null;
|
||
});
|
||
|
||
document.getElementById('photoGrid').addEventListener('drop', e => e.preventDefault());
|
||
|
||
document.getElementById('photoGrid').addEventListener('click', async e => {
|
||
const coverBtn = e.target.closest('.p-cover');
|
||
if (coverBtn) {
|
||
const photoId = parseInt(coverBtn.dataset.photoId, 10);
|
||
const photoPath = coverBtn.dataset.photoPath;
|
||
if (!isNaN(photoId)) {
|
||
try {
|
||
const res = await fetch(`${API}/api/groups/${currentGroupId}/photos/${photoId}/cover`, {
|
||
method: 'PUT',
|
||
headers: hdrJson(),
|
||
});
|
||
if (res.ok) {
|
||
const g = await res.json();
|
||
currentCover = g.cover_path || null;
|
||
const grp = allGroups.find(x => String(x.id) === String(currentGroupId));
|
||
if (grp) grp.cover_path = currentCover;
|
||
showToast('Обложка обновлена');
|
||
updateGroupCoverUI(currentCover);
|
||
loadGroupPhotos();
|
||
} else {
|
||
const err = await res.json();
|
||
alert(err.error || 'Ошибка');
|
||
}
|
||
} catch (err) {
|
||
alert('Ошибка сети: ' + err.message);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
const ed = e.target.closest('[data-edit-photo]');
|
||
if (ed) { openEditPhoto(parseInt(ed.dataset.editPhoto, 10)); return; }
|
||
const dl = e.target.closest('[data-del-photo]');
|
||
if (dl) { deletePhoto(parseInt(dl.dataset.delPhoto, 10)); }
|
||
});
|
||
|
||
async function populateNewGroupBranch() {
|
||
const res = await fetch(`${API}/api/branches`, { headers: hdr() });
|
||
if (res.ok) {
|
||
const branches = await res.json();
|
||
const sel = document.getElementById('newGroupBranch');
|
||
sel.innerHTML = '<option value="">— без филиала —</option>' + branches.map(b => `<option value="${b.id}">${esc(b.name)}</option>`).join('');
|
||
}
|
||
const tRes = await fetch(`${API}/api/users/tutors`, { headers: hdr() });
|
||
if (tRes.ok) {
|
||
const tutors = await tRes.json();
|
||
const tSel = document.getElementById('newGroupTutor');
|
||
tSel.innerHTML = '<option value="">— не назначен —</option>' + tutors.map(t => `<option value="${t.id}">${esc(t.name || t.username)}</option>`).join('');
|
||
}
|
||
}
|
||
|
||
(async () => {
|
||
if (await checkAuth()) {
|
||
buildSidebar(document.body.dataset.page);
|
||
await populateNewGroupBranch();
|
||
loadGroups();
|
||
}
|
||
})(); |