326 lines
14 KiB
HTML
326 lines
14 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Группы — Админ-панель</title>
|
||
<link rel="stylesheet" href="admin.css">
|
||
</head>
|
||
<body data-page="groups">
|
||
<div class="layout">
|
||
<div class="sidebar" id="sidebar"></div>
|
||
<div class="main">
|
||
<h2>Группы</h2>
|
||
<div class="add-row">
|
||
<input type="text" id="newGroup" placeholder="Название группы">
|
||
<button onclick="addGroup()">Добавить</button>
|
||
</div>
|
||
<div class="list" id="groupList"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-overlay" id="photosModal" onclick="if(event.target===this)closePhotos()">
|
||
<div class="gallery-modal">
|
||
<div class="gallery-head">
|
||
<h3 id="photosTitle">Фото</h3>
|
||
<button class="upload-btn" onclick="openAddPhoto()">Загрузить фото</button>
|
||
</div>
|
||
<div class="photo-grid" id="photoGrid"><div class="empty">Загрузка...</div></div>
|
||
<div class="pager" id="photosPager"></div>
|
||
<div class="gallery-foot">
|
||
<button class="cancel" onclick="closePhotos()">Закрыть</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-overlay" id="photoFormModal" onclick="if(event.target===this)closePhotoForm()">
|
||
<div class="edit-modal">
|
||
<h3 id="photoFormTitle">Добавить фото</h3>
|
||
<div>
|
||
<label>Выбрать файл (jpg/png и т.п.)</label>
|
||
<input type="file" id="photoFile" accept="image/*">
|
||
</div>
|
||
<div id="photoFormPreview" class="photo-preview" style="display:none"><img id="photoPreviewImg" alt=""></div>
|
||
<div>
|
||
<label>Подпись</label>
|
||
<textarea id="photoCaption" placeholder="Например: выступление, тренировка..."></textarea>
|
||
</div>
|
||
<div>
|
||
<label>Дата съёмки</label>
|
||
<input type="date" id="photoTakenAt">
|
||
</div>
|
||
<div class="actions">
|
||
<button class="cancel" onclick="closePhotoForm()">Отмена</button>
|
||
<button class="save" onclick="savePhotoForm()">Сохранить</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
<script src="admin.js"></script>
|
||
<script src="vendor/jszip.min.js"></script>
|
||
<script>
|
||
// --- Groups ---
|
||
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
|
||
|
||
async function loadGroups() {
|
||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||
const groups = await res.json();
|
||
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">
|
||
${g.cover_path
|
||
? `<img class="group-cover" src="${API}${g.cover_path}" onclick="openPhotos(${g.id}, '${esc(g.name).replace(/'/g, ''')}')" title="Фото группы (хронология)" alt="">`
|
||
: `<div class="group-cover empty-cover" onclick="openPhotos(${g.id}, '${esc(g.name).replace(/'/g, ''')}')" title="Фото группы (хронология)">📷</div>`}
|
||
<div class="group-head">
|
||
<input class="name-input" id="name-${g.id}" value="${esc(g.name)}">
|
||
<button class="del" onclick="delGroup(${g.id})" title="Удалить">×</button>
|
||
</div>
|
||
<div class="group-schedule">
|
||
<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" onclick="saveGroup(${g.id})">Сохранить</button>
|
||
<button class="photo-btn" onclick="openPhotos(${g.id}, '${esc(g.name).replace(/'/g, ''')}')" title="Фото группы (хронология)">Фото</button>
|
||
<button class="btn-link" onclick="exportGroup(${g.id}, '${esc(g.name).replace(/'/g, ''')}')" title="Экспортировать записи группы">CSV</button>
|
||
<button class="btn-link" onclick="exportGroupUrls(${g.id}, '${esc(g.name).replace(/'/g, ''')}')" title="Скачать .url файлы учеников группы">URL</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 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, 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;
|
||
const res = await fetch(`${API}/api/groups`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name }) });
|
||
if (res.ok) { input.value = ''; loadGroups(); }
|
||
else { const e = await res.json(); alert(e.error); }
|
||
}
|
||
|
||
async function delGroup(id) {
|
||
if (!confirm('Удалить группу? Фото группы также будут удалены.')) return;
|
||
await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() });
|
||
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;
|
||
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')}`;
|
||
return `
|
||
<div class="photo-item">
|
||
<img src="${API}${p.photo_path}" onclick="showImg('${API}${p.photo_path}')" alt="">
|
||
<div class="p-cap">${esc(p.caption || '')}</div>
|
||
<div class="p-date">${taken ? taken + ' · ' : ''}${uploaded}</div>
|
||
<div class="p-actions">
|
||
<button class="p-edit" onclick="openEditPhoto(${p.id})" title="Редактировать">✎</button>
|
||
<button class="p-del" onclick="deletePhoto(${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; }
|
||
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}`);
|
||
}
|
||
|
||
document.getElementById('newGroup').addEventListener('keydown', e => { if (e.key === 'Enter') addGroup(); });
|
||
document.getElementById('photoFile').addEventListener('change', previewPhotoFile);
|
||
|
||
(async () => {
|
||
if (await checkAuth()) {
|
||
buildSidebar(document.body.dataset.page);
|
||
loadGroups();
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html> |