refactor: move frontend JS to external files and enable strict CSP
This commit is contained in:
@@ -23,5 +23,8 @@ tmp/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# --- Playwright test artifacts ---
|
||||||
|
.playwright-mcp/
|
||||||
|
|
||||||
# --- Internal audit (not for commit) ---
|
# --- Internal audit (not for commit) ---
|
||||||
SECURITY_AUDIT.md
|
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>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>Админ-панель</title>
|
<title>Админ-панель</title>
|
||||||
<script>location.replace('dashboard.html')</script>
|
<meta http-equiv="refresh" content="0;url=dashboard.html">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+22
-4
@@ -42,7 +42,7 @@ function buildSidebar(active) {
|
|||||||
</nav>
|
</nav>
|
||||||
<div class="bottom">
|
<div class="bottom">
|
||||||
<a href="/">На главную</a>
|
<a href="/">На главную</a>
|
||||||
<a class="logout" onclick="doLogout()">Выйти</a>
|
<a class="logout" href="#" data-action="logout">Выйти</a>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,14 +137,32 @@ function copyText(text) {
|
|||||||
function renderPager(el, page, totalPages, onPage) {
|
function renderPager(el, page, totalPages, onPage) {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
if (totalPages <= 1) { el.innerHTML = ''; 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++) {
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
if (totalPages > 9 && i > 2 && i < totalPages - 1 && Math.abs(i - page) > 2) {
|
if (totalPages > 9 && i > 2 && i < totalPages - 1 && Math.abs(i - page) > 2) {
|
||||||
if (i === 3) html += `<button disabled>…</button>`;
|
if (i === 3) html += `<button disabled>…</button>`;
|
||||||
continue;
|
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.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">
|
<div class="actions-row">
|
||||||
<input type="search" id="auditFilter" class="settings-input" placeholder="Фильтр по действию..." style="max-width:300px">
|
<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>
|
<span class="hint" id="auditCount"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -39,77 +39,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script>
|
<script src="js/audit.js"></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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+5
-111
@@ -16,7 +16,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions-row" style="margin-bottom:16px">
|
<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> Добавить филиал
|
<span style="font-size:1.2rem">+</span> Добавить филиал
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Branch Modal -->
|
<!-- 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">
|
<div class="edit-modal" style="max-width:480px">
|
||||||
<h3 id="branchModalTitle">Создать филиал</h3>
|
<h3 id="branchModalTitle">Создать филиал</h3>
|
||||||
<form id="branchForm" class="settings-stack" style="gap:12px">
|
<form id="branchForm" class="settings-stack" style="gap:12px">
|
||||||
@@ -52,125 +52,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
<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>
|
<button type="submit" class="btn-primary" id="branchSubmitBtn">Создать</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="imgModal" onclick="this.classList.remove('open')">
|
<div class="modal-overlay" id="imgModal">
|
||||||
<img id="imgModalSrc" alt="">
|
<img id="imgModalSrc" alt="">
|
||||||
</div>
|
</div>
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script>
|
<script src="js/branches.js"></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>
|
|
||||||
<style>
|
<style>
|
||||||
.branches-grid {
|
.branches-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
+2
-111
@@ -60,120 +60,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="imgModal" onclick="this.classList.remove('open')">
|
<div class="modal-overlay" id="imgModal">
|
||||||
<img id="imgModalSrc" alt="">
|
<img id="imgModalSrc" alt="">
|
||||||
</div>
|
</div>
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script>
|
<script src="js/dashboard.js"></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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+3
-18
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Ошибка — WhatIDo</title>
|
<title>Ошибка — WhatIDo</title>
|
||||||
<link rel="stylesheet" href="admin.css">
|
<link rel="stylesheet" href="/admin.css">
|
||||||
<style>
|
<style>
|
||||||
.error-page{display:flex;align-items:center;justify-content:center;min-height:100dvh;padding:24px;text-align:center}
|
.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%}
|
.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>
|
<p class="error-message" id="errorMessage">Запрашиваемая страница не существует или была перемещена.</p>
|
||||||
<div class="error-actions">
|
<div class="error-actions">
|
||||||
<a href="/" class="ghost">На главную</a>
|
<a href="/" class="ghost">На главную</a>
|
||||||
<button onclick="history.back()">Назад</button>
|
<button type="button" id="backBtn">Назад</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="error-details" id="errorDetails" style="display:none">
|
<div class="error-details" id="errorDetails" style="display:none">
|
||||||
<strong>Детали:</strong>
|
<strong>Детали:</strong>
|
||||||
@@ -37,21 +37,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script src="/js/error.js"></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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+4
-164
@@ -25,8 +25,8 @@
|
|||||||
<div><label>По</label><input type="date" id="dateTo"></div>
|
<div><label>По</label><input type="date" id="dateTo"></div>
|
||||||
<div><label>Тип</label>
|
<div><label>Тип</label>
|
||||||
<div class="view-toggle" id="tabToggle">
|
<div class="view-toggle" id="tabToggle">
|
||||||
<button data-mode="attached" onclick="setMode('attached')">Прикреплённые</button>
|
<button data-mode="attached" type="button">Прикреплённые</button>
|
||||||
<button data-mode="detached" onclick="setMode('detached')">Откреплённые</button>
|
<button data-mode="detached" type="button">Откреплённые</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,172 +35,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="imgModal" onclick="this.classList.remove('open')">
|
<div class="modal-overlay" id="imgModal">
|
||||||
<img id="imgModalSrc">
|
<img id="imgModalSrc">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script>
|
<script src="js/files.js"></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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+7
-349
@@ -14,27 +14,27 @@
|
|||||||
<div class="add-row" style="align-items:center;gap:8px">
|
<div class="add-row" style="align-items:center;gap:8px">
|
||||||
<input type="text" id="newGroup" placeholder="Название группы" style="flex:1;max-width:300px">
|
<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>
|
<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>
|
||||||
<div class="list" id="groupList"></div>
|
<div class="list" id="groupList"></div>
|
||||||
</div>
|
</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-modal">
|
||||||
<div class="gallery-head">
|
<div class="gallery-head">
|
||||||
<h3 id="photosTitle">Фото</h3>
|
<h3 id="photosTitle">Фото</h3>
|
||||||
<button class="upload-btn" onclick="openAddPhoto()">Загрузить фото</button>
|
<button class="upload-btn" type="button" id="openPhotoBtn">Загрузить фото</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="photo-grid" id="photoGrid"><div class="empty">Загрузка...</div></div>
|
<div class="photo-grid" id="photoGrid"><div class="empty">Загрузка...</div></div>
|
||||||
<div class="pager" id="photosPager"></div>
|
<div class="pager" id="photosPager"></div>
|
||||||
<div class="gallery-foot">
|
<div class="gallery-foot">
|
||||||
<button class="cancel" onclick="closePhotos()">Закрыть</button>
|
<button class="cancel" type="button" id="closePhotosBtn">Закрыть</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<div class="edit-modal" style="max-width:480px">
|
||||||
<h3 id="photoFormTitle">Добавить фото</h3>
|
<h3 id="photoFormTitle">Добавить фото</h3>
|
||||||
<form id="photoForm" class="settings-stack" style="gap:12px">
|
<form id="photoForm" class="settings-stack" style="gap:12px">
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
<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>
|
<button type="submit" class="btn-primary">Сохранить</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -72,348 +72,6 @@
|
|||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="vendor/jszip.min.js"></script>
|
<script src="vendor/jszip.min.js"></script>
|
||||||
<script>
|
<script src="js/groups.js"></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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+7
-192
@@ -91,18 +91,18 @@ nav a:hover{color:var(--text)}
|
|||||||
<div class="photo-card">
|
<div class="photo-card">
|
||||||
<div class="preview-wrap">
|
<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">
|
<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>
|
||||||
<div class="photo-btns">
|
<div class="photo-btns">
|
||||||
<button type="button" class="cam" onclick="openCam()">Камера</button>
|
<button type="button" class="cam" id="openCamBtn">Камера</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="files-card">
|
<div class="files-card">
|
||||||
<div class="files-head">
|
<div class="files-head">
|
||||||
<label>Файлы проекта</label>
|
<label>Файлы проекта</label>
|
||||||
<button type="button" class="files-clear" id="filesClearBtn" onclick="clearFiles()">Очистить</button>
|
<button type="button" class="files-clear" id="filesClearBtn">Очистить</button>
|
||||||
</div>
|
</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>
|
<input type="file" id="filesInput" multiple hidden>
|
||||||
<div class="files-list" id="filesList"></div>
|
<div class="files-list" id="filesList"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,199 +142,14 @@ nav a:hover{color:var(--text)}
|
|||||||
<video id="camVideo" autoplay playsinline></video>
|
<video id="camVideo" autoplay playsinline></video>
|
||||||
<canvas id="camCanvas"></canvas>
|
<canvas id="camCanvas"></canvas>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="cancel" onclick="closeCam()">Отмена</button>
|
<button type="button" class="cancel" id="cancelCamBtn">Отмена</button>
|
||||||
<button class="capture" onclick="capture()">Снять</button>
|
<button type="button" class="capture" id="captureBtn">Снять</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast">Готово!</div>
|
<div class="toast" id="toast">Готово!</div>
|
||||||
|
|
||||||
<script>
|
<script src="js/index.js"></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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+12
-400
@@ -17,12 +17,12 @@
|
|||||||
<div><label>Группа</label><select id="groupFilter"><option value="">Все группы</option></select></div>
|
<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="dateFrom"></div>
|
||||||
<div><label>По</label><input type="date" id="dateTo"></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 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 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="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><label> </label>
|
||||||
<div class="view-toggle" id="viewToggle">
|
<div class="view-toggle" id="viewToggle">
|
||||||
<button data-view="list" onclick="setView('list')">Список</button>
|
<button data-view="list" type="button">Список</button>
|
||||||
<button data-view="cards" onclick="setView('cards')">Карточки</button>
|
<button data-view="cards" type="button">Карточки</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,11 +31,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="imgModal" onclick="this.classList.remove('open')">
|
<div class="modal-overlay" id="imgModal">
|
||||||
<img id="imgModalSrc">
|
<img id="imgModalSrc">
|
||||||
</div>
|
</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">
|
<div class="edit-modal" style="max-width:480px">
|
||||||
<h3>Редактирование записи</h3>
|
<h3>Редактирование записи</h3>
|
||||||
<form id="editForm" class="settings-stack" style="gap:12px">
|
<form id="editForm" class="settings-stack" style="gap:12px">
|
||||||
@@ -66,14 +66,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
<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>
|
<button type="submit" class="btn-primary">Сохранить</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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 class="edit-modal">
|
||||||
<div id="linkCreateView">
|
<div id="linkCreateView">
|
||||||
<h3>Создать ссылку</h3>
|
<h3>Создать ссылку</h3>
|
||||||
@@ -105,8 +105,8 @@
|
|||||||
<input type="password" id="linkPassword" placeholder="Оставьте пустым для открытого доступа">
|
<input type="password" id="linkPassword" placeholder="Оставьте пустым для открытого доступа">
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="cancel" onclick="closeLinkCreate()">Отмена</button>
|
<button class="cancel" type="button" id="linkCreateCloseBtn">Отмена</button>
|
||||||
<button class="save" onclick="createLink()">Создать</button>
|
<button class="save" type="button" id="linkCreateSaveBtn">Создать</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="linkResultView" style="display:none">
|
<div id="linkResultView" style="display:none">
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
<input type="text" id="linkUrlResult" readonly style="font-size:.8rem;color:var(--accent)">
|
<input type="text" id="linkUrlResult" readonly style="font-size:.8rem;color:var(--accent)">
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="cancel-btn" href="links.html">К списку ссылок</a>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,394 +123,6 @@
|
|||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script>
|
<script src="js/journal.js"></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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="linkModal" onclick="if(event.target===this)closeLinkModal()">
|
<div class="modal-overlay" id="linkModal">
|
||||||
<div class="edit-modal">
|
<div class="edit-modal">
|
||||||
<h3 id="linkModalTitle">Новая ссылка</h3>
|
<h3 id="linkModalTitle">Новая ссылка</h3>
|
||||||
<div>
|
<div>
|
||||||
@@ -56,153 +56,14 @@
|
|||||||
<input type="password" id="linkPassword" placeholder="Оставьте пустым для открытого доступа">
|
<input type="password" id="linkPassword" placeholder="Оставьте пустым для открытого доступа">
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="cancel" onclick="closeLinkModal()">Отмена</button>
|
<button class="cancel" type="button" id="linkCancelBtn">Отмена</button>
|
||||||
<button class="save" onclick="saveLink()">Сохранить</button>
|
<button class="save" type="button" id="linkSaveBtn">Сохранить</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script>
|
<script src="js/links.js"></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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+2
-21
@@ -24,29 +24,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
<div class="login">
|
<div class="login">
|
||||||
<h1>Админ-панель</h1>
|
<h1>Админ-панель</h1>
|
||||||
<input type="password" id="pwdInput" placeholder="Пароль" autofocus>
|
<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="err" id="loginErr">Неверный пароль</div>
|
||||||
<div class="back"><a href="/">На главную</a></div>
|
<div class="back"><a href="/">На главную</a></div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script src="js/login.js"></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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+4
-222
@@ -59,8 +59,8 @@
|
|||||||
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.</p>
|
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.</p>
|
||||||
<div class="data-stats" id="dataStats"><span class="chip">Загрузка статистики…</span></div>
|
<div class="data-stats" id="dataStats"><span class="chip">Загрузка статистики…</span></div>
|
||||||
<div class="card-foot">
|
<div class="card-foot">
|
||||||
<button class="btn-primary ghost" id="backupBtn" onclick="downloadBackup()">⬇️ Скачать бэкап</button>
|
<button class="btn-primary ghost" id="backupBtn" type="button">⬇️ Скачать бэкап</button>
|
||||||
<button class="btn-primary ghost" id="restoreBtn" onclick="document.getElementById('restoreFile').click()">⬆️ Восстановить из файла</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">
|
<input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none">
|
||||||
</div>
|
</div>
|
||||||
<div class="restore-status" id="restoreStatus"></div>
|
<div class="restore-status" id="restoreStatus"></div>
|
||||||
@@ -81,228 +81,10 @@
|
|||||||
|
|
||||||
<div class="save-bar" id="saveBar">
|
<div class="save-bar" id="saveBar">
|
||||||
<span class="hint" id="saveHint">⚠️ Есть несохранённые изменения</span>
|
<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>
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script>
|
<script src="js/settings.js"></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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+3
-153
@@ -76,167 +76,17 @@ body{font-family:'Segoe UI',system-ui,-apple-system,Roboto,sans-serif;background
|
|||||||
</div>
|
</div>
|
||||||
<div class="grid" id="grid"><div class="loading">Карточки загружаются...</div></div>
|
<div class="grid" id="grid"><div class="loading">Карточки загружаются...</div></div>
|
||||||
</main>
|
</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-modal" id="passwordModal">
|
||||||
<div class="password-box">
|
<div class="password-box">
|
||||||
<h3>🔒 Ссылка защищена паролем</h3>
|
<h3>🔒 Ссылка защищена паролем</h3>
|
||||||
<p>Введите пароль для доступа к записям</p>
|
<p>Введите пароль для доступа к записям</p>
|
||||||
<input type="password" id="passwordInput" placeholder="Пароль" autocomplete="current-password">
|
<input type="password" id="passwordInput" placeholder="Пароль" autocomplete="current-password">
|
||||||
<button onclick="submitPassword()">Войти</button>
|
<button type="button" id="passwordSubmitBtn">Войти</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script src="/js/share.js"></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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+10
-240
@@ -14,22 +14,22 @@
|
|||||||
<div class="add-row">
|
<div class="add-row">
|
||||||
<input type="text" id="newStudent" placeholder="Фамилия Имя" list="studentAddList">
|
<input type="text" id="newStudent" placeholder="Фамилия Имя" list="studentAddList">
|
||||||
<datalist id="studentAddList"></datalist>
|
<datalist id="studentAddList"></datalist>
|
||||||
<button onclick="addStudent()">Добавить</button>
|
<button type="button" id="addStudentBtn">Добавить</button>
|
||||||
<button class="btn-ghost" onclick="openBatchAdd()">Пакетное добавление</button>
|
<button class="btn-ghost" type="button" id="batchAddBtn">Пакетное добавление</button>
|
||||||
<button class="btn-ghost" onclick="openBatchAttach()">Прикрепить к группе</button>
|
<button class="btn-ghost" type="button" id="batchAttachBtn">Прикрепить к группе</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap">
|
<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">
|
<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>
|
<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" type="button" id="exportStudentsCsvBtn">Экспорт CSV</button>
|
||||||
<button class="btn-link" onclick="exportStudentsUrls()">Экспорт URL</button>
|
<button class="btn-link" type="button" id="exportStudentsUrlsBtn">Экспорт URL</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="list" id="studentList"></div>
|
<div class="list" id="studentList"></div>
|
||||||
<div class="pager" id="pager"></div>
|
<div class="pager" id="pager"></div>
|
||||||
</div>
|
</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">
|
<div class="edit-modal" style="max-width:480px">
|
||||||
<h3>Пакетное добавление учеников</h3>
|
<h3>Пакетное добавление учеников</h3>
|
||||||
<form id="batchForm" class="settings-stack" style="gap:12px">
|
<form id="batchForm" class="settings-stack" style="gap:12px">
|
||||||
@@ -46,14 +46,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
<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>
|
<button type="submit" class="btn-primary">Добавить</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="edit-modal" style="max-width:480px">
|
||||||
<h3>Прикрепить к группе</h3>
|
<h3>Прикрепить к группе</h3>
|
||||||
<form id="attachForm" class="settings-stack" style="gap:12px">
|
<form id="attachForm" class="settings-stack" style="gap:12px">
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
<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>
|
<button type="submit" class="btn-primary">Прикрепить</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -81,236 +81,6 @@
|
|||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script src="vendor/jszip.min.js"></script>
|
<script src="vendor/jszip.min.js"></script>
|
||||||
<script>
|
<script src="js/students.js"></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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+3
-91
@@ -13,107 +13,19 @@
|
|||||||
<h2>Корзина</h2>
|
<h2>Корзина</h2>
|
||||||
<div class="trash-head">
|
<div class="trash-head">
|
||||||
<span class="trash-muted" id="trashInfo"></span>
|
<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>
|
||||||
<div class="grid" id="grid"><div class="empty">Загрузка...</div></div>
|
<div class="grid" id="grid"><div class="empty">Загрузка...</div></div>
|
||||||
<div class="pager" id="pager"></div>
|
<div class="pager" id="pager"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="imgModal" onclick="this.classList.remove('open')">
|
<div class="modal-overlay" id="imgModal">
|
||||||
<img id="imgModalSrc">
|
<img id="imgModalSrc">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
<script>
|
<script src="js/trash.js"></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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -46,7 +46,22 @@ if (!ADMIN_PASSWORD) {
|
|||||||
process.exit(1);
|
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(express.json({ limit: '1mb' }));
|
||||||
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|||||||
Reference in New Issue
Block a user