feat: establish public SEO foundation
This commit is contained in:
@@ -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 sortLabel = { activity: "Сначала активные", confidence: "Сначала надёжные", freshness: "Сначала свежие" }[sort] ?? sort;
|
||||
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="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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
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>
|
||||
<p>RF4 Spotter собирает только сведения, необходимые для публикации и проверки рыболовных наблюдений, защиты от злоупотреблений и работы сервиса.</p>
|
||||
<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; }
|
||||
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>
|
||||
<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>
|
||||
|
||||
@@ -6,7 +6,7 @@ try { [fishes, waterbodies] = await Promise.all([api<DictionaryItem[]>("/api/v1/
|
||||
const state = Astro.url.searchParams.get("state");
|
||||
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>
|
||||
{state === "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";
|
||||
---
|
||||
<Layout title="Правила открытой альфы — RF4 Spotter">
|
||||
<Layout title="Правила открытой альфы — RF4 Spotter" description="Правила отправки, проверки, атрибуции и удаления пользовательских наблюдений в RF4 Spotter.">
|
||||
<article class="legal-page"><span class="eyebrow">Открытая альфа · 7 сентября 2026</span><h1>Правила участия</h1>
|
||||
<p>RF4 Spotter — неофициальный экспериментальный сервис сообщества. Данные могут быть неполными или устаревшими и не гарантируют результат в игре.</p>
|
||||
<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;
|
||||
} catch { unavailable = true; }
|
||||
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>
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user