Админка: всплывающие уведомления вместо alert()

alert() блокирует страницу и требует клика; заменил на тост внизу экрана:
появляется с анимацией, сам скрывается через 15 секунд (вход — 5 сек),
закрывается крестиком. Цвет полосы зависит от исхода: зелёный — успех,
красный — ошибка, синий — информация (например, «файл не изменился»).

Сообщения тех же действий: вход, сохранение ссылки, сохранение интервала,
принудительное обновление, ошибки запросов.
This commit is contained in:
2026-09-29 15:32:47 +03:00
parent d177560f7d
commit 67a6671d90

View File

@@ -43,6 +43,52 @@
.admin-hint { color: var(--muted-2); font-size: 12px; margin-top: 8px; }
.hidden { display: none !important; }
abbr.code { font-family: monospace; font-size: 13px; color: var(--text); word-break: break-all; }
/* всплывающие уведомления вместо alert(): сами исчезают через 15 секунд */
#toast-wrap {
position: fixed;
left: 50%;
bottom: 18px;
transform: translateX(-50%);
z-index: 9999;
display: flex;
flex-direction: column;
gap: 8px;
width: min(560px, calc(100vw - 32px));
}
.toast {
display: flex;
align-items: flex-start;
gap: 10px;
background: var(--surface);
border: 1px solid var(--border-2);
border-left: 4px solid var(--accent-blue);
box-shadow: 0 8px 24px var(--shadow);
padding: 12px 14px;
font-size: 14px;
color: var(--text);
white-space: pre-line;
animation: toast-in .18s ease-out;
}
.toast.ok { border-left-color: #2f9e5f; }
.toast.error { border-left-color: var(--accent-red-bright); }
.toast.hide { opacity: 0; transition: opacity .3s ease; }
.toast .toast-text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.toast .toast-close {
flex: 0 0 auto;
border: none;
background: none;
color: var(--muted);
font-size: 15px;
line-height: 1;
cursor: pointer;
padding: 2px 4px;
}
.toast .toast-close:hover { color: var(--text); }
@keyframes toast-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
</style>
</head>
<body>
@@ -104,8 +150,37 @@
</section>
</div>
<div id="toast-wrap"></div>
<script>
const $ = (id) => document.getElementById(id);
const TOAST_MS = 15000;
/** Всплывающее уведомление: само скрывается через 15 секунд. */
function toast(message, kind = "info", ms = TOAST_MS) {
const wrap = $("toast-wrap");
const el = document.createElement("div");
el.className = "toast " + kind;
const text = document.createElement("div");
text.className = "toast-text";
text.textContent = message;
const close = document.createElement("button");
close.type = "button";
close.className = "toast-close";
close.textContent = "✕";
close.setAttribute("aria-label", "Закрыть");
el.append(text, close);
wrap.appendChild(el);
const hide = () => {
clearTimeout(timer);
el.classList.add("hide");
setTimeout(() => el.remove(), 300);
};
const timer = setTimeout(hide, ms);
close.addEventListener("click", hide);
return hide;
}
async function api(path, options = {}) {
let resp;
@@ -178,7 +253,7 @@
async function loadState() {
const r = await api("/state");
if (r.status === 401) { showLogin(true); return; }
if (!r.ok) { alert("Ошибка: " + ((r.data && r.data.detail) || r.status)); return; }
if (!r.ok) { toast("Не удалось получить состояние: " + ((r.data && r.data.detail) || r.status), "error"); return; }
showLogin(false);
renderState(r.data);
}
@@ -199,31 +274,42 @@
return;
}
$("password").value = "";
toast("Вход выполнен.", "ok", 5000);
await loadState();
});
$("save-link").addEventListener("click", () => withButton($("save-link"), "Проверяю…", async () => {
const r = await post("/link", { link: $("link").value });
if (!r.ok) { alert("Ссылка не принята: " + ((r.data && r.data.detail) || r.status)); return; }
if (!r.ok) {
toast("Ссылка не принята: " + ((r.data && r.data.detail) || r.status), "error");
return;
}
renderState(r.data);
alert("Ссылка сохранена, расписание загружено.");
toast("Ссылка сохранена, расписание загружено.", "ok");
}));
$("save-interval").addEventListener("click", () => withButton($("save-interval"), "Сохраняю…", async () => {
const r = await post("/interval", { interval_minutes: Number($("interval").value) });
if (!r.ok) { alert("Не получилось: " + ((r.data && r.data.detail) || r.status)); return; }
if (!r.ok) {
toast("Интервал не сохранён: " + ((r.data && r.data.detail) || r.status), "error");
return;
}
toast(`Интервал обновления: ${r.data.interval_minutes} мин.`, "ok");
await loadState();
}));
$("refresh").addEventListener("click", () => withButton($("refresh"), "Обновляю…", async () => {
const r = await post("/refresh");
if (!r.ok) {
alert("Обновить не удалось: " + ((r.data && r.data.detail) || r.status));
toast("Обновить не удалось: " + ((r.data && r.data.detail) || r.status), "error");
} else if (r.data) {
renderState(r.data);
alert(r.data.changed
? "Готово: в файле обнаружены изменения — расписание обновлено."
: "Готово: файл не изменился (на Диске та же версия).\n\nЕсли вы только что правили таблицу, значит Диск ещё не отдаёт новую версию — попробуйте через минуту.");
toast(
r.data.changed
? "Готово: в файле обнаружены изменения — расписание обновлено."
: "Готово: файл не изменился (на Диске та же версия).\nЕсли вы только что правили таблицу, значит Диск ещё не отдаёт новую версию — попробуйте через минуту.",
r.data.changed ? "ok" : "info",
);
}
await loadState();
}));