redesign settings page UX/UI: structured cards, consistent fields, sticky save bar, backup stats and loading states
This commit is contained in:
+40
-2
@@ -125,7 +125,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.group-card .save-sched:hover{opacity:.85}
|
.group-card .save-sched:hover{opacity:.85}
|
||||||
.group-card .btn-link{background:var(--bg);border:1px solid var(--border);color:var(--accent);border-radius:8px;padding:8px 14px;font-size:.85rem;font-weight:600;cursor:pointer}
|
.group-card .btn-link{background:var(--bg);border:1px solid var(--border);color:var(--accent);border-radius:8px;padding:8px 14px;font-size:.85rem;font-weight:600;cursor:pointer}
|
||||||
.group-card .btn-link:hover{border-color:var(--accent)}
|
.group-card .btn-link:hover{border-color:var(--accent)}
|
||||||
.settings-card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:24px;max-width:640px}
|
.settings-card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:24px;max-width:680px;display:flex;flex-direction:column;gap:16px}
|
||||||
.settings-row{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap}
|
.settings-row{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap}
|
||||||
.settings-row label{font-weight:600;display:block;margin-bottom:4px}
|
.settings-row label{font-weight:600;display:block;margin-bottom:4px}
|
||||||
.settings-row .hint{font-size:.8rem;color:var(--muted);max-width:420px;margin:0}
|
.settings-row .hint{font-size:.8rem;color:var(--muted);max-width:420px;margin:0}
|
||||||
@@ -195,4 +195,42 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.restore-btn:hover{opacity:.85}
|
.restore-btn:hover{opacity:.85}
|
||||||
.del-btn{background:none;border:none;color:#ef4444;cursor:pointer;font-size:1.15rem;padding:4px;flex-shrink:0}
|
.del-btn{background:none;border:none;color:#ef4444;cursor:pointer;font-size:1.15rem;padding:4px;flex-shrink:0}
|
||||||
.del-btn:hover{opacity:.8}
|
.del-btn:hover{opacity:.8}
|
||||||
.trash-time{color:var(--muted);font-size:.75rem;margin-top:8px}
|
.trash-time{color:var(--muted);font-size:.75rem;margin-top:8px}
|
||||||
|
|
||||||
|
/* --- Настройки (UX/UI) --- */
|
||||||
|
.page-head{display:flex;align-items:baseline;gap:14px;margin-bottom:24px}
|
||||||
|
.page-head h2{font-size:1.3rem;font-weight:600;margin:0}
|
||||||
|
.page-sub{color:var(--muted);font-size:.85rem}
|
||||||
|
.settings-stack{display:flex;flex-direction:column;gap:20px;max-width:680px}
|
||||||
|
.settings-card .card-head{display:flex;align-items:center;gap:12px;padding-bottom:16px;border-bottom:1px solid var(--border);margin-top:-2px}
|
||||||
|
.settings-card .card-head .ic{width:38px;height:38px;border-radius:11px;background:rgba(37,99,235,.08);display:flex;align-items:center;justify-content:center;font-size:1.15rem;flex-shrink:0}
|
||||||
|
.settings-card .card-head h3{font-size:1.05rem;font-weight:700;margin:0}
|
||||||
|
.settings-card .hint{font-size:.82rem;color:var(--muted);line-height:1.5;margin:0;max-width:none}
|
||||||
|
.settings-row{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap}
|
||||||
|
.settings-field{display:flex;flex-direction:column;gap:8px}
|
||||||
|
.settings-field label{font-size:.86rem;font-weight:600;color:var(--text)}
|
||||||
|
.settings-input{background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:10px;padding:10px 14px;font-size:.9rem;outline:none;font-family:inherit;transition:border-color .15s,box-shadow .15s;width:100%}
|
||||||
|
.settings-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.12)}
|
||||||
|
.settings-input[type=number]{width:120px}
|
||||||
|
.unit-row{display:flex;align-items:center;gap:10px}
|
||||||
|
.unit{color:var(--muted);font-size:.85rem}
|
||||||
|
.btn-primary{display:inline-flex;align-items:center;gap:8px;background:var(--accent);color:#fff;border:none;border-radius:10px;padding:11px 20px;font-size:.9rem;font-weight:600;cursor:pointer;transition:opacity .15s;white-space:nowrap}
|
||||||
|
.btn-primary:hover{opacity:.85}
|
||||||
|
.btn-primary:disabled{opacity:.45;cursor:not-allowed}
|
||||||
|
.btn-primary.ghost{background:var(--card);border:1px solid var(--border);color:var(--accent)}
|
||||||
|
.btn-primary.ghost:hover{border-color:var(--accent);opacity:1}
|
||||||
|
.card-foot{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding-top:16px;border-top:1px solid var(--border)}
|
||||||
|
.data-stats{display:flex;flex-wrap:wrap;gap:10px}
|
||||||
|
.data-stats .chip{background:var(--bg);border:1px solid var(--border);border-radius:999px;padding:6px 14px;font-size:.8rem;color:var(--text);display:inline-flex;align-items:center;gap:6px}
|
||||||
|
.data-stats .chip b{color:var(--accent);font-weight:700}
|
||||||
|
.data-stats .chip a{color:#ef4444;text-decoration:none;font-weight:600}
|
||||||
|
.data-stats .chip a:hover{text-decoration:underline}
|
||||||
|
.spinner{width:14px;height:14px;border:2px solid rgba(255,255,255,.35);border-top-color:#fff;border-radius:50%;animation:spin .7s linear infinite;display:inline-block;flex-shrink:0}
|
||||||
|
.spinner.dark{border:2px solid rgba(37,99,235,.2);border-top-color:var(--accent)}
|
||||||
|
@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
.restore-status{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:.85rem;min-height:22px}
|
||||||
|
.restore-status.err{color:#ef4444}
|
||||||
|
.save-bar{position:fixed;left:calc(var(--sidebar) + 24px);right:24px;bottom:24px;z-index:60;display:none;align-items:center;justify-content:space-between;gap:16px;background:var(--card);border:1px solid var(--border);border-radius:14px;padding:12px 20px;box-shadow:0 10px 30px rgba(0,0,0,.14)}
|
||||||
|
.save-bar.show{display:flex}
|
||||||
|
.save-bar .hint{color:var(--muted);font-size:.85rem;display:inline-flex;align-items:center;gap:8px}
|
||||||
|
@media(max-width:640px){.save-bar{left:16px;right:16px}}
|
||||||
+165
-65
@@ -10,92 +10,176 @@
|
|||||||
<div class="layout">
|
<div class="layout">
|
||||||
<div class="sidebar" id="sidebar"></div>
|
<div class="sidebar" id="sidebar"></div>
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<h2>Настройки</h2>
|
<div class="page-head">
|
||||||
<div class="settings-card">
|
<h2>Настройки</h2>
|
||||||
<div class="settings-row">
|
<p class="page-sub">Параметры формы и данные системы</p>
|
||||||
<div>
|
|
||||||
<label for="spamInterval">Защита от повторов (минут)</label>
|
|
||||||
<p class="hint">Если ученик уже отправил ответ, следующая отправка от него будет отклонена в течение этого времени. 0 — отключить.</p>
|
|
||||||
</div>
|
|
||||||
<input type="number" id="spamInterval" min="0" max="1440" style="width:120px;padding:10px 14px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);font-size:.9rem">
|
|
||||||
</div>
|
|
||||||
<div style="display:flex;gap:10px;margin-top:24px">
|
|
||||||
<button class="btn-link" onclick="saveSettings()">Сохранить</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-card">
|
<div class="settings-stack">
|
||||||
<h3 style="margin:0 0 8px">Футер на форме</h3>
|
<div class="settings-card">
|
||||||
<p class="hint" style="margin-bottom:16px">Тексты показываются внизу формы ответа ученика.</p>
|
<div class="card-head">
|
||||||
<div class="settings-row">
|
<div class="ic">🛡️</div>
|
||||||
<div>
|
<h3>Защита от повторов</h3>
|
||||||
<label for="footerLeft">Текст слева</label>
|
|
||||||
<p class="hint">Нижняя часть формы, слева</p>
|
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="footerLeft" style="width:280px;padding:10px 14px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);font-size:.9rem" placeholder="">
|
<p class="hint">Если ученик уже отправил ответ, следующая отправка от него будет отклонена в течение заданного времени. 0 — отключить.</p>
|
||||||
</div>
|
<div class="settings-row">
|
||||||
<div class="settings-row" style="margin-top:16px">
|
<div class="settings-field">
|
||||||
<div>
|
<label for="spamInterval">Интервал ожидания</label>
|
||||||
<label for="footerRight">Текст справа</label>
|
<div class="unit-row">
|
||||||
<p class="hint">Нижняя часть формы, справа</p>
|
<input type="number" id="spamInterval" class="settings-input" min="0" max="1440" step="1">
|
||||||
|
<span class="unit">минут</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" id="footerRight" style="width:280px;padding:10px 14px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);font-size:.9rem" placeholder="">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<h3 style="margin:0 0 8px">Резервная копия</h3>
|
<div class="card-head">
|
||||||
<p class="hint">Скачайте архив со всеми данными (ученики, группы, журнал, настройки) и фотографиями. Восстановление полностью заменит текущие данные.</p>
|
<div class="ic">📄</div>
|
||||||
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:16px">
|
<h3>Футер на форме</h3>
|
||||||
<button class="btn-link" onclick="downloadBackup()">Скачать бэкап</button>
|
</div>
|
||||||
<button class="btn-link" onclick="document.getElementById('restoreFile').click()">Восстановить из файла</button>
|
<p class="hint">Тексты показываются внизу формы ответа ученика.</p>
|
||||||
<input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none">
|
<div class="settings-row">
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="footerLeft">Текст слева</label>
|
||||||
|
<input type="text" id="footerLeft" class="settings-input" placeholder="Например: Поле, руководитель">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="footerRight">Текст справа</label>
|
||||||
|
<input type="text" id="footerRight" class="settings-input" placeholder="Например: Год, курс">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="ic">🗄️</div>
|
||||||
|
<h3>Резервное копирование</h3>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<input type="file" id="restoreFile" accept=".tar.gz,.gz,application/gzip" style="display:none">
|
||||||
|
</div>
|
||||||
|
<div class="restore-status" id="restoreStatus"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="restoreStatus" class="hint" style="margin-top:12px"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="save-bar" id="saveBar">
|
||||||
|
<span class="hint" id="saveHint">⚠️ Есть несохранённые изменения</span>
|
||||||
|
<button class="btn-primary" id="saveBtn" onclick="saveSettings()">Сохранить изменения</button>
|
||||||
|
</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>
|
||||||
|
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() {
|
async function loadSettings() {
|
||||||
const res = await fetch(`${API}/api/settings`, { headers: hdr() });
|
const [sRes, stRes] = await Promise.all([
|
||||||
const s = await res.json();
|
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;
|
if (s.spam_interval_min !== undefined) document.getElementById('spamInterval').value = s.spam_interval_min;
|
||||||
document.getElementById('footerLeft').value = s.footer_left || '';
|
document.getElementById('footerLeft').value = s.footer_left || '';
|
||||||
document.getElementById('footerRight').value = s.footer_right || '';
|
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 = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
const v = document.getElementById('spamInterval').value;
|
const v = document.getElementById('spamInterval').value;
|
||||||
if (v === '' || Number(v) < 0) { alert('Введите число от 0'); return; }
|
if (v === '' || Number(v) < 0) { alert('Введите число от 0'); return; }
|
||||||
const res = await fetch(`${API}/api/settings`, {
|
const btn = document.getElementById('saveBtn');
|
||||||
method: 'PUT',
|
btn.dataset.loadingText = 'Сохранение...';
|
||||||
headers: hdrJson(),
|
setBtnLoading(btn, true);
|
||||||
body: JSON.stringify({ settings: {
|
try {
|
||||||
spam_interval_min: String(Math.round(Number(v))),
|
const res = await fetch(`${API}/api/settings`, {
|
||||||
footer_left: document.getElementById('footerLeft').value.trim(),
|
method: 'PUT',
|
||||||
footer_right: document.getElementById('footerRight').value.trim(),
|
headers: hdrJson(),
|
||||||
} })
|
body: JSON.stringify({ settings: {
|
||||||
});
|
spam_interval_min: String(Math.round(Number(v))),
|
||||||
if (res.ok) showToast('Настройки сохранены');
|
footer_left: document.getElementById('footerLeft').value.trim(),
|
||||||
else { const e = await res.json(); alert(e.error); }
|
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() {
|
async function downloadBackup() {
|
||||||
const res = await fetch(`${API}/api/backup`, { headers: hdr() });
|
const btn = document.getElementById('backupBtn');
|
||||||
if (!res.ok) { const t = await res.text(); alert('Ошибка: ' + t); return; }
|
btn.dataset.loadingText = 'Формирование...';
|
||||||
const blob = await res.blob();
|
setBtnLoading(btn, true);
|
||||||
const cd = res.headers.get('Content-Disposition') || '';
|
try {
|
||||||
const m = cd.match(/filename="([^"]+)"/);
|
const res = await fetch(`${API}/api/backup`, { headers: hdr() });
|
||||||
const name = m ? m[1] : `whatido-backup-${new Date().toISOString().slice(0, 10)}.json.gz`;
|
if (!res.ok) { const t = await res.text(); alert('Ошибка: ' + t); return; }
|
||||||
const url = URL.createObjectURL(blob);
|
const blob = await res.blob();
|
||||||
const a = document.createElement('a');
|
const cd = res.headers.get('Content-Disposition') || '';
|
||||||
a.href = url;
|
const m = cd.match(/filename="([^"]+)"/);
|
||||||
a.download = name;
|
const name = m ? m[1] : `whatido-backup-${new Date().toISOString().slice(0, 10)}.json.gz`;
|
||||||
a.click();
|
const url = URL.createObjectURL(blob);
|
||||||
URL.revokeObjectURL(url);
|
const a = document.createElement('a');
|
||||||
showToast('Бэкап скачан');
|
a.href = url;
|
||||||
|
a.download = name;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
showToast('Бэкап скачан');
|
||||||
|
} finally {
|
||||||
|
setBtnLoading(btn, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('restoreFile').addEventListener('change', async (e) => {
|
document.getElementById('restoreFile').addEventListener('change', async (e) => {
|
||||||
@@ -103,7 +187,12 @@ document.getElementById('restoreFile').addEventListener('change', async (e) => {
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
if (!confirm('Восстановление заменит все текущие данные. Продолжить?')) { e.target.value = ''; return; }
|
if (!confirm('Восстановление заменит все текущие данные. Продолжить?')) { e.target.value = ''; return; }
|
||||||
const st = document.getElementById('restoreStatus');
|
const st = document.getElementById('restoreStatus');
|
||||||
st.textContent = 'Восстановление...';
|
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();
|
const fd = new FormData();
|
||||||
fd.append('backup', file);
|
fd.append('backup', file);
|
||||||
let res;
|
let res;
|
||||||
@@ -114,16 +203,27 @@ document.getElementById('restoreFile').addEventListener('change', async (e) => {
|
|||||||
alert('Ошибка сети: ' + err.message);
|
alert('Ошибка сети: ' + err.message);
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
return;
|
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);
|
||||||
}
|
}
|
||||||
st.textContent = '';
|
|
||||||
if (res.ok) showToast('Данные восстановлены');
|
|
||||||
else { const t = await res.text(); alert('Ошибка: ' + t); }
|
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
if (await checkAuth()) {
|
if (await checkAuth()) {
|
||||||
buildSidebar(document.body.dataset.page);
|
buildSidebar(document.body.dataset.page);
|
||||||
|
bindDirty();
|
||||||
loadSettings();
|
loadSettings();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user