refactor: move frontend JS to external files and enable strict CSP
This commit is contained in:
@@ -23,5 +23,8 @@ tmp/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# --- Playwright test artifacts ---
|
||||
.playwright-mcp/
|
||||
|
||||
# --- Internal audit (not for commit) ---
|
||||
SECURITY_AUDIT.md
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
- generic [ref=f1e2]:
|
||||
- heading "Админ-панель" [level=1] [ref=f1e3]
|
||||
- textbox "Пароль" [ref=f1e4]
|
||||
- button "Войти" [ref=f1e5] [cursor=pointer]
|
||||
- link "На главную" [ref=f1e7] [cursor=pointer]:
|
||||
- /url: /
|
||||
@@ -1,6 +0,0 @@
|
||||
- generic [ref=f3e2]:
|
||||
- heading "Админ-панель" [level=1] [ref=f3e3]
|
||||
- textbox "Пароль" [active] [ref=f3e4]
|
||||
- button "Войти" [ref=f3e5] [cursor=pointer]
|
||||
- link "На главную" [ref=f3e7] [cursor=pointer]:
|
||||
- /url: /
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Админ-панель</title>
|
||||
<script>location.replace('dashboard.html')</script>
|
||||
<meta http-equiv="refresh" content="0;url=dashboard.html">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
|
||||
+22
-4
@@ -42,7 +42,7 @@ function buildSidebar(active) {
|
||||
</nav>
|
||||
<div class="bottom">
|
||||
<a href="/">На главную</a>
|
||||
<a class="logout" onclick="doLogout()">Выйти</a>
|
||||
<a class="logout" href="#" data-action="logout">Выйти</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -137,14 +137,32 @@ function copyText(text) {
|
||||
function renderPager(el, page, totalPages, onPage) {
|
||||
if (!el) return;
|
||||
if (totalPages <= 1) { el.innerHTML = ''; return; }
|
||||
let html = `<button ${page === 1 ? 'disabled' : ''} onclick="(${onPage})(${page - 1})">‹</button>`;
|
||||
let html = `<button ${page === 1 ? 'disabled' : ''} data-nav="-1">‹</button>`;
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
if (totalPages > 9 && i > 2 && i < totalPages - 1 && Math.abs(i - page) > 2) {
|
||||
if (i === 3) html += `<button disabled>…</button>`;
|
||||
continue;
|
||||
}
|
||||
html += `<button class="${i === page ? 'active' : ''}" onclick="(${onPage})(${i})">${i}</button>`;
|
||||
html += `<button class="${i === page ? 'active' : ''}" data-page="${i}">${i}</button>`;
|
||||
}
|
||||
html += `<button ${page === totalPages ? 'disabled' : ''} onclick="(${onPage})(${page + 1})">›</button>`;
|
||||
html += `<button ${page === totalPages ? 'disabled' : ''} data-nav="1">›</button>`;
|
||||
el.innerHTML = html;
|
||||
el._onPage = onPage;
|
||||
el._page = page;
|
||||
if (!el._bound) {
|
||||
el._bound = true;
|
||||
el.addEventListener('click', e => {
|
||||
const btn = e.target.closest('button');
|
||||
if (!btn || btn.disabled) return;
|
||||
const p = btn.dataset.page ? parseInt(btn.dataset.page, 10) : el._page + parseInt(btn.dataset.nav, 10);
|
||||
if (el._onPage) el._onPage(p);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
const imgEl = e.target.closest('[data-img]');
|
||||
if (imgEl) { e.preventDefault(); showImg(imgEl.dataset.img); return; }
|
||||
const out = e.target.closest('[data-action="logout"]');
|
||||
if (out) { e.preventDefault(); doLogout(); }
|
||||
});
|
||||
+2
-73
@@ -17,7 +17,7 @@
|
||||
|
||||
<div class="actions-row">
|
||||
<input type="search" id="auditFilter" class="settings-input" placeholder="Фильтр по действию..." style="max-width:300px">
|
||||
<button class="btn-link" onclick="loadAudit()" title="Обновить">Обновить</button>
|
||||
<button class="btn-link" id="auditRefreshBtn" type="button" title="Обновить">Обновить</button>
|
||||
<span class="hint" id="auditCount"></span>
|
||||
</div>
|
||||
|
||||
@@ -39,77 +39,6 @@
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="admin.js"></script>
|
||||
<script>
|
||||
const ACTION_LABELS = {
|
||||
'settings.update': 'Изменены настройки',
|
||||
'group.create': 'Создана группа',
|
||||
'group.update': 'Изменена группа',
|
||||
'group.delete': 'Удалена группа',
|
||||
'group.photo.create': 'Загружена фото группы',
|
||||
'group.photo.update': 'Изменено фото группы',
|
||||
'group.photo.delete': 'Удалено фото группы',
|
||||
'student.create': 'Добавлен ученик',
|
||||
'student.update': 'Изменён ученик',
|
||||
'student.delete': 'Удалён ученик',
|
||||
'student.batch-group': 'Ученики перенесены в группу',
|
||||
'entry.update': 'Изменена запись',
|
||||
'entry.soft-delete': 'Запись в корзину',
|
||||
'entry.restore': 'Запись восстановлена',
|
||||
'entry.permanent-delete': 'Запись удалена безвозвратно',
|
||||
'link.create': 'Создана ссылка',
|
||||
'link.update': 'Изменена ссылка',
|
||||
'link.delete': 'Удалена ссылка',
|
||||
'backup.download': 'Скачан бэкап',
|
||||
'backup.restore': 'Выполнено восстановление',
|
||||
};
|
||||
|
||||
function fmtTime(t) {
|
||||
try { return new Date(t).toLocaleString('ru'); } catch { return t || ''; }
|
||||
}
|
||||
|
||||
function dt(t) {
|
||||
if (!t) return '';
|
||||
if (typeof t === 'object') {
|
||||
return esc(JSON.stringify(t).slice(0, 120));
|
||||
}
|
||||
try {
|
||||
const o = JSON.parse(t);
|
||||
return esc(JSON.stringify(o).slice(0, 120));
|
||||
} catch { return esc(String(t).slice(0, 120)); }
|
||||
}
|
||||
|
||||
async function loadAudit() {
|
||||
const el = document.getElementById('auditTable').querySelector('tbody');
|
||||
el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Загрузка…</td></tr>';
|
||||
const res = await fetch(`${API}/api/audit?limit=500`, { headers: hdr() });
|
||||
if (!res.ok) { el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Ошибка загрузки</td></tr>'; return; }
|
||||
const rows = await res.json();
|
||||
document.getElementById('auditCount').textContent = rows.length ? `Записей: ${rows.length}` : '';
|
||||
const q = (document.getElementById('auditFilter').value || '').toLowerCase().trim();
|
||||
const filtered = q ? rows.filter(r => (ACTION_LABELS[r.action] || r.action).toLowerCase().includes(q)) : rows;
|
||||
if (!filtered.length) {
|
||||
document.getElementById('auditEmpty').style.display = 'block';
|
||||
el.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
document.getElementById('auditEmpty').style.display = 'none';
|
||||
el.innerHTML = filtered.map(r => `
|
||||
<tr>
|
||||
<td style="white-space:nowrap">${fmtTime(r.created_at)}</td>
|
||||
<td><b>${esc(ACTION_LABELS[r.action] || r.action)}</b></td>
|
||||
<td style="color:var(--muted)">${dt(r.target)}</td>
|
||||
<td style="color:var(--muted);white-space:nowrap">${esc(r.ip || '')}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
document.getElementById('auditFilter').addEventListener('input', loadAudit);
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
loadAudit();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="js/audit.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+5
-111
@@ -16,7 +16,7 @@
|
||||
</div>
|
||||
|
||||
<div class="actions-row" style="margin-bottom:16px">
|
||||
<button class="btn-primary" onclick="openBranchModal()" id="addBranchBtn">
|
||||
<button class="btn-primary" id="addBranchBtn" type="button">
|
||||
<span style="font-size:1.2rem">+</span> Добавить филиал
|
||||
</button>
|
||||
</div>
|
||||
@@ -28,7 +28,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Branch Modal -->
|
||||
<div class="modal-overlay" id="branchModal" onclick="if(event.target===this)closeBranchModal()">
|
||||
<div class="modal-overlay" id="branchModal">
|
||||
<div class="edit-modal" style="max-width:480px">
|
||||
<h3 id="branchModalTitle">Создать филиал</h3>
|
||||
<form id="branchForm" class="settings-stack" style="gap:12px">
|
||||
@@ -52,125 +52,19 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||
<button type="button" class="btn-primary ghost" onclick="closeBranchModal()">Отмена</button>
|
||||
<button type="button" class="btn-primary ghost" id="branchCancelBtn">Отмена</button>
|
||||
<button type="submit" class="btn-primary" id="branchSubmitBtn">Создать</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="imgModal" onclick="this.classList.remove('open')">
|
||||
<div class="modal-overlay" id="imgModal">
|
||||
<img id="imgModalSrc" alt="">
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="admin.js"></script>
|
||||
<script>
|
||||
let branches = [];
|
||||
|
||||
async function loadBranches() {
|
||||
const res = await fetch(`${API}/api/branches`, { headers: hdr() });
|
||||
if (!res.ok) return;
|
||||
branches = await res.json();
|
||||
renderBranches();
|
||||
}
|
||||
|
||||
function renderBranches() {
|
||||
const el = document.getElementById('branchesGrid');
|
||||
if (!branches.length) {
|
||||
el.innerHTML = '<div class="empty" style="grid-column:1/-1">Филиалов пока нет. Нажмите «Добавить филиал».</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = branches.map(b => `
|
||||
<div class="branch-card" data-id="${b.id}">
|
||||
<div class="branch-header">
|
||||
<span class="branch-name">${esc(b.name)}</span>
|
||||
<span class="branch-badge">${b.groups_count} ${plural(b.groups_count, 'группа', 'группы', 'групп')}</span>
|
||||
</div>
|
||||
${b.address ? `<div class="branch-meta">📍 ${esc(b.address)}</div>` : ''}
|
||||
${b.phone ? `<div class="branch-meta">📞 ${esc(b.phone)}</div>` : ''}
|
||||
<div class="branch-actions">
|
||||
<button class="branch-edit" onclick="openBranchModal(${b.id})" title="Редактировать">✎</button>
|
||||
${b.groups_count === 0 ? `<button class="branch-delete" onclick="deleteBranch(${b.id})" title="Удалить">×</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function plural(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;
|
||||
}
|
||||
|
||||
function openBranchModal(id = null) {
|
||||
const modal = document.getElementById('branchModal');
|
||||
const form = document.getElementById('branchForm');
|
||||
const title = document.getElementById('branchModalTitle');
|
||||
const submitBtn = document.getElementById('branchSubmitBtn');
|
||||
form.reset();
|
||||
document.getElementById('branchId').value = '';
|
||||
if (id) {
|
||||
const b = branches.find(x => x.id === id);
|
||||
if (!b) return;
|
||||
title.textContent = 'Редактировать филиал';
|
||||
submitBtn.textContent = 'Сохранить';
|
||||
form.name.value = b.name;
|
||||
form.address.value = b.address || '';
|
||||
form.phone.value = b.phone || '';
|
||||
document.getElementById('branchId').value = b.id;
|
||||
} else {
|
||||
title.textContent = 'Создать филиал';
|
||||
submitBtn.textContent = 'Создать';
|
||||
}
|
||||
modal.classList.add('open');
|
||||
setTimeout(() => form.name.focus(), 100);
|
||||
}
|
||||
|
||||
function closeBranchModal() {
|
||||
document.getElementById('branchModal').classList.remove('open');
|
||||
}
|
||||
|
||||
document.getElementById('branchForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
const id = fd.get('id');
|
||||
const name = fd.get('name').trim();
|
||||
const address = fd.get('address').trim();
|
||||
const phone = fd.get('phone').trim();
|
||||
if (!name) { alert('Название обязательно'); return; }
|
||||
|
||||
const url = id ? `${API}/api/branches/${id}` : `${API}/api/branches`;
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify({ name, address: address || null, phone: phone || null })
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast(id ? 'Филиал обновлён' : 'Филиал создан');
|
||||
closeBranchModal();
|
||||
loadBranches();
|
||||
} else { const err = await res.json(); alert(err.error || 'Ошибка'); }
|
||||
});
|
||||
|
||||
async function deleteBranch(id) {
|
||||
if (!confirm('Удалить этот филиал?')) return;
|
||||
const res = await fetch(`${API}/api/branches/${id}`, { method: 'DELETE', headers: hdr() });
|
||||
if (res.ok) {
|
||||
showToast('Филиал удалён');
|
||||
loadBranches();
|
||||
} else { const err = await res.json(); alert(err.error || 'Ошибка'); }
|
||||
}
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
loadBranches();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="js/branches.js"></script>
|
||||
<style>
|
||||
.branches-grid {
|
||||
display: grid;
|
||||
|
||||
+2
-111
@@ -60,120 +60,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="imgModal" onclick="this.classList.remove('open')">
|
||||
<div class="modal-overlay" id="imgModal">
|
||||
<img id="imgModalSrc" alt="">
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="admin.js"></script>
|
||||
<script>
|
||||
function plural(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;
|
||||
}
|
||||
|
||||
function renderStats(s) {
|
||||
document.getElementById('stats').innerHTML = `
|
||||
<div class="stat"><div class="num">${s.entries}</div><div class="label">Всего записей</div></div>
|
||||
<div class="stat"><div class="num">${s.students}</div><div class="label">Учеников</div></div>
|
||||
<div class="stat"><div class="num">${s.groups}</div><div class="label">Групп</div></div>
|
||||
<div class="stat"><div class="num">${s.today}</div><div class="label">Сегодня</div></div>
|
||||
<div class="stat"><div class="num" style="color:${s.trash ? '#ef4444' : 'var(--muted)'}">${s.trash}</div><div class="label"><a href="trash.html">В корзине →</a></div></div>
|
||||
`;
|
||||
}
|
||||
|
||||
function last14days() {
|
||||
const out = [];
|
||||
for (let i = 13; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); out.push(d); }
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderActivity(activity) {
|
||||
const map = {};
|
||||
activity.forEach(a => { map[a.d] = a.n; });
|
||||
const days = last14days().map(d => {
|
||||
const iso = d.toISOString().slice(0, 10);
|
||||
return { iso, label: d.toLocaleDateString('ru', { day: '2-digit', month: '2-digit' }), n: map[iso] || 0 };
|
||||
});
|
||||
const max = Math.max(1, ...days.map(x => x.n));
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
document.getElementById('activityChart').innerHTML = days.map(x => `
|
||||
<div class="bar-col" title="${x.iso} — ${x.n} ${plural(x.n, 'запись', 'записи', 'записей')}">
|
||||
<div class="bar ${x.iso === todayIso ? 'today' : ''}" style="height:${Math.round(x.n / max * 100)}%"></div>
|
||||
<div class="bar-day">${x.label}</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function renderRecent(rows) {
|
||||
const el = document.getElementById('recentList');
|
||||
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Записей пока нет</div>'; return; }
|
||||
el.innerHTML = rows.map(e => `
|
||||
<div class="recent-item">
|
||||
${e.photo_path
|
||||
? `<img src="${API}${e.photo_path}" onclick="showImg('${API}${e.photo_path}')" alt="">`
|
||||
: `<div class="thumb">🎓</div>`}
|
||||
<div class="r-info">
|
||||
<div class="r-name">${esc(e.student_name)}</div>
|
||||
<div class="r-meta">${esc(e.group_name)} · ${new Date(e.created_at).toLocaleString('ru', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}</div>
|
||||
</div>
|
||||
<a class="r-link" href="journal.html" title="В журнал">→</a>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function renderActive(rows) {
|
||||
const el = document.getElementById('activeList');
|
||||
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Сейчас нет идущих групп</div>'; return; }
|
||||
el.innerHTML = rows.map(g => `
|
||||
<div class="active-item">
|
||||
<span class="a-name">${esc(g.name)}</span>
|
||||
<span class="a-right">
|
||||
<span class="a-time">${g.day_label} ${g.time_start}–${g.time_end}</span>
|
||||
<span class="a-now">сейчас</span>
|
||||
</span>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function renderTop(rows) {
|
||||
const el = document.getElementById('topList');
|
||||
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Пока нет данных</div>'; return; }
|
||||
el.innerHTML = rows.map((x, i) => `
|
||||
<div class="top-item">
|
||||
<span class="top-rank ${i > 2 ? 'alt' : ''}">${i + 1}</span>
|
||||
<span class="top-name">${esc(x.student_name)}</span>
|
||||
<span class="top-count">${x.n}</span>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function renderPhotos(rows) {
|
||||
const el = document.getElementById('dashPhotos');
|
||||
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Фото групп пока нет</div>'; return; }
|
||||
el.innerHTML = rows.map(p => `
|
||||
<div class="dash-photo" title="${esc(p.group_name)}">
|
||||
<img src="${API}${p.photo_path}" onclick="showImg('${API}${p.photo_path}')" alt="">
|
||||
${p.caption ? `<div class="pcap">${esc(p.caption)}</div>` : ''}
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
const res = await fetch(`${API}/api/dashboard`, { headers: hdr() });
|
||||
if (!res.ok) { document.getElementById('stats').innerHTML = '<div class="dash-empty">Не удалось загрузить данные</div>'; return; }
|
||||
const d = await res.json();
|
||||
renderStats(d.stats);
|
||||
renderActivity(d.activity);
|
||||
renderRecent(d.recent_entries);
|
||||
renderActive(d.active_groups);
|
||||
renderTop(d.top_students);
|
||||
renderPhotos(d.photos);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
loadDashboard();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="js/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+3
-18
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Ошибка — WhatIDo</title>
|
||||
<link rel="stylesheet" href="admin.css">
|
||||
<link rel="stylesheet" href="/admin.css">
|
||||
<style>
|
||||
.error-page{display:flex;align-items:center;justify-content:center;min-height:100dvh;padding:24px;text-align:center}
|
||||
.error-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:48px 40px;max-width:400px;width:100%}
|
||||
@@ -29,7 +29,7 @@
|
||||
<p class="error-message" id="errorMessage">Запрашиваемая страница не существует или была перемещена.</p>
|
||||
<div class="error-actions">
|
||||
<a href="/" class="ghost">На главную</a>
|
||||
<button onclick="history.back()">Назад</button>
|
||||
<button type="button" id="backBtn">Назад</button>
|
||||
</div>
|
||||
<div class="error-details" id="errorDetails" style="display:none">
|
||||
<strong>Детали:</strong>
|
||||
@@ -37,21 +37,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
const params = new URLSearchParams(location.search);
|
||||
const code = params.get('code') || '404';
|
||||
const title = params.get('title') || '';
|
||||
const message = params.get('message') || '';
|
||||
const details = params.get('details') || '';
|
||||
document.getElementById('errorCode').textContent = code;
|
||||
document.getElementById('errorTitle').textContent = title || (code === '404' ? 'Страница не найдена' : 'Ошибка');
|
||||
document.getElementById('errorMessage').textContent = message || (code === '404' ? 'Запрашиваемая страница не существует или была перемещена.' : 'Произошла ошибка на сервере.');
|
||||
if (details) {
|
||||
document.getElementById('errorDetails').style.display = 'block';
|
||||
document.getElementById('errorStack').textContent = details;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="/js/error.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+4
-164
@@ -25,8 +25,8 @@
|
||||
<div><label>По</label><input type="date" id="dateTo"></div>
|
||||
<div><label>Тип</label>
|
||||
<div class="view-toggle" id="tabToggle">
|
||||
<button data-mode="attached" onclick="setMode('attached')">Прикреплённые</button>
|
||||
<button data-mode="detached" onclick="setMode('detached')">Откреплённые</button>
|
||||
<button data-mode="attached" type="button">Прикреплённые</button>
|
||||
<button data-mode="detached" type="button">Откреплённые</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -35,172 +35,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="imgModal" onclick="this.classList.remove('open')">
|
||||
<div class="modal-overlay" id="imgModal">
|
||||
<img id="imgModalSrc">
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="admin.js"></script>
|
||||
<script>
|
||||
const PAGE_SIZE = 20;
|
||||
let page = 1;
|
||||
let totalFiles = 0;
|
||||
let currentFiles = [];
|
||||
let groupsLoaded = false;
|
||||
let studentsLoaded = false;
|
||||
let searchTimer = null;
|
||||
let mode = 'attached';
|
||||
|
||||
function setMode(m) {
|
||||
mode = m === 'detached' ? 'detached' : 'attached';
|
||||
document.querySelectorAll('#tabToggle button').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.mode === mode);
|
||||
});
|
||||
const show = mode === 'attached';
|
||||
const keep = ['searchInput', 'studentFilter', 'groupFilter', 'dateFrom', 'dateTo'].map(id => document.getElementById(id));
|
||||
if (!show) { keep.forEach(el => { el.value = ''; }); }
|
||||
keep.forEach(el => { el.parentElement.style.display = show ? '' : 'none'; });
|
||||
page = 1;
|
||||
loadFiles();
|
||||
}
|
||||
|
||||
function isImageFile(name) {
|
||||
return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
||||
}
|
||||
|
||||
function humanSize(b) {
|
||||
if (!b) return '';
|
||||
if (b < 1024) return b + ' Б';
|
||||
if (b < 1048576) return (b / 1024).toFixed(1) + ' КБ';
|
||||
return (b / 1048576).toFixed(1) + ' МБ';
|
||||
}
|
||||
|
||||
function fileCardHTML(f) {
|
||||
const url = `${API}/api/files/${f.token}`;
|
||||
const thumb = isImageFile(f.name)
|
||||
? `<img src="${url}" onclick="showImg('${url}')" alt="">`
|
||||
: `<div class="file-icon">📄</div>`;
|
||||
const target = isImageFile(f.name)
|
||||
? `href="#" onclick="showImg('${url}');return false;"`
|
||||
: `href="${url}" download`;
|
||||
const meta = mode === 'detached'
|
||||
? `<div class="group" style="color:#f59e0b">Откреплён от записи, не привязан</div>`
|
||||
: `<div class="group">${esc(f.student_name)} · ${esc(f.group_name)}</div>`;
|
||||
const action = mode === 'detached'
|
||||
? `<button class="f-action f-del" onclick="deleteFile(${f.id})" title="Удалить файл навсегда">Удалить</button>`
|
||||
: `<button class="f-action" onclick="detachFile(${f.id})" title="Открепить файл от записи">Открепить</button>`;
|
||||
return `
|
||||
<div class="card">
|
||||
${thumb}
|
||||
<div class="info">
|
||||
<div class="file-name"><a ${target}>${esc(f.name)}</a></div>
|
||||
${meta}
|
||||
<div class="time">${new Date(f.created_at).toLocaleString('ru')}${f.size ? ' · ' + humanSize(f.size) : ''}</div>
|
||||
</div>
|
||||
<div class="f-actions">${action}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
const grid = document.getElementById('grid');
|
||||
if (!currentFiles.length) {
|
||||
grid.className = 'grid';
|
||||
grid.innerHTML = mode === 'detached'
|
||||
? '<div class="empty">Откреплённых файлов нет</div>'
|
||||
: '<div class="empty">Файлов не найдено</div>';
|
||||
return;
|
||||
}
|
||||
grid.className = 'grid';
|
||||
grid.innerHTML = currentFiles.map(fileCardHTML).join('');
|
||||
}
|
||||
|
||||
async function detachFile(id) {
|
||||
if (!confirm('Открепить файл от записи? Он переместится во вкладку «Откреплённые».')) return;
|
||||
const res = await fetch(`${API}/api/files/${id}/detach`, { method: 'POST', headers: hdr() });
|
||||
if (res.ok) { showToast('Файл откреплён'); loadFiles(); }
|
||||
else { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); }
|
||||
}
|
||||
|
||||
async function deleteFile(id) {
|
||||
if (!confirm('Удалить файл навсегда? Отменить это действие нельзя.')) return;
|
||||
const res = await fetch(`${API}/api/files/${id}`, { method: 'DELETE', headers: hdr() });
|
||||
if (res.ok) { showToast('Файл удалён'); loadFiles(); }
|
||||
else { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); }
|
||||
}
|
||||
|
||||
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 loadFiles() {
|
||||
const p = new URLSearchParams();
|
||||
if (mode === 'attached') {
|
||||
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/files${mode === 'detached' ? '/detached' : ''}?${p}`, { headers: hdr() });
|
||||
const data = await res.json();
|
||||
currentFiles = data.files;
|
||||
totalFiles = data.total;
|
||||
renderGrid();
|
||||
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalFiles / PAGE_SIZE)), goPage);
|
||||
}
|
||||
|
||||
function goPage(p) {
|
||||
page = p;
|
||||
loadFiles();
|
||||
}
|
||||
|
||||
document.getElementById('studentFilter').addEventListener('change', () => { page = 1; loadFiles(); });
|
||||
document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadFiles(); });
|
||||
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadFiles(); });
|
||||
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadFiles(); });
|
||||
document.getElementById('searchInput').addEventListener('input', () => {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => { page = 1; loadFiles(); }, 300);
|
||||
});
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
document.querySelectorAll('#tabToggle button').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.mode === mode);
|
||||
});
|
||||
loadGroupOptions();
|
||||
loadStudentOptions();
|
||||
loadFiles();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="js/files.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+7
-349
@@ -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="Удалить">×</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="Удалить">×</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>
|
||||
+7
-192
@@ -91,18 +91,18 @@ nav a:hover{color:var(--text)}
|
||||
<div class="photo-card">
|
||||
<div class="preview-wrap">
|
||||
<img class="preview" id="preview" alt="Фото" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='640' height='480'%3E%3Crect width='100%25' height='100%25' fill='none' stroke='%23475569' stroke-width='2' stroke-dasharray='8 8'/%3E%3Ctext x='50%25' y='50%25' fill='%2364748b' font-family='sans-serif' font-size='28' text-anchor='middle' dominant-baseline='middle'%3EФото%3C/text%3E%3C/svg%3E">
|
||||
<button type="button" class="remove-photo" id="removePhotoBtn" onclick="removePhoto()">Убрать фото</button>
|
||||
<button type="button" class="remove-photo" id="removePhotoBtn">Убрать фото</button>
|
||||
</div>
|
||||
<div class="photo-btns">
|
||||
<button type="button" class="cam" onclick="openCam()">Камера</button>
|
||||
<button type="button" class="cam" id="openCamBtn">Камера</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="files-card">
|
||||
<div class="files-head">
|
||||
<label>Файлы проекта</label>
|
||||
<button type="button" class="files-clear" id="filesClearBtn" onclick="clearFiles()">Очистить</button>
|
||||
<button type="button" class="files-clear" id="filesClearBtn">Очистить</button>
|
||||
</div>
|
||||
<button type="button" class="files-btn" onclick="document.getElementById('filesInput').click()">Выбрать файлы</button>
|
||||
<button type="button" class="files-btn" id="filesBtn">Выбрать файлы</button>
|
||||
<input type="file" id="filesInput" multiple hidden>
|
||||
<div class="files-list" id="filesList"></div>
|
||||
</div>
|
||||
@@ -142,199 +142,14 @@ nav a:hover{color:var(--text)}
|
||||
<video id="camVideo" autoplay playsinline></video>
|
||||
<canvas id="camCanvas"></canvas>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel" onclick="closeCam()">Отмена</button>
|
||||
<button class="capture" onclick="capture()">Снять</button>
|
||||
<button type="button" class="cancel" id="cancelCamBtn">Отмена</button>
|
||||
<button type="button" class="capture" id="captureBtn">Снять</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast">Готово!</div>
|
||||
|
||||
<script>
|
||||
const API = location.origin;
|
||||
const form = document.getElementById('form');
|
||||
const preview = document.getElementById('preview');
|
||||
const placeholderSrc = preview.src;
|
||||
const removePhotoBtn = document.getElementById('removePhotoBtn');
|
||||
const groupInput = document.getElementById('groupInput');
|
||||
const toast = document.getElementById('toast');
|
||||
const camVideo = document.getElementById('camVideo');
|
||||
const camCanvas = document.getElementById('camCanvas');
|
||||
const camModal = document.getElementById('camModal');
|
||||
let capturedBlob = null;
|
||||
let stream = null;
|
||||
const descInput = document.getElementById('descInput');
|
||||
const filesInput = document.getElementById('filesInput');
|
||||
const filesList = document.getElementById('filesList');
|
||||
const filesClearBtn = document.getElementById('filesClearBtn');
|
||||
let selectedFiles = [];
|
||||
|
||||
function humanSize(b) {
|
||||
if (b < 1024) return b + ' Б';
|
||||
if (b < 1048576) return (b / 1024).toFixed(1) + ' КБ';
|
||||
return (b / 1048576).toFixed(1) + ' МБ';
|
||||
}
|
||||
|
||||
function renderFiles() {
|
||||
filesList.innerHTML = '';
|
||||
selectedFiles.forEach((f, i) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'file-row';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'f-name';
|
||||
name.textContent = f.name;
|
||||
name.title = f.name;
|
||||
const size = document.createElement('span');
|
||||
size.className = 'f-size';
|
||||
size.textContent = humanSize(f.size);
|
||||
const del = document.createElement('button');
|
||||
del.type = 'button';
|
||||
del.textContent = '✕';
|
||||
del.onclick = () => removeFile(i);
|
||||
row.append(name, size, del);
|
||||
filesList.appendChild(row);
|
||||
});
|
||||
filesClearBtn.classList.toggle('show', selectedFiles.length > 0);
|
||||
}
|
||||
|
||||
function removeFile(i) {
|
||||
selectedFiles.splice(i, 1);
|
||||
renderFiles();
|
||||
}
|
||||
|
||||
function clearFiles() {
|
||||
selectedFiles = [];
|
||||
filesInput.value = '';
|
||||
renderFiles();
|
||||
}
|
||||
|
||||
filesInput.addEventListener('change', () => {
|
||||
const keep = 10 - selectedFiles.length;
|
||||
if (keep <= 0) { alert('Можно прикрепить не более 10 файлов'); filesInput.value = ''; return; }
|
||||
selectedFiles = selectedFiles.concat([...filesInput.files].slice(0, keep));
|
||||
if ([...filesInput.files].length > keep) alert('Можно прикрепить не более 10 файлов');
|
||||
filesInput.value = '';
|
||||
renderFiles();
|
||||
});
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
let studentName = '';
|
||||
function escName(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
if (params.get('nameInput')) {
|
||||
studentName = params.get('nameInput');
|
||||
document.getElementById('nameInput').value = studentName;
|
||||
const field = document.getElementById('nameField');
|
||||
field.innerHTML = `<label>Фамилия и имя</label><div class="fixed-name" id="fixedName">${escName(studentName)}</div>`;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const res = await fetch(`${API}/api/students`);
|
||||
const students = await res.json();
|
||||
const me = studentName ? students.find(s => s.name === studentName) : null;
|
||||
if (me && me.group_id) {
|
||||
groupInput.innerHTML = `<option value="${me.group_id}" selected>${escName(me.group_name || `Группа ${me.group_id}`)}</option>`;
|
||||
groupInput.value = String(me.group_id);
|
||||
} else {
|
||||
const groupsRes = await fetch(`${API}/api/groups/active`);
|
||||
const groups = await groupsRes.json();
|
||||
groups.forEach(g => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = g.id;
|
||||
opt.textContent = g.name;
|
||||
groupInput.appendChild(opt);
|
||||
});
|
||||
if (!groups.length) {
|
||||
groupInput.innerHTML = '<option value="" disabled selected>Нет активных групп</option>';
|
||||
}
|
||||
}
|
||||
try {
|
||||
const fRes = await fetch(`${API}/api/public-settings`);
|
||||
const f = await fRes.json();
|
||||
if (f.footer_left) document.getElementById('footerLeft').textContent = f.footer_left;
|
||||
if (f.footer_right) document.getElementById('footerRight').textContent = f.footer_right;
|
||||
} catch (e) { /* ignore */ }
|
||||
})();
|
||||
|
||||
function showPreview(src) {
|
||||
preview.src = src;
|
||||
preview.classList.add('has-photo');
|
||||
removePhotoBtn.classList.add('show');
|
||||
}
|
||||
|
||||
function removePhoto() {
|
||||
preview.src = placeholderSrc;
|
||||
preview.classList.remove('has-photo');
|
||||
removePhotoBtn.classList.remove('show');
|
||||
capturedBlob = null;
|
||||
}
|
||||
|
||||
async function openCam() {
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } } });
|
||||
camVideo.srcObject = stream;
|
||||
camModal.classList.add('open');
|
||||
} catch (e) {
|
||||
alert('Не удалось открыть камеру: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function closeCam() {
|
||||
camModal.classList.remove('open');
|
||||
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
|
||||
}
|
||||
|
||||
function capture() {
|
||||
camCanvas.width = camVideo.videoWidth;
|
||||
camCanvas.height = camVideo.videoHeight;
|
||||
const ctx = camCanvas.getContext('2d');
|
||||
ctx.translate(camCanvas.width, 0);
|
||||
ctx.scale(-1, 1);
|
||||
ctx.drawImage(camVideo, 0, 0);
|
||||
camCanvas.toBlob(blob => {
|
||||
capturedBlob = blob;
|
||||
showPreview(URL.createObjectURL(blob));
|
||||
closeCam();
|
||||
}, 'image/jpeg', 0.85);
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('submitBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Отправка...';
|
||||
|
||||
const fd = new FormData();
|
||||
if (capturedBlob) fd.append('photo', capturedBlob, 'photo.jpg');
|
||||
selectedFiles.forEach(f => fd.append('files', f, f.name));
|
||||
fd.append('student_name', studentName || document.getElementById('nameInput').value);
|
||||
fd.append('group_id', groupInput.value);
|
||||
fd.append('description', document.getElementById('descInput').value);
|
||||
fd.append('website', document.getElementById('hpWebsite').value);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/api/entries`, { method: 'POST', body: fd });
|
||||
if (res.status === 429) {
|
||||
const e = await res.json().catch(() => ({}));
|
||||
toast.textContent = e.error || 'Уже ответили: подождите';
|
||||
toast.classList.add('show', 'error');
|
||||
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
form.reset();
|
||||
removePhoto();
|
||||
clearFiles();
|
||||
toast.textContent = 'Готово!';
|
||||
toast.classList.remove('error');
|
||||
toast.classList.add('show');
|
||||
setTimeout(() => toast.classList.remove('show'), 2500);
|
||||
} catch (err) {
|
||||
alert('Ошибка: ' + err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Отправить';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="js/index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+12
-400
@@ -17,12 +17,12 @@
|
||||
<div><label>Группа</label><select id="groupFilter"><option value="">Все группы</option></select></div>
|
||||
<div><label>С</label><input type="date" id="dateFrom"></div>
|
||||
<div><label>По</label><input type="date" id="dateTo"></div>
|
||||
<div><label> </label><button onclick="exportCSV()" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Экспорт CSV</button></div>
|
||||
<div><label> </label><button onclick="openLinkCreate()" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Ссылка</button></div>
|
||||
<div><label> </label><button type="button" id="exportCsvBtn" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Экспорт CSV</button></div>
|
||||
<div><label> </label><button type="button" id="createLinkBtn" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Ссылка</button></div>
|
||||
<div><label> </label>
|
||||
<div class="view-toggle" id="viewToggle">
|
||||
<button data-view="list" onclick="setView('list')">Список</button>
|
||||
<button data-view="cards" onclick="setView('cards')">Карточки</button>
|
||||
<button data-view="list" type="button">Список</button>
|
||||
<button data-view="cards" type="button">Карточки</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,11 +31,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="imgModal" onclick="this.classList.remove('open')">
|
||||
<div class="modal-overlay" id="imgModal">
|
||||
<img id="imgModalSrc">
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="editModal" onclick="if(event.target===this)closeEdit()">
|
||||
<div class="modal-overlay" id="editModal">
|
||||
<div class="edit-modal" style="max-width:480px">
|
||||
<h3>Редактирование записи</h3>
|
||||
<form id="editForm" class="settings-stack" style="gap:12px">
|
||||
@@ -66,14 +66,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||
<button type="button" class="btn-primary ghost" onclick="closeEdit()">Отмена</button>
|
||||
<button type="button" class="btn-primary ghost" id="editCancelBtn">Отмена</button>
|
||||
<button type="submit" class="btn-primary">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="linkModal" onclick="if(event.target===this)closeLinkCreate()">
|
||||
<div class="modal-overlay" id="linkModal">
|
||||
<div class="edit-modal">
|
||||
<div id="linkCreateView">
|
||||
<h3>Создать ссылку</h3>
|
||||
@@ -105,8 +105,8 @@
|
||||
<input type="password" id="linkPassword" placeholder="Оставьте пустым для открытого доступа">
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="cancel" onclick="closeLinkCreate()">Отмена</button>
|
||||
<button class="save" onclick="createLink()">Создать</button>
|
||||
<button class="cancel" type="button" id="linkCreateCloseBtn">Отмена</button>
|
||||
<button class="save" type="button" id="linkCreateSaveBtn">Создать</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="linkResultView" style="display:none">
|
||||
@@ -115,7 +115,7 @@
|
||||
<input type="text" id="linkUrlResult" readonly style="font-size:.8rem;color:var(--accent)">
|
||||
<div class="actions">
|
||||
<a class="cancel-btn" href="links.html">К списку ссылок</a>
|
||||
<button class="save" onclick="copyLink()">Копировать</button>
|
||||
<button class="save" type="button" id="linkCopyBtn">Копировать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,394 +123,6 @@
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="admin.js"></script>
|
||||
<script>
|
||||
// --- Journal ---
|
||||
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="#" onclick="showImg('${API}/api/files/${f.token}');return false;" 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" onclick="detachFile(${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}" onclick="showImg('${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" onclick="openEdit(${e.id})" title="Редактировать">✎</button>
|
||||
<button class="del" onclick="delEntry(${e.id})" title="Удалить">×</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function cardEntryHTML(e) {
|
||||
return `
|
||||
<div class="journal-card">
|
||||
${e.photo_path
|
||||
? `<img src="${API}${e.photo_path}" onclick="showImg('${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" onclick="openEdit(${e.id})" title="Редактировать">✎</button>
|
||||
<button class="del" onclick="delEntry(${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" onclick="removeEditFile(${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);
|
||||
}
|
||||
|
||||
(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();
|
||||
}
|
||||
})();
|
||||
|
||||
// --- Ссылки ---
|
||||
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;
|
||||
}
|
||||
// Capture current student filter
|
||||
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); }
|
||||
</script>
|
||||
<script src="js/journal.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,71 @@
|
||||
const ACTION_LABELS = {
|
||||
'settings.update': 'Изменены настройки',
|
||||
'group.create': 'Создана группа',
|
||||
'group.update': 'Изменена группа',
|
||||
'group.delete': 'Удалена группа',
|
||||
'group.photo.create': 'Загружена фото группы',
|
||||
'group.photo.update': 'Изменено фото группы',
|
||||
'group.photo.delete': 'Удалено фото группы',
|
||||
'student.create': 'Добавлен ученик',
|
||||
'student.update': 'Изменён ученик',
|
||||
'student.delete': 'Удалён ученик',
|
||||
'student.batch-group': 'Ученики перенесены в группу',
|
||||
'entry.update': 'Изменена запись',
|
||||
'entry.soft-delete': 'Запись в корзину',
|
||||
'entry.restore': 'Запись восстановлена',
|
||||
'entry.permanent-delete': 'Запись удалена безвозвратно',
|
||||
'link.create': 'Создана ссылка',
|
||||
'link.update': 'Изменена ссылка',
|
||||
'link.delete': 'Удалена ссылка',
|
||||
'backup.download': 'Скачан бэкап',
|
||||
'backup.restore': 'Выполнено восстановление',
|
||||
};
|
||||
|
||||
function fmtTime(t) {
|
||||
try { return new Date(t).toLocaleString('ru'); } catch { return t || ''; }
|
||||
}
|
||||
|
||||
function dt(t) {
|
||||
if (!t) return '';
|
||||
if (typeof t === 'object') {
|
||||
return esc(JSON.stringify(t).slice(0, 120));
|
||||
}
|
||||
try {
|
||||
const o = JSON.parse(t);
|
||||
return esc(JSON.stringify(o).slice(0, 120));
|
||||
} catch { return esc(String(t).slice(0, 120)); }
|
||||
}
|
||||
|
||||
async function loadAudit() {
|
||||
const el = document.getElementById('auditTable').querySelector('tbody');
|
||||
el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Загрузка…</td></tr>';
|
||||
const res = await fetch(`${API}/api/audit?limit=500`, { headers: hdr() });
|
||||
if (!res.ok) { el.innerHTML = '<tr><td colspan="4" style="color:var(--muted)">Ошибка загрузки</td></tr>'; return; }
|
||||
const rows = await res.json();
|
||||
document.getElementById('auditCount').textContent = rows.length ? `Записей: ${rows.length}` : '';
|
||||
const q = (document.getElementById('auditFilter').value || '').toLowerCase().trim();
|
||||
const filtered = q ? rows.filter(r => (ACTION_LABELS[r.action] || r.action).toLowerCase().includes(q)) : rows;
|
||||
if (!filtered.length) {
|
||||
document.getElementById('auditEmpty').style.display = 'block';
|
||||
el.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
document.getElementById('auditEmpty').style.display = 'none';
|
||||
el.innerHTML = filtered.map(r => `
|
||||
<tr>
|
||||
<td style="white-space:nowrap">${fmtTime(r.created_at)}</td>
|
||||
<td><b>${esc(ACTION_LABELS[r.action] || r.action)}</b></td>
|
||||
<td style="color:var(--muted)">${dt(r.target)}</td>
|
||||
<td style="color:var(--muted);white-space:nowrap">${esc(r.ip || '')}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
document.getElementById('auditFilter').addEventListener('input', loadAudit);
|
||||
document.getElementById('auditRefreshBtn').addEventListener('click', loadAudit);
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
loadAudit();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,117 @@
|
||||
let branches = [];
|
||||
|
||||
async function loadBranches() {
|
||||
const res = await fetch(`${API}/api/branches`, { headers: hdr() });
|
||||
if (!res.ok) return;
|
||||
branches = await res.json();
|
||||
renderBranches();
|
||||
}
|
||||
|
||||
function renderBranches() {
|
||||
const el = document.getElementById('branchesGrid');
|
||||
if (!branches.length) {
|
||||
el.innerHTML = '<div class="empty" style="grid-column:1/-1">Филиалов пока нет. Нажмите «Добавить филиал».</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = branches.map(b => `
|
||||
<div class="branch-card" data-id="${b.id}">
|
||||
<div class="branch-header">
|
||||
<span class="branch-name">${esc(b.name)}</span>
|
||||
<span class="branch-badge">${b.groups_count} ${plural(b.groups_count, 'группа', 'группы', 'групп')}</span>
|
||||
</div>
|
||||
${b.address ? `<div class="branch-meta">📍 ${esc(b.address)}</div>` : ''}
|
||||
${b.phone ? `<div class="branch-meta">📞 ${esc(b.phone)}</div>` : ''}
|
||||
<div class="branch-actions">
|
||||
<button class="branch-edit" data-edit-branch="${b.id}" title="Редактировать">✎</button>
|
||||
${b.groups_count === 0 ? `<button class="branch-delete" data-del-branch="${b.id}" title="Удалить">×</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function plural(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;
|
||||
}
|
||||
|
||||
function openBranchModal(id = null) {
|
||||
const modal = document.getElementById('branchModal');
|
||||
const form = document.getElementById('branchForm');
|
||||
const title = document.getElementById('branchModalTitle');
|
||||
const submitBtn = document.getElementById('branchSubmitBtn');
|
||||
form.reset();
|
||||
document.getElementById('branchId').value = '';
|
||||
if (id) {
|
||||
const b = branches.find(x => x.id === id);
|
||||
if (!b) return;
|
||||
title.textContent = 'Редактировать филиал';
|
||||
submitBtn.textContent = 'Сохранить';
|
||||
form.name.value = b.name;
|
||||
form.address.value = b.address || '';
|
||||
form.phone.value = b.phone || '';
|
||||
document.getElementById('branchId').value = b.id;
|
||||
} else {
|
||||
title.textContent = 'Создать филиал';
|
||||
submitBtn.textContent = 'Создать';
|
||||
}
|
||||
modal.classList.add('open');
|
||||
setTimeout(() => form.name.focus(), 100);
|
||||
}
|
||||
|
||||
function closeBranchModal() {
|
||||
document.getElementById('branchModal').classList.remove('open');
|
||||
}
|
||||
|
||||
document.getElementById('branchForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
const id = fd.get('id');
|
||||
const name = fd.get('name').trim();
|
||||
const address = fd.get('address').trim();
|
||||
const phone = fd.get('phone').trim();
|
||||
if (!name) { alert('Название обязательно'); return; }
|
||||
|
||||
const url = id ? `${API}/api/branches/${id}` : `${API}/api/branches`;
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify({ name, address: address || null, phone: phone || null })
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast(id ? 'Филиал обновлён' : 'Филиал создан');
|
||||
closeBranchModal();
|
||||
loadBranches();
|
||||
} else { const err = await res.json(); alert(err.error || 'Ошибка'); }
|
||||
});
|
||||
|
||||
async function deleteBranch(id) {
|
||||
if (!confirm('Удалить этот филиал?')) return;
|
||||
const res = await fetch(`${API}/api/branches/${id}`, { method: 'DELETE', headers: hdr() });
|
||||
if (res.ok) {
|
||||
showToast('Филиал удалён');
|
||||
loadBranches();
|
||||
} else { const err = await res.json(); alert(err.error || 'Ошибка'); }
|
||||
}
|
||||
|
||||
document.getElementById('addBranchBtn').addEventListener('click', () => openBranchModal());
|
||||
document.getElementById('branchModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeBranchModal(); });
|
||||
document.getElementById('branchCancelBtn').addEventListener('click', closeBranchModal);
|
||||
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
|
||||
|
||||
document.getElementById('branchesGrid').addEventListener('click', e => {
|
||||
const ed = e.target.closest('[data-edit-branch]');
|
||||
if (ed) { openBranchModal(parseInt(ed.dataset.editBranch, 10)); return; }
|
||||
const dl = e.target.closest('[data-del-branch]');
|
||||
if (dl) { deleteBranch(parseInt(dl.dataset.delBranch, 10)); }
|
||||
});
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
loadBranches();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,110 @@
|
||||
function plural(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;
|
||||
}
|
||||
|
||||
function renderStats(s) {
|
||||
document.getElementById('stats').innerHTML = `
|
||||
<div class="stat"><div class="num">${s.entries}</div><div class="label">Всего записей</div></div>
|
||||
<div class="stat"><div class="num">${s.students}</div><div class="label">Учеников</div></div>
|
||||
<div class="stat"><div class="num">${s.groups}</div><div class="label">Групп</div></div>
|
||||
<div class="stat"><div class="num">${s.today}</div><div class="label">Сегодня</div></div>
|
||||
<div class="stat"><div class="num" style="color:${s.trash ? '#ef4444' : 'var(--muted)'}">${s.trash}</div><div class="label"><a href="trash.html">В корзине →</a></div></div>
|
||||
`;
|
||||
}
|
||||
|
||||
function last14days() {
|
||||
const out = [];
|
||||
for (let i = 13; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); out.push(d); }
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderActivity(activity) {
|
||||
const map = {};
|
||||
activity.forEach(a => { map[a.d] = a.n; });
|
||||
const days = last14days().map(d => {
|
||||
const iso = d.toISOString().slice(0, 10);
|
||||
return { iso, label: d.toLocaleDateString('ru', { day: '2-digit', month: '2-digit' }), n: map[iso] || 0 };
|
||||
});
|
||||
const max = Math.max(1, ...days.map(x => x.n));
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
document.getElementById('activityChart').innerHTML = days.map(x => `
|
||||
<div class="bar-col" title="${x.iso} — ${x.n} ${plural(x.n, 'запись', 'записи', 'записей')}">
|
||||
<div class="bar ${x.iso === todayIso ? 'today' : ''}" style="height:${Math.round(x.n / max * 100)}%"></div>
|
||||
<div class="bar-day">${x.label}</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function renderRecent(rows) {
|
||||
const el = document.getElementById('recentList');
|
||||
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Записей пока нет</div>'; return; }
|
||||
el.innerHTML = rows.map(e => `
|
||||
<div class="recent-item">
|
||||
${e.photo_path
|
||||
? `<img src="${API}${e.photo_path}" data-img="${API}${e.photo_path}" alt="">`
|
||||
: `<div class="thumb">🎓</div>`}
|
||||
<div class="r-info">
|
||||
<div class="r-name">${esc(e.student_name)}</div>
|
||||
<div class="r-meta">${esc(e.group_name)} · ${new Date(e.created_at).toLocaleString('ru', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}</div>
|
||||
</div>
|
||||
<a class="r-link" href="journal.html" title="В журнал">→</a>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function renderActive(rows) {
|
||||
const el = document.getElementById('activeList');
|
||||
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Сейчас нет идущих групп</div>'; return; }
|
||||
el.innerHTML = rows.map(g => `
|
||||
<div class="active-item">
|
||||
<span class="a-name">${esc(g.name)}</span>
|
||||
<span class="a-right">
|
||||
<span class="a-time">${g.day_label} ${g.time_start}–${g.time_end}</span>
|
||||
<span class="a-now">сейчас</span>
|
||||
</span>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function renderTop(rows) {
|
||||
const el = document.getElementById('topList');
|
||||
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Пока нет данных</div>'; return; }
|
||||
el.innerHTML = rows.map((x, i) => `
|
||||
<div class="top-item">
|
||||
<span class="top-rank ${i > 2 ? 'alt' : ''}">${i + 1}</span>
|
||||
<span class="top-name">${esc(x.student_name)}</span>
|
||||
<span class="top-count">${x.n}</span>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function renderPhotos(rows) {
|
||||
const el = document.getElementById('dashPhotos');
|
||||
if (!rows.length) { el.innerHTML = '<div class="dash-empty">Фото групп пока нет</div>'; return; }
|
||||
el.innerHTML = rows.map(p => `
|
||||
<div class="dash-photo" title="${esc(p.group_name)}">
|
||||
<img src="${API}${p.photo_path}" data-img="${API}${p.photo_path}" alt="">
|
||||
${p.caption ? `<div class="pcap">${esc(p.caption)}</div>` : ''}
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
const res = await fetch(`${API}/api/dashboard`, { headers: hdr() });
|
||||
if (!res.ok) { document.getElementById('stats').innerHTML = '<div class="dash-empty">Не удалось загрузить данные</div>'; return; }
|
||||
const d = await res.json();
|
||||
renderStats(d.stats);
|
||||
renderActivity(d.activity);
|
||||
renderRecent(d.recent_entries);
|
||||
renderActive(d.active_groups);
|
||||
renderTop(d.top_students);
|
||||
renderPhotos(d.photos);
|
||||
}
|
||||
|
||||
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
loadDashboard();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,16 @@
|
||||
(function(){
|
||||
const params = new URLSearchParams(location.search);
|
||||
const code = params.get('code') || '404';
|
||||
const title = params.get('title') || '';
|
||||
const message = params.get('message') || '';
|
||||
const details = params.get('details') || '';
|
||||
document.getElementById('errorCode').textContent = code;
|
||||
document.getElementById('errorTitle').textContent = title || (code === '404' ? 'Страница не найдена' : 'Ошибка');
|
||||
document.getElementById('errorMessage').textContent = message || (code === '404' ? 'Запрашиваемая страница не существует или была перемещена.' : 'Произошла ошибка на сервере.');
|
||||
if (details) {
|
||||
document.getElementById('errorDetails').style.display = 'block';
|
||||
document.getElementById('errorStack').textContent = details;
|
||||
}
|
||||
})();
|
||||
|
||||
document.querySelector('.error-actions button').addEventListener('click', () => history.back());
|
||||
@@ -0,0 +1,169 @@
|
||||
const PAGE_SIZE = 20;
|
||||
let page = 1;
|
||||
let totalFiles = 0;
|
||||
let currentFiles = [];
|
||||
let groupsLoaded = false;
|
||||
let studentsLoaded = false;
|
||||
let searchTimer = null;
|
||||
let mode = 'attached';
|
||||
|
||||
function setMode(m) {
|
||||
mode = m === 'detached' ? 'detached' : 'attached';
|
||||
document.querySelectorAll('#tabToggle button').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.mode === mode);
|
||||
});
|
||||
const show = mode === 'attached';
|
||||
const keep = ['searchInput', 'studentFilter', 'groupFilter', 'dateFrom', 'dateTo'].map(id => document.getElementById(id));
|
||||
if (!show) { keep.forEach(el => { el.value = ''; }); }
|
||||
keep.forEach(el => { el.parentElement.style.display = show ? '' : 'none'; });
|
||||
page = 1;
|
||||
loadFiles();
|
||||
}
|
||||
|
||||
function isImageFile(name) {
|
||||
return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
||||
}
|
||||
|
||||
function humanSize(b) {
|
||||
if (!b) return '';
|
||||
if (b < 1024) return b + ' Б';
|
||||
if (b < 1048576) return (b / 1024).toFixed(1) + ' КБ';
|
||||
return (b / 1048576).toFixed(1) + ' МБ';
|
||||
}
|
||||
|
||||
function fileCardHTML(f) {
|
||||
const url = `${API}/api/files/${f.token}`;
|
||||
const thumb = isImageFile(f.name)
|
||||
? `<img src="${url}" data-img="${esc(url)}" alt="">`
|
||||
: `<div class="file-icon">📄</div>`;
|
||||
const target = isImageFile(f.name)
|
||||
? `href="#" data-img="${esc(url)}"`
|
||||
: `href="${url}" download`;
|
||||
const meta = mode === 'detached'
|
||||
? `<div class="group" style="color:#f59e0b">Откреплён от записи, не привязан</div>`
|
||||
: `<div class="group">${esc(f.student_name)} · ${esc(f.group_name)}</div>`;
|
||||
const action = mode === 'detached'
|
||||
? `<button class="f-action f-del" data-delete="${f.id}" title="Удалить файл навсегда">Удалить</button>`
|
||||
: `<button class="f-action" data-detach="${f.id}" title="Открепить файл от записи">Открепить</button>`;
|
||||
return `
|
||||
<div class="card">
|
||||
${thumb}
|
||||
<div class="info">
|
||||
<div class="file-name"><a ${target}>${esc(f.name)}</a></div>
|
||||
${meta}
|
||||
<div class="time">${new Date(f.created_at).toLocaleString('ru')}${f.size ? ' · ' + humanSize(f.size) : ''}</div>
|
||||
</div>
|
||||
<div class="f-actions">${action}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
const grid = document.getElementById('grid');
|
||||
if (!currentFiles.length) {
|
||||
grid.className = 'grid';
|
||||
grid.innerHTML = mode === 'detached'
|
||||
? '<div class="empty">Откреплённых файлов нет</div>'
|
||||
: '<div class="empty">Файлов не найдено</div>';
|
||||
return;
|
||||
}
|
||||
grid.className = 'grid';
|
||||
grid.innerHTML = currentFiles.map(fileCardHTML).join('');
|
||||
}
|
||||
|
||||
async function detachFile(id) {
|
||||
if (!confirm('Открепить файл от записи? Он переместится во вкладку «Откреплённые».')) return;
|
||||
const res = await fetch(`${API}/api/files/${id}/detach`, { method: 'POST', headers: hdr() });
|
||||
if (res.ok) { showToast('Файл откреплён'); loadFiles(); }
|
||||
else { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); }
|
||||
}
|
||||
|
||||
async function deleteFile(id) {
|
||||
if (!confirm('Удалить файл навсегда? Отменить это действие нельзя.')) return;
|
||||
const res = await fetch(`${API}/api/files/${id}`, { method: 'DELETE', headers: hdr() });
|
||||
if (res.ok) { showToast('Файл удалён'); loadFiles(); }
|
||||
else { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); }
|
||||
}
|
||||
|
||||
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 loadFiles() {
|
||||
const p = new URLSearchParams();
|
||||
if (mode === 'attached') {
|
||||
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/files${mode === 'detached' ? '/detached' : ''}?${p}`, { headers: hdr() });
|
||||
const data = await res.json();
|
||||
currentFiles = data.files;
|
||||
totalFiles = data.total;
|
||||
renderGrid();
|
||||
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalFiles / PAGE_SIZE)), goPage);
|
||||
}
|
||||
|
||||
function goPage(p) {
|
||||
page = p;
|
||||
loadFiles();
|
||||
}
|
||||
|
||||
document.getElementById('studentFilter').addEventListener('change', () => { page = 1; loadFiles(); });
|
||||
document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadFiles(); });
|
||||
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadFiles(); });
|
||||
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadFiles(); });
|
||||
document.getElementById('searchInput').addEventListener('input', () => {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => { page = 1; loadFiles(); }, 300);
|
||||
});
|
||||
|
||||
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
|
||||
document.querySelectorAll('#tabToggle button').forEach(b => b.addEventListener('click', () => setMode(b.dataset.mode)));
|
||||
|
||||
document.getElementById('grid').addEventListener('click', e => {
|
||||
const dt = e.target.closest('[data-detach]');
|
||||
if (dt) { detachFile(parseInt(dt.dataset.detach, 10)); return; }
|
||||
const del = e.target.closest('[data-delete]');
|
||||
if (del) { deleteFile(parseInt(del.dataset.delete, 10)); }
|
||||
});
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
document.querySelectorAll('#tabToggle button').forEach(b => {
|
||||
b.classList.toggle('active', b.dataset.mode === mode);
|
||||
});
|
||||
loadGroupOptions();
|
||||
loadStudentOptions();
|
||||
loadFiles();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,357 @@
|
||||
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)}" title="Фото группы (хронология)" alt="">`
|
||||
: `<div class="group-cover empty-cover" data-group-id="${g.id}" data-group-name="${esc(g.name)}" title="Фото группы (хронология)">📷</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)}
|
||||
<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>
|
||||
</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}" data-img="${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" 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; }
|
||||
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('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('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 cover = e.target.closest('[data-group-id]');
|
||||
if (cover) {
|
||||
const id = parseInt(cover.dataset.groupId, 10);
|
||||
const name = cover.dataset.groupName;
|
||||
if (!isNaN(id)) openPhotos(id, name);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
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('');
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
await populateNewGroupBranch();
|
||||
loadGroups();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,191 @@
|
||||
const API = location.origin;
|
||||
const form = document.getElementById('form');
|
||||
const preview = document.getElementById('preview');
|
||||
const placeholderSrc = preview.src;
|
||||
const removePhotoBtn = document.getElementById('removePhotoBtn');
|
||||
const groupInput = document.getElementById('groupInput');
|
||||
const toast = document.getElementById('toast');
|
||||
const camVideo = document.getElementById('camVideo');
|
||||
const camCanvas = document.getElementById('camCanvas');
|
||||
const camModal = document.getElementById('camModal');
|
||||
let capturedBlob = null;
|
||||
let stream = null;
|
||||
const descInput = document.getElementById('descInput');
|
||||
const filesInput = document.getElementById('filesInput');
|
||||
const filesList = document.getElementById('filesList');
|
||||
const filesClearBtn = document.getElementById('filesClearBtn');
|
||||
let selectedFiles = [];
|
||||
|
||||
function humanSize(b) {
|
||||
if (b < 1024) return b + ' Б';
|
||||
if (b < 1048576) return (b / 1024).toFixed(1) + ' КБ';
|
||||
return (b / 1048576).toFixed(1) + ' МБ';
|
||||
}
|
||||
|
||||
function renderFiles() {
|
||||
filesList.innerHTML = '';
|
||||
selectedFiles.forEach((f, i) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'file-row';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'f-name';
|
||||
name.textContent = f.name;
|
||||
name.title = f.name;
|
||||
const size = document.createElement('span');
|
||||
size.className = 'f-size';
|
||||
size.textContent = humanSize(f.size);
|
||||
const del = document.createElement('button');
|
||||
del.type = 'button';
|
||||
del.textContent = '✕';
|
||||
del.addEventListener('click', () => removeFile(i));
|
||||
row.append(name, size, del);
|
||||
filesList.appendChild(row);
|
||||
});
|
||||
filesClearBtn.classList.toggle('show', selectedFiles.length > 0);
|
||||
}
|
||||
|
||||
function removeFile(i) {
|
||||
selectedFiles.splice(i, 1);
|
||||
renderFiles();
|
||||
}
|
||||
|
||||
function clearFiles() {
|
||||
selectedFiles = [];
|
||||
filesInput.value = '';
|
||||
renderFiles();
|
||||
}
|
||||
|
||||
filesInput.addEventListener('change', () => {
|
||||
const keep = 10 - selectedFiles.length;
|
||||
if (keep <= 0) { alert('Можно прикрепить не более 10 файлов'); filesInput.value = ''; return; }
|
||||
selectedFiles = selectedFiles.concat([...filesInput.files].slice(0, keep));
|
||||
if ([...filesInput.files].length > keep) alert('Можно прикрепить не более 10 файлов');
|
||||
filesInput.value = '';
|
||||
renderFiles();
|
||||
});
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
let studentName = '';
|
||||
function escName(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
if (params.get('nameInput')) {
|
||||
studentName = params.get('nameInput');
|
||||
document.getElementById('nameInput').value = studentName;
|
||||
const field = document.getElementById('nameField');
|
||||
field.innerHTML = `<label>Фамилия и имя</label><div class="fixed-name" id="fixedName">${escName(studentName)}</div>`;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const res = await fetch(`${API}/api/students`);
|
||||
const students = await res.json();
|
||||
const me = studentName ? students.find(s => s.name === studentName) : null;
|
||||
if (me && me.group_id) {
|
||||
groupInput.innerHTML = `<option value="${me.group_id}" selected>${escName(me.group_name || `Группа ${me.group_id}`)}</option>`;
|
||||
groupInput.value = String(me.group_id);
|
||||
} else {
|
||||
const groupsRes = await fetch(`${API}/api/groups/active`);
|
||||
const groups = await groupsRes.json();
|
||||
groups.forEach(g => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = g.id;
|
||||
opt.textContent = g.name;
|
||||
groupInput.appendChild(opt);
|
||||
});
|
||||
if (!groups.length) {
|
||||
groupInput.innerHTML = '<option value="" disabled selected>Нет активных групп</option>';
|
||||
}
|
||||
}
|
||||
try {
|
||||
const fRes = await fetch(`${API}/api/public-settings`);
|
||||
const f = await fRes.json();
|
||||
if (f.footer_left) document.getElementById('footerLeft').textContent = f.footer_left;
|
||||
if (f.footer_right) document.getElementById('footerRight').textContent = f.footer_right;
|
||||
} catch (e) { /* ignore */ }
|
||||
})();
|
||||
|
||||
function showPreview(src) {
|
||||
preview.src = src;
|
||||
preview.classList.add('has-photo');
|
||||
removePhotoBtn.classList.add('show');
|
||||
}
|
||||
|
||||
function removePhoto() {
|
||||
preview.src = placeholderSrc;
|
||||
preview.classList.remove('has-photo');
|
||||
removePhotoBtn.classList.remove('show');
|
||||
capturedBlob = null;
|
||||
}
|
||||
|
||||
async function openCam() {
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } } });
|
||||
camVideo.srcObject = stream;
|
||||
camModal.classList.add('open');
|
||||
} catch (e) {
|
||||
alert('Не удалось открыть камеру: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function closeCam() {
|
||||
camModal.classList.remove('open');
|
||||
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
|
||||
}
|
||||
|
||||
function capture() {
|
||||
camCanvas.width = camVideo.videoWidth;
|
||||
camCanvas.height = camVideo.videoHeight;
|
||||
const ctx = camCanvas.getContext('2d');
|
||||
ctx.translate(camCanvas.width, 0);
|
||||
ctx.scale(-1, 1);
|
||||
ctx.drawImage(camVideo, 0, 0);
|
||||
camCanvas.toBlob(blob => {
|
||||
capturedBlob = blob;
|
||||
showPreview(URL.createObjectURL(blob));
|
||||
closeCam();
|
||||
}, 'image/jpeg', 0.85);
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('submitBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Отправка...';
|
||||
|
||||
const fd = new FormData();
|
||||
if (capturedBlob) fd.append('photo', capturedBlob, 'photo.jpg');
|
||||
selectedFiles.forEach(f => fd.append('files', f, f.name));
|
||||
fd.append('student_name', studentName || document.getElementById('nameInput').value);
|
||||
fd.append('group_id', groupInput.value);
|
||||
fd.append('description', document.getElementById('descInput').value);
|
||||
fd.append('website', document.getElementById('hpWebsite').value);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API}/api/entries`, { method: 'POST', body: fd });
|
||||
if (res.status === 429) {
|
||||
const e = await res.json().catch(() => ({}));
|
||||
toast.textContent = e.error || 'Уже ответили: подождите';
|
||||
toast.classList.add('show', 'error');
|
||||
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
form.reset();
|
||||
removePhoto();
|
||||
clearFiles();
|
||||
toast.textContent = 'Готово!';
|
||||
toast.classList.remove('error');
|
||||
toast.classList.add('show');
|
||||
setTimeout(() => toast.classList.remove('show'), 2500);
|
||||
} catch (err) {
|
||||
alert('Ошибка: ' + err.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Отправить';
|
||||
}
|
||||
});
|
||||
|
||||
removePhotoBtn.addEventListener('click', removePhoto);
|
||||
document.getElementById('openCamBtn').addEventListener('click', openCam);
|
||||
filesClearBtn.addEventListener('click', clearFiles);
|
||||
document.getElementById('filesBtn').addEventListener('click', () => document.getElementById('filesInput').click());
|
||||
document.getElementById('cancelCamBtn').addEventListener('click', closeCam);
|
||||
document.getElementById('captureBtn').addEventListener('click', capture);
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,151 @@
|
||||
let links = [];
|
||||
let groups = [];
|
||||
let editingId = null;
|
||||
let studentsLoaded = false;
|
||||
|
||||
function linkUrl(token) { return `${location.origin}/s/${token}`; }
|
||||
|
||||
async function loadLinks() {
|
||||
const res = await fetch(`${API}/api/links`, { headers: hdr() });
|
||||
links = await res.json();
|
||||
const el = document.getElementById('linksList');
|
||||
if (!links.length) { el.innerHTML = '<div class="empty">Ссылок пока нет. Создайте её в Журнале кнопкой «Ссылка».</div>'; return; }
|
||||
el.innerHTML = links.map(l => `
|
||||
<div class="item">
|
||||
<div style="min-width:0">
|
||||
<div class="name">${esc(l.name)}</div>
|
||||
<div style="font-size:.8rem;color:var(--muted);margin-top:2px">
|
||||
${l.group_name ? `Группа: <b>${esc(l.group_name)}</b>` : ''}
|
||||
${l.student_name ? `${l.group_name ? ' · ' : ''}Ученик: <b>${esc(l.student_name)}</b>` : ''}
|
||||
${l.date_from || l.date_to ? ` · период: <b>${l.date_from ? l.date_from.split('-').reverse().join('.') : '…'}</b> — <b>${l.date_to ? l.date_to.split('-').reverse().join('.') : '…'}</b>` : ' · весь период'}
|
||||
${l.expires_at ? ` · истекает ${new Date(l.expires_at).toLocaleDateString('ru')}` : ''}
|
||||
${l.anonymize_names ? ` · <span style="color:#f59e0b">анонимизировано</span>` : ''}
|
||||
${l.access_password_hash ? ` · <span style="color:#2563eb">🔒 пароль</span>` : ''}
|
||||
· создана ${new Date(l.created_at).toLocaleString('ru')}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-shrink:0;align-items:center">
|
||||
<a class="btn-link" style="text-decoration:none" href="/s/${l.token}" target="_blank" title="Открыть">Открыть</a>
|
||||
<button class="btn-link" data-copy="${esc(linkUrl(l.token))}" title="Копировать ссылку">Копировать</button>
|
||||
<button class="btn-link" data-edit-link="${l.id}" title="Редактировать">✎</button>
|
||||
<button class="btn-link" style="color:var(--muted)" data-del-link="${l.id}" title="Удалить">×</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function fmtDate(d) { const [y, m, day] = (d || '').split('-'); return d ? `${day}.${m}.${y}` : ''; }
|
||||
|
||||
async function openLinkModal() {
|
||||
editingId = null;
|
||||
document.getElementById('linkModalTitle').textContent = 'Новая ссылка';
|
||||
document.getElementById('linkName').value = '';
|
||||
document.getElementById('linkDateFrom').value = '';
|
||||
document.getElementById('linkDateTo').value = '';
|
||||
document.getElementById('linkExpiresDays').value = '7';
|
||||
document.getElementById('linkAnonymizeNames').checked = false;
|
||||
document.getElementById('linkPassword').value = '';
|
||||
if (!groups.length) {
|
||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||
groups = await res.json();
|
||||
document.getElementById('linkGroup').innerHTML = '<option value="">— без группы —</option>' +
|
||||
groups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
|
||||
}
|
||||
if (!studentsLoaded) {
|
||||
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('');
|
||||
studentsLoaded = true;
|
||||
}
|
||||
document.getElementById('linkModal').classList.add('open');
|
||||
}
|
||||
|
||||
async function openLinkEdit(id) {
|
||||
const l = links.find(x => x.id === id);
|
||||
if (!l) return;
|
||||
editingId = id;
|
||||
document.getElementById('linkModalTitle').textContent = 'Редактирование ссылки';
|
||||
document.getElementById('linkName').value = l.name;
|
||||
document.getElementById('linkDateFrom').value = l.date_from || '';
|
||||
document.getElementById('linkDateTo').value = l.date_to || '';
|
||||
document.getElementById('linkExpiresDays').value = l.expires_at ? Math.ceil((new Date(l.expires_at) - new Date()) / (1000 * 60 * 60 * 24)) : '7';
|
||||
document.getElementById('linkAnonymizeNames').checked = l.anonymize_names || false;
|
||||
document.getElementById('linkPassword').value = '';
|
||||
if (!groups.length) {
|
||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||
groups = await res.json();
|
||||
document.getElementById('linkGroup').innerHTML = '<option value="">— без группы —</option>' +
|
||||
groups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
|
||||
}
|
||||
document.getElementById('linkGroup').value = l.group_id || '';
|
||||
if (!studentsLoaded) {
|
||||
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('');
|
||||
studentsLoaded = true;
|
||||
}
|
||||
document.getElementById('linkStudent').value = l.student_name || '';
|
||||
document.getElementById('linkModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeLinkModal() {
|
||||
document.getElementById('linkModal').classList.remove('open');
|
||||
editingId = null;
|
||||
}
|
||||
|
||||
async function saveLink() {
|
||||
const name = document.getElementById('linkName').value.trim();
|
||||
const group_id = document.getElementById('linkGroup').value;
|
||||
const student_name = document.getElementById('linkStudent').value;
|
||||
const date_from = document.getElementById('linkDateFrom').value;
|
||||
const date_to = document.getElementById('linkDateTo').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 body = JSON.stringify({ name, group_id: group_id || null, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, anonymize_names, expires_at, access_password: access_password || null });
|
||||
const res = await fetch(editingId ? `${API}/api/links/${editingId}` : `${API}/api/links`, {
|
||||
method: editingId ? 'PUT' : 'POST',
|
||||
headers: hdrJson(),
|
||||
body
|
||||
});
|
||||
if (res.ok) {
|
||||
closeLinkModal();
|
||||
loadLinks();
|
||||
showToast(editingId ? 'Ссылка обновлена' : 'Ссылка создана');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.error || 'Ошибка');
|
||||
}
|
||||
}
|
||||
|
||||
async function delLink(id) {
|
||||
if (!confirm('Удалить ссылку?')) return;
|
||||
await fetch(`${API}/api/links/${id}`, { method: 'DELETE', headers: hdr() });
|
||||
loadLinks();
|
||||
showToast('Ссылка удалена');
|
||||
}
|
||||
|
||||
document.getElementById('linkModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeLinkModal(); });
|
||||
document.getElementById('linkCancelBtn').addEventListener('click', closeLinkModal);
|
||||
document.getElementById('linkSaveBtn').addEventListener('click', saveLink);
|
||||
|
||||
document.getElementById('linksList').addEventListener('click', e => {
|
||||
const cp = e.target.closest('[data-copy]');
|
||||
if (cp) { copyText(cp.dataset.copy); return; }
|
||||
const ed = e.target.closest('[data-edit-link]');
|
||||
if (ed) { openLinkEdit(parseInt(ed.dataset.editLink, 10)); return; }
|
||||
const dl = e.target.closest('[data-del-link]');
|
||||
if (dl) { delLink(parseInt(dl.dataset.delLink, 10)); }
|
||||
});
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
loadLinks();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,20 @@
|
||||
const API = location.origin;
|
||||
|
||||
async function doLogin() {
|
||||
const pwd = document.getElementById('pwdInput').value;
|
||||
if (!pwd) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/api/stats`, { headers: { 'X-Admin-Token': pwd } });
|
||||
if (res.ok) {
|
||||
sessionStorage.setItem('adminToken', pwd);
|
||||
location.href = 'dashboard.html';
|
||||
} else {
|
||||
document.getElementById('loginErr').style.display = 'block';
|
||||
}
|
||||
} catch {
|
||||
document.getElementById('loginErr').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('pwdInput').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
|
||||
document.querySelector('.login button').addEventListener('click', doLogin);
|
||||
@@ -0,0 +1,221 @@
|
||||
const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight'];
|
||||
|
||||
function setBtnLoading(btn, on, spinnerClass) {
|
||||
if (on) {
|
||||
btn.dataset.label = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = `<span class="spinner ${spinnerClass || ''}"></span> ${btn.dataset.loadingText || 'Обработка...'}`;
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = btn.dataset.label || btn.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
function updateDirty() {
|
||||
const dirty = DIRTY_FIELDS.some(id => {
|
||||
const el = document.getElementById(id);
|
||||
return String(el.dataset.original ?? '') !== el.value;
|
||||
});
|
||||
document.getElementById('saveBar').classList.toggle('show', dirty);
|
||||
}
|
||||
|
||||
function bindDirty() {
|
||||
DIRTY_FIELDS.forEach(id => {
|
||||
document.getElementById(id).addEventListener('input', updateDirty);
|
||||
});
|
||||
}
|
||||
|
||||
function snapshotDirty() {
|
||||
DIRTY_FIELDS.forEach(id => {
|
||||
document.getElementById(id).dataset.original = document.getElementById(id).value;
|
||||
});
|
||||
updateDirty();
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const [sRes, stRes] = await Promise.all([
|
||||
fetch(`${API}/api/settings`, { headers: hdr() }),
|
||||
fetch(`${API}/api/stats`, { headers: hdr() }),
|
||||
]);
|
||||
const s = await sRes.json();
|
||||
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
|
||||
document.getElementById('footerLeft').value = s.footer_left || '';
|
||||
document.getElementById('footerRight').value = s.footer_right || '';
|
||||
snapshotDirty();
|
||||
if (stRes.ok) {
|
||||
const st = await stRes.json();
|
||||
document.getElementById('dataStats').innerHTML = `
|
||||
<span class="chip">Записей: <b>${st.entries}</b></span>
|
||||
<span class="chip">Учеников: <b>${st.students}</b></span>
|
||||
<span class="chip">Групп: <b>${st.groups}</b></span>
|
||||
<span class="chip">Сегодня: <b>${st.today}</b></span>
|
||||
<span class="chip">${st.trash ? `В корзине: <b>${st.trash}</b> <a href="trash.html">→</a>` : ''}</span>
|
||||
`;
|
||||
} else {
|
||||
document.getElementById('dataStats').innerHTML = '';
|
||||
}
|
||||
|
||||
const siRes = await fetch(`${API}/api/system-info`, { headers: hdr() });
|
||||
if (siRes.ok) {
|
||||
const si = await siRes.json();
|
||||
renderSystemInfo(si);
|
||||
} else {
|
||||
document.getElementById('systemInfo').innerHTML = '<div class="sys-card err">Ошибка загрузки</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSystemInfo(si) {
|
||||
const db = si.database || {};
|
||||
const photos = si.photos || {};
|
||||
const files = si.files || {};
|
||||
const uploads = si.uploads || {};
|
||||
const disk = si.disk;
|
||||
|
||||
let tablesHtml = '';
|
||||
if (db.tables && db.tables.length) {
|
||||
tablesHtml = '<details class="sys-details"><summary>Таблицы БД</summary><div class="sys-table-list">' +
|
||||
db.tables.map(t => `<div class="sys-table-row"><span>${t.name}</span><span>${t.size}</span></div>`).join('') +
|
||||
'</div></details>';
|
||||
}
|
||||
|
||||
let diskHtml = '';
|
||||
if (disk) {
|
||||
const pct = disk.used_pct;
|
||||
const color = pct > 90 ? '#ef4444' : pct > 70 ? '#f59e0b' : '#16a34a';
|
||||
diskHtml = `
|
||||
<div class="sys-item">
|
||||
<span class="sys-label">Диск</span>
|
||||
<span class="sys-value">${disk.used} / ${disk.total}</span>
|
||||
</div>
|
||||
<div class="sys-bar-wrap">
|
||||
<div class="sys-bar" style="width:${pct}%; background:${color}"></div>
|
||||
</div>
|
||||
<div class="sys-meta">Свободно: ${disk.free} (${100 - pct}%)</div>
|
||||
`;
|
||||
}
|
||||
|
||||
document.getElementById('systemInfo').innerHTML = `
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">База данных</div>
|
||||
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${db.size || '—'}</span></div>
|
||||
${tablesHtml}
|
||||
</div>
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Фото</div>
|
||||
<div class="sys-item"><span class="sys-label">Фото групп</span><span class="sys-value">${photos.group_photos?.count || 0}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Фото в записях</span><span class="sys-value">${photos.entry_photos?.count || 0}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Всего</span><span class="sys-value"><b>${photos.total_count || 0}</b></span></div>
|
||||
</div>
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Файлы</div>
|
||||
<div class="sys-item"><span class="sys-label">Проектные файлы</span><span class="sys-value">${files.project_files?.count || 0}</span></div>
|
||||
</div>
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Загрузки</div>
|
||||
<div class="sys-item"><span class="sys-label">Файлов</span><span class="sys-value">${uploads.count || 0}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${uploads.size || '0 B'}</span></div>
|
||||
</div>
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Диск</div>
|
||||
${diskHtml || '<div class="sys-meta">Недоступно</div>'}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const v = document.getElementById('spamInterval').value;
|
||||
if (v === '' || Number(v) < 1) { alert('Введите число от 1'); return; }
|
||||
const btn = document.getElementById('saveBtn');
|
||||
btn.dataset.loadingText = 'Сохранение...';
|
||||
setBtnLoading(btn, true);
|
||||
try {
|
||||
const res = await fetch(`${API}/api/settings`, {
|
||||
method: 'PUT',
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify({ settings: {
|
||||
spam_interval_min: String(Math.round(Number(v))),
|
||||
footer_left: document.getElementById('footerLeft').value.trim(),
|
||||
footer_right: document.getElementById('footerRight').value.trim(),
|
||||
} })
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast('Настройки сохранены');
|
||||
snapshotDirty();
|
||||
} else { const e = await res.json(); alert(e.error); }
|
||||
} finally {
|
||||
setBtnLoading(btn, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadBackup() {
|
||||
const btn = document.getElementById('backupBtn');
|
||||
btn.dataset.loadingText = 'Формирование...';
|
||||
setBtnLoading(btn, true);
|
||||
try {
|
||||
const res = await fetch(`${API}/api/backup`, { headers: hdr() });
|
||||
if (!res.ok) { const t = await res.text(); alert('Ошибка: ' + t); return; }
|
||||
const blob = await res.blob();
|
||||
const cd = res.headers.get('Content-Disposition') || '';
|
||||
const m = cd.match(/filename="([^"]+)"/);
|
||||
const name = m ? m[1] : `whatido-backup-${new Date().toISOString().slice(0, 10)}.json.gz`;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast('Бэкап скачан');
|
||||
} finally {
|
||||
setBtnLoading(btn, false);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('restoreFile').addEventListener('change', async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
if (!confirm('Восстановление заменит все текущие данные. Продолжить?')) { e.target.value = ''; return; }
|
||||
const st = document.getElementById('restoreStatus');
|
||||
const backupBtn = document.getElementById('backupBtn');
|
||||
const restoreBtn = document.getElementById('restoreBtn');
|
||||
st.classList.remove('err');
|
||||
st.innerHTML = '<span class="spinner dark"></span> Восстановление...';
|
||||
backupBtn.disabled = true;
|
||||
restoreBtn.disabled = true;
|
||||
const fd = new FormData();
|
||||
fd.append('backup', file);
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${API}/api/restore`, { method: 'POST', headers: hdr(), body: fd });
|
||||
} catch (err) {
|
||||
st.textContent = '';
|
||||
alert('Ошибка сети: ' + err.message);
|
||||
e.target.value = '';
|
||||
return;
|
||||
} finally {
|
||||
backupBtn.disabled = false;
|
||||
restoreBtn.disabled = false;
|
||||
}
|
||||
if (res.ok) {
|
||||
showToast('Данные восстановлены');
|
||||
st.textContent = '';
|
||||
loadSettings();
|
||||
} else {
|
||||
st.classList.add('err');
|
||||
st.innerHTML = 'Ошибка восстановления';
|
||||
const t = await res.text();
|
||||
alert('Ошибка: ' + t);
|
||||
}
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
document.getElementById('backupBtn').addEventListener('click', downloadBackup);
|
||||
document.getElementById('restoreBtn').addEventListener('click', () => document.getElementById('restoreFile').click());
|
||||
document.getElementById('saveBtn').addEventListener('click', saveSettings);
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
bindDirty();
|
||||
loadSettings();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,157 @@
|
||||
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||||
function fmt(d){return d?d.split('-').reverse().join('.'):'весь период'}
|
||||
function showImg(src){document.getElementById('lightboxImg').src=src;document.getElementById('lightbox').classList.add('open')}
|
||||
function isImageFile(name){return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')}
|
||||
function fileType(name){
|
||||
if(/\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')) return 'image';
|
||||
if(/\.(mp3|wav|ogg|flac|m4a|aac|opus)$/i.test(name||'')) return 'music';
|
||||
if(/\.(pptx?|odp|key)$/i.test(name||'')) return 'presentation';
|
||||
if(/\.(pdf|docx?|xlsx?|txt|rtf|odt|ods|csv|md)$/i.test(name||'')) return 'document';
|
||||
return 'other';
|
||||
}
|
||||
function fileIcon(t){
|
||||
const icons = {image:'🖼️',music:'🎵',presentation:'📊',document:'📄',other:'📁'};
|
||||
return icons[t]||icons.other;
|
||||
}
|
||||
function filesHTML(files, password, shareToken){
|
||||
if(!files||!files.length)return '';
|
||||
const pw = password ? `?password=${encodeURIComponent(password)}` : '';
|
||||
return `<div class="files-block">
|
||||
<div class="files-label">Работы резидента</div>
|
||||
<div class="files">${files.map(f=>{
|
||||
const t = fileType(f.name);
|
||||
const url = `/api/share/${shareToken}/files/${f.token}${pw}`;
|
||||
return `<a class="ftype-${t}" title="${esc(f.name)}" ${isImageFile(f.name)
|
||||
?`href="#" data-img="${esc(url)}"`
|
||||
:`href="${url}" download`}>
|
||||
<span class="ficon" alt="${esc(f.name)}">${fileIcon(t)}</span>
|
||||
</a>`;
|
||||
}).join('')}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
let currentToken = '';
|
||||
let currentPassword = null;
|
||||
|
||||
async function fetchShareData(token, password) {
|
||||
const headers = {};
|
||||
if (password) headers['x-share-password'] = password;
|
||||
const res = await fetch('/api/share/' + token, { headers });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return { ok: res.ok, status: res.status, data };
|
||||
}
|
||||
|
||||
async function loadShare() {
|
||||
const token = location.pathname.split('/').filter(Boolean).pop() || '';
|
||||
currentToken = token;
|
||||
currentPassword = null;
|
||||
|
||||
const tryLoad = async (pwd) => {
|
||||
const result = await fetchShareData(token, pwd);
|
||||
if (result.ok) {
|
||||
renderShare(result.data, pwd);
|
||||
return true;
|
||||
}
|
||||
if (result.status === 401 && result.data.passwordRequired) {
|
||||
showPasswordModal();
|
||||
return false;
|
||||
}
|
||||
if (result.status === 410) {
|
||||
showError('Срок действия ссылки истёк');
|
||||
return true;
|
||||
}
|
||||
showError('Ссылка не найдена или была удалена');
|
||||
return true;
|
||||
};
|
||||
|
||||
await tryLoad(null);
|
||||
}
|
||||
|
||||
function showPasswordModal() {
|
||||
document.getElementById('passwordInput').value = '';
|
||||
document.getElementById('passwordModal').classList.add('open');
|
||||
document.getElementById('passwordInput').focus();
|
||||
}
|
||||
|
||||
function hidePasswordModal() {
|
||||
document.getElementById('passwordModal').classList.remove('open');
|
||||
}
|
||||
|
||||
async function submitPassword() {
|
||||
const pwd = document.getElementById('passwordInput').value;
|
||||
if (!pwd) return;
|
||||
const result = await fetchShareData(currentToken, pwd);
|
||||
if (result.ok) {
|
||||
hidePasswordModal();
|
||||
currentPassword = pwd;
|
||||
renderShare(result.data, pwd);
|
||||
} else {
|
||||
alert('Неверный пароль');
|
||||
document.getElementById('passwordInput').value = '';
|
||||
document.getElementById('passwordInput').focus();
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
document.getElementById('title').textContent = msg;
|
||||
document.getElementById('grid').innerHTML = `<div class="empty"><div class="big">🤔</div>${esc(msg)}</div>`;
|
||||
document.getElementById('photosBlock').style.display = 'none';
|
||||
}
|
||||
|
||||
function renderShare(data, password) {
|
||||
const heading = data.group_name || data.name || 'Записи занятий';
|
||||
document.title = heading;
|
||||
document.getElementById('title').textContent = heading;
|
||||
const chips = [];
|
||||
if (data.group_name) chips.push(`Группа: <b>${esc(data.group_name)}</b>`);
|
||||
if (data.date_from || data.date_to) chips.push(`Период: <b>${fmt(data.date_from)}</b> — <b>${fmt(data.date_to)}</b>`);
|
||||
document.getElementById('chips').innerHTML = chips.map(c => `<span class="chip">${c}</span>`).join('');
|
||||
|
||||
const pb = document.getElementById('photosBlock');
|
||||
if (data.photos && data.photos.length) {
|
||||
document.getElementById('photosGrid').innerHTML = data.photos.map(p => `
|
||||
<a class="gphoto" href="#" data-img="${esc(p.photo_path)}">
|
||||
<img src="${p.photo_path}" alt="${esc(p.caption || '')}" loading="lazy">
|
||||
${p.caption ? `<div class="gcap">${esc(p.caption)}</div>` : ''}
|
||||
</a>
|
||||
`).join('');
|
||||
pb.style.display = 'block';
|
||||
} else {
|
||||
pb.style.display = 'none';
|
||||
}
|
||||
|
||||
const grid = document.getElementById('grid');
|
||||
if (!data.entries.length) {
|
||||
grid.innerHTML = '<div class="empty"><div class="big">🎈</div>Записей пока нет</div>';
|
||||
return;
|
||||
}
|
||||
const isAnonymized = data.anonymize_names;
|
||||
grid.innerHTML = data.entries.map(e => `
|
||||
<div class="card">
|
||||
<div class="photo">
|
||||
${e.photo_path
|
||||
? `<img src="${e.photo_path}" data-img="${esc(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>
|
||||
<div class="body">
|
||||
${!isAnonymized ? `<div class="name">${esc(e.student_name)}</div>` : ''}
|
||||
${!isAnonymized && e.group_name ? `<div><span class="group">${esc(e.group_name)}</span></div>` : ''}
|
||||
<div class="desc">${esc(e.description)}</div>
|
||||
${filesHTML(e.files, password, currentToken)}
|
||||
</div>
|
||||
<div class="foot">${new Date(e.created_at).toLocaleString('ru',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
document.getElementById('lightbox').addEventListener('click', () => document.getElementById('lightbox').classList.remove('open'));
|
||||
document.getElementById('passwordModal').querySelector('button').addEventListener('click', submitPassword);
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
const imgEl = e.target.closest('[data-img]');
|
||||
if (imgEl) { e.preventDefault(); showImg(imgEl.dataset.img); }
|
||||
});
|
||||
|
||||
(async()=>{
|
||||
await loadShare();
|
||||
})();
|
||||
@@ -0,0 +1,246 @@
|
||||
let allStudents = [];
|
||||
let allGroups = [];
|
||||
let groupOptionsLoaded = false;
|
||||
let sPage = 1;
|
||||
const S_PAGE_SIZE = 25;
|
||||
let sSearchTimer = null;
|
||||
let aSearchTimer = null;
|
||||
let attachGroupLoaded = false;
|
||||
|
||||
async function loadGroupOptions(sel, selected) {
|
||||
if (!groupOptionsLoaded) {
|
||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||
allGroups = await res.json();
|
||||
groupOptionsLoaded = true;
|
||||
const filt = document.getElementById('studentGroupFilter');
|
||||
if (filt && filt.options.length <= 1) {
|
||||
filt.innerHTML = `<option value="">Все группы</option>` +
|
||||
allGroups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
|
||||
}
|
||||
}
|
||||
if (sel) {
|
||||
sel.innerHTML = `<option value="">— не привязана —</option>` +
|
||||
allGroups.map(g => `<option value="${g.id}" ${selected == g.id ? 'selected' : ''}>${esc(g.name)}</option>`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStudents() {
|
||||
const res = await fetch(`${API}/api/students`, { headers: hdr() });
|
||||
allStudents = await res.json();
|
||||
renderStudents();
|
||||
}
|
||||
|
||||
function renderStudents() {
|
||||
const q = document.getElementById('studentSearch').value.trim().toLowerCase();
|
||||
const gid = document.getElementById('studentGroupFilter').value;
|
||||
let filtered = q ? allStudents.filter(s => s.name.toLowerCase().includes(q)) : allStudents;
|
||||
if (gid) filtered = filtered.filter(s => String(s.group_id) === gid);
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / S_PAGE_SIZE));
|
||||
if (sPage > totalPages) sPage = 1;
|
||||
const slice = filtered.slice((sPage - 1) * S_PAGE_SIZE, sPage * S_PAGE_SIZE);
|
||||
const list = document.getElementById('studentList');
|
||||
if (!slice.length) { list.innerHTML = '<div style="color:var(--muted);text-align:center;padding:32px">Ничего не найдено</div>'; }
|
||||
else {
|
||||
list.innerHTML = slice.map(s => `
|
||||
<div class="item">
|
||||
<input class="name-input student-name-input" id="sname-${s.id}" value="${esc(s.name)}">
|
||||
<select class="group-select" id="sgroup-${s.id}"></select>
|
||||
<div class="student-actions">
|
||||
<button class="btn-save" data-save-student="${s.id}">Сохранить</button>
|
||||
<button class="btn-link" data-copy-student="${esc(s.name)}" title="Скопировать ссылку с подстановкой имени">Ссылка</button>
|
||||
<button class="btn-del" data-del-student="${s.id}" title="Удалить">×</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
slice.forEach(s => loadGroupOptions(document.getElementById(`sgroup-${s.id}`), s.group_id));
|
||||
}
|
||||
renderPager(document.getElementById('pager'), sPage, totalPages, goStudentPage);
|
||||
}
|
||||
|
||||
function goStudentPage(p) {
|
||||
sPage = p;
|
||||
renderStudents();
|
||||
}
|
||||
|
||||
async function addStudent() {
|
||||
const input = document.getElementById('newStudent');
|
||||
const name = input.value.trim();
|
||||
if (!name) return;
|
||||
const res = await fetch(`${API}/api/students`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name }) });
|
||||
if (res.ok) { input.value = ''; loadStudents(); fillAutocomplete(); showToast(`Добавлен: ${name}`); }
|
||||
else { const e = await res.json(); alert(e.error); }
|
||||
}
|
||||
|
||||
async function saveStudent(id) {
|
||||
const name = document.getElementById(`sname-${id}`).value.trim();
|
||||
if (!name) return;
|
||||
const group_id = document.getElementById(`sgroup-${id}`).value;
|
||||
const res = await fetch(`${API}/api/students/${id}`, { method: 'PUT', headers: hdrJson(), body: JSON.stringify({ name, group_id }) });
|
||||
if (res.ok) { loadStudents(); fillAutocomplete(); showToast(`Сохранено: ${name}`); }
|
||||
else { const e = await res.json(); alert(e.error); }
|
||||
}
|
||||
|
||||
async function delStudent(id) {
|
||||
if (!confirm('Удалить ученика из списка? Старые записи останутся.')) return;
|
||||
await fetch(`${API}/api/students/${id}`, { method: 'DELETE', headers: hdr() });
|
||||
loadStudents();
|
||||
fillAutocomplete();
|
||||
}
|
||||
|
||||
function openBatchAdd() {
|
||||
document.getElementById('batchText').value = '';
|
||||
loadGroupOptions(document.getElementById('batchGroup'), '');
|
||||
document.getElementById('batchModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeBatchAdd() {
|
||||
document.getElementById('batchModal').classList.remove('open');
|
||||
}
|
||||
|
||||
async function batchAdd() {
|
||||
const raw = document.getElementById('batchText').value;
|
||||
const names = [...new Set(raw.split('\n').map(s => s.trim()).filter(Boolean))];
|
||||
if (!names.length) return;
|
||||
const group_id = document.getElementById('batchGroup').value;
|
||||
let added = 0, dupes = 0;
|
||||
for (const name of names) {
|
||||
const res = await fetch(`${API}/api/students`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name, group_id: group_id || null }) });
|
||||
if (res.ok) added++;
|
||||
else if (res.status === 409) dupes++;
|
||||
}
|
||||
closeBatchAdd();
|
||||
loadStudents();
|
||||
fillAutocomplete();
|
||||
showToast(`Добавлено: ${added}${dupes ? `, дубликатов: ${dupes}` : ''}`);
|
||||
}
|
||||
|
||||
document.getElementById('newStudent').addEventListener('keydown', e => { if (e.key === 'Enter') addStudent(); });
|
||||
document.getElementById('batchForm').addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
batchAdd();
|
||||
});
|
||||
document.getElementById('studentSearch').addEventListener('input', () => {
|
||||
clearTimeout(sSearchTimer);
|
||||
sSearchTimer = setTimeout(() => { sPage = 1; renderStudents(); }, 300);
|
||||
});
|
||||
document.getElementById('studentGroupFilter').addEventListener('change', () => { sPage = 1; renderStudents(); });
|
||||
|
||||
function exportStudentsCSV() {
|
||||
const gid = document.getElementById('studentGroupFilter').value;
|
||||
const list = gid ? allStudents.filter(s => String(s.group_id) === gid) : allStudents;
|
||||
if (!list.length) { alert('Нет данных для экспорта'); return; }
|
||||
const rows = [['Фамилия Имя', 'Группа', 'Ссылка на страницу']];
|
||||
list.forEach(s => {
|
||||
rows.push([
|
||||
s.name,
|
||||
s.group_name || '',
|
||||
`${location.origin}/?nameInput=${encodeURIComponent(s.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;
|
||||
const groupName = gid ? (allGroups.find(g => g.id == gid)?.name || 'group') : 'all';
|
||||
a.download = `students_${groupName.replace(/[\\/:*?"<>|]/g, '_')}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast(`Экспортировано: ${list.length}`);
|
||||
}
|
||||
|
||||
async function exportStudentsUrls() {
|
||||
const gid = document.getElementById('studentGroupFilter').value;
|
||||
const list = gid ? allStudents.filter(s => String(s.group_id) === gid) : allStudents;
|
||||
if (!list.length) { alert('Нет данных для экспорта'); return; }
|
||||
const groupName = gid ? (allGroups.find(g => g.id == gid)?.name || 'group') : 'all';
|
||||
await exportStudentUrlZip(list, `students_${groupName.replace(/[\\/:*?"<>|]/g, '_')}.zip`);
|
||||
showToast(`Экспортировано: ${list.length}`);
|
||||
}
|
||||
|
||||
async function openBatchAttach() {
|
||||
const sel = document.getElementById('attachGroup');
|
||||
if (!attachGroupLoaded) {
|
||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||
const groups = await res.json();
|
||||
sel.innerHTML = `<option value="">— выберите группу —</option>` +
|
||||
groups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
|
||||
attachGroupLoaded = true;
|
||||
}
|
||||
document.getElementById('attachSearch').value = '';
|
||||
renderAttachList();
|
||||
document.getElementById('attachModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeBatchAttach() {
|
||||
document.getElementById('attachModal').classList.remove('open');
|
||||
}
|
||||
|
||||
function renderAttachList() {
|
||||
const q = document.getElementById('attachSearch').value.trim().toLowerCase();
|
||||
const filtered = q ? allStudents.filter(s => s.name.toLowerCase().includes(q)) : allStudents;
|
||||
const list = document.getElementById('attachList');
|
||||
if (!filtered.length) { list.innerHTML = '<div style="color:var(--muted);text-align:center;padding:16px">Никого не найдено</div>'; return; }
|
||||
list.innerHTML = filtered.map(s => `
|
||||
<label>
|
||||
<input type="checkbox" value="${s.id}">
|
||||
<span>${esc(s.name)}</span>
|
||||
<span class="cur">${s.group_name ? esc(s.group_name) : ''}</span>
|
||||
</label>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function batchAttach() {
|
||||
const group_id = document.getElementById('attachGroup').value;
|
||||
const ids = [...document.querySelectorAll('#attachList input[type=checkbox]:checked')].map(c => c.value);
|
||||
if (!group_id) { alert('Выберите группу'); return; }
|
||||
if (!ids.length) { alert('Выберите учеников'); return; }
|
||||
const res = await fetch(`${API}/api/students/batch-group`, {
|
||||
method: 'POST',
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify({ group_id, student_ids: ids })
|
||||
});
|
||||
if (res.ok) {
|
||||
const r = await res.json();
|
||||
closeBatchAttach();
|
||||
loadStudents();
|
||||
showToast(`Прикреплено: ${r.updated}`);
|
||||
} else { const e = await res.json(); alert(e.error); }
|
||||
}
|
||||
|
||||
document.getElementById('attachForm').addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
batchAttach();
|
||||
});
|
||||
document.getElementById('attachSearch').addEventListener('input', () => {
|
||||
clearTimeout(aSearchTimer);
|
||||
aSearchTimer = setTimeout(renderAttachList, 300);
|
||||
});
|
||||
|
||||
document.getElementById('addStudentBtn').addEventListener('click', addStudent);
|
||||
document.getElementById('batchAddBtn').addEventListener('click', openBatchAdd);
|
||||
document.getElementById('batchAttachBtn').addEventListener('click', openBatchAttach);
|
||||
document.getElementById('exportStudentsCsvBtn').addEventListener('click', exportStudentsCSV);
|
||||
document.getElementById('exportStudentsUrlsBtn').addEventListener('click', exportStudentsUrls);
|
||||
document.getElementById('batchModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeBatchAdd(); });
|
||||
document.getElementById('attachModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeBatchAttach(); });
|
||||
document.getElementById('batchCancelBtn').addEventListener('click', closeBatchAdd);
|
||||
document.getElementById('attachCancelBtn').addEventListener('click', closeBatchAttach);
|
||||
|
||||
document.getElementById('studentList').addEventListener('click', e => {
|
||||
const sv = e.target.closest('[data-save-student]');
|
||||
if (sv) { saveStudent(parseInt(sv.dataset.saveStudent, 10)); return; }
|
||||
const cp = e.target.closest('[data-copy-student]');
|
||||
if (cp) { copyStudentLink(cp.dataset.copyStudent); return; }
|
||||
const dl = e.target.closest('[data-del-student]');
|
||||
if (dl) { delStudent(parseInt(dl.dataset.delStudent, 10)); }
|
||||
});
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
fillAutocomplete();
|
||||
loadGroupOptions();
|
||||
loadStudents();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,97 @@
|
||||
const PAGE_SIZE = 20;
|
||||
let currentEntries = [];
|
||||
let page = 1;
|
||||
let totalEntries = 0;
|
||||
|
||||
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 =>
|
||||
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>`
|
||||
).join('')}</div>`;
|
||||
}
|
||||
|
||||
function entryHTML(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="trash-time">Удалена: ${new Date(e.deleted_at).toLocaleString('ru')}</div>
|
||||
</div>
|
||||
<button class="restore-btn" data-restore="${e.id}" title="Восстановить">Восстановить</button>
|
||||
<button class="del-btn" data-hard="${e.id}" title="Удалить навсегда (вместе с файлами)">×</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
const grid = document.getElementById('grid');
|
||||
grid.className = 'grid';
|
||||
grid.innerHTML = currentEntries.length
|
||||
? currentEntries.map(entryHTML).join('')
|
||||
: '<div class="empty">Корзина пуста</div>';
|
||||
document.getElementById('trashInfo').textContent =
|
||||
totalEntries > 0 ? `Записей в корзине: ${totalEntries}` : '';
|
||||
document.getElementById('emptyBtn').disabled = totalEntries === 0;
|
||||
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalEntries / PAGE_SIZE)), goPage);
|
||||
}
|
||||
|
||||
async function loadTrash() {
|
||||
const p = new URLSearchParams();
|
||||
p.set('limit', PAGE_SIZE);
|
||||
p.set('offset', (page - 1) * PAGE_SIZE);
|
||||
const res = await fetch(`${API}/api/trash?${p}`, { headers: hdr() });
|
||||
const data = await res.json();
|
||||
currentEntries = data.entries;
|
||||
totalEntries = data.total;
|
||||
renderGrid();
|
||||
}
|
||||
|
||||
function goPage(p) { page = p; loadTrash(); }
|
||||
|
||||
async function restoreEntry(id) {
|
||||
const res = await fetch(`${API}/api/entries/${id}/restore`, { method: 'PUT', headers: hdr() });
|
||||
if (res.ok) { showToast('Запись восстановлена'); loadTrash(); }
|
||||
else { const err = await res.json(); alert(err.error || 'Ошибка'); }
|
||||
}
|
||||
|
||||
async function hardDelete(id) {
|
||||
if (!confirm('Удалить запись навсегда? Файлы и фото будут удалены безвозвратно.')) return;
|
||||
await fetch(`${API}/api/entries/${id}/permanent`, { method: 'DELETE', headers: hdr() });
|
||||
showToast('Запись удалена навсегда');
|
||||
loadTrash();
|
||||
}
|
||||
|
||||
async function emptyTrash() {
|
||||
if (totalEntries === 0) return;
|
||||
if (!confirm(`Удалить все записи из корзины (${totalEntries})? Файлы будут удалены безвозвратно.`)) return;
|
||||
const res = await fetch(`${API}/api/trash`, { method: 'DELETE', headers: hdr() });
|
||||
if (res.ok) { showToast('Корзина очищена'); page = 1; loadTrash(); }
|
||||
else { const err = await res.json(); alert(err.error || 'Ошибка'); }
|
||||
}
|
||||
|
||||
document.getElementById('emptyBtn').addEventListener('click', emptyTrash);
|
||||
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
|
||||
|
||||
document.getElementById('grid').addEventListener('click', e => {
|
||||
const rs = e.target.closest('[data-restore]');
|
||||
if (rs) { restoreEntry(parseInt(rs.dataset.restore, 10)); return; }
|
||||
const hd = e.target.closest('[data-hard]');
|
||||
if (hd) { hardDelete(parseInt(hd.dataset.hard, 10)); }
|
||||
});
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
loadTrash();
|
||||
}
|
||||
})();
|
||||
+4
-143
@@ -16,7 +16,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="linkModal" onclick="if(event.target===this)closeLinkModal()">
|
||||
<div class="modal-overlay" id="linkModal">
|
||||
<div class="edit-modal">
|
||||
<h3 id="linkModalTitle">Новая ссылка</h3>
|
||||
<div>
|
||||
@@ -56,153 +56,14 @@
|
||||
<input type="password" id="linkPassword" placeholder="Оставьте пустым для открытого доступа">
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="cancel" onclick="closeLinkModal()">Отмена</button>
|
||||
<button class="save" onclick="saveLink()">Сохранить</button>
|
||||
<button class="cancel" type="button" id="linkCancelBtn">Отмена</button>
|
||||
<button class="save" type="button" id="linkSaveBtn">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="admin.js"></script>
|
||||
<script>
|
||||
let links = [];
|
||||
let groups = [];
|
||||
let editingId = null;
|
||||
let studentsLoaded = false;
|
||||
|
||||
function linkUrl(token) { return `${location.origin}/s/${token}`; }
|
||||
|
||||
async function loadLinks() {
|
||||
const res = await fetch(`${API}/api/links`, { headers: hdr() });
|
||||
links = await res.json();
|
||||
const el = document.getElementById('linksList');
|
||||
if (!links.length) { el.innerHTML = '<div class="empty">Ссылок пока нет. Создайте её в Журнале кнопкой «Ссылка».</div>'; return; }
|
||||
el.innerHTML = links.map(l => `
|
||||
<div class="item">
|
||||
<div style="min-width:0">
|
||||
<div class="name">${esc(l.name)}</div>
|
||||
<div style="font-size:.8rem;color:var(--muted);margin-top:2px">
|
||||
${l.group_name ? `Группа: <b>${esc(l.group_name)}</b>` : ''}
|
||||
${l.student_name ? `${l.group_name ? ' · ' : ''}Ученик: <b>${esc(l.student_name)}</b>` : ''}
|
||||
${l.date_from || l.date_to ? ` · период: <b>${l.date_from ? l.date_from.split('-').reverse().join('.') : '…'}</b> — <b>${l.date_to ? l.date_to.split('-').reverse().join('.') : '…'}</b>` : ' · весь период'}
|
||||
${l.expires_at ? ` · истекает ${new Date(l.expires_at).toLocaleDateString('ru')}` : ''}
|
||||
${l.anonymize_names ? ` · <span style="color:#f59e0b">анонимизировано</span>` : ''}
|
||||
${l.access_password_hash ? ` · <span style="color:#2563eb">🔒 пароль</span>` : ''}
|
||||
· создана ${new Date(l.created_at).toLocaleString('ru')}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-shrink:0;align-items:center">
|
||||
<a class="btn-link" style="text-decoration:none" href="/s/${l.token}" target="_blank" title="Открыть">Открыть</a>
|
||||
<button class="btn-link" onclick="copyText('${linkUrl(l.token)}')" title="Копировать ссылку">Копировать</button>
|
||||
<button class="btn-link" onclick="openLinkEdit(${l.id})" title="Редактировать">✎</button>
|
||||
<button class="btn-link" style="color:var(--muted)" onclick="delLink(${l.id})" title="Удалить">×</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function fmtDate(d) { const [y, m, day] = (d || '').split('-'); return d ? `${day}.${m}.${y}` : ''; }
|
||||
|
||||
async function openLinkModal() {
|
||||
editingId = null;
|
||||
document.getElementById('linkModalTitle').textContent = 'Новая ссылка';
|
||||
document.getElementById('linkName').value = '';
|
||||
document.getElementById('linkDateFrom').value = '';
|
||||
document.getElementById('linkDateTo').value = '';
|
||||
document.getElementById('linkExpiresDays').value = '7';
|
||||
document.getElementById('linkAnonymizeNames').checked = false;
|
||||
document.getElementById('linkPassword').value = '';
|
||||
if (!groups.length) {
|
||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||
groups = await res.json();
|
||||
document.getElementById('linkGroup').innerHTML = '<option value="">— без группы —</option>' +
|
||||
groups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
|
||||
}
|
||||
if (!studentsLoaded) {
|
||||
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('');
|
||||
studentsLoaded = true;
|
||||
}
|
||||
document.getElementById('linkModal').classList.add('open');
|
||||
}
|
||||
|
||||
async function openLinkEdit(id) {
|
||||
const l = links.find(x => x.id === id);
|
||||
if (!l) return;
|
||||
editingId = id;
|
||||
document.getElementById('linkModalTitle').textContent = 'Редактирование ссылки';
|
||||
document.getElementById('linkName').value = l.name;
|
||||
document.getElementById('linkDateFrom').value = l.date_from || '';
|
||||
document.getElementById('linkDateTo').value = l.date_to || '';
|
||||
document.getElementById('linkExpiresDays').value = l.expires_at ? Math.ceil((new Date(l.expires_at) - new Date()) / (1000 * 60 * 60 * 24)) : '7';
|
||||
document.getElementById('linkAnonymizeNames').checked = l.anonymize_names || false;
|
||||
document.getElementById('linkPassword').value = ''; // Never show existing password
|
||||
if (!groups.length) {
|
||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||
groups = await res.json();
|
||||
document.getElementById('linkGroup').innerHTML = '<option value="">— без группы —</option>' +
|
||||
groups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
|
||||
}
|
||||
document.getElementById('linkGroup').value = l.group_id || '';
|
||||
if (!studentsLoaded) {
|
||||
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('');
|
||||
studentsLoaded = true;
|
||||
}
|
||||
document.getElementById('linkStudent').value = l.student_name || '';
|
||||
document.getElementById('linkModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeLinkModal() {
|
||||
document.getElementById('linkModal').classList.remove('open');
|
||||
editingId = null;
|
||||
}
|
||||
|
||||
async function saveLink() {
|
||||
const name = document.getElementById('linkName').value.trim();
|
||||
const group_id = document.getElementById('linkGroup').value;
|
||||
const student_name = document.getElementById('linkStudent').value;
|
||||
const date_from = document.getElementById('linkDateFrom').value;
|
||||
const date_to = document.getElementById('linkDateTo').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 body = JSON.stringify({ name, group_id: group_id || null, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, anonymize_names, expires_at, access_password: access_password || null });
|
||||
const res = await fetch(editingId ? `${API}/api/links/${editingId}` : `${API}/api/links`, {
|
||||
method: editingId ? 'PUT' : 'POST',
|
||||
headers: hdrJson(),
|
||||
body
|
||||
});
|
||||
if (res.ok) {
|
||||
closeLinkModal();
|
||||
loadLinks();
|
||||
showToast(editingId ? 'Ссылка обновлена' : 'Ссылка создана');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(err.error || 'Ошибка');
|
||||
}
|
||||
}
|
||||
|
||||
async function delLink(id) {
|
||||
if (!confirm('Удалить ссылку?')) return;
|
||||
await fetch(`${API}/api/links/${id}`, { method: 'DELETE', headers: hdr() });
|
||||
loadLinks();
|
||||
showToast('Ссылка удалена');
|
||||
}
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
loadLinks();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="js/links.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+2
-21
@@ -24,29 +24,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
||||
<div class="login">
|
||||
<h1>Админ-панель</h1>
|
||||
<input type="password" id="pwdInput" placeholder="Пароль" autofocus>
|
||||
<button onclick="doLogin()">Войти</button>
|
||||
<button type="button" id="loginBtn">Войти</button>
|
||||
<div class="err" id="loginErr">Неверный пароль</div>
|
||||
<div class="back"><a href="/">На главную</a></div>
|
||||
</div>
|
||||
<script>
|
||||
const API = location.origin;
|
||||
document.getElementById('pwdInput').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
|
||||
|
||||
async function doLogin() {
|
||||
const pwd = document.getElementById('pwdInput').value;
|
||||
if (!pwd) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/api/stats`, { headers: { 'X-Admin-Token': pwd } });
|
||||
if (res.ok) {
|
||||
sessionStorage.setItem('adminToken', pwd);
|
||||
location.href = 'dashboard.html';
|
||||
} else {
|
||||
document.getElementById('loginErr').style.display = 'block';
|
||||
}
|
||||
} catch {
|
||||
document.getElementById('loginErr').style.display = 'block';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="js/login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+4
-222
@@ -59,8 +59,8 @@
|
||||
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.</p>
|
||||
<div class="data-stats" id="dataStats"><span class="chip">Загрузка статистики…</span></div>
|
||||
<div class="card-foot">
|
||||
<button class="btn-primary ghost" id="backupBtn" onclick="downloadBackup()">⬇️ Скачать бэкап</button>
|
||||
<button class="btn-primary ghost" id="restoreBtn" onclick="document.getElementById('restoreFile').click()">⬆️ Восстановить из файла</button>
|
||||
<button class="btn-primary ghost" id="backupBtn" type="button">⬇️ Скачать бэкап</button>
|
||||
<button class="btn-primary ghost" id="restoreBtn" type="button">⬆️ Восстановить из файла</button>
|
||||
<input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none">
|
||||
</div>
|
||||
<div class="restore-status" id="restoreStatus"></div>
|
||||
@@ -81,228 +81,10 @@
|
||||
|
||||
<div class="save-bar" id="saveBar">
|
||||
<span class="hint" id="saveHint">⚠️ Есть несохранённые изменения</span>
|
||||
<button class="btn-primary" id="saveBtn" onclick="saveSettings()">Сохранить изменения</button>
|
||||
<button class="btn-primary" id="saveBtn" type="button">Сохранить изменения</button>
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="admin.js"></script>
|
||||
<script>
|
||||
const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight'];
|
||||
|
||||
function setBtnLoading(btn, on, spinnerClass) {
|
||||
if (on) {
|
||||
btn.dataset.label = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = `<span class="spinner ${spinnerClass || ''}"></span> ${btn.dataset.loadingText || 'Обработка...'}`;
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = btn.dataset.label || btn.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
function updateDirty() {
|
||||
const dirty = DIRTY_FIELDS.some(id => {
|
||||
const el = document.getElementById(id);
|
||||
return String(el.dataset.original ?? '') !== el.value;
|
||||
});
|
||||
document.getElementById('saveBar').classList.toggle('show', dirty);
|
||||
}
|
||||
|
||||
function bindDirty() {
|
||||
DIRTY_FIELDS.forEach(id => {
|
||||
document.getElementById(id).addEventListener('input', updateDirty);
|
||||
});
|
||||
}
|
||||
|
||||
function snapshotDirty() {
|
||||
DIRTY_FIELDS.forEach(id => {
|
||||
document.getElementById(id).dataset.original = document.getElementById(id).value;
|
||||
});
|
||||
updateDirty();
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const [sRes, stRes] = await Promise.all([
|
||||
fetch(`${API}/api/settings`, { headers: hdr() }),
|
||||
fetch(`${API}/api/stats`, { headers: hdr() }),
|
||||
]);
|
||||
const s = await sRes.json();
|
||||
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
|
||||
document.getElementById('footerLeft').value = s.footer_left || '';
|
||||
document.getElementById('footerRight').value = s.footer_right || '';
|
||||
snapshotDirty();
|
||||
if (stRes.ok) {
|
||||
const st = await stRes.json();
|
||||
document.getElementById('dataStats').innerHTML = `
|
||||
<span class="chip">Записей: <b>${st.entries}</b></span>
|
||||
<span class="chip">Учеников: <b>${st.students}</b></span>
|
||||
<span class="chip">Групп: <b>${st.groups}</b></span>
|
||||
<span class="chip">Сегодня: <b>${st.today}</b></span>
|
||||
<span class="chip">${st.trash ? `В корзине: <b>${st.trash}</b> <a href="trash.html">→</a>` : ''}</span>
|
||||
`;
|
||||
} else {
|
||||
document.getElementById('dataStats').innerHTML = '';
|
||||
}
|
||||
|
||||
const siRes = await fetch(`${API}/api/system-info`, { headers: hdr() });
|
||||
if (siRes.ok) {
|
||||
const si = await siRes.json();
|
||||
renderSystemInfo(si);
|
||||
} else {
|
||||
document.getElementById('systemInfo').innerHTML = '<div class="sys-card err">Ошибка загрузки</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderSystemInfo(si) {
|
||||
const db = si.database || {};
|
||||
const photos = si.photos || {};
|
||||
const files = si.files || {};
|
||||
const uploads = si.uploads || {};
|
||||
const disk = si.disk;
|
||||
|
||||
let tablesHtml = '';
|
||||
if (db.tables && db.tables.length) {
|
||||
tablesHtml = '<details class="sys-details"><summary>Таблицы БД</summary><div class="sys-table-list">' +
|
||||
db.tables.map(t => `<div class="sys-table-row"><span>${t.name}</span><span>${t.size}</span></div>`).join('') +
|
||||
'</div></details>';
|
||||
}
|
||||
|
||||
let diskHtml = '';
|
||||
if (disk) {
|
||||
const pct = disk.used_pct;
|
||||
const color = pct > 90 ? '#ef4444' : pct > 70 ? '#f59e0b' : '#16a34a';
|
||||
diskHtml = `
|
||||
<div class="sys-item">
|
||||
<span class="sys-label">Диск</span>
|
||||
<span class="sys-value">${disk.used} / ${disk.total}</span>
|
||||
</div>
|
||||
<div class="sys-bar-wrap">
|
||||
<div class="sys-bar" style="width:${pct}%; background:${color}"></div>
|
||||
</div>
|
||||
<div class="sys-meta">Свободно: ${disk.free} (${100 - pct}%)</div>
|
||||
`;
|
||||
}
|
||||
|
||||
document.getElementById('systemInfo').innerHTML = `
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">База данных</div>
|
||||
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${db.size || '—'}</span></div>
|
||||
${tablesHtml}
|
||||
</div>
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Фото</div>
|
||||
<div class="sys-item"><span class="sys-label">Фото групп</span><span class="sys-value">${photos.group_photos?.count || 0}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Фото в записях</span><span class="sys-value">${photos.entry_photos?.count || 0}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Всего</span><span class="sys-value"><b>${photos.total_count || 0}</b></span></div>
|
||||
</div>
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Файлы</div>
|
||||
<div class="sys-item"><span class="sys-label">Проектные файлы</span><span class="sys-value">${files.project_files?.count || 0}</span></div>
|
||||
</div>
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Загрузки</div>
|
||||
<div class="sys-item"><span class="sys-label">Файлов</span><span class="sys-value">${uploads.count || 0}</span></div>
|
||||
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${uploads.size || '0 B'}</span></div>
|
||||
</div>
|
||||
<div class="sys-card">
|
||||
<div class="sys-title">Диск</div>
|
||||
${diskHtml || '<div class="sys-meta">Недоступно</div>'}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const v = document.getElementById('spamInterval').value;
|
||||
if (v === '' || Number(v) < 1) { alert('Введите число от 1'); return; }
|
||||
const btn = document.getElementById('saveBtn');
|
||||
btn.dataset.loadingText = 'Сохранение...';
|
||||
setBtnLoading(btn, true);
|
||||
try {
|
||||
const res = await fetch(`${API}/api/settings`, {
|
||||
method: 'PUT',
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify({ settings: {
|
||||
spam_interval_min: String(Math.round(Number(v))),
|
||||
footer_left: document.getElementById('footerLeft').value.trim(),
|
||||
footer_right: document.getElementById('footerRight').value.trim(),
|
||||
} })
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast('Настройки сохранены');
|
||||
snapshotDirty();
|
||||
} else { const e = await res.json(); alert(e.error); }
|
||||
} finally {
|
||||
setBtnLoading(btn, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadBackup() {
|
||||
const btn = document.getElementById('backupBtn');
|
||||
btn.dataset.loadingText = 'Формирование...';
|
||||
setBtnLoading(btn, true);
|
||||
try {
|
||||
const res = await fetch(`${API}/api/backup`, { headers: hdr() });
|
||||
if (!res.ok) { const t = await res.text(); alert('Ошибка: ' + t); return; }
|
||||
const blob = await res.blob();
|
||||
const cd = res.headers.get('Content-Disposition') || '';
|
||||
const m = cd.match(/filename="([^"]+)"/);
|
||||
const name = m ? m[1] : `whatido-backup-${new Date().toISOString().slice(0, 10)}.json.gz`;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast('Бэкап скачан');
|
||||
} finally {
|
||||
setBtnLoading(btn, false);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('restoreFile').addEventListener('change', async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
if (!confirm('Восстановление заменит все текущие данные. Продолжить?')) { e.target.value = ''; return; }
|
||||
const st = document.getElementById('restoreStatus');
|
||||
const backupBtn = document.getElementById('backupBtn');
|
||||
const restoreBtn = document.getElementById('restoreBtn');
|
||||
st.classList.remove('err');
|
||||
st.innerHTML = '<span class="spinner dark"></span> Восстановление...';
|
||||
backupBtn.disabled = true;
|
||||
restoreBtn.disabled = true;
|
||||
const fd = new FormData();
|
||||
fd.append('backup', file);
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${API}/api/restore`, { method: 'POST', headers: hdr(), body: fd });
|
||||
} catch (err) {
|
||||
st.textContent = '';
|
||||
alert('Ошибка сети: ' + err.message);
|
||||
e.target.value = '';
|
||||
return;
|
||||
} finally {
|
||||
backupBtn.disabled = false;
|
||||
restoreBtn.disabled = false;
|
||||
}
|
||||
if (res.ok) {
|
||||
showToast('Данные восстановлены');
|
||||
st.textContent = '';
|
||||
loadSettings();
|
||||
} else {
|
||||
st.classList.add('err');
|
||||
st.innerHTML = 'Ошибка восстановления';
|
||||
const t = await res.text();
|
||||
alert('Ошибка: ' + t);
|
||||
}
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
bindDirty();
|
||||
loadSettings();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="js/settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+3
-153
@@ -76,167 +76,17 @@ body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background
|
||||
</div>
|
||||
<div class="grid" id="grid"><div class="loading">Карточки загружаются...</div></div>
|
||||
</main>
|
||||
<div class="lightbox" id="lightbox" onclick="this.classList.remove('open')"><img id="lightboxImg" alt=""></div>
|
||||
<div class="lightbox" id="lightbox"><img id="lightboxImg" alt=""></div>
|
||||
|
||||
<div class="password-modal" id="passwordModal">
|
||||
<div class="password-box">
|
||||
<h3>🔒 Ссылка защищена паролем</h3>
|
||||
<p>Введите пароль для доступа к записям</p>
|
||||
<input type="password" id="passwordInput" placeholder="Пароль" autocomplete="current-password">
|
||||
<button onclick="submitPassword()">Войти</button>
|
||||
<button type="button" id="passwordSubmitBtn">Войти</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||||
function fmt(d){return d?d.split('-').reverse().join('.'):'весь период'}
|
||||
function showImg(src){document.getElementById('lightboxImg').src=src;document.getElementById('lightbox').classList.add('open')}
|
||||
function isImageFile(name){return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')}
|
||||
function fileType(name){
|
||||
if(/\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name||'')) return 'image';
|
||||
if(/\.(mp3|wav|ogg|flac|m4a|aac|opus)$/i.test(name||'')) return 'music';
|
||||
if(/\.(pptx?|odp|key)$/i.test(name||'')) return 'presentation';
|
||||
if(/\.(pdf|docx?|xlsx?|txt|rtf|odt|ods|csv|md)$/i.test(name||'')) return 'document';
|
||||
return 'other';
|
||||
}
|
||||
function fileIcon(t){
|
||||
const icons = {image:'🖼️',music:'🎵',presentation:'📊',document:'📄',other:'📁'};
|
||||
return icons[t]||icons.other;
|
||||
}
|
||||
function filesHTML(files, password, shareToken){
|
||||
if(!files||!files.length)return '';
|
||||
const pw = password ? `?password=${encodeURIComponent(password)}` : '';
|
||||
return `<div class="files-block">
|
||||
<div class="files-label">Работы резидента</div>
|
||||
<div class="files">${files.map(f=>{
|
||||
const t = fileType(f.name);
|
||||
const url = `/api/share/${shareToken}/files/${f.token}${pw}`;
|
||||
return `<a class="ftype-${t}" title="${esc(f.name)}" ${isImageFile(f.name)
|
||||
?`href="#" onclick="showImg('${url}');return false;"`
|
||||
:`href="${url}" download`}>
|
||||
<span class="ficon" alt="${esc(f.name)}">${fileIcon(t)}</span>
|
||||
</a>`;
|
||||
}).join('')}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
let currentToken = '';
|
||||
let currentPassword = null;
|
||||
|
||||
async function fetchShareData(token, password) {
|
||||
const headers = {};
|
||||
if (password) headers['x-share-password'] = password;
|
||||
const res = await fetch('/api/share/' + token, { headers });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return { ok: res.ok, status: res.status, data };
|
||||
}
|
||||
|
||||
async function loadShare() {
|
||||
const token = location.pathname.split('/').filter(Boolean).pop() || '';
|
||||
currentToken = token;
|
||||
currentPassword = null;
|
||||
|
||||
const tryLoad = async (pwd) => {
|
||||
const result = await fetchShareData(token, pwd);
|
||||
if (result.ok) {
|
||||
renderShare(result.data, pwd);
|
||||
return true;
|
||||
}
|
||||
if (result.status === 401 && result.data.passwordRequired) {
|
||||
showPasswordModal();
|
||||
return false;
|
||||
}
|
||||
if (result.status === 410) {
|
||||
showError('Срок действия ссылки истёк');
|
||||
return true;
|
||||
}
|
||||
showError('Ссылка не найдена или была удалена');
|
||||
return true;
|
||||
};
|
||||
|
||||
await tryLoad(null);
|
||||
}
|
||||
|
||||
function showPasswordModal() {
|
||||
document.getElementById('passwordInput').value = '';
|
||||
document.getElementById('passwordModal').classList.add('open');
|
||||
document.getElementById('passwordInput').focus();
|
||||
}
|
||||
|
||||
function hidePasswordModal() {
|
||||
document.getElementById('passwordModal').classList.remove('open');
|
||||
}
|
||||
|
||||
async function submitPassword() {
|
||||
const pwd = document.getElementById('passwordInput').value;
|
||||
if (!pwd) return;
|
||||
const result = await fetchShareData(currentToken, pwd);
|
||||
if (result.ok) {
|
||||
hidePasswordModal();
|
||||
currentPassword = pwd;
|
||||
renderShare(result.data, pwd);
|
||||
} else {
|
||||
alert('Неверный пароль');
|
||||
document.getElementById('passwordInput').value = '';
|
||||
document.getElementById('passwordInput').focus();
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
document.getElementById('title').textContent = msg;
|
||||
document.getElementById('grid').innerHTML = `<div class="empty"><div class="big">🤔</div>${esc(msg)}</div>`;
|
||||
document.getElementById('photosBlock').style.display = 'none';
|
||||
}
|
||||
|
||||
function renderShare(data, password) {
|
||||
const heading = data.group_name || data.name || 'Записи занятий';
|
||||
document.title = heading;
|
||||
document.getElementById('title').textContent = heading;
|
||||
const chips = [];
|
||||
if (data.group_name) chips.push(`Группа: <b>${esc(data.group_name)}</b>`);
|
||||
if (data.date_from || data.date_to) chips.push(`Период: <b>${fmt(data.date_from)}</b> — <b>${fmt(data.date_to)}</b>`);
|
||||
document.getElementById('chips').innerHTML = chips.map(c => `<span class="chip">${c}</span>`).join('');
|
||||
|
||||
const pb = document.getElementById('photosBlock');
|
||||
if (data.photos && data.photos.length) {
|
||||
document.getElementById('photosGrid').innerHTML = data.photos.map(p => `
|
||||
<a class="gphoto" href="#" onclick="showImg('${p.photo_path}');return false;">
|
||||
<img src="${p.photo_path}" alt="${esc(p.caption || '')}" loading="lazy">
|
||||
${p.caption ? `<div class="gcap">${esc(p.caption)}</div>` : ''}
|
||||
</a>
|
||||
`).join('');
|
||||
pb.style.display = 'block';
|
||||
} else {
|
||||
pb.style.display = 'none';
|
||||
}
|
||||
|
||||
const grid = document.getElementById('grid');
|
||||
if (!data.entries.length) {
|
||||
grid.innerHTML = '<div class="empty"><div class="big">🎈</div>Записей пока нет</div>';
|
||||
return;
|
||||
}
|
||||
const isAnonymized = data.anonymize_names;
|
||||
grid.innerHTML = data.entries.map(e => `
|
||||
<div class="card">
|
||||
<div class="photo">
|
||||
${e.photo_path
|
||||
? `<img src="${e.photo_path}" onclick="showImg('${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>
|
||||
<div class="body">
|
||||
${!isAnonymized ? `<div class="name">${esc(e.student_name)}</div>` : ''}
|
||||
${!isAnonymized && e.group_name ? `<div><span class="group">${esc(e.group_name)}</span></div>` : ''}
|
||||
<div class="desc">${esc(e.description)}</div>
|
||||
${filesHTML(e.files, password, currentToken)}
|
||||
</div>
|
||||
<div class="foot">${new Date(e.created_at).toLocaleString('ru',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'})}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
(async()=>{
|
||||
await loadShare();
|
||||
})();
|
||||
</script>
|
||||
<script src="/js/share.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+10
-240
@@ -14,22 +14,22 @@
|
||||
<div class="add-row">
|
||||
<input type="text" id="newStudent" placeholder="Фамилия Имя" list="studentAddList">
|
||||
<datalist id="studentAddList"></datalist>
|
||||
<button onclick="addStudent()">Добавить</button>
|
||||
<button class="btn-ghost" onclick="openBatchAdd()">Пакетное добавление</button>
|
||||
<button class="btn-ghost" onclick="openBatchAttach()">Прикрепить к группе</button>
|
||||
<button type="button" id="addStudentBtn">Добавить</button>
|
||||
<button class="btn-ghost" type="button" id="batchAddBtn">Пакетное добавление</button>
|
||||
<button class="btn-ghost" type="button" id="batchAttachBtn">Прикрепить к группе</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap">
|
||||
<input type="text" id="studentSearch" class="search-input" placeholder="Поиск по имени..." style="flex:1;min-width:200px;max-width:340px;padding:10px 14px;font-size:.9rem">
|
||||
<select id="studentGroupFilter" class="search-input" style="padding:10px 14px"><option value="">Все группы</option></select>
|
||||
<button class="btn-link" onclick="exportStudentsCSV()">Экспорт CSV</button>
|
||||
<button class="btn-link" onclick="exportStudentsUrls()">Экспорт URL</button>
|
||||
<button class="btn-link" type="button" id="exportStudentsCsvBtn">Экспорт CSV</button>
|
||||
<button class="btn-link" type="button" id="exportStudentsUrlsBtn">Экспорт URL</button>
|
||||
</div>
|
||||
<div class="list" id="studentList"></div>
|
||||
<div class="pager" id="pager"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="batchModal" onclick="if(event.target===this)closeBatchAdd()">
|
||||
<div class="modal-overlay" id="batchModal">
|
||||
<div class="edit-modal" style="max-width:480px">
|
||||
<h3>Пакетное добавление учеников</h3>
|
||||
<form id="batchForm" class="settings-stack" style="gap:12px">
|
||||
@@ -46,14 +46,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||
<button type="button" class="btn-primary ghost" onclick="closeBatchAdd()">Отмена</button>
|
||||
<button type="button" class="btn-primary ghost" id="batchCancelBtn">Отмена</button>
|
||||
<button type="submit" class="btn-primary">Добавить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="attachModal" onclick="if(event.target===this)closeBatchAttach()">
|
||||
<div class="modal-overlay" id="attachModal">
|
||||
<div class="edit-modal" style="max-width:480px">
|
||||
<h3>Прикрепить к группе</h3>
|
||||
<form id="attachForm" class="settings-stack" style="gap:12px">
|
||||
@@ -71,7 +71,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||
<button type="button" class="btn-primary ghost" onclick="closeBatchAttach()">Отмена</button>
|
||||
<button type="button" class="btn-primary ghost" id="attachCancelBtn">Отмена</button>
|
||||
<button type="submit" class="btn-primary">Прикрепить</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -81,236 +81,6 @@
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="admin.js"></script>
|
||||
<script src="vendor/jszip.min.js"></script>
|
||||
<script>
|
||||
// --- Students ---
|
||||
let allStudents = [];
|
||||
let allGroups = [];
|
||||
let groupOptionsLoaded = false;
|
||||
let sPage = 1;
|
||||
const S_PAGE_SIZE = 25;
|
||||
let sSearchTimer = null;
|
||||
let aSearchTimer = null;
|
||||
let attachGroupLoaded = false;
|
||||
|
||||
async function loadGroupOptions(sel, selected) {
|
||||
if (!groupOptionsLoaded) {
|
||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||
allGroups = await res.json();
|
||||
groupOptionsLoaded = true;
|
||||
const filt = document.getElementById('studentGroupFilter');
|
||||
if (filt && filt.options.length <= 1) {
|
||||
filt.innerHTML = `<option value="">Все группы</option>` +
|
||||
allGroups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
|
||||
}
|
||||
}
|
||||
if (sel) {
|
||||
sel.innerHTML = `<option value="">— не привязана —</option>` +
|
||||
allGroups.map(g => `<option value="${g.id}" ${selected == g.id ? 'selected' : ''}>${esc(g.name)}</option>`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStudents() {
|
||||
const res = await fetch(`${API}/api/students`, { headers: hdr() });
|
||||
allStudents = await res.json();
|
||||
renderStudents();
|
||||
}
|
||||
|
||||
function renderStudents() {
|
||||
const q = document.getElementById('studentSearch').value.trim().toLowerCase();
|
||||
const gid = document.getElementById('studentGroupFilter').value;
|
||||
let filtered = q ? allStudents.filter(s => s.name.toLowerCase().includes(q)) : allStudents;
|
||||
if (gid) filtered = filtered.filter(s => String(s.group_id) === gid);
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / S_PAGE_SIZE));
|
||||
if (sPage > totalPages) sPage = 1;
|
||||
const slice = filtered.slice((sPage - 1) * S_PAGE_SIZE, sPage * S_PAGE_SIZE);
|
||||
const list = document.getElementById('studentList');
|
||||
if (!slice.length) { list.innerHTML = '<div style="color:var(--muted);text-align:center;padding:32px">Ничего не найдено</div>'; }
|
||||
else {
|
||||
list.innerHTML = slice.map(s => `
|
||||
<div class="item">
|
||||
<input class="name-input student-name-input" id="sname-${s.id}" value="${esc(s.name)}">
|
||||
<select class="group-select" id="sgroup-${s.id}"></select>
|
||||
<div class="student-actions">
|
||||
<button class="btn-save" onclick="saveStudent(${s.id})">Сохранить</button>
|
||||
<button class="btn-link" onclick="copyStudentLink('${esc(s.name).replace(/'/g, ''')}')" title="Скопировать ссылку с подстановкой имени">Ссылка</button>
|
||||
<button class="btn-del" onclick="delStudent(${s.id})" title="Удалить">×</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
slice.forEach(s => loadGroupOptions(document.getElementById(`sgroup-${s.id}`), s.group_id));
|
||||
}
|
||||
renderPager(document.getElementById('pager'), sPage, totalPages, goStudentPage);
|
||||
}
|
||||
|
||||
function goStudentPage(p) {
|
||||
sPage = p;
|
||||
renderStudents();
|
||||
}
|
||||
|
||||
async function addStudent() {
|
||||
const input = document.getElementById('newStudent');
|
||||
const name = input.value.trim();
|
||||
if (!name) return;
|
||||
const res = await fetch(`${API}/api/students`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name }) });
|
||||
if (res.ok) { input.value = ''; loadStudents(); fillAutocomplete(); showToast(`Добавлен: ${name}`); }
|
||||
else { const e = await res.json(); alert(e.error); }
|
||||
}
|
||||
|
||||
async function saveStudent(id) {
|
||||
const name = document.getElementById(`sname-${id}`).value.trim();
|
||||
if (!name) return;
|
||||
const group_id = document.getElementById(`sgroup-${id}`).value;
|
||||
const res = await fetch(`${API}/api/students/${id}`, { method: 'PUT', headers: hdrJson(), body: JSON.stringify({ name, group_id }) });
|
||||
if (res.ok) { loadStudents(); fillAutocomplete(); showToast(`Сохранено: ${name}`); }
|
||||
else { const e = await res.json(); alert(e.error); }
|
||||
}
|
||||
|
||||
async function delStudent(id) {
|
||||
if (!confirm('Удалить ученика из списка? Старые записи останутся.')) return;
|
||||
await fetch(`${API}/api/students/${id}`, { method: 'DELETE', headers: hdr() });
|
||||
loadStudents();
|
||||
fillAutocomplete();
|
||||
}
|
||||
|
||||
function openBatchAdd() {
|
||||
document.getElementById('batchText').value = '';
|
||||
loadGroupOptions(document.getElementById('batchGroup'), '');
|
||||
document.getElementById('batchModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeBatchAdd() {
|
||||
document.getElementById('batchModal').classList.remove('open');
|
||||
}
|
||||
|
||||
async function batchAdd() {
|
||||
const raw = document.getElementById('batchText').value;
|
||||
const names = [...new Set(raw.split('\n').map(s => s.trim()).filter(Boolean))];
|
||||
if (!names.length) return;
|
||||
const group_id = document.getElementById('batchGroup').value;
|
||||
let added = 0, dupes = 0;
|
||||
for (const name of names) {
|
||||
const res = await fetch(`${API}/api/students`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name, group_id: group_id || null }) });
|
||||
if (res.ok) added++;
|
||||
else if (res.status === 409) dupes++;
|
||||
}
|
||||
closeBatchAdd();
|
||||
loadStudents();
|
||||
fillAutocomplete();
|
||||
showToast(`Добавлено: ${added}${dupes ? `, дубликатов: ${dupes}` : ''}`);
|
||||
}
|
||||
|
||||
document.getElementById('newStudent').addEventListener('keydown', e => { if (e.key === 'Enter') addStudent(); });
|
||||
document.getElementById('batchForm').addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
batchAdd();
|
||||
});
|
||||
document.getElementById('studentSearch').addEventListener('input', () => {
|
||||
clearTimeout(sSearchTimer);
|
||||
sSearchTimer = setTimeout(() => { sPage = 1; renderStudents(); }, 300);
|
||||
});
|
||||
document.getElementById('studentGroupFilter').addEventListener('change', () => { sPage = 1; renderStudents(); });
|
||||
|
||||
function exportStudentsCSV() {
|
||||
const gid = document.getElementById('studentGroupFilter').value;
|
||||
const list = gid ? allStudents.filter(s => String(s.group_id) === gid) : allStudents;
|
||||
if (!list.length) { alert('Нет данных для экспорта'); return; }
|
||||
const rows = [['Фамилия Имя', 'Группа', 'Ссылка на страницу']];
|
||||
list.forEach(s => {
|
||||
rows.push([
|
||||
s.name,
|
||||
s.group_name || '',
|
||||
`${location.origin}/?nameInput=${encodeURIComponent(s.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;
|
||||
const groupName = gid ? (allGroups.find(g => g.id == gid)?.name || 'group') : 'all';
|
||||
a.download = `students_${groupName.replace(/[\\/:*?"<>|]/g, '_')}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
showToast(`Экспортировано: ${list.length}`);
|
||||
}
|
||||
|
||||
async function exportStudentsUrls() {
|
||||
const gid = document.getElementById('studentGroupFilter').value;
|
||||
const list = gid ? allStudents.filter(s => String(s.group_id) === gid) : allStudents;
|
||||
if (!list.length) { alert('Нет данных для экспорта'); return; }
|
||||
const groupName = gid ? (allGroups.find(g => g.id == gid)?.name || 'group') : 'all';
|
||||
await exportStudentUrlZip(list, `students_${groupName.replace(/[\\/:*?"<>|]/g, '_')}.zip`);
|
||||
showToast(`Экспортировано: ${list.length}`);
|
||||
}
|
||||
|
||||
// --- Batch attach ---
|
||||
async function openBatchAttach() {
|
||||
const sel = document.getElementById('attachGroup');
|
||||
if (!attachGroupLoaded) {
|
||||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||||
const groups = await res.json();
|
||||
sel.innerHTML = `<option value="">— выберите группу —</option>` +
|
||||
groups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
|
||||
attachGroupLoaded = true;
|
||||
}
|
||||
document.getElementById('attachSearch').value = '';
|
||||
renderAttachList();
|
||||
document.getElementById('attachModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeBatchAttach() {
|
||||
document.getElementById('attachModal').classList.remove('open');
|
||||
}
|
||||
|
||||
function renderAttachList() {
|
||||
const q = document.getElementById('attachSearch').value.trim().toLowerCase();
|
||||
const filtered = q ? allStudents.filter(s => s.name.toLowerCase().includes(q)) : allStudents;
|
||||
const list = document.getElementById('attachList');
|
||||
if (!filtered.length) { list.innerHTML = '<div style="color:var(--muted);text-align:center;padding:16px">Никого не найдено</div>'; return; }
|
||||
list.innerHTML = filtered.map(s => `
|
||||
<label>
|
||||
<input type="checkbox" value="${s.id}">
|
||||
<span>${esc(s.name)}</span>
|
||||
<span class="cur">${s.group_name ? esc(s.group_name) : ''}</span>
|
||||
</label>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function batchAttach() {
|
||||
const group_id = document.getElementById('attachGroup').value;
|
||||
const ids = [...document.querySelectorAll('#attachList input[type=checkbox]:checked')].map(c => c.value);
|
||||
if (!group_id) { alert('Выберите группу'); return; }
|
||||
if (!ids.length) { alert('Выберите учеников'); return; }
|
||||
const res = await fetch(`${API}/api/students/batch-group`, {
|
||||
method: 'POST',
|
||||
headers: hdrJson(),
|
||||
body: JSON.stringify({ group_id, student_ids: ids })
|
||||
});
|
||||
if (res.ok) {
|
||||
const r = await res.json();
|
||||
closeBatchAttach();
|
||||
loadStudents();
|
||||
showToast(`Прикреплено: ${r.updated}`);
|
||||
} else { const e = await res.json(); alert(e.error); }
|
||||
}
|
||||
|
||||
document.getElementById('attachForm').addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
batchAttach();
|
||||
});
|
||||
document.getElementById('attachSearch').addEventListener('input', () => {
|
||||
clearTimeout(aSearchTimer);
|
||||
aSearchTimer = setTimeout(renderAttachList, 300);
|
||||
});
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
fillAutocomplete();
|
||||
loadGroupOptions();
|
||||
loadStudents();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="js/students.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+3
-91
@@ -13,107 +13,19 @@
|
||||
<h2>Корзина</h2>
|
||||
<div class="trash-head">
|
||||
<span class="trash-muted" id="trashInfo"></span>
|
||||
<button class="danger-btn" id="emptyBtn" onclick="emptyTrash()">Очистить корзину</button>
|
||||
<button class="danger-btn" id="emptyBtn" type="button">Очистить корзину</button>
|
||||
</div>
|
||||
<div class="grid" id="grid"><div class="empty">Загрузка...</div></div>
|
||||
<div class="pager" id="pager"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="imgModal" onclick="this.classList.remove('open')">
|
||||
<div class="modal-overlay" id="imgModal">
|
||||
<img id="imgModalSrc">
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="admin.js"></script>
|
||||
<script>
|
||||
const PAGE_SIZE = 20;
|
||||
let currentEntries = [];
|
||||
let page = 1;
|
||||
let totalEntries = 0;
|
||||
|
||||
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 =>
|
||||
isImageFile(f.name)
|
||||
? `<a class="f-link" href="#" onclick="showImg('${API}/api/files/${f.token}');return false;" title="Открыть">${esc(f.name)}</a>`
|
||||
: `<a class="f-link" href="${API}/api/files/${f.token}" download title="Скачать">${esc(f.name)}</a>`
|
||||
).join('')}</div>`;
|
||||
}
|
||||
|
||||
function entryHTML(e) {
|
||||
return `
|
||||
<div class="card">
|
||||
${e.photo_path ? `<img src="${API}${e.photo_path}" onclick="showImg('${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="trash-time">Удалена: ${new Date(e.deleted_at).toLocaleString('ru')}</div>
|
||||
</div>
|
||||
<button class="restore-btn" onclick="restoreEntry(${e.id})" title="Восстановить">Восстановить</button>
|
||||
<button class="del-btn" onclick="hardDelete(${e.id})" title="Удалить навсегда (вместе с файлами)">×</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
const grid = document.getElementById('grid');
|
||||
grid.className = 'grid';
|
||||
grid.innerHTML = currentEntries.length
|
||||
? currentEntries.map(entryHTML).join('')
|
||||
: '<div class="empty">Корзина пуста</div>';
|
||||
document.getElementById('trashInfo').textContent =
|
||||
totalEntries > 0 ? `Записей в корзине: ${totalEntries}` : '';
|
||||
document.getElementById('emptyBtn').disabled = totalEntries === 0;
|
||||
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalEntries / PAGE_SIZE)), goPage);
|
||||
}
|
||||
|
||||
async function loadTrash() {
|
||||
const p = new URLSearchParams();
|
||||
p.set('limit', PAGE_SIZE);
|
||||
p.set('offset', (page - 1) * PAGE_SIZE);
|
||||
const res = await fetch(`${API}/api/trash?${p}`, { headers: hdr() });
|
||||
const data = await res.json();
|
||||
currentEntries = data.entries;
|
||||
totalEntries = data.total;
|
||||
renderGrid();
|
||||
}
|
||||
|
||||
function goPage(p) { page = p; loadTrash(); }
|
||||
|
||||
async function restoreEntry(id) {
|
||||
const res = await fetch(`${API}/api/entries/${id}/restore`, { method: 'PUT', headers: hdr() });
|
||||
if (res.ok) { showToast('Запись восстановлена'); loadTrash(); }
|
||||
else { const err = await res.json(); alert(err.error || 'Ошибка'); }
|
||||
}
|
||||
|
||||
async function hardDelete(id) {
|
||||
if (!confirm('Удалить запись навсегда? Файлы и фото будут удалены безвозвратно.')) return;
|
||||
await fetch(`${API}/api/entries/${id}/permanent`, { method: 'DELETE', headers: hdr() });
|
||||
showToast('Запись удалена навсегда');
|
||||
loadTrash();
|
||||
}
|
||||
|
||||
async function emptyTrash() {
|
||||
if (totalEntries === 0) return;
|
||||
if (!confirm(`Удалить все записи из корзины (${totalEntries})? Файлы будут удалены безвозвратно.`)) return;
|
||||
const res = await fetch(`${API}/api/trash`, { method: 'DELETE', headers: hdr() });
|
||||
if (res.ok) { showToast('Корзина очищена'); page = 1; loadTrash(); }
|
||||
else { const err = await res.json(); alert(err.error || 'Ошибка'); }
|
||||
}
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
loadTrash();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="js/trash.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -46,7 +46,22 @@ if (!ADMIN_PASSWORD) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
app.use(helmet({ contentSecurityPolicy: false }));
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", "data:", "blob:"],
|
||||
mediaSrc: ["'self'", "blob:"],
|
||||
connectSrc: ["'self'"],
|
||||
objectSrc: ["'none'"],
|
||||
baseUri: ["'self'"],
|
||||
formAction: ["'self'"],
|
||||
frameAncestors: ["'none'"]
|
||||
}
|
||||
}
|
||||
}));
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
Reference in New Issue
Block a user