From 2477543029f913448f16cedbc89aaa954d79fda7 Mon Sep 17 00:00:00 2001 From: IK Date: Sat, 12 Sep 2026 10:02:19 +0700 Subject: [PATCH] refactor: unify public state panels --- apps/web/src/components/StatePanel.astro | 8 ++++++++ apps/web/src/pages/fish/[slug].astro | 3 ++- apps/web/src/pages/fish/index.astro | 3 ++- apps/web/src/pages/records.astro | 5 +++-- apps/web/src/pages/status.astro | 3 ++- apps/web/src/pages/waterbodies/[slug].astro | 3 ++- apps/web/src/pages/waterbodies/[slug]/[fish].astro | 3 ++- apps/web/src/pages/waterbodies/index.astro | 3 ++- apps/web/src/styles/global.css | 3 +++ docs/ROADMAP.md | 2 +- docs/brand-system.md | 2 ++ 11 files changed, 29 insertions(+), 9 deletions(-) create mode 100644 apps/web/src/components/StatePanel.astro diff --git a/apps/web/src/components/StatePanel.astro b/apps/web/src/components/StatePanel.astro new file mode 100644 index 0000000..0987cbe --- /dev/null +++ b/apps/web/src/components/StatePanel.astro @@ -0,0 +1,8 @@ +--- +const { title, description, actionHref, actionLabel, tone = "empty", compact = false, contained = true } = Astro.props as { title: string; description?: string; actionHref?: string; actionLabel?: string; tone?: "empty" | "error" | "unavailable"; compact?: boolean; contained?: boolean }; +--- +
+

{title}

