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

This commit is contained in:
dev
2026-09-09 10:06:49 +03:00
parent d6e589d2f5
commit 018c65adc5
34 changed files with 2444 additions and 2307 deletions
+4 -222
View File
@@ -59,8 +59,8 @@
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.</p>
<div class="data-stats" id="dataStats"><span class="chip">Загрузка статистики…</span></div>
<div class="card-foot">
<button class="btn-primary ghost" id="backupBtn" onclick="downloadBackup()">⬇️ Скачать бэкап</button>
<button class="btn-primary ghost" id="restoreBtn" onclick="document.getElementById('restoreFile').click()">⬆️ Восстановить из файла</button>
<button class="btn-primary ghost" id="backupBtn" type="button">⬇️ Скачать бэкап</button>
<button class="btn-primary ghost" id="restoreBtn" type="button">⬆️ Восстановить из файла</button>
<input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none">
</div>
<div class="restore-status" id="restoreStatus"></div>
@@ -81,228 +81,10 @@
<div class="save-bar" id="saveBar">
<span class="hint" id="saveHint">⚠️ Есть несохранённые изменения</span>
<button class="btn-primary" id="saveBtn" onclick="saveSettings()">Сохранить изменения</button>
<button class="btn-primary" id="saveBtn" type="button">Сохранить изменения</button>
</div>
<div class="toast" id="toast"></div>
<script src="admin.js"></script>
<script>
const DIRTY_FIELDS = ['spamInterval', 'footerLeft', 'footerRight'];
function setBtnLoading(btn, on, spinnerClass) {
if (on) {
btn.dataset.label = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = `<span class="spinner ${spinnerClass || ''}"></span> ${btn.dataset.loadingText || 'Обработка...'}`;
} else {
btn.disabled = false;
btn.innerHTML = btn.dataset.label || btn.innerHTML;
}
}
function updateDirty() {
const dirty = DIRTY_FIELDS.some(id => {
const el = document.getElementById(id);
return String(el.dataset.original ?? '') !== el.value;
});
document.getElementById('saveBar').classList.toggle('show', dirty);
}
function bindDirty() {
DIRTY_FIELDS.forEach(id => {
document.getElementById(id).addEventListener('input', updateDirty);
});
}
function snapshotDirty() {
DIRTY_FIELDS.forEach(id => {
document.getElementById(id).dataset.original = document.getElementById(id).value;
});
updateDirty();
}
async function loadSettings() {
const [sRes, stRes] = await Promise.all([
fetch(`${API}/api/settings`, { headers: hdr() }),
fetch(`${API}/api/stats`, { headers: hdr() }),
]);
const s = await sRes.json();
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
document.getElementById('footerLeft').value = s.footer_left || '';
document.getElementById('footerRight').value = s.footer_right || '';
snapshotDirty();
if (stRes.ok) {
const st = await stRes.json();
document.getElementById('dataStats').innerHTML = `
<span class="chip">Записей: <b>${st.entries}</b></span>
<span class="chip">Учеников: <b>${st.students}</b></span>
<span class="chip">Групп: <b>${st.groups}</b></span>
<span class="chip">Сегодня: <b>${st.today}</b></span>
<span class="chip">${st.trash ? `В корзине: <b>${st.trash}</b> <a href="trash.html">→</a>` : ''}</span>
`;
} else {
document.getElementById('dataStats').innerHTML = '';
}
const siRes = await fetch(`${API}/api/system-info`, { headers: hdr() });
if (siRes.ok) {
const si = await siRes.json();
renderSystemInfo(si);
} else {
document.getElementById('systemInfo').innerHTML = '<div class="sys-card err">Ошибка загрузки</div>';
}
}
function renderSystemInfo(si) {
const db = si.database || {};
const photos = si.photos || {};
const files = si.files || {};
const uploads = si.uploads || {};
const disk = si.disk;
let tablesHtml = '';
if (db.tables && db.tables.length) {
tablesHtml = '<details class="sys-details"><summary>Таблицы БД</summary><div class="sys-table-list">' +
db.tables.map(t => `<div class="sys-table-row"><span>${t.name}</span><span>${t.size}</span></div>`).join('') +
'</div></details>';
}
let diskHtml = '';
if (disk) {
const pct = disk.used_pct;
const color = pct > 90 ? '#ef4444' : pct > 70 ? '#f59e0b' : '#16a34a';
diskHtml = `
<div class="sys-item">
<span class="sys-label">Диск</span>
<span class="sys-value">${disk.used} / ${disk.total}</span>
</div>
<div class="sys-bar-wrap">
<div class="sys-bar" style="width:${pct}%; background:${color}"></div>
</div>
<div class="sys-meta">Свободно: ${disk.free} (${100 - pct}%)</div>
`;
}
document.getElementById('systemInfo').innerHTML = `
<div class="sys-card">
<div class="sys-title">База данных</div>
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${db.size || '—'}</span></div>
${tablesHtml}
</div>
<div class="sys-card">
<div class="sys-title">Фото</div>
<div class="sys-item"><span class="sys-label">Фото групп</span><span class="sys-value">${photos.group_photos?.count || 0}</span></div>
<div class="sys-item"><span class="sys-label">Фото в записях</span><span class="sys-value">${photos.entry_photos?.count || 0}</span></div>
<div class="sys-item"><span class="sys-label">Всего</span><span class="sys-value"><b>${photos.total_count || 0}</b></span></div>
</div>
<div class="sys-card">
<div class="sys-title">Файлы</div>
<div class="sys-item"><span class="sys-label">Проектные файлы</span><span class="sys-value">${files.project_files?.count || 0}</span></div>
</div>
<div class="sys-card">
<div class="sys-title">Загрузки</div>
<div class="sys-item"><span class="sys-label">Файлов</span><span class="sys-value">${uploads.count || 0}</span></div>
<div class="sys-item"><span class="sys-label">Размер</span><span class="sys-value">${uploads.size || '0 B'}</span></div>
</div>
<div class="sys-card">
<div class="sys-title">Диск</div>
${diskHtml || '<div class="sys-meta">Недоступно</div>'}
</div>
`;
}
async function saveSettings() {
const v = document.getElementById('spamInterval').value;
if (v === '' || Number(v) < 1) { alert('Введите число от 1'); return; }
const btn = document.getElementById('saveBtn');
btn.dataset.loadingText = 'Сохранение...';
setBtnLoading(btn, true);
try {
const res = await fetch(`${API}/api/settings`, {
method: 'PUT',
headers: hdrJson(),
body: JSON.stringify({ settings: {
spam_interval_min: String(Math.round(Number(v))),
footer_left: document.getElementById('footerLeft').value.trim(),
footer_right: document.getElementById('footerRight').value.trim(),
} })
});
if (res.ok) {
showToast('Настройки сохранены');
snapshotDirty();
} else { const e = await res.json(); alert(e.error); }
} finally {
setBtnLoading(btn, false);
}
}
async function downloadBackup() {
const btn = document.getElementById('backupBtn');
btn.dataset.loadingText = 'Формирование...';
setBtnLoading(btn, true);
try {
const res = await fetch(`${API}/api/backup`, { headers: hdr() });
if (!res.ok) { const t = await res.text(); alert('Ошибка: ' + t); return; }
const blob = await res.blob();
const cd = res.headers.get('Content-Disposition') || '';
const m = cd.match(/filename="([^"]+)"/);
const name = m ? m[1] : `whatido-backup-${new Date().toISOString().slice(0, 10)}.json.gz`;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
showToast('Бэкап скачан');
} finally {
setBtnLoading(btn, false);
}
}
document.getElementById('restoreFile').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
if (!confirm('Восстановление заменит все текущие данные. Продолжить?')) { e.target.value = ''; return; }
const st = document.getElementById('restoreStatus');
const backupBtn = document.getElementById('backupBtn');
const restoreBtn = document.getElementById('restoreBtn');
st.classList.remove('err');
st.innerHTML = '<span class="spinner dark"></span> Восстановление...';
backupBtn.disabled = true;
restoreBtn.disabled = true;
const fd = new FormData();
fd.append('backup', file);
let res;
try {
res = await fetch(`${API}/api/restore`, { method: 'POST', headers: hdr(), body: fd });
} catch (err) {
st.textContent = '';
alert('Ошибка сети: ' + err.message);
e.target.value = '';
return;
} finally {
backupBtn.disabled = false;
restoreBtn.disabled = false;
}
if (res.ok) {
showToast('Данные восстановлены');
st.textContent = '';
loadSettings();
} else {
st.classList.add('err');
st.innerHTML = 'Ошибка восстановления';
const t = await res.text();
alert('Ошибка: ' + t);
}
e.target.value = '';
});
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
bindDirty();
loadSettings();
}
})();
</script>
<script src="js/settings.js"></script>
</body>
</html>