feat: add accessible queue loading states
This commit is contained in:
@@ -11,6 +11,7 @@ import "../styles/empty-states.css";
|
|||||||
import "../styles/alpha-banner.css";
|
import "../styles/alpha-banner.css";
|
||||||
import "../styles/signal-pagination.css";
|
import "../styles/signal-pagination.css";
|
||||||
import "../styles/dashboard-polish.css";
|
import "../styles/dashboard-polish.css";
|
||||||
|
import "../styles/loading-states.css";
|
||||||
import FishingIcon from "../components/FishingIcon.astro";
|
import FishingIcon from "../components/FishingIcon.astro";
|
||||||
import AlphaBanner from "../components/AlphaBanner.astro";
|
import AlphaBanner from "../components/AlphaBanner.astro";
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -25,15 +25,18 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
const pageNumber = document.querySelector<HTMLElement>("[data-page-number]");
|
const pageNumber = document.querySelector<HTMLElement>("[data-page-number]");
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
let token = ""; let fishes: Record<string, string>[] = []; let waters: Record<string, string>[] = [];
|
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 => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
||||||
const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } };
|
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("");
|
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 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() {
|
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`)]);
|
[fishes, waters] = await Promise.all([json(`${root.dataset.apiUrl}/api/v1/fishes`), json(`${root.dataset.apiUrl}/api/v1/waterbodies`)]);
|
||||||
pages?.setAttribute("hidden", "");
|
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}`}});
|
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(); }
|
if (!rows.length && offset > 0) { offset = 0; return loadQueue(); }
|
||||||
const pending = rows.slice(0, 50);
|
const pending = rows.slice(0, 50);
|
||||||
if (pages) pages.hidden = !pending.length;
|
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 (previous) previous.disabled = true;
|
||||||
if (next) next.disabled = true;
|
if (next) next.disabled = true;
|
||||||
try { await loadQueue(); }
|
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));
|
previous?.addEventListener("click", () => changePage(-50));
|
||||||
next?.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 {
|
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")) {
|
if (button.hasAttribute("data-suggest")) {
|
||||||
const suggestion = await json(`${base}/alias-suggestions`, {headers});
|
const suggestion = await json(`${base}/alias-suggestions`, {headers});
|
||||||
|
|||||||
@@ -15,18 +15,21 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
const list = document.querySelector<HTMLElement>("[data-moderation-list]");
|
const list = document.querySelector<HTMLElement>("[data-moderation-list]");
|
||||||
const error = document.querySelector<HTMLElement>("[data-admin-error]");
|
const error = document.querySelector<HTMLElement>("[data-admin-error]");
|
||||||
let token = "";
|
let token = "";
|
||||||
|
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 => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
||||||
const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } };
|
const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } };
|
||||||
async function loadQueue() {
|
async function loadQueue() {
|
||||||
if (!root || !list) return;
|
if (!root || !list) return;
|
||||||
error?.setAttribute("hidden", ""); list.innerHTML = '<div class="state"><h2>Загружаем очередь…</h2></div>';
|
error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();
|
||||||
const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports?status=pending`, {headers:{Authorization:`Bearer ${token}`}});
|
const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports?status=pending`, {headers:{Authorization:`Bearer ${token}`}});
|
||||||
if (!response.ok) throw new Error(response.status === 401 ? "Неверный административный токен." : "Не удалось загрузить очередь.");
|
if (!response.ok) throw new Error(response.status === 401 ? "Неверный административный токен." : "Не удалось загрузить очередь.");
|
||||||
const reports: Record<string, unknown>[] = await response.json();
|
const reports: Record<string, unknown>[] = await response.json();
|
||||||
|
setLoading(false);
|
||||||
if (!reports.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Новых уловов для проверки нет.</p></div>'; return; }
|
if (!reports.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Новых уловов для проверки нет.</p></div>'; return; }
|
||||||
list.innerHTML = reports.map(report => `<article class="moderation-card" data-report-id="${esc(report.id)}"><div class="moderation-summary"><span class="activity-pill"><i></i>На проверке</span><h2>${esc(report.fish)}</h2><p>${esc(report.waterbody)} · ${esc(report.coordinates)}</p><dl><div><dt>Вес</dt><dd>${esc(report.weight_g)} г</dd></div><div><dt>Приманка</dt><dd>${esc(report.bait)}</dd></div><div><dt>Игрок</dt><dd>${esc(report.player_name)}</dd></div><div><dt>Отправлено</dt><dd>${esc(new Date(String(report.reported_at)).toLocaleString("ru-RU"))}</dd></div></dl>${report.comment ? `<blockquote>${esc(report.comment)}</blockquote>` : ""}</div><div class="moderation-proof">${report.screenshot_url ? `<a href="${esc(report.screenshot_url)}" target="_blank" rel="noreferrer"><img src="${esc(report.screenshot_url)}" alt="Скриншот улова ${esc(report.fish)}" /></a>` : '<div class="no-proof">Скриншот не приложен</div>'}</div><div class="moderation-actions"><label>Причина решения<textarea rows="2" maxlength="1000"></textarea></label><div><button type="button" data-decision="approved">Одобрить</button><button class="reject" type="button" data-decision="rejected">Отклонить</button><button class="delete" type="button" data-delete>Удалить</button></div></div></article>`).join("");
|
list.innerHTML = reports.map(report => `<article class="moderation-card" data-report-id="${esc(report.id)}"><div class="moderation-summary"><span class="activity-pill"><i></i>На проверке</span><h2>${esc(report.fish)}</h2><p>${esc(report.waterbody)} · ${esc(report.coordinates)}</p><dl><div><dt>Вес</dt><dd>${esc(report.weight_g)} г</dd></div><div><dt>Приманка</dt><dd>${esc(report.bait)}</dd></div><div><dt>Игрок</dt><dd>${esc(report.player_name)}</dd></div><div><dt>Отправлено</dt><dd>${esc(new Date(String(report.reported_at)).toLocaleString("ru-RU"))}</dd></div></dl>${report.comment ? `<blockquote>${esc(report.comment)}</blockquote>` : ""}</div><div class="moderation-proof">${report.screenshot_url ? `<a href="${esc(report.screenshot_url)}" target="_blank" rel="noreferrer"><img src="${esc(report.screenshot_url)}" alt="Скриншот улова ${esc(report.fish)}" /></a>` : '<div class="no-proof">Скриншот не приложен</div>'}</div><div class="moderation-actions"><label>Причина решения<textarea rows="2" maxlength="1000"></textarea></label><div><button type="button" data-decision="approved">Одобрить</button><button class="reject" type="button" data-decision="rejected">Отклонить</button><button class="delete" type="button" data-delete>Удалить</button></div></div></article>`).join("");
|
||||||
}
|
}
|
||||||
login?.addEventListener("submit", async event => { event.preventDefault(); 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(); 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 => {
|
list?.addEventListener("click", async event => {
|
||||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button[data-decision],button[data-delete]"); const card = button?.closest<HTMLElement>("[data-report-id]"); if (!button || !card || !root) return;
|
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button[data-decision],button[data-delete]"); const card = button?.closest<HTMLElement>("[data-report-id]"); if (!button || !card || !root) return;
|
||||||
const reason = card.querySelector<HTMLTextAreaElement>("textarea")?.value.trim() || null;
|
const reason = card.querySelector<HTMLTextAreaElement>("textarea")?.value.trim() || null;
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
.loading-grid{display:grid;gap:18px}.loading-card{position:relative;overflow:hidden;min-height:210px;padding:25px;border:1px solid #d4ddd6;border-radius:16px;background:#fff}.loading-card:after{content:"";position:absolute;inset:0;transform:translateX(-100%);background:linear-gradient(100deg,transparent 20%,#f7faf5 48%,transparent 76%);animation:loading-sweep 1.4s ease-in-out infinite}.loading-line{display:block;width:100%;height:13px;margin-top:18px;border-radius:999px;background:#e7ede7}.loading-line--label{width:22%;height:9px;margin-top:0;background:#d8e2d8}.loading-line--title{width:48%;height:30px;margin-top:24px}.loading-line--short{width:64%}.moderation-list[aria-busy="true"]{cursor:progress}.sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}@keyframes loading-sweep{to{transform:translateX(100%)}}@media(prefers-reduced-motion:reduce){.loading-card:after{animation:none;background:#f7faf533}}
|
||||||
+1
-1
@@ -21,7 +21,7 @@
|
|||||||
- [ ] **Q01 · Документы источников.** Приложить или дать устойчивые ссылки на первичные разрешения RF4DB, RF4-STAT, RF4MAP и RF4 Posts; для каждого зафиксировать атрибуцию, точный production-лимит, срок хранения и процедуру удаления.
|
- [ ] **Q01 · Документы источников.** Приложить или дать устойчивые ссылки на первичные разрешения RF4DB, RF4-STAT, RF4MAP и RF4 Posts; для каждого зафиксировать атрибуцию, точный production-лимит, срок хранения и процедуру удаления.
|
||||||
- [ ] **Q02 · Управляемое удаление источника.** Добавить обнаружение изменённых/удалённых опубликованных записей без отдельного частого обхода: статус, журнал решения и безопасное исключение из активности после проверки.
|
- [ ] **Q02 · Управляемое удаление источника.** Добавить обнаружение изменённых/удалённых опубликованных записей без отдельного частого обхода: статус, журнал решения и безопасное исключение из активности после проверки.
|
||||||
- [ ] **Q03 · Целостность ссылок.** Проверять исходные ссылки только во время разрешённого планового обращения к площадке, разделяя `missing`, `temporary_error` и `blocked`; не создавать дополнительный сетевой цикл.
|
- [ ] **Q03 · Целостность ссылок.** Проверять исходные ссылки только во время разрешённого планового обращения к площадке, разделяя `missing`, `temporary_error` и `blocked`; не создавать дополнительный сетевой цикл.
|
||||||
- [ ] **Q04 · Skeleton и переходы.** Добавить лёгкие skeleton-состояния динамическим публичным блокам, без layout shift и с учётом `prefers-reduced-motion`.
|
- [x] **Q04 · Состояния ожидания.** Асинхронные admin-очереди получили каркасные карточки, `aria-busy`, очистку при ошибке и поддержку `prefers-reduced-motion`. Публичные страницы остаются SSR и не показывают искусственный skeleton; форма уже блокирует повторную отправку и сообщает «Отправка…».
|
||||||
- [ ] **Q05 · Визуальная матрица.** Проверить empty/one/many/long/error на 320/390/768/1280 px, keyboard и 200% zoom; исправить только воспроизведённые проблемы.
|
- [ ] **Q05 · Визуальная матрица.** Проверить empty/one/many/long/error на 320/390/768/1280 px, keyboard и 200% zoom; исправить только воспроизведённые проблемы.
|
||||||
- [ ] **Q06 · Performance baseline.** Повторить Lighthouse на production-сборке после оптимизации hero; записать LCP/CLS/INP и бюджет в документацию.
|
- [ ] **Q06 · Performance baseline.** Повторить Lighthouse на production-сборке после оптимизации hero; записать LCP/CLS/INP и бюджет в документацию.
|
||||||
- [ ] **Q07 · Нагрузочная методика.** Подготовить воспроизводимый сценарий измерения p95 для activity, records, staging и moderation без объявления результатов до запуска на целевом сервере.
|
- [ ] **Q07 · Нагрузочная методика.** Подготовить воспроизводимый сценарий измерения p95 для activity, records, staging и moderation без объявления результатов до запуска на целевом сервере.
|
||||||
|
|||||||
Reference in New Issue
Block a user