+ {description &&

{description}

} + {actionHref && actionLabel && {actionLabel}} +
diff --git a/apps/web/src/pages/fish/[slug].astro b/apps/web/src/pages/fish/[slug].astro index 4fc8a1c..067739e 100644 --- a/apps/web/src/pages/fish/[slug].astro +++ b/apps/web/src/pages/fish/[slug].astro @@ -1,6 +1,7 @@ --- import ActivityCard from "../../components/ActivityCard.astro"; import PageHero from "../../components/PageHero.astro"; +import StatePanel from "../../components/StatePanel.astro"; import Layout from "../../layouts/Layout.astro"; import { api, plural, type Activity, type DictionaryItem, type PaginatedActivity } from "../../lib/api"; const { slug } = Astro.params; @@ -22,5 +23,5 @@ const schema = fish ? { "@context":"https://schema.org", "@type":"CollectionPage ← Все виды рыб - {unavailable ?

Данные временно недоступны

: !fish ?

Такой рыбы нет в справочнике

Открыть каталог
:
{items.length ? items.map(item => ) :

Свежих точек пока нет

Проверьте позже или посмотрите полевые сигналы на главной.

}
} + {unavailable ? : !fish ? :
{items.length ? items.map(item => ) : }
}
diff --git a/apps/web/src/pages/fish/index.astro b/apps/web/src/pages/fish/index.astro index 4fa548c..0e55b77 100644 --- a/apps/web/src/pages/fish/index.astro +++ b/apps/web/src/pages/fish/index.astro @@ -2,6 +2,7 @@ import Layout from "../../layouts/Layout.astro"; import FishSilhouette from "../../components/FishSilhouette.astro"; import PageHero from "../../components/PageHero.astro"; +import StatePanel from "../../components/StatePanel.astro"; import { api, type DictionaryItem } from "../../lib/api"; let fishes: DictionaryItem[] = [], unavailable = false; try { fishes = await api("/api/v1/fishes?limit=500"); } catch { unavailable = true; } @@ -13,5 +14,5 @@ if (unavailable) { --- - {unavailable ?

Каталог временно недоступен

: } + {unavailable ? : }
diff --git a/apps/web/src/pages/records.astro b/apps/web/src/pages/records.astro index 9c4e5be..057b4b8 100644 --- a/apps/web/src/pages/records.astro +++ b/apps/web/src/pages/records.astro @@ -2,6 +2,7 @@ import Layout from "../layouts/Layout.astro"; import SourceBadge from "../components/SourceBadge.astro"; import SectionHeading from "../components/SectionHeading.astro"; +import StatePanel from "../components/StatePanel.astro"; import { api, kg, type DictionaryItem, type ImportRun, type OfficialRecord, type PaginatedOfficialRecord } from "../lib/api"; const params = Astro.url.searchParams; const fish = params.get("fish") ?? ""; @@ -23,9 +24,9 @@ const last = runs[0];
Публичные данные RF4

Официальные
рекорды

{last ? `Импорт: ${last.status}` : "Импорт ещё не запускался"}{last?.finished_at && {new Date(last.finished_at).toLocaleString("ru-RU")} · {last.rows_seen} строк}
{(fish || waterbody) && Сбросить}
- {unavailable &&

Источник временно недоступен

} + {unavailable && } {!unavailable && items.length &&
РыбаВесВодоёмПриманкаИгрокДата и источник
{items.map(record =>
{record.fish}{kg(record.weight_g)}{record.waterbody}{record.bait ?? "—"}{record.player_name ?? "—"}
)}
} {!unavailable && items.length && offset + items.length < totalRecords && { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}`}>Показать ещё {offset + items.length} из {totalRecords}} - {!unavailable && !items.length &&

Рекорды ещё не импортированы

Для выбранных условий записей пока нет.

} + {!unavailable && !items.length && }

Источник: официальный сайт Russian Fishing 4. Координаты в официальных таблицах отсутствуют.

diff --git a/apps/web/src/pages/status.astro b/apps/web/src/pages/status.astro index f0723a0..de0fc6d 100644 --- a/apps/web/src/pages/status.astro +++ b/apps/web/src/pages/status.astro @@ -3,6 +3,7 @@ import Layout from "../layouts/Layout.astro"; import SourceBadge from "../components/SourceBadge.astro"; import DataLegend from "../components/DataLegend.astro"; import PageHero from "../components/PageHero.astro"; +import StatePanel from "../components/StatePanel.astro"; import { ago, api, plural, type SourceStatus } from "../lib/api"; let sources: SourceStatus[] = [], unavailable = false; try { sources = await api("/api/v1/source-status"); } catch { unavailable = true; } @@ -10,6 +11,6 @@ const labels = { healthy:"Актуален", stale:"Данные устарел --- - {unavailable ?

Статус временно недоступен

:
{sources.map(source =>
{labels[source.status]}

{source.name}

{source.observations} {plural(source.observations,["наблюдение","наблюдения","наблюдений"])}

{source.last_success_at ? `Последнее успешное обновление ${ago(source.last_success_at)}` : "Успешных запусков пока нет"}

)}
} + {unavailable ? :
{sources.map(source =>
{labels[source.status]}

{source.name}

{source.observations} {plural(source.observations,["наблюдение","наблюдения","наблюдений"])}

{source.last_success_at ? `Последнее успешное обновление ${ago(source.last_success_at)}` : "Успешных запусков пока нет"}

)}
}
diff --git a/apps/web/src/pages/waterbodies/[slug].astro b/apps/web/src/pages/waterbodies/[slug].astro index c38618e..59c00e1 100644 --- a/apps/web/src/pages/waterbodies/[slug].astro +++ b/apps/web/src/pages/waterbodies/[slug].astro @@ -1,6 +1,7 @@ --- import ActivityCard from "../../components/ActivityCard.astro"; import PageHero from "../../components/PageHero.astro"; +import StatePanel from "../../components/StatePanel.astro"; import Layout from "../../layouts/Layout.astro"; import { api, plural, type Activity, type DictionaryItem, type PaginatedActivity } from "../../lib/api"; const { slug } = Astro.params; @@ -22,5 +23,5 @@ const schema = water ? { "@context":"https://schema.org", "@type":"CollectionPag ← Все водоёмы - {unavailable ?

Данные временно недоступны

: !water ?

Такого водоёма нет в справочнике

Открыть каталог
:
{items.length ? items.map(item => ) :

Свежих точек пока нет

Проверьте позже или посмотрите полевые сигналы на главной.

}
} + {unavailable ? : !water ? :
{items.length ? items.map(item => ) : }
}
diff --git a/apps/web/src/pages/waterbodies/[slug]/[fish].astro b/apps/web/src/pages/waterbodies/[slug]/[fish].astro index 61fd741..7645359 100644 --- a/apps/web/src/pages/waterbodies/[slug]/[fish].astro +++ b/apps/web/src/pages/waterbodies/[slug]/[fish].astro @@ -1,6 +1,7 @@ --- import ActivityCard from "../../../components/ActivityCard.astro"; import PageHero from "../../../components/PageHero.astro"; +import StatePanel from "../../../components/StatePanel.astro"; import Layout from "../../../layouts/Layout.astro"; import { api, plural, type Activity, type DictionaryItem, type PaginatedActivity } from "../../../lib/api"; const { slug, fish: fishSlug } = Astro.params; @@ -22,5 +23,5 @@ const schema = valid ? { "@type":"CollectionPage", name:`${fish!.name_ru} на $ ← К водоёму - {unavailable ?

Данные временно недоступны

: !valid ?

Такого сочетания нет в справочнике

Вернуться на главную
:
{items.length ? items.map(item => ) :

Свежих точек пока нет

Данные появятся после новых подтверждённых наблюдений.

}
} + {unavailable ? : !valid ? :
{items.length ? items.map(item => ) : }
}
diff --git a/apps/web/src/pages/waterbodies/index.astro b/apps/web/src/pages/waterbodies/index.astro index 804ab2d..cefb784 100644 --- a/apps/web/src/pages/waterbodies/index.astro +++ b/apps/web/src/pages/waterbodies/index.astro @@ -1,6 +1,7 @@ --- import Layout from "../../layouts/Layout.astro"; import PageHero from "../../components/PageHero.astro"; +import StatePanel from "../../components/StatePanel.astro"; import { api, type DictionaryItem } from "../../lib/api"; let waters: DictionaryItem[] = [], unavailable = false; try { waters = await api("/api/v1/waterbodies?limit=500"); } catch { unavailable = true; } @@ -12,5 +13,5 @@ if (unavailable) { --- - {unavailable ?

Каталог временно недоступен

: } + {unavailable ? : }
diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index 9da1ad2..e504f90 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -47,3 +47,6 @@ footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32 /* Shared editorial signature for data sections. */ .section-heading{gap:24px}.section-heading>div{min-width:0}.section-heading p{max-width:700px;margin:10px 0 0;color:var(--text-muted);line-height:1.55}.section-heading--editorial{padding-top:68px;border-top:1px solid var(--border);margin-bottom:25px}.section-heading--editorial h2{font-size:46px;line-height:.95}.section-heading--editorial .result-count{align-self:end}@media(max-width:720px){.section-heading--editorial{display:block;padding-top:45px}.section-heading--editorial .result-count{display:inline-block;margin-top:14px}} + +/* One semantic treatment for empty, unavailable and invalid public states. */ +.state.unavailable-state{border-style:solid;border-color:color-mix(in srgb,var(--warning) 35%,var(--border));background:color-mix(in srgb,var(--warning-soft) 35%,var(--surface))}.state.error-state{background:color-mix(in srgb,var(--danger-soft) 28%,var(--surface))}.state>a{margin-top:8px;font-weight:750;text-underline-offset:4px} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index cf3f081..f4691bf 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -23,7 +23,7 @@ - [x] **B01 · Иерархия имени.** RF4 Spotter — единое имя продукта в UI, metadata, manifest и документации; «Ни хвоста, ни чешуи» — поддерживающий слоган. Обновлены wordmark в header/footer и подпись выпуска. - [x] **B02 · Дизайн-токены.** Добавлены семантические роли поверхностей, текста, границ, фокуса, success/warning/danger и всех источников. На токены переведены паспорта данных, status-карточки, skeleton и focus; контрастные пары зафиксированы в brand system. - [x] **B03 · Графическая грамматика.** Мотивы закреплены за функциями: крючок — бренд, поплавок — активность/ожидание, леска — время, радар — координаты, силуэт — сущность рыбы. Убраны ложная рябь неполных сигналов и дублирующие радар круги карточки лидера; неполнота теперь подчёркнута спокойной полевой меткой. -- [ ] **B04 · Компонентная подпись.** В работе: общие `SectionHeading` и `PageHero` унифицируют рекорды, сигналы, каталоги и статус; далее унифицировать CTA и системные состояния публичных/admin-страниц. +- [ ] **B04 · Компонентная подпись.** В работе: `SectionHeading`, `PageHero` и `StatePanel` унифицируют заголовки, hero и состояния публичных каталогов, статуса и рекордов; далее унифицировать CTA и состояния admin-страниц. - [ ] **B05 · Motion-система.** Оставить только смысловые микроанимации поклёвки/пульса/перехода, задать общие длительности и полностью поддержать reduced motion. - [ ] **B06 · Brand QA.** Проверить favicon, maskable icons, manifest, OG 1200×630, светлый/тёмный фон, 16/32 px и mobile/desktop screenshots; записать правила в [brand-system.md](brand-system.md). diff --git a/docs/brand-system.md b/docs/brand-system.md index 8242247..ddac191 100644 --- a/docs/brand-system.md +++ b/docs/brand-system.md @@ -42,6 +42,8 @@ ## Компонентная подпись +`StatePanel` — единая рамка для пустых, недоступных и ошибочных состояний. Пустое состояние имеет роль `status`, недоступное и ошибочное — `alert`; цвет сообщает смысл, а не заменяет заголовок и пояснение. На вложенных карточках компонент используется без внешнего `content-grid`. + Секции данных используют `SectionHeading`: короткий uppercase eyebrow задаёт контекст, антиквенный заголовок называет сущность, необязательное описание объясняет правила, а счётчик всегда расположен справа или под текстом на мобильном. Так число результатов не конкурирует с названием раздела и одинаково читается в рекордах и полевых сигналах. Каталоги, detail-выборки и страницы прозрачности используют `PageHero`: один контекст, один `h1`, одно короткое пояснение. Это сохраняет одинаковый вертикальный ритм и SEO-иерархию независимо от наличия данных.