refactor: move frontend JS to external files and enable strict CSP

This commit is contained in:
dev
2026-09-09 10:06:49 +03:00
parent d6e589d2f5
commit 018c65adc5
34 changed files with 2444 additions and 2307 deletions
+7 -349
View File
@@ -14,27 +14,27 @@
<div class="add-row" style="align-items:center;gap:8px">
<input type="text" id="newGroup" placeholder="Название группы" style="flex:1;max-width:300px">
<select id="newGroupBranch" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;min-width:180px"><option value="">— без филиала —</option></select>
<button onclick="addGroup()">Добавить</button>
<button type="button" id="addGroupBtn">Добавить</button>
</div>
<div class="list" id="groupList"></div>
</div>
</div>
<div class="modal-overlay" id="photosModal" onclick="if(event.target===this)closePhotos()">
<div class="modal-overlay" id="photosModal">
<div class="gallery-modal">
<div class="gallery-head">
<h3 id="photosTitle">Фото</h3>
<button class="upload-btn" onclick="openAddPhoto()">Загрузить фото</button>
<button class="upload-btn" type="button" id="openPhotoBtn">Загрузить фото</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>
<button class="cancel" type="button" id="closePhotosBtn">Закрыть</button>
</div>
</div>
</div>
<div class="modal-overlay" id="photoFormModal" onclick="if(event.target===this)closePhotoForm()">
<div class="modal-overlay" id="photoFormModal">
<div class="edit-modal" style="max-width:480px">
<h3 id="photoFormTitle">Добавить фото</h3>
<form id="photoForm" class="settings-stack" style="gap:12px">
@@ -62,7 +62,7 @@
</div>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" onclick="closePhotoForm()">Отмена</button>
<button type="button" class="btn-primary ghost" id="closePhotoFormBtn">Отмена</button>
<button type="submit" class="btn-primary">Сохранить</button>
</div>
</form>
@@ -72,348 +72,6 @@
<div class="toast" id="toast"></div>
<script src="admin.js"></script>
<script src="vendor/jszip.min.js"></script>
<script>
// --- Groups ---
const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
let branchesCache = [];
let allGroups = [];
let currentCover = null;
async function loadBranchesCache() {
const res = await fetch(`${API}/api/branches`, { headers: hdr() });
if (res.ok) branchesCache = await res.json();
}
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();
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">
${g.cover_path
? `<img class="group-cover" src="${API}${g.cover_path}" data-group-id="${g.id}" data-group-name="${esc(g.name).replace(/"/g, '"')}" title="Фото группы (хронология)" alt="">`
: `<div class="group-cover empty-cover" data-group-id="${g.id}" data-group-name="${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="Удалить">&times;</button>
</div>
<div class="group-schedule">
${branchSelectHTML(g.branch_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" onclick="saveGroup(${g.id})">Сохранить</button>
<button class="photo-btn" data-group-id="${g.id}" data-group-name="${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 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 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, 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();
const branchSelect = document.getElementById('newGroupBranch');
const branch_id = branchSelect ? branchSelect.value : '';
const res = await fetch(`${API}/api/groups`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name, branch_id: branch_id || null }) });
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;
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)}">
<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-cover${isCover ? ' active' : ''}" data-photo-id="${p.id}" data-photo-path="${esc(p.photo_path)}" title="${isCover ? 'Текущая обложка' : 'Сделать обложкой'}" ${isCover ? 'disabled' : ''}>🖼️</button>
<button class="p-edit" onclick="openEditPhoto(${p.id})" title="Редактировать">✎</button>
<button class="p-del" onclick="deletePhoto(${p.id})" title="Удалить">&times;</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);
document.getElementById('photoForm').addEventListener('submit', e => {
e.preventDefault();
savePhotoForm();
});
// Event delegation for group cover clicks
document.getElementById('groupList').addEventListener('click', e => {
const target = e.target.closest('[data-group-id]');
if (target) {
const id = parseInt(target.dataset.groupId, 10);
const name = target.dataset.groupName;
if (!isNaN(id)) openPhotos(id, name);
}
});
// Event delegation for photo actions in modal
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;
showToast('Обложка обновлена');
loadGroupPhotos();
loadGroups();
} else {
const err = await res.json();
alert(err.error || 'Ошибка');
}
} catch (err) {
alert('Ошибка сети: ' + err.message);
}
}
}
});
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('');
}
}
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
await populateNewGroupBranch();
loadGroups();
}
})();
</script>
<script src="js/groups.js"></script>
</body>
</html>