Админка: всплывающие уведомления вместо alert()
alert() блокирует страницу и требует клика; заменил на тост внизу экрана: появляется с анимацией, сам скрывается через 15 секунд (вход — 5 сек), закрывается крестиком. Цвет полосы зависит от исхода: зелёный — успех, красный — ошибка, синий — информация (например, «файл не изменился»). Сообщения тех же действий: вход, сохранение ссылки, сохранение интервала, принудительное обновление, ошибки запросов.
This commit is contained in:
@@ -43,6 +43,52 @@
|
|||||||
.admin-hint { color: var(--muted-2); font-size: 12px; margin-top: 8px; }
|
.admin-hint { color: var(--muted-2); font-size: 12px; margin-top: 8px; }
|
||||||
.hidden { display: none !important; }
|
.hidden { display: none !important; }
|
||||||
abbr.code { font-family: monospace; font-size: 13px; color: var(--text); word-break: break-all; }
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -104,8 +150,37 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="toast-wrap"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const $ = (id) => document.getElementById(id);
|
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 = {}) {
|
async function api(path, options = {}) {
|
||||||
let resp;
|
let resp;
|
||||||
@@ -178,7 +253,7 @@
|
|||||||
async function loadState() {
|
async function loadState() {
|
||||||
const r = await api("/state");
|
const r = await api("/state");
|
||||||
if (r.status === 401) { showLogin(true); return; }
|
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);
|
showLogin(false);
|
||||||
renderState(r.data);
|
renderState(r.data);
|
||||||
}
|
}
|
||||||
@@ -199,31 +274,42 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$("password").value = "";
|
$("password").value = "";
|
||||||
|
toast("Вход выполнен.", "ok", 5000);
|
||||||
await loadState();
|
await loadState();
|
||||||
});
|
});
|
||||||
|
|
||||||
$("save-link").addEventListener("click", () => withButton($("save-link"), "Проверяю…", async () => {
|
$("save-link").addEventListener("click", () => withButton($("save-link"), "Проверяю…", async () => {
|
||||||
const r = await post("/link", { link: $("link").value });
|
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);
|
renderState(r.data);
|
||||||
alert("Ссылка сохранена, расписание загружено.");
|
toast("Ссылка сохранена, расписание загружено.", "ok");
|
||||||
}));
|
}));
|
||||||
|
|
||||||
$("save-interval").addEventListener("click", () => withButton($("save-interval"), "Сохраняю…", async () => {
|
$("save-interval").addEventListener("click", () => withButton($("save-interval"), "Сохраняю…", async () => {
|
||||||
const r = await post("/interval", { interval_minutes: Number($("interval").value) });
|
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();
|
await loadState();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
$("refresh").addEventListener("click", () => withButton($("refresh"), "Обновляю…", async () => {
|
$("refresh").addEventListener("click", () => withButton($("refresh"), "Обновляю…", async () => {
|
||||||
const r = await post("/refresh");
|
const r = await post("/refresh");
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
alert("Обновить не удалось: " + ((r.data && r.data.detail) || r.status));
|
toast("Обновить не удалось: " + ((r.data && r.data.detail) || r.status), "error");
|
||||||
} else if (r.data) {
|
} else if (r.data) {
|
||||||
renderState(r.data);
|
renderState(r.data);
|
||||||
alert(r.data.changed
|
toast(
|
||||||
|
r.data.changed
|
||||||
? "Готово: в файле обнаружены изменения — расписание обновлено."
|
? "Готово: в файле обнаружены изменения — расписание обновлено."
|
||||||
: "Готово: файл не изменился (на Диске та же версия).\n\nЕсли вы только что правили таблицу, значит Диск ещё не отдаёт новую версию — попробуйте через минуту.");
|
: "Готово: файл не изменился (на Диске та же версия).\nЕсли вы только что правили таблицу, значит Диск ещё не отдаёт новую версию — попробуйте через минуту.",
|
||||||
|
r.data.changed ? "ok" : "info",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await loadState();
|
await loadState();
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user