UI: адаптив, журнал, спиннеры; docs: README и api_routes

- Навигация: выезжающее меню при узком экране (nav-mobile.js)
- Журнал: карточки <620px, компактная пагинация, время в две строки <920px
- Создание кластера: оверлей загрузки, инкрементальное обновление таблицы заданий
- Документация: полноэкранный спиннер при загрузке и навигации
- Главная: масштабирование CTA, статистика 2 колонки <520px, донаты перенос <710px
- README: env.example, новые фичи UI, автор в конце файла
- api_routes: маршрут /cluster-create, спиннеры, шаблоны; автор в конце
- env.example: автор перенесён в конец файла
This commit is contained in:
Sergey Antropoff
2026-04-05 00:18:19 +03:00
parent 4b703801e1
commit f0b24c8901
12 changed files with 2426 additions and 173 deletions
+131
View File
@@ -0,0 +1,131 @@
/**
* Адаптивное меню шапки: выезжающая панель при ширине viewport < 920px.
* Закрытие по клику на подложку, Escape, переходу на другую страницу и при расширении окна.
*
* Автор: Сергей Антропов
* Сайт: https://devops.org.ru
*/
(function () {
"use strict";
var MQ = "(max-width: 919px)";
var OPEN_CLASS = "top-nav-actions--open";
var BODY_CLASS = "nav-mobile-open";
var TOGGLE_OPEN_CLASS = "nav-menu-toggle--open";
function mqMobile() {
return window.matchMedia(MQ).matches;
}
/** Закрывает выпадающее меню API (дублирует логику из base.html, чтобы не дублировать DOM-события). */
function closeApiDropdown() {
var dd = document.querySelector(".nav-dropdown");
if (!dd) return;
var btn = document.getElementById("nav-api-trigger");
var menu = document.getElementById("nav-api-menu");
dd.classList.remove("nav-dropdown--open");
if (btn) btn.setAttribute("aria-expanded", "false");
if (menu) menu.setAttribute("hidden", "");
}
function getEls() {
return {
toggle: document.getElementById("nav-menu-toggle"),
drawer: document.getElementById("top-nav-drawer"),
backdrop: document.getElementById("nav-menu-backdrop"),
};
}
function setOpen(open) {
var els = getEls();
if (!els.toggle || !els.drawer || !els.backdrop) return;
els.toggle.setAttribute("aria-expanded", open ? "true" : "false");
els.toggle.setAttribute(
"aria-label",
open ? "Закрыть меню разделов" : "Открыть меню разделов",
);
els.toggle.classList.toggle(TOGGLE_OPEN_CLASS, open);
els.drawer.classList.toggle(OPEN_CLASS, open);
if (open) {
closeApiDropdown();
els.backdrop.removeAttribute("hidden");
els.backdrop.setAttribute("aria-hidden", "false");
document.body.classList.add(BODY_CLASS);
var closeBtn = document.getElementById("nav-drawer-close");
if (closeBtn && mqMobile()) {
try {
closeBtn.focus();
} catch (e) {
/* focus не всегда доступен */
}
}
} else {
els.backdrop.setAttribute("hidden", "");
els.backdrop.setAttribute("aria-hidden", "true");
document.body.classList.remove(BODY_CLASS);
closeApiDropdown();
}
}
function toggle() {
var els = getEls();
if (!els.drawer) return;
setOpen(!els.drawer.classList.contains(OPEN_CLASS));
}
function closeIfMobile() {
if (mqMobile()) setOpen(false);
}
function onResize() {
if (!mqMobile()) setOpen(false);
}
function init() {
var els = getEls();
if (!els.toggle || !els.drawer || !els.backdrop) return;
els.toggle.addEventListener("click", function (e) {
e.stopPropagation();
if (!mqMobile()) return;
toggle();
});
els.backdrop.addEventListener("click", function () {
closeIfMobile();
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") closeIfMobile();
});
window.addEventListener("resize", onResize);
/* Закрытие при уходе со страницы по ссылке внутри панели */
els.drawer.addEventListener("click", function (e) {
var a = e.target.closest("a[href]");
if (!a || !mqMobile()) return;
if (a.getAttribute("href") === "#" || a.getAttribute("href") === "") return;
if (a.getAttribute("target") === "_blank") return;
/* data-open-window открывает новое окно — страница не перезагружается */
if (a.hasAttribute("data-open-window")) return;
setOpen(false);
});
var closeDrawer = document.getElementById("nav-drawer-close");
if (closeDrawer) {
closeDrawer.addEventListener("click", function (e) {
e.stopPropagation();
closeIfMobile();
});
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();