feat: add accessible queue loading states
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s

This commit is contained in:
ik
2026-09-11 08:08:52 +07:00
parent 45b73ec54a
commit 4fef0e8b74
5 changed files with 14 additions and 6 deletions
@@ -25,15 +25,18 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
const pageNumber = document.querySelector<HTMLElement>("[data-page-number]");
let offset = 0;
let token = ""; let fishes: Record<string, string>[] = []; let waters: Record<string, string>[] = [];
const loadingCards = () => `<div class="loading-grid" aria-hidden="true">${Array.from({length:2}, () => '<div class="loading-card"><span class="loading-line loading-line--label"></span><span class="loading-line loading-line--title"></span><span class="loading-line"></span><span class="loading-line loading-line--short"></span></div>').join("")}</div><span class="sr-only">Загружаем внешние наблюдения</span>`;
const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading));
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[char] ?? char));
const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } };
const options = (items: Record<string, string>[], selected?: unknown) => items.map(item => `<option value="${esc(item.slug)}" ${item.slug === selected ? "selected" : ""}>${esc(item.name_ru)}</option>`).join("");
async function json(url: string, init: RequestInit = {}) { const response = await fetch(url, init); if (!response.ok) throw new Error(response.status === 401 ? "Неверный административный токен." : `Запрос завершился ошибкой ${response.status}.`); return response.json(); }
async function loadQueue() {
if (!root || !list) return; error?.setAttribute("hidden", ""); list.innerHTML = '<div class="state"><h2>Загружаем staging…</h2></div>';
if (!root || !list) return; error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();
[fishes, waters] = await Promise.all([json(`${root.dataset.apiUrl}/api/v1/fishes`), json(`${root.dataset.apiUrl}/api/v1/waterbodies`)]);
pages?.setAttribute("hidden", "");
const rows = await json(`${root.dataset.apiUrl}/api/v1/admin/external-observations?status=review&limit=51&offset=${offset}`, {headers:{Authorization:`Bearer ${token}`}});
setLoading(false);
if (!rows.length && offset > 0) { offset = 0; return loadQueue(); }
const pending = rows.slice(0, 50);
if (pages) pages.hidden = !pending.length;
@@ -49,11 +52,11 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
if (previous) previous.disabled = true;
if (next) next.disabled = true;
try { await loadQueue(); }
catch { offset = oldOffset; fail("Не удалось загрузить страницу. Повторите вход в очередь."); if (login) login.hidden = false; }
catch { offset = oldOffset; setLoading(false); if (list) list.innerHTML = ""; fail("Не удалось загрузить страницу. Повторите вход в очередь."); if (login) login.hidden = false; }
}
previous?.addEventListener("click", () => changePage(-50));
next?.addEventListener("click", () => changePage(50));
login?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
login?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; } catch (cause) { setLoading(false); if (list) list.innerHTML = ""; fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
list?.addEventListener("click", async event => { const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button"); const card = button?.closest<HTMLElement>("[data-observation-id]"); if (!button || !card || !root) return; button.disabled = true; const base = `${root.dataset.apiUrl}/api/v1/admin/external-observations/${card.dataset.observationId}`; const headers = {Authorization:`Bearer ${token}`,"Content-Type":"application/json"}; try {
if (button.hasAttribute("data-suggest")) {
const suggestion = await json(`${base}/alias-suggestions`, {headers});