Files
schedule/src/frontend/admin.html
Vladislav Kopnin 163278c146 Шапка одной высоты с админкой, ховер кнопок как у остальных
- на сайте логотип лежит внутри ссылки и как строчный элемент добавлял 7px:
  шапка была 70px против 63px в админке. Теперь логотип блочный, а ссылка —
  inline-flex: обе шапки 63px (и min-height layout сходится точно);
- выход и «Админка» при наведении только меняют цвет (тёмно-красный и
  синий соответственно), без подъёма, тени и transform — как остальные
  кнопки проекта.
2026-10-10 13:02:43 +03:00

464 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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-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>
<button class="topbar-logout" id="logout" type="button">Выйти</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 noreferrer">открыть таблицу</a>
</div>
</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>
</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>
</dl>
<div id="auth-lock" class="admin-alert warn hidden" style="margin:10px 0 0"></div>
</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 || {};
const lock = $("auth-lock");
if (throttle.locked_until) {
lock.textContent = `Вход заблокирован до ${fmt(throttle.locked_until)} — слишком много неудачных попыток.`;
lock.classList.remove("hidden");
} else {
lock.classList.add("hidden");
}
}
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>