feat: establish public SEO foundation
This commit is contained in:
@@ -12,6 +12,8 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
|
|||||||
|
|
||||||
На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва.
|
На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва.
|
||||||
|
|
||||||
|
Базовый SEO-контур готов для `rf4spotter.ru`: страницы имеют уникальные метаданные, canonical, Open Graph/Twitter Card и JSON-LD; доступны динамические `/robots.txt` и `/sitemap.xml`, административные и ошибочные страницы закрыты от индексации, добавлена собственная страница 404. Фирменное OG-изображение и индексируемые страницы рыб/водоёмов остаются следующими пакетами в roadmap.
|
||||||
|
|
||||||
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md).
|
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md).
|
||||||
|
|
||||||
Production-контур для домена `rf4spotter.ru`, TLS, секреты, backup/restore и команды первого запуска описаны в [`deploy/README.md`](deploy/README.md). Он использует отдельный `compose.production.yaml`; локальный `compose.yaml` остаётся средой разработки. Production seed добавляет только справочники — демонстрационные уловы отключены. Изолированные проверки `deploy/test-production-bootstrap.sh` и `deploy/test-backup-restore.sh` подтверждают старт с пустых volumes и восстановление данных.
|
Production-контур для домена `rf4spotter.ru`, TLS, секреты, backup/restore и команды первого запуска описаны в [`deploy/README.md`](deploy/README.md). Он использует отдельный `compose.production.yaml`; локальный `compose.yaml` остаётся средой разработки. Production seed добавляет только справочники — демонстрационные уловы отключены. Изолированные проверки `deploy/test-production-bootstrap.sh` и `deploy/test-backup-restore.sh` подтверждают старт с пустых volumes и восстановление данных.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineConfig } from "astro/config";
|
|||||||
import node from "@astrojs/node";
|
import node from "@astrojs/node";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
site: process.env.PUBLIC_SITE_URL || "https://rf4spotter.ru",
|
||||||
output: "server",
|
output: "server",
|
||||||
adapter: node({ mode: "standalone" }),
|
adapter: node({ mode: "standalone" }),
|
||||||
server: { host: true, port: 4321 },
|
server: { host: true, port: 4321 },
|
||||||
|
|||||||
@@ -1,12 +1,47 @@
|
|||||||
---
|
---
|
||||||
import "../styles/global.css";
|
import "../styles/global.css";
|
||||||
import FishingIcon from "../components/FishingIcon.astro";
|
import FishingIcon from "../components/FishingIcon.astro";
|
||||||
const { title = "RF4 Spotter" } = Astro.props;
|
const {
|
||||||
|
title = "RF4 Spotter — свежие точки клёва Russian Fishing 4",
|
||||||
|
description = "Свежие точки, уловы и честная оценка активности в Russian Fishing 4 с обязательным указанием источников.",
|
||||||
|
noindex = false,
|
||||||
|
image = "/lake-dawn.png",
|
||||||
|
structuredData = null,
|
||||||
|
} = Astro.props;
|
||||||
const path = Astro.url.pathname;
|
const path = Astro.url.pathname;
|
||||||
|
const siteUrl = import.meta.env.PUBLIC_SITE_URL || "https://rf4spotter.ru";
|
||||||
|
const canonical = new URL(path, siteUrl).toString();
|
||||||
|
const socialImage = new URL(image, siteUrl).toString();
|
||||||
|
const preventIndexing = noindex || path.startsWith("/admin/");
|
||||||
|
const websiteJsonLd = { "@type": "WebSite", name: "RF4 Spotter", url: siteUrl, inLanguage: "ru" };
|
||||||
|
const jsonLd = JSON.stringify({
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@graph": structuredData ? [websiteJsonLd, structuredData] : [websiteJsonLd],
|
||||||
|
}).replaceAll("<", "\\u003c");
|
||||||
---
|
---
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width" /><meta name="description" content="Свежие точки и статистика клёва Russian Fishing 4" /><link rel="icon" href="/favicon.svg" /><title>{title}</title></head>
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<meta name="robots" content={preventIndexing ? "noindex, nofollow" : "index, follow, max-image-preview:large"} />
|
||||||
|
<link rel="canonical" href={canonical} />
|
||||||
|
<link rel="icon" href="/favicon.svg" />
|
||||||
|
<meta property="og:locale" content="ru_RU" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:site_name" content="RF4 Spotter" />
|
||||||
|
<meta property="og:title" content={title} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
<meta property="og:url" content={canonical} />
|
||||||
|
<meta property="og:image" content={socialImage} />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content={title} />
|
||||||
|
<meta name="twitter:description" content={description} />
|
||||||
|
<meta name="twitter:image" content={socialImage} />
|
||||||
|
<script type="application/ld+json" set:html={jsonLd} is:inline />
|
||||||
|
<title>{title}</title>
|
||||||
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<a class="skip-link" href="#main-content">Перейти к содержимому</a>
|
<a class="skip-link" href="#main-content">Перейти к содержимому</a>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
import Layout from "../layouts/Layout.astro";
|
||||||
|
import FishingIcon from "../components/FishingIcon.astro";
|
||||||
|
Astro.response.status = 404;
|
||||||
|
---
|
||||||
|
<Layout title="Страница не найдена — RF4 Spotter" description="Такой страницы нет. Вернитесь к свежим точкам клёва Russian Fishing 4." noindex={true}>
|
||||||
|
<section class="not-found content-grid"><div class="not-found__float"><FishingIcon name="float" size={54}/><strong>404</strong></div><span class="overline">Сошла с крючка</span><h1>Эта страница<br/><em>не клюнула.</em></h1><p>Возможно, ссылка устарела или такой точки больше нет. Свежие наблюдения уже ждут на главной.</p><a href="/">Вернуться к горячим точкам <FishingIcon name="arrow" size={18}/></a></section>
|
||||||
|
</Layout>
|
||||||
@@ -26,8 +26,15 @@ const selectedFish = fishes.find(item => item.slug === fish)?.name_ru ?? "Люб
|
|||||||
const periodLabel = { "6": "6 часов", "12": "12 часов", "24": "24 часа", "72": "72 часа" }[hours] ?? hours;
|
const periodLabel = { "6": "6 часов", "12": "12 часов", "24": "24 часа", "72": "72 часа" }[hours] ?? hours;
|
||||||
const sortLabel = { activity: "Сначала активные", confidence: "Сначала надёжные", freshness: "Сначала свежие" }[sort] ?? sort;
|
const sortLabel = { activity: "Сначала активные", confidence: "Сначала надёжные", freshness: "Сначала свежие" }[sort] ?? sort;
|
||||||
const filtersChanged = Boolean(waterbody || fish || hours !== "24" || sort !== "activity");
|
const filtersChanged = Boolean(waterbody || fish || hours !== "24" || sort !== "activity");
|
||||||
|
const datasetJsonLd = {
|
||||||
|
"@context": "https://schema.org", "@type": "Dataset",
|
||||||
|
name: "RF4 Spotter — наблюдения об активности рыбы",
|
||||||
|
description: "Агрегированные и исходные наблюдения по точкам ловли в Russian Fishing 4 с указанием происхождения и полноты данных.",
|
||||||
|
url: "https://rf4spotter.ru/", inLanguage: "ru",
|
||||||
|
creator: { "@type": "Organization", name: "RF4 Spotter" }, isAccessibleForFree: true,
|
||||||
|
};
|
||||||
---
|
---
|
||||||
<Layout title="Что клюёт сейчас — RF4 Spotter">
|
<Layout title="Что клюёт сейчас в Russian Fishing 4 — RF4 Spotter" description="Свежие точки клёва RF4, рабочие приманки, вес уловов и прозрачная оценка данных с указанием каждого источника." structuredData={datasetJsonLd}>
|
||||||
<section class="intro content-grid"><div class="intro-copy"><span class="eyebrow"><b>RF4</b> Живая карта клёва</span><h1>Выбирай место,<br/><em>пока клюёт.</em></h1><p>Свежие точки, рабочие приманки и честная оценка данных от игроков.</p></div><div class="lake-card"><img src="/lake-dawn.png" alt="Туманное озеро на рассвете"/><div class="lake-overlay"><div><span class="overline">Пульс водоёмов</span><strong>{items.length} {plural(items.length, ["точка показывает", "точки показывают", "точек показывают"])} активность</strong></div><div class="pulse-orb"><span></span></div></div></div></section>
|
<section class="intro content-grid"><div class="intro-copy"><span class="eyebrow"><b>RF4</b> Живая карта клёва</span><h1>Выбирай место,<br/><em>пока клюёт.</em></h1><p>Свежие точки, рабочие приманки и честная оценка данных от игроков.</p></div><div class="lake-card"><img src="/lake-dawn.png" alt="Туманное озеро на рассвете"/><div class="lake-overlay"><div><span class="overline">Пульс водоёмов</span><strong>{items.length} {plural(items.length, ["точка показывает", "точки показывают", "точек показывают"])} активность</strong></div><div class="pulse-orb"><span></span></div></div></div></section>
|
||||||
<section class="filters-wrap"><form class="filters content-grid" method="get" action="/#results">
|
<section class="filters-wrap"><form class="filters content-grid" method="get" action="/#results">
|
||||||
<label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(x => <option value={x.slug} selected={waterbody === x.slug}>{x.name_ru}</option>)}</select></label>
|
<label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(x => <option value={x.slug} selected={waterbody === x.slug}>{x.name_ru}</option>)}</select></label>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../layouts/Layout.astro";
|
import Layout from "../layouts/Layout.astro";
|
||||||
---
|
---
|
||||||
<Layout title="Конфиденциальность — RF4 Spotter">
|
<Layout title="Политика конфиденциальности — RF4 Spotter" description="Как RF4 Spotter обрабатывает и удаляет данные пользовательских наблюдений, ники и скриншоты.">
|
||||||
<article class="legal-page"><span class="eyebrow">Открытая альфа · 7 сентября 2026</span><h1>Конфиденциальность</h1>
|
<article class="legal-page"><span class="eyebrow">Открытая альфа · 7 сентября 2026</span><h1>Конфиденциальность</h1>
|
||||||
<p>RF4 Spotter собирает только сведения, необходимые для публикации и проверки рыболовных наблюдений, защиты от злоупотреблений и работы сервиса.</p>
|
<p>RF4 Spotter собирает только сведения, необходимые для публикации и проверки рыболовных наблюдений, защиты от злоупотреблений и работы сервиса.</p>
|
||||||
<h2>Что обрабатывается</h2><ul><li>рыба, водоём, координаты, вес и необязательные сведения об улове;</li><li>необязательные ник и скриншот;</li><li>HMAC-отпечаток адреса для ограничения частоты — исходный IP не хранится;</li><li>источник, внешний идентификатор и URL импортированных наблюдений.</li></ul>
|
<h2>Что обрабатывается</h2><ul><li>рыба, водоём, координаты, вес и необязательные сведения об улове;</li><li>необязательные ник и скриншот;</li><li>HMAC-отпечаток адреса для ограничения частоты — исходный IP не хранится;</li><li>источник, внешний идентификатор и URL импортированных наблюдений.</li></ul>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ let records: OfficialRecord[] = [], runs: ImportRun[] = [], fishes: DictionaryIt
|
|||||||
try { [records, runs, fishes, waterbodies] = await Promise.all([api<OfficialRecord[]>(`/api/v1/records?${new URLSearchParams({ fish, waterbody })}`), api<ImportRun[]>("/api/v1/imports?limit=1"), api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]); } catch { unavailable = true; }
|
try { [records, runs, fishes, waterbodies] = await Promise.all([api<OfficialRecord[]>(`/api/v1/records?${new URLSearchParams({ fish, waterbody })}`), api<ImportRun[]>("/api/v1/imports?limit=1"), api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]); } catch { unavailable = true; }
|
||||||
const last = runs[0];
|
const last = runs[0];
|
||||||
---
|
---
|
||||||
<Layout title="Официальные рекорды — RF4 Spotter">
|
<Layout title="Официальные рекорды Russian Fishing 4 — RF4 Spotter" description="Последние официальные рекорды RF4 по рыбам и водоёмам: вес, приманка, игрок, дата и прямая ссылка на источник.">
|
||||||
<section class="records-hero"><div><span class="eyebrow">Публичные данные RF4</span><h1>Официальные<br/><em>рекорды</em></h1></div><div class="source-status"><span class:list={["status-dot", last?.status]}></span><strong>{last ? `Импорт: ${last.status}` : "Импорт ещё не запускался"}</strong>{last?.finished_at && <small>{new Date(last.finished_at).toLocaleString("ru-RU")} · {last.rows_seen} строк</small>}</div></section>
|
<section class="records-hero"><div><span class="eyebrow">Публичные данные RF4</span><h1>Официальные<br/><em>рекорды</em></h1></div><div class="source-status"><span class:list={["status-dot", last?.status]}></span><strong>{last ? `Импорт: ${last.status}` : "Импорт ещё не запускался"}</strong>{last?.finished_at && <small>{new Date(last.finished_at).toLocaleString("ru-RU")} · {last.rows_seen} строк</small>}</div></section>
|
||||||
<form class="record-filters" method="get"><label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(item => <option value={item.slug} selected={fish === item.slug}>{item.name_ru}</option>)}</select></label><label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(item => <option value={item.slug} selected={waterbody === item.slug}>{item.name_ru}</option>)}</select></label><button>Фильтровать</button>{(fish || waterbody) && <a href="/records">Сбросить</a>}</form>
|
<form class="record-filters" method="get"><label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(item => <option value={item.slug} selected={fish === item.slug}>{item.name_ru}</option>)}</select></label><label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(item => <option value={item.slug} selected={waterbody === item.slug}>{item.name_ru}</option>)}</select></label><button>Фильтровать</button>{(fish || waterbody) && <a href="/records">Сбросить</a>}</form>
|
||||||
<div class="section-heading content-grid"><div><span class="overline">Официальный источник</span><h2>Последние записи</h2></div><span class="result-count">{records.length} показано</span></div>
|
<div class="section-heading content-grid"><div><span class="overline">Официальный источник</span><h2>Последние записи</h2></div><span class="result-count">{records.length} показано</span></div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ try { [fishes, waterbodies] = await Promise.all([api<DictionaryItem[]>("/api/v1/
|
|||||||
const state = Astro.url.searchParams.get("state");
|
const state = Astro.url.searchParams.get("state");
|
||||||
const reportId = Astro.url.searchParams.get("report_id");
|
const reportId = Astro.url.searchParams.get("report_id");
|
||||||
---
|
---
|
||||||
<Layout title="Добавить улов — RF4 Spotter">
|
<Layout title="Добавить улов Russian Fishing 4 — RF4 Spotter" description="Отправьте собственное наблюдение об улове RF4 на проверку и помогите игрокам находить актуальные точки.">
|
||||||
<section class="form-hero"><div><span class="eyebrow"><b>+1</b> Помочь сообществу</span><h1>Добавить<br/><em>свой улов</em></h1></div><p>Около минуты — и рабочая точка появится в общей статистике после проверки модератором.</p></section>
|
<section class="form-hero"><div><span class="eyebrow"><b>+1</b> Помочь сообществу</span><h1>Добавить<br/><em>свой улов</em></h1></div><p>Около минуты — и рабочая точка появится в общей статистике после проверки модератором.</p></section>
|
||||||
{state === "sent" && <div class="notice success">Улов отправлен на модерацию. Спасибо!</div>}
|
{state === "sent" && <div class="notice success">Улов отправлен на модерацию. Спасибо!</div>}
|
||||||
{state === "screenshot_sent" && <div class="notice success">Скриншот добавлен к ранее созданной заявке.</div>}
|
{state === "screenshot_sent" && <div class="notice success">Скриншот добавлен к ранее созданной заявке.</div>}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
|
||||||
|
export const GET: APIRoute = ({ site }) => {
|
||||||
|
const origin = site?.origin ?? import.meta.env.PUBLIC_SITE_URL ?? "https://rf4spotter.ru";
|
||||||
|
const body = [
|
||||||
|
"User-agent: *",
|
||||||
|
"Allow: /",
|
||||||
|
"Disallow: /admin/",
|
||||||
|
"Disallow: /api/",
|
||||||
|
"Disallow: /health",
|
||||||
|
"Disallow: /ready",
|
||||||
|
`Sitemap: ${origin}/sitemap.xml`,
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
return new Response(body, { headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "public, max-age=3600" } });
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../layouts/Layout.astro";
|
import Layout from "../layouts/Layout.astro";
|
||||||
---
|
---
|
||||||
<Layout title="Правила открытой альфы — RF4 Spotter">
|
<Layout title="Правила открытой альфы — RF4 Spotter" description="Правила отправки, проверки, атрибуции и удаления пользовательских наблюдений в RF4 Spotter.">
|
||||||
<article class="legal-page"><span class="eyebrow">Открытая альфа · 7 сентября 2026</span><h1>Правила участия</h1>
|
<article class="legal-page"><span class="eyebrow">Открытая альфа · 7 сентября 2026</span><h1>Правила участия</h1>
|
||||||
<p>RF4 Spotter — неофициальный экспериментальный сервис сообщества. Данные могут быть неполными или устаревшими и не гарантируют результат в игре.</p>
|
<p>RF4 Spotter — неофициальный экспериментальный сервис сообщества. Данные могут быть неполными или устаревшими и не гарантируют результат в игре.</p>
|
||||||
<h2>Можно</h2><ul><li>отправлять собственные достоверные наблюдения из Russian Fishing 4;</li><li>указывать необязательный ник и прикладывать свой скриншот;</li><li>сообщать об ошибках, устаревших точках и злоупотреблениях.</li></ul>
|
<h2>Можно</h2><ul><li>отправлять собственные достоверные наблюдения из Russian Fishing 4;</li><li>указывать необязательный ник и прикладывать свой скриншот;</li><li>сообщать об ошибках, устаревших точках и злоупотреблениях.</li></ul>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { api, type Activity } from "../lib/api";
|
||||||
|
|
||||||
|
const escapeXml = (value: string) => value.replace(/[<>&'\"]/g, character => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ })[character] ?? character);
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ site }) => {
|
||||||
|
const origin = site?.origin ?? import.meta.env.PUBLIC_SITE_URL ?? "https://rf4spotter.ru";
|
||||||
|
const paths = new Set(["/", "/records", "/report", "/rules", "/privacy"]);
|
||||||
|
try {
|
||||||
|
const activity = await api<Activity[]>("/api/v1/activity?hours=72&limit=100");
|
||||||
|
activity.forEach(item => paths.add(`/spots/${item.spot_id}`));
|
||||||
|
} catch {
|
||||||
|
// A temporary API outage must not make the static part of the sitemap unavailable.
|
||||||
|
}
|
||||||
|
const urls = [...paths].map(path => `<url><loc>${escapeXml(new URL(path, origin).toString())}</loc></url>`).join("");
|
||||||
|
return new Response(`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`, {
|
||||||
|
headers: { "Content-Type": "application/xml; charset=utf-8", "Cache-Control": "public, max-age=1800" },
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -9,8 +9,13 @@ try {
|
|||||||
spot = spotResult; catches = catchResult; activity = activityRows.find(item => item.spot_id === id) ?? null;
|
spot = spotResult; catches = catchResult; activity = activityRows.find(item => item.spot_id === id) ?? null;
|
||||||
} catch { unavailable = true; }
|
} catch { unavailable = true; }
|
||||||
const level = activity ? activityLevel(activity.activity_score) : null;
|
const level = activity ? activityLevel(activity.activity_score) : null;
|
||||||
|
const spotDescription = spot ? `Свежие уловы и активность на точке ${spot.x}:${spot.y}, ${spot.waterbody}: рыба, вес, приманки и источники данных.` : "Данные точки ловли Russian Fishing 4.";
|
||||||
|
const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [
|
||||||
|
{ "@type": "ListItem", position: 1, name: "Сейчас клюёт", item: "https://rf4spotter.ru/" },
|
||||||
|
{ "@type": "ListItem", position: 2, name: `${spot.waterbody} ${spot.x}:${spot.y}`, item: `https://rf4spotter.ru/spots/${id}` },
|
||||||
|
] } : null;
|
||||||
---
|
---
|
||||||
<Layout title={spot ? `${spot.waterbody} ${spot.x}:${spot.y} — RF4 Spotter` : "Точка — RF4 Spotter"}>
|
<Layout title={spot ? `Точка ${spot.x}:${spot.y}, ${spot.waterbody} — RF4 Spotter` : "Точка не найдена — RF4 Spotter"} description={spotDescription} noindex={!spot} structuredData={breadcrumbs}>
|
||||||
<a class="back" href="/">← Все активные точки</a>
|
<a class="back" href="/">← Все активные точки</a>
|
||||||
{unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
|
{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><div class="pin">{spot.x}<span>:</span>{spot.y}</div></section>
|
||||||
|
|||||||
@@ -34,3 +34,4 @@ footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32
|
|||||||
.signal-section{padding:0 0 100px}.signal-heading{display:flex;align-items:end;justify-content:space-between;gap:35px;padding-top:68px;border-top:1px solid #cbd6ce;margin-bottom:25px}.signal-heading h2{font:400 46px/.95 Georgia,serif;letter-spacing:-.045em;margin:8px 0 10px}.signal-heading p{max-width:700px;color:#4d605d;line-height:1.55;margin:0}.signal-count{padding:7px 12px;border:1px solid #c2cec5;border-radius:999px;color:#526662;font-size:12px;white-space:nowrap}.signal-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:13px}.signal-card{position:relative;overflow:hidden;padding:20px;border:1px solid #d6cda9;border-radius:15px;background:linear-gradient(145deg,#fffdf5,#f8f5e9);box-shadow:0 10px 25px #443d1a0a}.signal-card:after{content:"";position:absolute;width:80px;height:80px;right:-40px;bottom:-40px;border:1px solid #bdac6b55;border-radius:50%;box-shadow:0 0 0 13px #bdac6b14}.signal-card__top{display:flex;justify-content:space-between;align-items:center;gap:8px}.quality-chip{display:inline-flex;align-items:center;gap:5px;color:#76570c;font-size:10px;font-weight:750}.quality-chip i{width:18px;height:18px;display:grid;place-items:center;border-radius:50%;background:#f2d77e;font-style:normal}.signal-card h3{font:400 24px Georgia,serif;margin:17px 0 3px}.signal-card>p{margin:0;color:#576966;font-size:13px}.signal-card dl{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:18px 0}.signal-card dl div{padding-top:9px;border-top:1px solid #d9d3b9}.signal-card dt{color:#776f56;font-size:9px;text-transform:uppercase;letter-spacing:.08em}.signal-card dd{margin:4px 0 0;font:400 14px Georgia,serif}.signal-card .missing-note{position:relative;z-index:1;padding:9px 11px;border-radius:8px;background:#efe5bd;color:#6b550f;font-size:11px}.missing-note span{font-weight:800}
|
.signal-section{padding:0 0 100px}.signal-heading{display:flex;align-items:end;justify-content:space-between;gap:35px;padding-top:68px;border-top:1px solid #cbd6ce;margin-bottom:25px}.signal-heading h2{font:400 46px/.95 Georgia,serif;letter-spacing:-.045em;margin:8px 0 10px}.signal-heading p{max-width:700px;color:#4d605d;line-height:1.55;margin:0}.signal-count{padding:7px 12px;border:1px solid #c2cec5;border-radius:999px;color:#526662;font-size:12px;white-space:nowrap}.signal-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:13px}.signal-card{position:relative;overflow:hidden;padding:20px;border:1px solid #d6cda9;border-radius:15px;background:linear-gradient(145deg,#fffdf5,#f8f5e9);box-shadow:0 10px 25px #443d1a0a}.signal-card:after{content:"";position:absolute;width:80px;height:80px;right:-40px;bottom:-40px;border:1px solid #bdac6b55;border-radius:50%;box-shadow:0 0 0 13px #bdac6b14}.signal-card__top{display:flex;justify-content:space-between;align-items:center;gap:8px}.quality-chip{display:inline-flex;align-items:center;gap:5px;color:#76570c;font-size:10px;font-weight:750}.quality-chip i{width:18px;height:18px;display:grid;place-items:center;border-radius:50%;background:#f2d77e;font-style:normal}.signal-card h3{font:400 24px Georgia,serif;margin:17px 0 3px}.signal-card>p{margin:0;color:#576966;font-size:13px}.signal-card dl{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:18px 0}.signal-card dl div{padding-top:9px;border-top:1px solid #d9d3b9}.signal-card dt{color:#776f56;font-size:9px;text-transform:uppercase;letter-spacing:.08em}.signal-card dd{margin:4px 0 0;font:400 14px Georgia,serif}.signal-card .missing-note{position:relative;z-index:1;padding:9px 11px;border-radius:8px;background:#efe5bd;color:#6b550f;font-size:11px}.missing-note span{font-weight:800}
|
||||||
@media(max-width:1040px){.signal-grid{grid-template-columns:repeat(2,1fr)}}
|
@media(max-width:1040px){.signal-grid{grid-template-columns:repeat(2,1fr)}}
|
||||||
@media(max-width:720px){.signal-heading{display:block;padding-top:45px}.signal-count{display:inline-block;margin-top:14px}.signal-grid{grid-template-columns:1fr}.signal-card__top{align-items:flex-start}.source-strip{margin-bottom:4px}}
|
@media(max-width:720px){.signal-heading{display:block;padding-top:45px}.signal-count{display:inline-block;margin-top:14px}.signal-grid{grid-template-columns:1fr}.signal-card__top{align-items:flex-start}.source-strip{margin-bottom:4px}}
|
||||||
|
.not-found{min-height:calc(100vh - 204px);display:flex;flex-direction:column;align-items:flex-start;justify-content:center;padding-block:70px}.not-found__float{width:130px;height:130px;display:grid;place-items:center;position:relative;margin-bottom:28px;border:1px solid #b8c8be;border-radius:50%;color:#55751e;box-shadow:0 0 0 16px #dce7d722}.not-found__float strong{position:absolute;right:-18px;bottom:5px;padding:5px 9px;border-radius:20px;background:var(--deep);color:var(--lime);font:italic 14px Georgia,serif}.not-found h1{margin:14px 0 22px;font:400 clamp(58px,8vw,105px)/.88 Georgia,serif;letter-spacing:-.06em}.not-found h1 em{color:#497074;font-weight:400}.not-found p{max-width:600px;color:#4d605d;font-size:17px;line-height:1.6}.not-found>a{display:flex;align-items:center;gap:10px;margin-top:18px;padding:14px 18px;border-radius:10px;background:var(--deep);color:#fff;font-weight:750;text-decoration:none}.not-found>a .fish-icon{color:var(--lime)}
|
||||||
|
|||||||
+58
-4
@@ -107,6 +107,58 @@
|
|||||||
- [ ] Определить процедуру повторной проверки опубликованного внешнего улова при изменении или удалении записи у источника.
|
- [ ] Определить процедуру повторной проверки опубликованного внешнего улова при изменении или удалении записи у источника.
|
||||||
- [ ] Согласовать один добровольный канал сообщества и правила происхождения, модерации и удаления сообщений.
|
- [ ] Согласовать один добровольный канал сообщества и правила происхождения, модерации и удаления сообщений.
|
||||||
|
|
||||||
|
## Pre-deploy: SEO, визуальный язык и техническая полировка
|
||||||
|
|
||||||
|
### SEO и структура публичного сайта
|
||||||
|
|
||||||
|
- [x] Добавить единый SEO-контракт страниц: уникальные `title`/`description`, canonical URL, Open Graph и Twitter Card (7 сентября 2026).
|
||||||
|
- [ ] Подготовить фирменное OG-изображение 1200×630 и проверить превью ссылок.
|
||||||
|
- [x] Добавить production-aware `robots.txt`, исключить `/admin`, `/api`, `/health` и `/ready` из индексации (7 сентября 2026).
|
||||||
|
- [x] Добавить динамический `sitemap.xml` для существующих статических страниц и публичных точек; страницы рыб и водоёмов включить после реализации их маршрутов (7 сентября 2026).
|
||||||
|
- [x] Добавить JSON-LD: `WebSite`, `Dataset` и `BreadcrumbList` на соответствующих страницах (7 сентября 2026).
|
||||||
|
- [x] Сделать собственную полезную страницу 404 с возвратом к свежим точкам (7 сентября 2026).
|
||||||
|
- [x] Явно отдавать `noindex` на административных страницах и состояниях ошибок (7 сентября 2026).
|
||||||
|
- [ ] Добавить человекочитаемые постоянные URL точек без поломки существующих UUID-ссылок.
|
||||||
|
- [ ] Создать индексируемые страницы `/fish/{slug}`, `/waterbodies/{slug}` и сочетания водоём + рыба с уникальным содержимым.
|
||||||
|
|
||||||
|
### Графические элементы и объяснение данных
|
||||||
|
|
||||||
|
- [ ] Подготовить фирменный знак «крючок + поплавок + координатная сетка» и согласованный набор favicon/app icons.
|
||||||
|
- [ ] Добавить координатный радар или мини-карту на страницу точки.
|
||||||
|
- [ ] Добавить временную шкалу активности 24/72 часа в рыболовной стилистике.
|
||||||
|
- [ ] Добавить лёгкие силуэты видов рыб, не перегружая карточки результатов.
|
||||||
|
- [ ] Собрать единый визуальный «паспорт данных»: источник, свежесть, полнота и доверие.
|
||||||
|
- [ ] Добавить публичную легенду цветов источников и статусов качества.
|
||||||
|
- [ ] Добавить skeleton-состояния для динамических публичных блоков.
|
||||||
|
- [ ] Добавить ненавязчивый баннер открытой альфы и канал обратной связи.
|
||||||
|
- [ ] Дополнить пустые состояния собственными рыболовными иллюстрациями с поддержкой `prefers-reduced-motion`.
|
||||||
|
|
||||||
|
### Производительность, данные и эксплуатация
|
||||||
|
|
||||||
|
- [ ] Добавить управляемый scheduler всех разрешённых community-парсеров с cooldown ≥30 минут, PostgreSQL lock, backoff и журналом запусков.
|
||||||
|
- [ ] Показывать публичное безопасное состояние источников: актуален, устарел, временно ограничен или изменился DOM.
|
||||||
|
- [ ] Добавить короткое серверное кэширование публичных GET API и проверить корректную инвалидацию после публикации.
|
||||||
|
- [ ] Настроить долгий immutable cache для хешированных assets и разумный cache для изображений/favicon.
|
||||||
|
- [ ] Добавить пагинацию или «Показать ещё» для публичной ленты полевых сигналов.
|
||||||
|
- [ ] Ограничить визуальные повторы одинаковых сигналов без потери provenance.
|
||||||
|
- [ ] Добавить безопасный административный экспорт диагностики без персональных данных.
|
||||||
|
- [ ] Контролировать рост PostgreSQL и MinIO с порогами предупреждений.
|
||||||
|
- [ ] Добавить фоновую проверку битых исходных ссылок с соблюдением лимитов источников.
|
||||||
|
- [ ] Показывать commit SHA/версию в readiness и административной диагностике.
|
||||||
|
- [ ] Добавить публичную `/status` без внутренних адресов, секретов и лишних деталей.
|
||||||
|
- [ ] После появления сервера подключить privacy-friendly аналитику без cookies либо собственные агрегированные счётчики.
|
||||||
|
- [ ] Зафиксировать нагрузочный бюджет и проверить p95 публичных API на целевом сервере.
|
||||||
|
|
||||||
|
### Обязательные внешние условия открытия альфы
|
||||||
|
|
||||||
|
- [ ] Заменить все демонстрационные production-секреты.
|
||||||
|
- [ ] Настроить DNS `rf4spotter.ru` и `files.rf4spotter.ru`, корректный TLS и закрыть внутренние порты.
|
||||||
|
- [ ] Создать контакты `privacy`/`abuse` и определить срок реакции.
|
||||||
|
- [ ] Приложить первичные документы разрешений и точные продуктивные лимиты источников.
|
||||||
|
- [ ] Настроить внешнее хранение резервных копий и проверить восстановление с сервера.
|
||||||
|
- [ ] Подключить реальный канал уведомлений мониторинга.
|
||||||
|
- [ ] Наполнить альфу разрешённым набором реальных данных и выполнить финальный `preflight`.
|
||||||
|
|
||||||
## Этап 5 — пилот
|
## Этап 5 — пилот
|
||||||
|
|
||||||
- [x] Опубликовать страницы `/rules` и `/privacy`, добавить ссылки в footer и обязательное несохраняемое согласие перед отправкой улова.
|
- [x] Опубликовать страницы `/rules` и `/privacy`, добавить ссылки в footer и обязательное несохраняемое согласие перед отправкой улова.
|
||||||
@@ -122,11 +174,13 @@
|
|||||||
|
|
||||||
## Ближайший рабочий пакет
|
## Ближайший рабочий пакет
|
||||||
|
|
||||||
Технический production-контур, health/readiness, backup/restore и безопасные логи готовы. Следующие пункты выполняются строго по одному:
|
Технический production-контур, health/readiness, backup/restore и безопасные логи готовы. Ближайшие пункты выполняются небольшими проверяемыми пакетами:
|
||||||
|
|
||||||
1. наполнить полный разрешённый каталог; проверка целостности `audit-catalog` уже готова;
|
1. базовый SEO-контракт, `robots.txt`, sitemap, structured data и 404;
|
||||||
2. создать и проверить контакты `privacy`/`abuse`;
|
2. индексируемые страницы рыб и водоёмов;
|
||||||
3. мониторинг, нагрузка, DNS/TLS и production-профиль на целевом сервере.
|
3. community scheduler и наблюдаемость источников;
|
||||||
|
4. графический паспорт данных, OG-изображение и легенда;
|
||||||
|
5. внешние контакты, мониторинг, нагрузка, DNS/TLS и production-профиль на целевом сервере.
|
||||||
|
|
||||||
После каждого пункта необходимо:
|
После каждого пункта необходимо:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user