fix(ui): пункты меню не прячутся под футером; сквозные сценарии панели
web/index.html: - мобильное меню после раскрытия не держит анимационную высоту Bootstrap: нижние пункты (Обзор/История/API-ключи) уезжали под .sidebar-footer и не нажимались - на десктопе рельс #nav скроллится сам, футер не сжимается; активный пункт доскролливается в область видимости - failure() принимает заголовок: ошибка валидации источника больше не подписана «Не удалось загрузить данные» tools/reachability.py: общая проверка «элемент виден, но перекрыт другим» — hit-test по центру с scrollIntoView, пропуск скрытых узлов и закрытых <details>. tools/panel_scenarios.py: 11 сквозных сценариев реального пользователя — deep-link фильтр, экспорт CSV/JSON по байтам, читалка и ссылка на оригинал, дайджест, поиск источников, Telegram-сессия, форма источника (отказ без записи в реестр), клавиатурный flow (/ ? Esc 1-9) со сверкой строк ленты с API, мобильное меню, адаптивность 320-1920 без горизонтального переполнения, зум. tools/ui_smoke.py: reachability-проверки десктопного рельса, контролов и мобильного меню. tests: +3 регрессии на CSS/заголовки ошибок; pytest 93 passed, smoke exit 0, сценарии exit 0, page_errors пуст.
This commit is contained in:
@@ -170,7 +170,7 @@ a:hover { color: var(--omni-foreground); }
|
||||
.navbar-vertical .nav-link-icon .icon { width: var(--omni-space-4); height: var(--omni-space-4); }
|
||||
.navbar details > summary { cursor: pointer; }
|
||||
.navbar details[open] > summary { margin-bottom: var(--omni-space-2); }
|
||||
.sidebar-footer { padding: var(--omni-space-4) var(--omni-space-3); }
|
||||
.sidebar-footer { flex: 0 0 auto; padding: var(--omni-space-4) var(--omni-space-3); }
|
||||
#health { line-height: 1.6; }
|
||||
.sidebar-footer .btn { padding-inline: 0; white-space: normal; text-align: start; }
|
||||
#sidebar-toggle { position: static; inset: auto; transform: none; width: auto; min-width: 5.5rem; height: auto; min-height: var(--omni-control-height); flex: 0 0 auto; gap: var(--omni-space-2); padding: var(--omni-space-2) var(--omni-space-3); border: 1px solid var(--omni-border); border-radius: var(--omni-radius); color: var(--omni-foreground); font-size: var(--omni-text-base); background: var(--omni-background); box-shadow: none; }
|
||||
@@ -277,15 +277,19 @@ pre { padding: var(--omni-space-4); }
|
||||
@media (min-width: 992px) {
|
||||
.navbar.navbar-vertical > .container-fluid { height: 100%; padding: var(--omni-space-6) var(--omni-space-2) 0; }
|
||||
.navbar.navbar-vertical .navbar-brand { padding: 0 var(--omni-space-3) var(--omni-space-6); }
|
||||
#sidebar-menu { flex: 1; align-items: stretch; }
|
||||
#sidebar-menu { display: flex; flex-direction: column; flex: 1; align-items: stretch; min-height: 0; }
|
||||
#sidebar-menu > #nav { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
|
||||
}
|
||||
@media (max-width: 991.98px) {
|
||||
.navbar.navbar-vertical { border-inline-end: 0; border-bottom: 1px solid var(--omni-border); }
|
||||
.navbar.navbar-vertical > .container-fluid { padding: var(--omni-space-3) var(--omni-space-4); justify-content: space-between; flex-wrap: wrap; }
|
||||
.navbar.navbar-vertical .navbar-brand { flex: 1; padding: 0; }
|
||||
#sidebar-menu { flex-basis: 100%; }
|
||||
#sidebar-menu { flex-basis: 100%; flex-direction: column; align-items: stretch; }
|
||||
/* Без фикс-высоты после раскрытия: иначе список уезжает под футер и пункты не нажимаются. */
|
||||
#sidebar-menu.show:not(.collapsing) { height: auto !important; max-height: none !important; overflow: visible !important; }
|
||||
#sidebar-menu > #nav { flex: 0 0 auto; overflow: visible; }
|
||||
.navbar-vertical .navbar-nav { padding-inline: 0; }
|
||||
.sidebar-footer { margin-top: var(--omni-space-4) !important; padding-inline: 0; }
|
||||
.sidebar-footer { flex: 0 0 auto; margin-top: var(--omni-space-4) !important; padding-inline: 0; }
|
||||
.page-wrapper .container-xl { padding-inline: var(--omni-space-6); }
|
||||
}
|
||||
@media (max-width: 575.98px) {
|
||||
@@ -407,7 +411,7 @@ async function api(path,opt={}){
|
||||
const post=(path,body)=>api(path,{method:"POST",body:JSON.stringify(body||{})});
|
||||
async function copy(value){if(!value){toast("Нет данных для копирования",true);return}try{if(!navigator.clipboard)throw new Error("clipboard");await navigator.clipboard.writeText(value);toast("Скопировано")}catch{toast("Не удалось скопировать. Разрешите доступ к буферу обмена.",true)}}
|
||||
const retries=new Map();let retrySerial=0;
|
||||
function failure(error,retry){const id=String(++retrySerial);if(retry){retries.set(id,retry);if(retries.size>40)retries.delete(retries.keys().next().value)}return `<div class="alert alert-danger mb-0" role="alert"><h3 class="alert-title">Не удалось загрузить данные</h3><div class="text-break">${text(error.message||error)}</div>${retry?`<button class="btn btn-sm mt-3" type="button" data-retry="${id}">Повторить</button>`:""}</div>`}
|
||||
function failure(error,retry,title="Не удалось загрузить данные"){const id=String(++retrySerial);if(retry){retries.set(id,retry);if(retries.size>40)retries.delete(retries.keys().next().value)}return `<div class="alert alert-danger mb-0" role="alert"><h3 class="alert-title">${esc(title)}</h3><div class="text-break">${text(error.message||error)}</div>${retry?`<button class="btn btn-sm mt-3" type="button" data-retry="${id}">Повторить</button>`:""}</div>`}
|
||||
function loading(label="Загрузка данных…",rows=3){return `<div role="status" class="p-4"><div class="d-flex align-items-center gap-2 text-secondary"><span class="spinner-border spinner-border-sm" aria-hidden="true"></span>${esc(label)}</div><div class="placeholder-glow mt-3" aria-hidden="true">${Array(rows).fill('<div class="mb-3"><span class="placeholder col-8"></span><span class="placeholder col-5 d-block mt-2"></span></div>').join("")}</div></div>`}
|
||||
function empty(title,description,action=""){return `<div class="p-4"><h3 class="mb-2">${esc(title)}</h3><p class="text-secondary mb-0">${esc(description)}</p>${action?`<div class="mt-3">${action}</div>`:""}</div>`}
|
||||
const BUSY=new Set();
|
||||
@@ -458,6 +462,7 @@ function paintNav(){
|
||||
const systemOpen=$("#system-nav")?.open||["overview","runs","keys"].includes(S.view);
|
||||
const link=id=>{const n=NAV.find(v=>v.id===id);return `<li class="nav-item"><a class="nav-link ${S.view===id?"active":""}" href="#/${id}" data-nav="${id}" ${S.view===id?'aria-current="page"':""}><span class="nav-link-icon d-md-none d-lg-inline-block"><svg class="icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${n.icon}"/></svg></span><span class="nav-link-title">${n.label}</span></a></li>`};
|
||||
$("#nav").innerHTML=`<div class="subheader px-3 mt-3 mb-2">Рабочая область</div><ul class="navbar-nav">${["feed","abuse","sources","tg"].map(link).join("")}</ul><div class="subheader px-3 mt-4 mb-2">Инструменты</div><ul class="navbar-nav">${["watch","digest"].map(link).join("")}</ul><details id="system-nav" class="mt-4 mb-3" ${systemOpen?"open":""}><summary class="subheader px-3">Система</summary><ul class="navbar-nav mt-2">${["overview","runs","keys"].map(link).join("")}</ul></details>`;
|
||||
const rail=$("#nav");if(rail.scrollHeight>rail.clientHeight)rail.querySelector(".nav-link.active")?.scrollIntoView({block:"nearest"});
|
||||
}
|
||||
function pageHeader(){const n=NAV.find(n=>n.id===S.view);document.title="OMNI — "+n.label;$("#crumb").textContent=n.label;$("#page-group").textContent=n.group;$("#page-description").textContent=n.description;
|
||||
const primary={feed:'<button class="btn btn-primary" data-act="run" data-lock="feed" type="button">Собрать записи</button>',sources:'<button class="btn btn-primary" data-act="add-src" type="button">Добавить источник</button>',abuse:S.tab==="signals"?'<button class="btn btn-primary" data-act="summary" data-lock="summary" type="button">Сводка по находкам</button>':"",digest:'<button class="btn btn-primary" data-act="digest" data-lock="digest" type="button">Собрать дайджест</button>',overview:'<a class="btn btn-primary" href="#/feed" data-nav="feed">Открыть ленту</a>'};
|
||||
@@ -548,7 +553,7 @@ async function startJob(path,body,key,el,label,after){return busy(el,key,async()
|
||||
function showModal(title,body,footer){modalReturnFocus=document.activeElement;$("#modal-box").innerHTML=`<div class="modal-header"><h2 class="modal-title" id="modal-title">${esc(title)}</h2><button class="btn btn-ghost-secondary btn-sm" type="button" data-bs-dismiss="modal" aria-label="Закрыть диалог">Закрыть</button></div><div class="modal-body">${body}</div><div class="modal-footer">${footer}</div>`;tabler.Modal.getOrCreateInstance($("#modal")).show()}
|
||||
$("#modal").addEventListener("hidden.bs.modal",()=>{if(modalReturnFocus?.isConnected)modalReturnFocus.focus({preventScroll:true});modalReturnFocus=null});
|
||||
function addSource(){showModal("Новый источник",`<form id="add-source-form" class="row g-3"><div class="col-sm-6"><label class="form-label" for="a-name">Название</label><input class="form-control" id="a-name" placeholder="example-forum" required></div><div class="col-sm-6"><label class="form-label" for="a-cat">Категория</label><input class="form-control" id="a-cat" value="community" list="cats-list" required><datalist id="cats-list">${S.cats.map(c=>`<option value="${esc(c)}">`).join("")}</datalist></div><div class="col-12"><label class="form-label" for="a-url">Адрес источника</label><input class="form-control" id="a-url" type="url" placeholder="https://example.com/rss" required></div><div class="col-sm-8"><label class="form-label" for="a-adapter">Адаптер</label><select class="form-select" id="a-adapter">${["rss","html_listing","json","tg_web","xenforo_latest","discourse","sitemap_recent"].map(a=>`<option>${a}</option>`).join("")}</select></div><div class="col-sm-4"><label class="form-label" for="a-lim">Лимит записей</label><input class="form-control" type="number" id="a-lim" min="1" max="500" value="20" required></div><div class="col-12"><label class="form-label" for="a-tags">Метки через запятую</label><input class="form-control" id="a-tags" placeholder="Тема, язык, проект"></div><div class="col-12" id="add-source-error" aria-live="polite"></div></form>`,'<button class="btn" type="button" data-bs-dismiss="modal">Отмена</button><button class="btn btn-primary" type="submit" form="add-source-form" data-lock="source-write">Добавить источник</button>')}
|
||||
async function saveSource(){const origin=activeContext,body={name:$("#a-name").value.trim(),category:$("#a-cat").value.trim()||"community",url:$("#a-url").value.trim(),adapter:$("#a-adapter").value,limit:int($("#a-lim").value,1,500),tags:$("#a-tags").value.split(",").map(t=>t.trim()).filter(Boolean)};if(!safeUrl(body.url)){$("#add-source-error").innerHTML=failure(new Error("Нужен адрес с протоколом https:// или http://"));return}try{await post("/api/sources/add",body);tabler.Modal.getOrCreateInstance($("#modal")).hide();toast("Источник добавлен");if(origin.valid())await render()}catch(error){if($("#add-source-error"))$("#add-source-error").innerHTML=failure(error)}}
|
||||
async function saveSource(){const origin=activeContext,body={name:$("#a-name").value.trim(),category:$("#a-cat").value.trim()||"community",url:$("#a-url").value.trim(),adapter:$("#a-adapter").value,limit:int($("#a-lim").value,1,500),tags:$("#a-tags").value.split(",").map(t=>t.trim()).filter(Boolean)};if(!safeUrl(body.url)){$("#add-source-error").innerHTML=failure(new Error("Нужен адрес с протоколом https:// или http://"),null,"Проверьте адрес источника");return}try{await post("/api/sources/add",body);tabler.Modal.getOrCreateInstance($("#modal")).hide();toast("Источник добавлен");if(origin.valid())await render()}catch(error){if($("#add-source-error"))$("#add-source-error").innerHTML=failure(error)}}
|
||||
async function bulkToggle(on,el){const names=selectedSrc();if(!names.length){toast("Выберите источники",true);return}await busy(el,"source-write",async()=>{const ctx=activeContext;for(const name of names)await post("/api/sources/toggle",{name,enabled:on});toast(`${on?"Включено":"Выключено"}: ${names.length}`);if(ctx.valid())await loadSources(ctx)})}
|
||||
async function toggleSource(name,on,el,detail=false){await busy(el,"source-write",async()=>{const ctx=activeContext,reader=readerVersion;try{await post("/api/sources/toggle",{name,enabled:on});toast(on?"Источник включён":"Источник выключен");if(ctx.valid()&&S.view==="sources")await loadSources(ctx);if(detail&&reader===readerVersion&&$("#drawer").classList.contains("show"))await srcDetail(name)}catch(error){if(el?.type==="checkbox")el.checked=!on;throw error}})}
|
||||
async function addWatch(q,type="all",el){if(q.trim().length<2){toast("Введите не менее двух символов",true);return}await busy(el,"watch-write",async()=>{const ctx=activeContext;await post("/api/watch/add",{q:q.trim(),type});toast("Правило отслеживания добавлено");if(ctx.valid()&&S.view==="watch")await render()})}
|
||||
|
||||
Reference in New Issue
Block a user