refactor: move frontend JS to external files and enable strict CSP
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
let currentEntries = [];
|
||||
let editId = null;
|
||||
let editGroups = [];
|
||||
let groupsLoaded = false;
|
||||
let studentsLoaded = false;
|
||||
const PAGE_SIZE = 20;
|
||||
let page = 1;
|
||||
let totalEntries = 0;
|
||||
let searchTimer = null;
|
||||
let view = localStorage.getItem('journalView') === 'cards' ? 'cards' : 'list';
|
||||
|
||||
function isImageFile(name) {
|
||||
return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
||||
}
|
||||
|
||||
function filesHTML(files) {
|
||||
if (!files || !files.length) return '';
|
||||
return `<div class="entry-files">${files.map(f => {
|
||||
const link = isImageFile(f.name)
|
||||
? `<a class="f-link" href="#" data-img="${API}/api/files/${f.token}" title="Открыть">${esc(f.name)}</a>`
|
||||
: `<a class="f-link" href="${API}/api/files/${f.token}" download title="Скачать">${esc(f.name)}</a>`;
|
||||
return `<span class="f-item">${link}<button class="f-detach" data-detach="${f.id}" title="Открепить файл">✕</button></span>`;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
|
||||
function findFile(id) {
|
||||
for (const e of currentEntries) {
|
||||
const f = (e.files || []).find(x => x.id === id);
|
||||
if (f) return f;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function detachFile(id) {
|
||||
const f = findFile(id);
|
||||
if (!f) return;
|
||||
if (!confirm(`Открепить файл «${f.name}» от записи? Он переместится в «Файлы → Откреплённые».`)) return;
|
||||
const res = await fetch(`${API}/api/files/${id}/detach`, { method: 'POST', headers: hdr() });
|
||||
if (res.ok) {
|
||||
showToast('Файл откреплён');
|
||||
loadEntries();
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
alert(err.error || 'Ошибка');
|
||||
}
|
||||
}
|
||||
|
||||
function gridEntryHTML(e) {
|
||||
return `
|
||||
<div class="card">
|
||||
${e.photo_path ? `<img src="${API}${e.photo_path}" data-img="${API}${e.photo_path}" alt="">` : ''}
|
||||
<div class="info">
|
||||
<div class="name">${esc(e.student_name)}</div>
|
||||
<div class="group">${esc(e.group_name)}</div>
|
||||
<div class="desc">${esc(e.description)}</div>
|
||||
${filesHTML(e.files)}
|
||||
<div class="time">${new Date(e.created_at).toLocaleString('ru')}</div>
|
||||
</div>
|
||||
<button class="edit" data-edit="${e.id}" title="Редактировать">✎</button>
|
||||
<button class="del" data-del="${e.id}" title="Удалить">×</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function cardEntryHTML(e) {
|
||||
return `
|
||||
<div class="journal-card">
|
||||
${e.photo_path
|
||||
? `<img src="${API}${e.photo_path}" data-img="${API}${e.photo_path}" alt="">`
|
||||
: `<div class="no-photo"><svg viewBox="0 0 240 160" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><circle cx="105" cy="48" r="15"/><path d="M74 140 C74 106 88 92 105 92 C122 92 136 106 136 140 Z"/><circle cx="145" cy="58" r="18"/><path d="M108 144 C108 104 124 88 145 88 C166 88 182 104 182 144 Z"/></svg></div>`}
|
||||
<div class="body">
|
||||
<div class="name">${esc(e.student_name)}</div>
|
||||
<div class="group">${esc(e.group_name)}</div>
|
||||
<div class="desc">${esc(e.description)}</div>
|
||||
${filesHTML(e.files)}
|
||||
</div>
|
||||
<div class="foot">
|
||||
<span>${new Date(e.created_at).toLocaleString('ru')}</span>
|
||||
<span>
|
||||
<button class="edit" data-edit="${e.id}" title="Редактировать">✎</button>
|
||||
<button class="del" data-del="${e.id}" title="Удалить">×</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
const grid = document.getElementById('grid');
|
||||
if (!currentEntries.length) {
|
||||
grid.className = 'grid';
|
||||
grid.innerHTML = '<div class="empty">Ничего не найдено</div>';
|
||||
return;
|
||||
}
|
||||
if (view === 'cards') {
|
||||
grid.className = 'journal-cards';
|
||||
grid.innerHTML = currentEntries.map(cardEntryHTML).join('');
|
||||
} else {
|
||||
grid.className = 'grid';
|
||||
grid.innerHTML = currentEntries.map(gridEntryHTML).join('');
|
||||
}
|
||||
}
|
||||
|
||||
function setView(v) {
|
||||
view = v === 'cards' ? 'cards' : 'list';
|
||||
localStorage.setItem('journalView', view);
|
||||
document.querySelectorAll('#viewToggle button').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.view === view);
|
||||
});
|
||||
renderGrid();
|
||||
}
|
||||
|
||||
function setDefaultDates() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
document.getElementById('dateFrom').value = today;
|
||||
document.getElementById('dateTo').value = today;
|
||||
}
|
||||
|
||||
async function loadGroupOptions() {
|
||||
if (groupsLoaded) return;
|
||||
const sel = document.getElementById('groupFilter');
|
||||
while (sel.options.length > 1) sel.remove(1);
|
||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||
const groups = await res.json();
|
||||
groups.forEach(g => {
|
||||
const o = document.createElement('option');
|
||||
o.value = g.id; o.textContent = g.name;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
groupsLoaded = true;
|
||||
}
|
||||
|
||||
async function loadStudentOptions() {
|
||||
if (studentsLoaded) return;
|
||||
const dl = document.getElementById('studentFilterList');
|
||||
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
|
||||
const names = await res.json();
|
||||
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
|
||||
studentsLoaded = true;
|
||||
}
|
||||
|
||||
async function loadEntries() {
|
||||
const p = new URLSearchParams();
|
||||
const q = document.getElementById('searchInput').value;
|
||||
const sn = document.getElementById('studentFilter').value;
|
||||
const gid = document.getElementById('groupFilter').value;
|
||||
const df = document.getElementById('dateFrom').value;
|
||||
const dt = document.getElementById('dateTo').value;
|
||||
if (q) p.set('search', q);
|
||||
if (sn) p.set('student_name', sn);
|
||||
if (gid) p.set('group_id', gid);
|
||||
if (df) p.set('date_from', df);
|
||||
if (dt) p.set('date_to', dt);
|
||||
p.set('limit', PAGE_SIZE);
|
||||
p.set('offset', (page - 1) * PAGE_SIZE);
|
||||
const res = await fetch(`${API}/api/entries?${p}`, { headers: hdr() });
|
||||
const data = await res.json();
|
||||
currentEntries = data.entries;
|
||||
totalEntries = data.total;
|
||||
renderGrid();
|
||||
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalEntries / PAGE_SIZE)), goPage);
|
||||
}
|
||||
|
||||
function goPage(p) {
|
||||
page = p;
|
||||
loadEntries();
|
||||
}
|
||||
|
||||
async function openEdit(id) {
|
||||
const e = currentEntries.find(x => x.id === id);
|
||||
if (!e) return;
|
||||
editId = id;
|
||||
fillAutocomplete();
|
||||
document.getElementById('editName').value = e.student_name;
|
||||
document.getElementById('editDesc').value = e.description;
|
||||
const sel = document.getElementById('editGroup');
|
||||
if (!editGroups.length) {
|
||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||
editGroups = await res.json();
|
||||
sel.innerHTML = editGroups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
|
||||
}
|
||||
sel.value = e.group_id;
|
||||
document.getElementById('editModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeEdit() {
|
||||
document.getElementById('editModal').classList.remove('open');
|
||||
document.getElementById('editFiles').value = '';
|
||||
document.getElementById('editFilesPreview').innerHTML = '';
|
||||
editId = null;
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editId) return;
|
||||
const name = document.getElementById('editName').value.trim();
|
||||
const group_id = document.getElementById('editGroup').value;
|
||||
const description = document.getElementById('editDesc').value.trim();
|
||||
if (!name || !group_id || !description) { alert('Заполните все поля'); return; }
|
||||
const res = await fetch(`${API}/api/entries/${editId}`, {
|
||||
method: 'PUT',
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify({ student_name: name, group_id, description })
|
||||
});
|
||||
if (!res.ok) { const err = await res.json(); alert(err.error); return; }
|
||||
|
||||
const files = document.getElementById('editFiles').files;
|
||||
if (files.length) {
|
||||
const formData = new FormData();
|
||||
for (const f of files) formData.append('files', f);
|
||||
const uploadRes = await fetch(`${API}/api/entries/${editId}/files`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Admin-Token': token },
|
||||
body: formData
|
||||
});
|
||||
if (!uploadRes.ok) {
|
||||
const err = await uploadRes.json().catch(() => ({}));
|
||||
alert(err.error || 'Ошибка загрузки файлов');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
closeEdit();
|
||||
loadEntries();
|
||||
showToast('Запись обновлена');
|
||||
}
|
||||
|
||||
async function delEntry(id) {
|
||||
if (!confirm('Переместить запись в корзину? Файлы и фото сохранятся.')) return;
|
||||
await fetch(`${API}/api/entries/${id}`, { method: 'DELETE', headers: hdr() });
|
||||
showToast('Запись перемещена в корзину');
|
||||
loadEntries();
|
||||
}
|
||||
|
||||
document.getElementById('studentFilter').addEventListener('change', () => { page = 1; loadEntries(); });
|
||||
document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadEntries(); });
|
||||
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadEntries(); });
|
||||
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadEntries(); });
|
||||
document.getElementById('searchInput').addEventListener('input', () => {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => { page = 1; loadEntries(); }, 300);
|
||||
});
|
||||
document.getElementById('editForm').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
saveEdit();
|
||||
});
|
||||
document.getElementById('editFiles').addEventListener('change', (e) => {
|
||||
const preview = document.getElementById('editFilesPreview');
|
||||
preview.innerHTML = '';
|
||||
Array.from(e.target.files).forEach((f, i) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'file-preview-item';
|
||||
div.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:.8rem';
|
||||
div.innerHTML = `<span>${esc(f.name)}</span> <span style="color:var(--muted)">${(f.size/1024).toFixed(1)} KB</span> <button type="button" data-remove-file="${i}" style="background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;line-height:1">✕</button>`;
|
||||
preview.appendChild(div);
|
||||
});
|
||||
});
|
||||
|
||||
function removeEditFile(index) {
|
||||
const input = document.getElementById('editFiles');
|
||||
const dt = new DataTransfer();
|
||||
Array.from(input.files).forEach((f, i) => { if (i !== index) dt.items.add(f); });
|
||||
input.files = dt.files;
|
||||
input.dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
async function exportCSV() {
|
||||
const p = new URLSearchParams();
|
||||
const q = document.getElementById('searchInput').value;
|
||||
const sn = document.getElementById('studentFilter').value;
|
||||
const gid = document.getElementById('groupFilter').value;
|
||||
const df = document.getElementById('dateFrom').value;
|
||||
const dt = document.getElementById('dateTo').value;
|
||||
if (q) p.set('search', q);
|
||||
if (sn) p.set('student_name', sn);
|
||||
if (gid) p.set('group_id', gid);
|
||||
if (df) p.set('date_from', df);
|
||||
if (dt) p.set('date_to', dt);
|
||||
const res = await fetch(`${API}/api/entries?${p}`, { headers: hdr() });
|
||||
const entries = (await res.json()).entries;
|
||||
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'),
|
||||
e.photo_path ? `${API}${e.photo_path}` : ''
|
||||
]);
|
||||
});
|
||||
const csv = rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(';')).join('\n');
|
||||
const bom = '\uFEFF';
|
||||
const blob = new Blob([bom + csv], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const dateLabel = df && dt ? `${df}_${dt}` : 'all';
|
||||
a.download = `journal_${dateLabel}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
let linkGroups = [];
|
||||
let linkStudentsLoaded = false;
|
||||
let createdLinkUrl = '';
|
||||
|
||||
function fmtD(d) { return d ? d.split('-').reverse().join('.') : 'весь период'; }
|
||||
|
||||
async function openLinkCreate() {
|
||||
const gid = document.getElementById('groupFilter').value;
|
||||
if (!gid) { alert('Сначала выберите группу в фильтре'); return; }
|
||||
const df = document.getElementById('dateFrom').value;
|
||||
const dt = document.getElementById('dateTo').value;
|
||||
if (!linkGroups.length) {
|
||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||
linkGroups = await res.json();
|
||||
}
|
||||
if (!linkStudentsLoaded) {
|
||||
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
|
||||
const names = await res.json();
|
||||
const dl = document.getElementById('linkStudentList');
|
||||
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
|
||||
linkStudentsLoaded = true;
|
||||
}
|
||||
const currentStudent = document.getElementById('studentFilter').value;
|
||||
const g = linkGroups.find(x => String(x.id) === String(gid));
|
||||
document.getElementById('linkPreview').innerHTML =
|
||||
`Будут показаны записи: <b>${esc(g ? g.name : '')}</b><br>Период: <b>${fmtD(df)}</b> — <b>${fmtD(dt)}</b>`;
|
||||
document.getElementById('linkGroupPreview').value = g ? g.name : '';
|
||||
const dfl = df.split('-').reverse().join('.');
|
||||
document.getElementById('linkNameInput').value = `${g ? g.name : ''} ${dfl}`;
|
||||
document.getElementById('linkStudent').value = currentStudent;
|
||||
document.getElementById('linkExpiresDays').value = '7';
|
||||
document.getElementById('linkAnonymizeNames').checked = false;
|
||||
document.getElementById('linkPassword').value = '';
|
||||
document.getElementById('linkCreateView').style.display = '';
|
||||
document.getElementById('linkResultView').style.display = 'none';
|
||||
document.getElementById('linkModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeLinkCreate() {
|
||||
document.getElementById('linkModal').classList.remove('open');
|
||||
}
|
||||
|
||||
async function createLink() {
|
||||
const name = document.getElementById('linkNameInput').value.trim();
|
||||
const group_id = document.getElementById('groupFilter').value;
|
||||
const student_name = document.getElementById('linkStudent').value;
|
||||
const date_from = document.getElementById('dateFrom').value;
|
||||
const date_to = document.getElementById('dateTo').value;
|
||||
const expires_days = parseInt(document.getElementById('linkExpiresDays').value, 10);
|
||||
const anonymize_names = document.getElementById('linkAnonymizeNames').checked;
|
||||
const access_password = document.getElementById('linkPassword').value;
|
||||
if (!name) { alert('Введите название'); return; }
|
||||
if (expires_days < 1 || expires_days > 365) { alert('Дней истечения: 1-365'); return; }
|
||||
const expires_at = new Date(Date.now() + expires_days * 24 * 60 * 60 * 1000).toISOString();
|
||||
const res = await fetch(`${API}/api/links`, {
|
||||
method: 'POST',
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify({ name, group_id, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, anonymize_names, expires_at, access_password: access_password || null })
|
||||
});
|
||||
if (!res.ok) { const err = await res.json(); alert(err.error || 'Ошибка'); return; }
|
||||
const link = await res.json();
|
||||
createdLinkUrl = `${location.origin}/s/${link.token}`;
|
||||
document.getElementById('linkUrlResult').value = createdLinkUrl;
|
||||
document.getElementById('linkCreateView').style.display = 'none';
|
||||
document.getElementById('linkResultView').style.display = '';
|
||||
showToast('Ссылка создана');
|
||||
}
|
||||
|
||||
function copyLink() { copyText(createdLinkUrl); }
|
||||
|
||||
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
|
||||
document.getElementById('editModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeEdit(); });
|
||||
document.getElementById('linkModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeLinkCreate(); });
|
||||
|
||||
document.getElementById('grid').addEventListener('click', e => {
|
||||
const det = e.target.closest('[data-detach]');
|
||||
if (det) { detachFile(parseInt(det.dataset.detach, 10)); return; }
|
||||
const ed = e.target.closest('[data-edit]');
|
||||
if (ed) { openEdit(parseInt(ed.dataset.edit, 10)); return; }
|
||||
const dl = e.target.closest('[data-del]');
|
||||
if (dl) { delEntry(parseInt(dl.dataset.del, 10)); }
|
||||
});
|
||||
|
||||
document.getElementById('editFilesPreview').addEventListener('click', e => {
|
||||
const btn = e.target.closest('[data-remove-file]');
|
||||
if (btn) removeEditFile(parseInt(btn.dataset.removeFile, 10));
|
||||
});
|
||||
|
||||
document.getElementById('exportCsvBtn').addEventListener('click', exportCSV);
|
||||
document.getElementById('createLinkBtn').addEventListener('click', openLinkCreate);
|
||||
document.querySelectorAll('#viewToggle button').forEach(b => b.addEventListener('click', () => setView(b.dataset.view)));
|
||||
document.getElementById('editCancelBtn').addEventListener('click', closeEdit);
|
||||
document.getElementById('linkCreateCloseBtn').addEventListener('click', closeLinkCreate);
|
||||
document.getElementById('linkCreateSaveBtn').addEventListener('click', createLink);
|
||||
document.getElementById('linkCopyBtn').addEventListener('click', copyLink);
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
document.querySelectorAll('#viewToggle button').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.view === view);
|
||||
});
|
||||
setDefaultDates();
|
||||
loadGroupOptions();
|
||||
loadStudentOptions();
|
||||
loadEntries();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user