Compare commits
4
Commits
f2a97d2b80
...
6d88ba030f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d88ba030f | ||
|
|
0a27c55da7 | ||
|
|
72057c7b99 | ||
|
|
ebdeaf3659 |
@@ -12,9 +12,9 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
|
||||
|
||||
На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва.
|
||||
|
||||
Базовый SEO-контур готов для `rf4spotter.ru`: страницы имеют уникальные метаданные, canonical, Open Graph/Twitter Card, фирменное изображение 1200×630 и JSON-LD; доступны динамические `/robots.txt` и `/sitemap.xml`, административные и ошибочные страницы закрыты от индексации, добавлена собственная страница 404. Индексируемые каталоги рыб и водоёмов, detail-страницы и сочетания водоём + рыба строятся из актуального разрешённого справочника и включаются в sitemap. Подключены фирменные favicon/app icons, web manifest и production-кэширование статических ресурсов. Карточки активности показывают единый паспорт данных: источники, свежесть, полноту и уровень доверия.
|
||||
Базовый SEO-контур готов для `rf4spotter.ru`: страницы имеют уникальные метаданные, canonical, Open Graph/Twitter Card, фирменное изображение 1200×630 и JSON-LD; доступны динамические `/robots.txt` и `/sitemap.xml`, административные и ошибочные страницы закрыты от индексации, добавлена собственная страница 404. Индексируемые каталоги рыб и водоёмов, detail-страницы и сочетания водоём + рыба строятся из актуального разрешённого справочника и включаются в sitemap. Подключены фирменные favicon/app icons, web manifest и production-кэширование статических ресурсов. Карточки активности показывают единый паспорт данных: источники, свежесть, полноту и уровень доверия. На `/status` опубликована легенда цветов всех источников и статусов качества.
|
||||
|
||||
Публичные точки используют постоянные читаемые адреса вида `/spots/kuori-85x92`; старые UUID-адреса остаются совместимыми и перенаправляются на канонический URL.
|
||||
Публичные точки используют постоянные читаемые адреса вида `/spots/kuori-85x92`; старые UUID-адреса остаются совместимыми и перенаправляются на канонический URL. На странице точки координаты дополнительно показаны фирменным радаром, который не имитирует отсутствующую географию водоёма, а уловы за 72 часа — шкалой-леской с 12-часовым шагом. Карточки активности и каталог дополнены лёгкими SVG-силуэтами рыб без внешних графических зависимостей.
|
||||
|
||||
Все пять community-парсеров подключены к отдельному scheduler-процессу. Состояние запусков и ошибок хранится в PostgreSQL, параллельный запуск одного источника блокируется, минимальный интервал жёстко ограничен 1800 секундами. Локально процесс включается профилем `docker compose --profile scheduler up -d`; detail-URL RF4MAP/RF4 Posts задаются переменными окружения.
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Activity } from "../lib/api";
|
||||
import { activityLevel, ago, kg, plural, spotPath } from "../lib/api";
|
||||
import FishingIcon from "./FishingIcon.astro";
|
||||
import DataPassport from "./DataPassport.astro";
|
||||
import FishSilhouette from "./FishSilhouette.astro";
|
||||
const { item } = Astro.props as { item: Activity };
|
||||
const level = activityLevel(item.activity_score);
|
||||
const limited = item.catches < 3;
|
||||
@@ -12,6 +13,7 @@ const limited = item.catches < 3;
|
||||
<div class="spot-main">
|
||||
<div class="spot-topline"><span>{item.waterbody}</span><span class="activity-pill" data-activity-level={level.short}><i></i>{level.short}</span>{limited && <span class="data-quality">Данных мало</span>}</div>
|
||||
<h3>{item.fish}</h3>
|
||||
<FishSilhouette name={item.fish}/>
|
||||
<div class="spot-meta"><span><FishingIcon name="pin" size={14}/> {item.x}:{item.y}</span><span><FishingIcon name="clock" size={14}/> {ago(item.last_confirmed_at)}</span></div>
|
||||
<p class="data-note">{item.explanation}</p>
|
||||
<DataPassport sources={item.sources} observedAt={item.last_confirmed_at} completeness={limited ? 75 : 100} confidence={item.confidence_score}/>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
import type { Catch } from "../lib/api";
|
||||
const { catches } = Astro.props as { catches: Catch[] };
|
||||
const now = Date.now();
|
||||
const step = 12 * 60 * 60 * 1000;
|
||||
const buckets = Array.from({ length: 6 }, (_, index) => ({ index, count: 0 }));
|
||||
for (const item of catches) {
|
||||
const timestamp = new Date(item.caught_at ?? item.reported_at).getTime();
|
||||
const age = Math.floor((now - timestamp) / step);
|
||||
if (age >= 0 && age < buckets.length) buckets[buckets.length - 1 - age].count += 1;
|
||||
}
|
||||
const max = Math.max(1, ...buckets.map(bucket => bucket.count));
|
||||
---
|
||||
<section class="activity-timeline" aria-labelledby="timeline-title">
|
||||
<header><div><span class="overline">Последние 72 часа</span><h2 id="timeline-title">Леска активности</h2></div><p>По показанным ниже уловам · шаг 12 часов</p></header>
|
||||
<div class="timeline-chart" role="img" aria-label={`Распределение ${catches.length} показанных уловов за последние 72 часа`}>
|
||||
{buckets.map((bucket, index) => <div class="timeline-slot"><span class="timeline-line" style={`--height:${Math.max(8, bucket.count / max * 100)}%`}><i></i></span><strong>{bucket.count}</strong><small>{index === 0 ? "−72 ч" : index === 5 ? "сейчас" : `−${(5-index)*12} ч`}</small></div>)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
const { x, y } = Astro.props as { x: number; y: number };
|
||||
---
|
||||
<div class="coordinate-radar" role="img" aria-label={`Координатный радар точки X ${x}, Y ${y}`}>
|
||||
<svg viewBox="0 0 180 180" aria-hidden="true">
|
||||
<circle cx="90" cy="90" r="72"/><circle cx="90" cy="90" r="48"/><circle cx="90" cy="90" r="24"/>
|
||||
<path d="M90 10v160M10 90h160M34 34l112 112M146 34 34 146"/>
|
||||
<path class="radar-sweep" d="M90 90 90 18A72 72 0 0 1 157 64Z"/>
|
||||
<circle class="radar-mark" cx="90" cy="90" r="7"/><circle class="radar-pulse" cx="90" cy="90" r="13"/>
|
||||
</svg>
|
||||
<span class="radar-north">N</span>
|
||||
<strong><small>X</small>{x}<i></i><small>Y</small>{y}</strong>
|
||||
<span class="radar-caption">Координаты в игре</span>
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
import SourceBadge from "./SourceBadge.astro";
|
||||
const sourceGroups = [
|
||||
{ source: "rf4-official", text: "официальные таблицы игры" },
|
||||
{ source: "players", text: "сообщения игроков" },
|
||||
{ source: "rf4db", text: "разрешённый community-источник" },
|
||||
{ source: "rf4stat-fishing", text: "разрешённая статистика уловов" },
|
||||
{ source: "rf4stat-post", text: "разрешённые публикации" },
|
||||
{ source: "rf4map", text: "разрешённые точки карты" },
|
||||
{ source: "rf4posts-spot", text: "разрешённые полевые сигналы" },
|
||||
];
|
||||
---
|
||||
<section class="data-legend content-grid" aria-labelledby="legend-title">
|
||||
<div class="data-legend__intro"><span class="overline">Как читать плашки</span><h2 id="legend-title">Легенда данных</h2><p>Цвет означает происхождение, а статус — пригодность записи. Плашка источника ведёт на исходную публикацию, когда ссылка доступна.</p></div>
|
||||
<div class="data-legend__body">
|
||||
<div class="legend-sources">{sourceGroups.map(item => <div><SourceBadge source={item.source}/><span>{item.text}</span></div>)}</div>
|
||||
<div class="legend-quality">
|
||||
<div><i data-quality="verified">✓</i><p><strong>Учтено</strong><span>Проверено и участвует в расчёте активности.</span></p></div>
|
||||
<div><i data-quality="unverified">?</i><p><strong>Ждёт проверки</strong><span>Показывается, но пока не влияет на индекс.</span></p></div>
|
||||
<div><i data-quality="incomplete">!</i><p><strong>Неполные данные</strong><span>Часть полей отсутствует и явно перечислена.</span></p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
const { name, size = 92 } = Astro.props as { name: string; size?: number };
|
||||
const variant = [...name].reduce((sum, char) => sum + (char.codePointAt(0) ?? 0), 0) % 3;
|
||||
---
|
||||
<svg class="fish-silhouette" width={size} height={Math.round(size * .48)} viewBox="0 0 120 58" aria-hidden="true">
|
||||
{variant === 0 && <><path d="M22 29C38 7 78 8 101 29 78 50 38 51 22 29Z"/><path d="m25 29-22-18v36Z"/><path d="M55 14 69 2l8 15M57 44l15 11 8-16"/><circle cx="91" cy="25" r="2.4" class="fish-eye"/></>}
|
||||
{variant === 1 && <><path d="M19 30C39 14 80 13 104 29 80 45 39 47 19 30Z"/><path d="M22 30 3 17v28Z"/><path d="m48 17 11-14 13 13M47 44l12 11 13-12"/><path d="M101 29h15"/><circle cx="94" cy="25" r="2.4" class="fish-eye"/></>}
|
||||
{variant === 2 && <><path d="M20 30C34 3 79 4 103 29 79 54 34 55 20 30Z"/><path d="M23 30 2 8v44Z"/><path d="m52 10 8-9 14 10M52 49l9 8 14-10"/><circle cx="92" cy="24" r="2.4" class="fish-eye"/></>}
|
||||
</svg>
|
||||
@@ -3,6 +3,10 @@ import "../styles/global.css";
|
||||
import "../styles/catalog.css";
|
||||
import "../styles/source-status.css";
|
||||
import "../styles/data-passport.css";
|
||||
import "../styles/data-legend.css";
|
||||
import "../styles/coordinate-radar.css";
|
||||
import "../styles/activity-timeline.css";
|
||||
import "../styles/fish-silhouette.css";
|
||||
import FishingIcon from "../components/FishingIcon.astro";
|
||||
const {
|
||||
title = "RF4 Spotter — свежие точки клёва Russian Fishing 4",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import FishSilhouette from "../../components/FishSilhouette.astro";
|
||||
import { api, type DictionaryItem } from "../../lib/api";
|
||||
let fishes: DictionaryItem[] = [], unavailable = false;
|
||||
try { fishes = await api<DictionaryItem[]>("/api/v1/fishes?limit=500"); } catch { unavailable = true; }
|
||||
---
|
||||
<Layout title="Все виды рыб Russian Fishing 4 — RF4 Spotter" description="Каталог рыб RF4 со свежими точками, уловами, приманками и прозрачными источниками данных.">
|
||||
<section class="catalog-hero content-grid"><span class="overline">Справочник RF4</span><h1>Рыбы</h1><p>Выберите вид, чтобы увидеть свежие подтверждённые точки и полевые сигналы.</p></section>
|
||||
{unavailable ? <div class="state content-grid"><h2>Каталог временно недоступен</h2></div> : <nav class="catalog-grid content-grid" aria-label="Виды рыб">{fishes.map(fish => <a href={`/fish/${fish.slug}`}><span>Вид рыбы</span><strong>{fish.name_ru}</strong><i>→</i></a>)}</nav>}
|
||||
{unavailable ? <div class="state content-grid"><h2>Каталог временно недоступен</h2></div> : <nav class="catalog-grid content-grid" aria-label="Виды рыб">{fishes.map(fish => <a href={`/fish/${fish.slug}`}><span>Вид рыбы</span><strong>{fish.name_ru}</strong><i>→</i><FishSilhouette name={fish.name_ru} size={76}/></a>)}</nav>}
|
||||
</Layout>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import SourceBadge from "../../components/SourceBadge.astro";
|
||||
import CoordinateRadar from "../../components/CoordinateRadar.astro";
|
||||
import ActivityTimeline from "../../components/ActivityTimeline.astro";
|
||||
import { activityLevel, api, kg, plural, type Activity, type Catch, type Spot } from "../../lib/api";
|
||||
const { id } = Astro.params;
|
||||
let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false;
|
||||
@@ -23,8 +25,9 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr
|
||||
<Layout title={spot ? `Точка ${spot.x}:${spot.y}, ${spot.waterbody} — RF4 Spotter` : "Точка не найдена — RF4 Spotter"} description={spotDescription} noindex={!spot} structuredData={breadcrumbs}>
|
||||
<a class="back" href="/">← Все активные точки</a>
|
||||
{unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
|
||||
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p></div><div class="pin">{spot.x}<span>:</span>{spot.y}</div></section>
|
||||
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p></div><CoordinateRadar x={spot.x} y={spot.y}/></section>
|
||||
<div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div>
|
||||
<ActivityTimeline catches={catches}/>
|
||||
<section class="detail-grid"><div><div class="section-heading"><h2>Последние уловы</h2></div>{catches.length ? <div class="catch-list">{catches.map(item => <article><div><strong>{item.fish}</strong><span>{item.bait ?? "Приманка не указана"}</span><SourceBadge source={item.source_system} href={item.source_url}/></div><div><strong>{kg(item.weight_g)}</strong><span>{item.player_name ?? "Анонимно"}</span></div></article>)}</div> : <div class="state compact-state"><h2>Уловов пока нет</h2><p>Для этой точки нет одобренных наблюдений.</p></div>}</div><aside><span class="eyebrow">Оценка за 24 часа</span>{activity && level ? <><h2>{activity.activity_score} / 100</h2><strong data-activity-level={level.short}>{level.description}</strong><div class="source-strip">{activity.sources.map(source => <SourceBadge source={source}/>)}</div><p>{activity.explanation}</p><p class="note">Основано на {activity.catches} {plural(activity.catches, ["наблюдении", "наблюдениях", "наблюдениях"])} от {activity.unique_players} {plural(activity.unique_players, ["игрока", "игроков", "игроков"])}. Уверенность: {activity.confidence_score}%.</p></> : <p>За последние 24 часа данных для расчёта нет.</p>}<span class="eyebrow">Лучшие приманки</span>{spot.top_baits.length ? <ol>{spot.top_baits.map(name => <li>{name}</li>)}</ol> : <p>Недостаточно данных.</p>}<p class="note">Учитываются только одобренные наблюдения.</p></aside></section>
|
||||
</>}
|
||||
</Layout>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import SourceBadge from "../components/SourceBadge.astro";
|
||||
import DataLegend from "../components/DataLegend.astro";
|
||||
import { ago, api, plural, type SourceStatus } from "../lib/api";
|
||||
let sources: SourceStatus[] = [], unavailable = false;
|
||||
try { sources = await api<SourceStatus[]>("/api/v1/source-status"); } catch { unavailable = true; }
|
||||
@@ -9,4 +10,5 @@ const labels = { healthy:"Актуален", stale:"Данные устарел
|
||||
<Layout title="Состояние источников — RF4 Spotter" description="Текущее безопасное состояние источников данных RF4 Spotter.">
|
||||
<section class="catalog-hero content-grid"><span class="overline">Прозрачность данных</span><h1>Статус источников</h1><p>Пауза или изменение страницы не удаляют уже опубликованные данные.</p></section>
|
||||
{unavailable ? <div class="state content-grid"><h2>Статус временно недоступен</h2></div> : <section class="source-health-grid content-grid">{sources.map(source => <article data-status={source.status}><div><SourceBadge source={source.source_system}/><span class="health-state"><i></i>{labels[source.status]}</span></div><h2>{source.name}</h2><strong>{source.observations} {plural(source.observations,["наблюдение","наблюдения","наблюдений"])}</strong><p>{source.last_success_at ? `Последнее успешное обновление ${ago(source.last_success_at)}` : "Успешных запусков пока нет"}</p></article>)}</section>}
|
||||
<DataLegend />
|
||||
</Layout>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.activity-timeline{width:min(1180px,calc(100% - 64px));margin:20px auto 0;padding:25px 28px;border-radius:14px;background:#fff;border:1px solid #d9e1da}.activity-timeline header{display:flex;align-items:end;justify-content:space-between;gap:20px}.activity-timeline h2{font:400 29px Georgia,serif;margin:5px 0 0}.activity-timeline header p{margin:0;color:#71817e;font-size:10px}
|
||||
.timeline-chart{height:145px;display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-top:18px;padding-top:8px;border-bottom:1px solid #a9bbb1;background:repeating-linear-gradient(to top,transparent 0,transparent 35px,#dce5de 36px)}.timeline-slot{position:relative;display:grid;grid-template-rows:1fr auto;place-items:center}.timeline-line{position:relative;align-self:end;width:2px;height:var(--height);min-height:10px;background:#3f7779}.timeline-line i{position:absolute;left:0;top:0;width:12px;height:18px;margin:-4px 0 0 -5px;border-radius:7px 7px 9px 9px;background:linear-gradient(#ff785a 0 35%,var(--lime) 35%);box-shadow:0 0 0 4px #c9f45b18}.timeline-slot strong{position:absolute;top:2px;font:400 14px Georgia,serif;color:#365c5e}.timeline-slot small{position:absolute;bottom:-20px;color:#71817e;font-size:8px;white-space:nowrap}.activity-timeline+*{margin-top:42px}
|
||||
@media(max-width:720px){.activity-timeline{width:calc(100% - 28px);padding:20px 15px}.activity-timeline header{display:block}.activity-timeline header p{margin-top:6px}.timeline-slot small{font-size:7px}}
|
||||
@@ -0,0 +1 @@
|
||||
.coordinate-radar{position:relative;flex:0 0 180px;width:180px;height:180px;display:grid;place-items:center;color:var(--lime)}.coordinate-radar svg{position:absolute;inset:0;width:100%;height:100%;fill:none;stroke:#ffffff20;stroke-width:1}.coordinate-radar .radar-sweep{fill:#c9f45b0d;stroke:#c9f45b38}.coordinate-radar .radar-mark{fill:var(--lime);stroke:none}.coordinate-radar .radar-pulse{stroke:var(--lime);stroke-width:2;opacity:.55}.coordinate-radar strong{position:relative;display:grid;grid-template-columns:auto auto 1px auto auto;align-items:baseline;gap:4px;padding:7px 10px;border-radius:7px;background:#082226d9;font:400 20px Georgia,serif}.coordinate-radar strong small{font:700 8px Inter,sans-serif;color:#9eb0ad}.coordinate-radar strong i{width:1px;height:14px;background:#ffffff35}.radar-north{position:absolute;top:3px;font-size:8px;letter-spacing:.15em}.radar-caption{position:absolute;bottom:1px;color:#91a5a1;font-size:8px;text-transform:uppercase;letter-spacing:.12em}@media(max-width:720px){.coordinate-radar{display:none}}
|
||||
@@ -0,0 +1 @@
|
||||
.data-legend{display:grid;grid-template-columns:.7fr 1.3fr;gap:60px;margin-bottom:100px;padding-top:42px;border-top:1px solid #cbd6ce}.data-legend h2{font:400 42px/.95 Georgia,serif;margin:10px 0 14px}.data-legend__intro p{max-width:440px;color:#526662;line-height:1.55}.data-legend__body{display:grid;grid-template-columns:1fr 1fr;gap:30px}.legend-sources{display:grid;gap:8px}.legend-sources>div{display:flex;align-items:center;justify-content:space-between;gap:15px;padding-bottom:8px;border-bottom:1px solid #d8e0da}.legend-sources>div>span{color:#687976;font-size:10px;text-align:right}.legend-quality{display:grid;align-content:start;gap:9px}.legend-quality>div{display:grid;grid-template-columns:30px 1fr;gap:10px;padding:10px;border-radius:10px;background:#fff}.legend-quality i{width:28px;height:28px;display:grid;place-items:center;border-radius:50%;background:#e2efcf;color:#49651f;font-style:normal;font-weight:800}.legend-quality i[data-quality="unverified"]{background:#e8e2f4;color:#59438d}.legend-quality i[data-quality="incomplete"]{background:#f5e6b8;color:#755609}.legend-quality p{display:flex;flex-direction:column;margin:0}.legend-quality strong{font-size:12px}.legend-quality span{margin-top:3px;color:#687976;font-size:10px;line-height:1.35}@media(max-width:900px){.data-legend{grid-template-columns:1fr;gap:25px}.data-legend__body{grid-template-columns:1fr 1fr}}@media(max-width:620px){.data-legend__body{grid-template-columns:1fr}}
|
||||
@@ -0,0 +1 @@
|
||||
.fish-silhouette{fill:currentColor;color:#315f63;pointer-events:none}.fish-silhouette .fish-eye{fill:var(--paper)}.spot-main{position:relative}.spot-main>.fish-silhouette{position:absolute;right:2px;top:38px;opacity:.075}.catalog-grid>a{position:relative;overflow:hidden}.catalog-grid>a>.fish-silhouette{position:absolute;right:34px;bottom:-3px;opacity:.07;transform:rotate(-4deg)}@media(max-width:720px){.spot-main>.fish-silhouette{width:68px;right:0;opacity:.055}}
|
||||
+4
-4
@@ -124,11 +124,11 @@
|
||||
### Графические элементы и объяснение данных
|
||||
|
||||
- [x] Подготовить фирменный знак «крючок + поплавок + координатная сетка» и согласованный набор favicon/app icons (7 сентября 2026).
|
||||
- [ ] Добавить координатный радар или мини-карту на страницу точки.
|
||||
- [ ] Добавить временную шкалу активности 24/72 часа в рыболовной стилистике.
|
||||
- [ ] Добавить лёгкие силуэты видов рыб, не перегружая карточки результатов.
|
||||
- [x] Добавить честный координатный радар без имитации отсутствующей карты на страницу точки (7 сентября 2026).
|
||||
- [x] Добавить временную шкалу активности 72 часа с 12-часовым шагом в виде лески с поплавками (7 сентября 2026).
|
||||
- [x] Добавить лёгкие детерминированные SVG-силуэты видов рыб в карточки результатов и каталог (7 сентября 2026).
|
||||
- [x] Собрать единый визуальный «паспорт данных»: источник, свежесть, полнота и доверие; применить к карточкам активности (7 сентября 2026).
|
||||
- [ ] Добавить публичную легенду цветов источников и статусов качества.
|
||||
- [x] Добавить публичную легенду цветов источников и статусов качества на странице `/status` (7 сентября 2026).
|
||||
- [ ] Добавить skeleton-состояния для динамических публичных блоков.
|
||||
- [ ] Добавить ненавязчивый баннер открытой альфы и канал обратной связи.
|
||||
- [ ] Дополнить пустые состояния собственными рыболовными иллюстрациями с поддержкой `prefers-reduced-motion`.
|
||||
|
||||
Reference in New Issue
Block a user