diff --git a/apps/web/src/layouts/Layout.astro b/apps/web/src/layouts/Layout.astro index 1e81ea5..247af3b 100644 --- a/apps/web/src/layouts/Layout.astro +++ b/apps/web/src/layouts/Layout.astro @@ -11,6 +11,7 @@ import "../styles/empty-states.css"; import "../styles/alpha-banner.css"; import "../styles/signal-pagination.css"; import "../styles/dashboard-polish.css"; +import "../styles/loading-states.css"; import FishingIcon from "../components/FishingIcon.astro"; import AlphaBanner from "../components/AlphaBanner.astro"; const { diff --git a/apps/web/src/pages/admin/external-sources.astro b/apps/web/src/pages/admin/external-sources.astro index d6f0bdc..a97f5b2 100644 --- a/apps/web/src/pages/admin/external-sources.astro +++ b/apps/web/src/pages/admin/external-sources.astro @@ -25,15 +25,18 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; const pageNumber = document.querySelector("[data-page-number]"); let offset = 0; let token = ""; let fishes: Record[] = []; let waters: Record[] = []; + const loadingCards = () => `Загружаем внешние наблюдения`; + const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading)); const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char)); const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } }; const options = (items: Record[], selected?: unknown) => items.map(item => ``).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 = '

Загружаем staging…

'; + 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("button"); const card = button?.closest("[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}); diff --git a/apps/web/src/pages/admin/moderation.astro b/apps/web/src/pages/admin/moderation.astro index dd10592..af88c00 100644 --- a/apps/web/src/pages/admin/moderation.astro +++ b/apps/web/src/pages/admin/moderation.astro @@ -15,18 +15,21 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; const list = document.querySelector("[data-moderation-list]"); const error = document.querySelector("[data-admin-error]"); let token = ""; + const loadingCards = () => `Загружаем очередь модерации`; + const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading)); const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char)); const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } }; async function loadQueue() { if (!root || !list) return; - error?.setAttribute("hidden", ""); list.innerHTML = '

Загружаем очередь…

'; + 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}`}}); if (!response.ok) throw new Error(response.status === 401 ? "Неверный административный токен." : "Не удалось загрузить очередь."); const reports: Record[] = await response.json(); + setLoading(false); if (!reports.length) { list.innerHTML = '

Очередь пуста

Новых уловов для проверки нет.

'; return; } list.innerHTML = reports.map(report => `
На проверке

${esc(report.fish)}

${esc(report.waterbody)} · ${esc(report.coordinates)}

Вес
${esc(report.weight_g)} г
Приманка
${esc(report.bait)}
Игрок
${esc(report.player_name)}
Отправлено
${esc(new Date(String(report.reported_at)).toLocaleString("ru-RU"))}
${report.comment ? `
${esc(report.comment)}
` : ""}
${report.screenshot_url ? `Скриншот улова ${esc(report.fish)}` : '
Скриншот не приложен
'}
`).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 => { const button = (event.target as HTMLElement).closest("button[data-decision],button[data-delete]"); const card = button?.closest("[data-report-id]"); if (!button || !card || !root) return; const reason = card.querySelector("textarea")?.value.trim() || null; diff --git a/apps/web/src/styles/loading-states.css b/apps/web/src/styles/loading-states.css new file mode 100644 index 0000000..c95510b --- /dev/null +++ b/apps/web/src/styles/loading-states.css @@ -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}} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b91a79c..b3dc2f4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -21,7 +21,7 @@ - [ ] **Q01 · Документы источников.** Приложить или дать устойчивые ссылки на первичные разрешения RF4DB, RF4-STAT, RF4MAP и RF4 Posts; для каждого зафиксировать атрибуцию, точный production-лимит, срок хранения и процедуру удаления. - [ ] **Q02 · Управляемое удаление источника.** Добавить обнаружение изменённых/удалённых опубликованных записей без отдельного частого обхода: статус, журнал решения и безопасное исключение из активности после проверки. - [ ] **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; исправить только воспроизведённые проблемы. - [ ] **Q06 · Performance baseline.** Повторить Lighthouse на production-сборке после оптимизации hero; записать LCP/CLS/INP и бюджет в документацию. - [ ] **Q07 · Нагрузочная методика.** Подготовить воспроизводимый сценарий измерения p95 для activity, records, staging и moderation без объявления результатов до запуска на целевом сервере.