- шапка как на сайте (логотип факультета, «на сайт», переключатель светлой/тёмной темы — тема общая с сайтом и не мигает при загрузке); - крупная шапка состояния: «Работает / Ожу данные / Ошибка загрузки», сколько обновлено назад, число групп и текущий интервал; рядом главная кнопка «Обновить сейчас»; - вместо россыпи чипов — карточки: «Ссылка на расписание» (с кнопкой «открыть таблицу»), «Обновление» (интервал + факты: изменился ли файл, дата, отпечаток), «Входы в админку» (последняя попытка, последний успешный вход, остаток попыток или блокировка), «Данные расписания»; - предупреждения (ошибка загрузки, невозможность сохранить настройки) вынесены отдельной плашкой под шапкой, а не спрятаны среди полей; - верстка адаптивная: сетка карточек, на телефоне одна колонка и подписи под значениями; формы с подписями и подсказками, кнопки с ясной иерархией; - уведомления и вся логика прежние; тесты 160.
484 lines
23 KiB
HTML
484 lines
23 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<meta name="robots" content="noindex, nofollow">
|
||
<title>Админка расписания</title>
|
||
<link rel="icon" type="image/svg+xml" href="/img/app-icon.svg">
|
||
<link rel="stylesheet" href="/style.css">
|
||
<script>
|
||
// тему применяем до отрисовки, чтобы не мигало
|
||
try {
|
||
const saved = localStorage.getItem("theme");
|
||
document.documentElement.setAttribute("data-theme", saved === "dark" ? "dark" : "light");
|
||
} catch (_) {}
|
||
</script>
|
||
<style>
|
||
body { background: var(--bg); }
|
||
|
||
.admin-main { max-width: 1080px; margin: 0 auto; padding: 22px 18px 70px; }
|
||
.admin-actions { display: flex; align-items: center; gap: 8px; }
|
||
.admin-ghost {
|
||
display: inline-flex; align-items: center; height: 36px; padding: 0 12px;
|
||
border: 1px solid var(--border-2); background: var(--surface); color: var(--muted);
|
||
font-size: 13px; font-weight: 600; text-decoration: none;
|
||
}
|
||
.admin-ghost:hover { color: var(--accent); border-color: var(--accent); }
|
||
|
||
/* ---- вход ---- */
|
||
.admin-login {
|
||
max-width: 420px; margin: 6vh auto 0; padding: 30px 28px 26px;
|
||
background: var(--surface); border: 1px solid var(--border);
|
||
box-shadow: 0 14px 40px var(--shadow); text-align: center;
|
||
}
|
||
.admin-login-logo {
|
||
width: 58px; height: 58px; padding: 10px; margin: 0 auto 14px;
|
||
background: linear-gradient(160deg, var(--nav-1), var(--nav-2));
|
||
border-radius: 14px; display: block;
|
||
}
|
||
.admin-login h1 { font-size: 20px; color: var(--text); margin-bottom: 6px; }
|
||
.admin-login-hint { font-size: 13px; color: var(--muted); margin-bottom: 20px; }
|
||
.admin-login form { display: flex; flex-direction: column; gap: 12px; text-align: left; }
|
||
.admin-login .admin-btn { margin-top: 4px; width: 100%; }
|
||
|
||
/* ---- шапка состояния ---- */
|
||
.admin-hero {
|
||
display: flex; align-items: center; justify-content: space-between; gap: 16px;
|
||
flex-wrap: wrap;
|
||
background: var(--surface); border: 1px solid var(--border); border-left: 4px solid var(--accent);
|
||
padding: 16px 18px; margin-bottom: 16px;
|
||
}
|
||
.admin-hero.is-ok { border-left-color: #2f9e5f; }
|
||
.admin-hero.is-error { border-left-color: var(--accent-red-bright); }
|
||
.admin-hero-state { display: flex; align-items: center; gap: 9px; font-size: 18px; font-weight: 700; color: var(--text); }
|
||
.admin-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--muted-2); }
|
||
.admin-hero.is-ok .admin-dot { background: #2f9e5f; }
|
||
.admin-hero.is-error .admin-dot { background: var(--accent-red-bright); }
|
||
.admin-hero-sub { color: var(--muted); font-size: 13px; margin-top: 5px; }
|
||
|
||
/* ---- карточки ---- */
|
||
.admin-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(330px, 1fr)); gap: 14px; }
|
||
.admin-card {
|
||
background: var(--surface); border: 1px solid var(--border); padding: 16px 18px 14px;
|
||
display: flex; flex-direction: column; gap: 10px; min-width: 0;
|
||
}
|
||
.admin-card h2 {
|
||
font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px;
|
||
color: var(--muted); font-weight: 700;
|
||
}
|
||
.admin-card-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||
.admin-inline { display: flex; align-items: flex-end; gap: 10px; flex-wrap: wrap; }
|
||
|
||
.admin-field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
|
||
.admin-field > span { font-size: 12px; color: var(--muted); }
|
||
.admin-field-narrow { flex: 0 1 160px; }
|
||
.admin-field input {
|
||
font-size: 14px; padding: 9px 11px; width: 100%; min-width: 0;
|
||
border: 1px solid var(--border-2); background: var(--surface-2); color: var(--text);
|
||
}
|
||
.admin-field input:focus { outline: 2px solid var(--accent-blue); outline-offset: -1px; }
|
||
|
||
.admin-btn {
|
||
font-size: 14px; font-weight: 600; padding: 9px 14px; cursor: pointer; white-space: nowrap;
|
||
border: 1px solid var(--accent); background: var(--accent); color: var(--on-accent);
|
||
}
|
||
.admin-btn:hover { filter: brightness(1.06); }
|
||
.admin-btn.secondary { background: var(--surface-2); color: var(--text); border-color: var(--border-2); }
|
||
.admin-btn.danger { background: transparent; color: var(--accent-red-bright); border-color: var(--accent-red-bright); }
|
||
.admin-btn-lg { font-size: 15px; padding: 11px 18px; }
|
||
.admin-btn[disabled] { opacity: .55; cursor: default; filter: none; }
|
||
|
||
.admin-link { font-size: 13px; color: var(--accent-blue); text-decoration: none; }
|
||
.admin-link:hover { text-decoration: underline; }
|
||
|
||
/* ---- факты ---- */
|
||
.admin-facts { display: flex; flex-direction: column; gap: 6px; margin: 0; }
|
||
.admin-facts > div { display: flex; gap: 12px; justify-content: space-between; align-items: baseline; }
|
||
.admin-facts dt { font-size: 13px; color: var(--muted); flex: 0 0 auto; }
|
||
.admin-facts dd {
|
||
margin: 0; font-size: 14px; color: var(--text); font-weight: 600; text-align: right;
|
||
min-width: 0; overflow-wrap: anywhere;
|
||
}
|
||
.admin-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-weight: 500; font-size: 13px; }
|
||
.good { color: #2f9e5f; }
|
||
.bad { color: var(--accent-red-bright); }
|
||
|
||
.admin-hint { font-size: 12px; color: var(--muted-2); line-height: 1.45; }
|
||
.admin-alert {
|
||
font-size: 13px; padding: 10px 12px; margin-bottom: 14px;
|
||
background: var(--error-bg); border: 1px solid var(--error-border); color: var(--error-text);
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.admin-alert.warn { background: color-mix(in srgb, #b8860b 12%, var(--surface)); border-color: #b8860b; color: #8a6300; }
|
||
.hidden { display: none !important; }
|
||
|
||
/* ---- уведомления ---- */
|
||
#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 10px 30px 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; } }
|
||
|
||
@media (max-width: 640px) {
|
||
.admin-main { padding: 16px 12px 60px; }
|
||
.admin-hero { padding: 14px; }
|
||
.admin-hero .admin-btn { width: 100%; }
|
||
.admin-login { margin-top: 2vh; padding: 24px 18px; }
|
||
.admin-facts > div { flex-direction: column; gap: 2px; }
|
||
.admin-facts dd { text-align: left; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="topbar">
|
||
<div class="topbar-brand">
|
||
<img class="topbar-logo" src="/img/owl.svg" alt="">
|
||
<div class="topbar-text">
|
||
<span class="topbar-university">Расписание — админка</span>
|
||
<span class="topbar-faculty">источник данных и обновление</span>
|
||
</div>
|
||
</div>
|
||
<div class="admin-actions">
|
||
<a class="admin-ghost" href="/">← на сайт</a>
|
||
<button class="theme-toggle" id="theme-toggle" title="Переключить тему">
|
||
<svg class="icon icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||
<svg class="icon icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<main class="admin-main">
|
||
<section id="login-card" class="admin-login hidden">
|
||
<img class="admin-login-logo" src="/img/owl-white.svg" alt="">
|
||
<h1>Вход в админку</h1>
|
||
<p class="admin-login-hint">Страница открывается только по секретной ссылке. После нескольких неудачных попыток вход временно блокируется.</p>
|
||
<form id="login-form" autocomplete="on">
|
||
<label class="admin-field"><span>Логин</span><input id="login" autocomplete="username" required></label>
|
||
<label class="admin-field"><span>Пароль</span><input id="password" type="password" autocomplete="current-password" required></label>
|
||
<button class="admin-btn" type="submit">Войти</button>
|
||
</form>
|
||
<div id="login-error" class="admin-alert hidden" style="margin:14px 0 0"></div>
|
||
</section>
|
||
|
||
<section id="panel" class="hidden">
|
||
<div class="admin-hero" id="hero">
|
||
<div>
|
||
<div class="admin-hero-state"><span class="admin-dot"></span><span id="hero-title">Загружаю…</span></div>
|
||
<div class="admin-hero-sub" id="hero-sub"></div>
|
||
</div>
|
||
<button class="admin-btn admin-btn-lg" id="refresh" type="button">Обновить сейчас</button>
|
||
</div>
|
||
<div id="hero-alert" class="admin-alert hidden"></div>
|
||
|
||
<div class="admin-grid">
|
||
<article class="admin-card">
|
||
<h2>Ссылка на расписание</h2>
|
||
<label class="admin-field"><span>Публичная ссылка Яндекс.Диска</span>
|
||
<input id="link" spellcheck="false" placeholder="https://disk.yandex.ru/i/...">
|
||
</label>
|
||
<div class="admin-card-actions">
|
||
<button class="admin-btn" id="save-link" type="button">Сохранить ссылку</button>
|
||
<a class="admin-link" id="link-open" href="#" target="_blank" rel="noopener">открыть таблицу</a>
|
||
</div>
|
||
<p class="admin-hint">Ссылка проверяется скачиванием: если не открывается — не сохранится, данные на сайте останутся прежними.
|
||
Значение хранится в файле настроек и <b>имеет приоритет над .env</b>.</p>
|
||
</article>
|
||
|
||
<article class="admin-card">
|
||
<h2>Обновление</h2>
|
||
<div class="admin-inline">
|
||
<label class="admin-field admin-field-narrow"><span>Интервал, минут</span>
|
||
<input id="interval" type="number" min="1" max="1440" step="1">
|
||
</label>
|
||
<button class="admin-btn secondary" id="save-interval" type="button">Сохранить</button>
|
||
</div>
|
||
<dl class="admin-facts">
|
||
<div><dt>Файл при последнем обновлении</dt><dd id="fact-changed">—</dd></div>
|
||
<div><dt>Дата файла на Диске</dt><dd id="fact-modified">—</dd></div>
|
||
<div><dt>Отпечаток файла</dt><dd id="fact-hash" class="admin-mono">—</dd></div>
|
||
</dl>
|
||
<p class="admin-hint">По интервалу таблица перечитывается, только если на Диске изменилась дата файла.
|
||
Кнопка «Обновить сейчас» скачивает таблицу всегда — её и нажимайте сразу после правок.</p>
|
||
</article>
|
||
|
||
<article class="admin-card">
|
||
<h2>Входы в админку</h2>
|
||
<dl class="admin-facts">
|
||
<div><dt>Последняя попытка</dt><dd id="auth-attempt">—</dd></div>
|
||
<div><dt>Последний успешный вход</dt><dd id="auth-success">—</dd></div>
|
||
<div><dt>Осталось попыток</dt><dd id="auth-throttle">—</dd></div>
|
||
</dl>
|
||
<div class="admin-card-actions" style="margin-top:auto">
|
||
<button class="admin-btn danger" id="logout" type="button">Выйти</button>
|
||
</div>
|
||
<p class="admin-hint">Каждая попытка запоминается: откуда, когда и чем закончилась. Порог и время блокировки задаются в .env.</p>
|
||
</article>
|
||
|
||
<article class="admin-card">
|
||
<h2>Данные расписания</h2>
|
||
<dl class="admin-facts">
|
||
<div><dt>Групп</dt><dd id="fact-groups">—</dd></div>
|
||
<div><dt>Обновлено</dt><dd id="fact-updated">—</dd></div>
|
||
<div><dt>Файл настроек</dt><dd id="fact-settings" class="admin-mono">—</dd></div>
|
||
</dl>
|
||
</article>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
<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;
|
||
try {
|
||
resp = await fetch("/api/admin" + path, { credentials: "same-origin", ...options });
|
||
} catch (err) {
|
||
return { ok: false, status: 0, data: { detail: "Сеть недоступна: " + err.message } };
|
||
}
|
||
let data = null;
|
||
try { data = await resp.json(); } catch (_) {}
|
||
return { ok: resp.ok, status: resp.status, data };
|
||
}
|
||
|
||
function post(path, payload) {
|
||
return api(path, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload || {}),
|
||
});
|
||
}
|
||
|
||
function fmt(value) {
|
||
if (!value) return "—";
|
||
const d = new Date(value);
|
||
return isNaN(d) ? String(value) : d.toLocaleString("ru-RU", { dateStyle: "short", timeStyle: "short" });
|
||
}
|
||
|
||
/** «5 минут назад» для свежести данных. */
|
||
function ago(value) {
|
||
if (!value) return "";
|
||
const d = new Date(value);
|
||
if (isNaN(d)) return "";
|
||
const sec = Math.max(0, Math.round((Date.now() - d.getTime()) / 1000));
|
||
if (sec < 60) return "только что";
|
||
const min = Math.round(sec / 60);
|
||
if (min < 60) return `${min} мин назад`;
|
||
const hours = Math.round(min / 60);
|
||
if (hours < 24) return `${hours} ч назад`;
|
||
return `${Math.round(hours / 24)} дн назад`;
|
||
}
|
||
|
||
function loginText(info) {
|
||
if (!info) return "нет данных";
|
||
const status = info.ok === undefined ? "" : ` · ${info.ok ? "успешно" : "неудачно"}`;
|
||
return `${fmt(info.at)} · ${info.ip}${status}`;
|
||
}
|
||
|
||
function setText(id, value, cls) {
|
||
const el = $(id);
|
||
if (!el) return;
|
||
el.textContent = value;
|
||
el.classList.remove("good", "bad");
|
||
if (cls) el.classList.add(cls);
|
||
}
|
||
|
||
function renderState(state) {
|
||
// крупная строка состояния
|
||
const hero = $("hero");
|
||
const hasError = Boolean(state.error);
|
||
hero.classList.toggle("is-error", hasError);
|
||
hero.classList.toggle("is-ok", !hasError && state.has_data);
|
||
$("hero-title").textContent = hasError
|
||
? "Ошибка загрузки расписания"
|
||
: (state.has_data ? "Работает" : "Жду данные");
|
||
const parts = [];
|
||
if (state.last_updated) parts.push(`обновлено ${fmt(state.last_updated)} (${ago(state.last_updated)})`);
|
||
parts.push(`групп: ${state.groups}`);
|
||
parts.push(`интервал ${state.interval_minutes} мин`);
|
||
$("hero-sub").textContent = parts.join(" · ");
|
||
|
||
const alert = $("hero-alert");
|
||
const warnings = [];
|
||
if (state.error) warnings.push("Ошибка загрузки: " + state.error);
|
||
if (state.settings_save_error) {
|
||
warnings.push("Настройки не сохраняются на диск: " + state.settings_save_error +
|
||
" — после перезапуска службы ссылка и интервал вернутся к значениям из .env");
|
||
}
|
||
alert.classList.toggle("warn", !state.error && Boolean(state.settings_save_error));
|
||
alert.textContent = warnings.join(" ");
|
||
alert.classList.toggle("hidden", !warnings.length);
|
||
|
||
// ссылка
|
||
$("link").value = state.link || "";
|
||
const open = $("link-open");
|
||
open.href = state.link || "#";
|
||
open.classList.toggle("hidden", !state.link);
|
||
|
||
// обновление
|
||
$("interval").value = state.interval_minutes;
|
||
setText("fact-changed", state.changed ? "изменился" : "без изменений", state.changed ? "good" : null);
|
||
setText("fact-modified", fmt(state.table_modified));
|
||
setText("fact-hash", state.content_hash || "—");
|
||
|
||
// входы
|
||
const attempt = state.last_attempt;
|
||
setText("auth-attempt", loginText(attempt), attempt ? (attempt.ok ? "good" : "bad") : null);
|
||
setText("auth-success", loginText(state.last_success));
|
||
const throttle = state.throttle || {};
|
||
if (throttle.locked_until) {
|
||
setText("auth-throttle", `заблокировано до ${fmt(throttle.locked_until)}`, "bad");
|
||
} else if (throttle.max_attempts) {
|
||
setText("auth-throttle", `${throttle.attempts_left} из ${throttle.max_attempts}`);
|
||
}
|
||
|
||
// данные
|
||
setText("fact-groups", String(state.groups));
|
||
setText("fact-updated", state.last_updated ? `${fmt(state.last_updated)} (${ago(state.last_updated)})` : "—");
|
||
setText("fact-settings", state.settings_file || "—");
|
||
}
|
||
|
||
function showLogin(show) {
|
||
$("login-card").classList.toggle("hidden", !show);
|
||
$("panel").classList.toggle("hidden", show);
|
||
if (show) $("login").focus();
|
||
}
|
||
|
||
function busy(button, text) {
|
||
const original = button.textContent;
|
||
button.disabled = true;
|
||
button.textContent = text;
|
||
return () => { button.disabled = false; button.textContent = original; };
|
||
}
|
||
|
||
async function withButton(button, text, fn) {
|
||
const done = busy(button, text);
|
||
try { await fn(); } finally { done(); }
|
||
}
|
||
|
||
async function loadState() {
|
||
const r = await api("/state");
|
||
if (r.status === 401) { showLogin(true); return; }
|
||
if (!r.ok) {
|
||
toast("Не удалось получить состояние: " + ((r.data && r.data.detail) || r.status), "error");
|
||
return;
|
||
}
|
||
showLogin(false);
|
||
renderState(r.data);
|
||
}
|
||
|
||
$("login-form").addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
const error = $("login-error");
|
||
error.classList.add("hidden");
|
||
const r = await post("/login", { login: $("login").value, password: $("password").value });
|
||
if (!r.ok) {
|
||
error.textContent = (r.data && r.data.detail) || "Не удалось войти";
|
||
error.classList.remove("hidden");
|
||
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) {
|
||
toast("Ссылка не принята: " + ((r.data && r.data.detail) || r.status), "error");
|
||
return;
|
||
}
|
||
renderState(r.data);
|
||
toast("Ссылка сохранена, расписание загружено.", "ok");
|
||
}));
|
||
|
||
$("save-interval").addEventListener("click", () => withButton($("save-interval"), "Сохраняю…", async () => {
|
||
const r = await post("/interval", { interval_minutes: Number($("interval").value) });
|
||
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) {
|
||
toast("Обновить не удалось: " + ((r.data && r.data.detail) || r.status), "error");
|
||
} else if (r.data) {
|
||
renderState(r.data);
|
||
toast(
|
||
r.data.changed
|
||
? "Готово: в файле обнаружены изменения — расписание обновлено."
|
||
: "Готово: файл не изменился (на Диске та же версия).\nЕсли вы только что правили таблицу, значит Диск ещё не отдаёт новую версию — попробуйте через минуту.",
|
||
r.data.changed ? "ok" : "info",
|
||
);
|
||
}
|
||
await loadState();
|
||
}));
|
||
|
||
$("logout").addEventListener("click", async () => {
|
||
await post("/logout");
|
||
showLogin(true);
|
||
});
|
||
|
||
$("theme-toggle").addEventListener("click", () => {
|
||
const current = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
|
||
const next = current === "dark" ? "light" : "dark";
|
||
document.documentElement.setAttribute("data-theme", next);
|
||
try { localStorage.setItem("theme", next); } catch (_) {}
|
||
});
|
||
|
||
loadState();
|
||
</script>
|
||
</body>
|
||
</html>
|