feat: add searchable fish and waterbody pages

This commit is contained in:
ik
2026-09-07 16:39:28 +07:00
parent 7e60daed6f
commit 233c5887eb
10 changed files with 91 additions and 5 deletions
+2 -1
View File
@@ -1,5 +1,6 @@
---
import "../styles/global.css";
import "../styles/catalog.css";
import FishingIcon from "../components/FishingIcon.astro";
const {
title = "RF4 Spotter — свежие точки клёва Russian Fishing 4",
@@ -51,7 +52,7 @@ const jsonLd = JSON.stringify({
<a class="skip-link" href="#main-content">Перейти к содержимому</a>
<header class="topbar">
<a href="/" class="brand" aria-label="Ни хвоста, ни чешуи"><span class="brand-mark" aria-hidden="true"><FishingIcon name="hook" size={24}/></span><span class="brand-name"><span>Ни хвоста,</span><strong>ни чешуи</strong></span></a>
<nav aria-label="Разделы сайта"><a class:list={{active:path === "/"}} aria-current={path === "/" ? "page" : undefined} href="/"><FishingIcon name="float"/> <span>Сейчас клюёт</span></a><a class:list={{active:path.startsWith("/records")}} aria-current={path.startsWith("/records") ? "page" : undefined} href="/records"><FishingIcon name="trophy"/> <span>Рекорды</span></a><a class:list={{active:path.startsWith("/report")}} aria-current={path.startsWith("/report") ? "page" : undefined} href="/report"><FishingIcon name="plus"/> <span>Добавить улов</span></a></nav>
<nav aria-label="Разделы сайта"><a class:list={{active:path === "/"}} aria-current={path === "/" ? "page" : undefined} href="/"><FishingIcon name="float"/> <span>Сейчас клюёт</span></a><a class:list={{active:path.startsWith("/waterbodies") || path.startsWith("/fish")}} aria-current={path.startsWith("/waterbodies") || path.startsWith("/fish") ? "page" : undefined} href="/waterbodies"><FishingIcon name="ripple"/> <span>Каталог</span></a><a class:list={{active:path.startsWith("/records")}} aria-current={path.startsWith("/records") ? "page" : undefined} href="/records"><FishingIcon name="trophy"/> <span>Рекорды</span></a><a class:list={{active:path.startsWith("/report")}} aria-current={path.startsWith("/report") ? "page" : undefined} href="/report"><FishingIcon name="plus"/> <span>Добавить улов</span></a></nav>
<p class="live-badge"><span></span> Свежие данные и честная оценка</p>
</header>
<main id="main-content" tabindex="-1"><slot /></main>
+20
View File
@@ -0,0 +1,20 @@
---
import ActivityCard from "../../components/ActivityCard.astro";
import Layout from "../../layouts/Layout.astro";
import { api, plural, type Activity, type DictionaryItem } from "../../lib/api";
const { slug } = Astro.params;
let fish: DictionaryItem | undefined, items: Activity[] = [], unavailable = false;
try {
const fishes = await api<DictionaryItem[]>("/api/v1/fishes?limit=500");
fish = fishes.find(item => item.slug === slug);
if (fish) items = await api<Activity[]>(`/api/v1/activity?hours=72&fish=${encodeURIComponent(fish.slug)}&limit=100`);
} catch { unavailable = true; }
if (!fish && !unavailable) Astro.response.status = 404;
const waters = [...new Map(items.map(item => [item.waterbody_slug, item.waterbody])).entries()];
const schema = fish ? { "@context":"https://schema.org", "@type":"CollectionPage", name:`Где ловить ${fish.name_ru} в RF4`, url:`https://rf4spotter.ru/fish/${fish.slug}` } : null;
---
<Layout title={fish ? `Где ловить ${fish.name_ru} в RF4 — свежие точки` : "Рыба не найдена — RF4 Spotter"} description={fish ? `Свежие точки ловли ${fish.name_ru} в Russian Fishing 4: водоёмы, координаты, приманки, активность и источники наблюдений.` : "Такого вида рыбы нет в каталоге RF4 Spotter."} noindex={!fish} structuredData={schema}>
<a class="back" href="/fish">← Все виды рыб</a>
<section class="catalog-hero content-grid"><span class="overline">Свежие данные за 72 часа</span><h1>{fish?.name_ru ?? "Рыба не найдена"}</h1>{fish && <p>{items.length} {plural(items.length,["активная точка","активные точки","активных точек"])} на {waters.length} {plural(waters.length,["водоёме","водоёмах","водоёмах"])}.</p>}</section>
{unavailable ? <div class="state content-grid"><h2>Данные временно недоступны</h2></div> : !fish ? <div class="state content-grid"><h2>Такой рыбы нет в справочнике</h2><a href="/fish">Открыть каталог</a></div> : <section class="catalog-results content-grid"><aside><span class="overline">Водоёмы</span>{waters.length ? <nav>{waters.map(([waterSlug,name]) => <a href={`/waterbodies/${waterSlug}/${fish!.slug}`}>{name}</a>)}</nav> : <p>Свежих подтверждённых водоёмов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <div class="state"><h2>Свежих точек пока нет</h2><p>Проверьте позже или посмотрите полевые сигналы на главной.</p></div>}</div></section>}
</Layout>
+10
View File
@@ -0,0 +1,10 @@
---
import Layout from "../../layouts/Layout.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>}
</Layout>
+6 -2
View File
@@ -1,5 +1,5 @@
import type { APIRoute } from "astro";
import { api, type Activity } from "../lib/api";
import { api, type Activity, type DictionaryItem } from "../lib/api";
const escapeXml = (value: string) => value.replace(/[<>&'\"]/g, character => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", "'": "&apos;", '"': "&quot;" })[character] ?? character);
@@ -7,8 +7,12 @@ 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");
const [activity, fishes, waters] = await Promise.all([api<Activity[]>("/api/v1/activity?hours=72&limit=100"), api<DictionaryItem[]>("/api/v1/fishes?limit=500"), api<DictionaryItem[]>("/api/v1/waterbodies?limit=500")]);
paths.add("/fish"); paths.add("/waterbodies");
fishes.forEach(item => paths.add(`/fish/${item.slug}`));
waters.forEach(item => paths.add(`/waterbodies/${item.slug}`));
activity.forEach(item => paths.add(`/spots/${item.spot_id}`));
activity.forEach(item => paths.add(`/waterbodies/${item.waterbody_slug}/${item.fish_slug}`));
} catch {
// A temporary API outage must not make the static part of the sitemap unavailable.
}
@@ -0,0 +1,20 @@
---
import ActivityCard from "../../components/ActivityCard.astro";
import Layout from "../../layouts/Layout.astro";
import { api, plural, type Activity, type DictionaryItem } from "../../lib/api";
const { slug } = Astro.params;
let water: DictionaryItem | undefined, items: Activity[] = [], unavailable = false;
try {
const waters = await api<DictionaryItem[]>("/api/v1/waterbodies?limit=500");
water = waters.find(item => item.slug === slug);
if (water) items = await api<Activity[]>(`/api/v1/activity?hours=72&waterbody=${encodeURIComponent(water.slug)}&limit=100`);
} catch { unavailable = true; }
if (!water && !unavailable) Astro.response.status = 404;
const fishes = [...new Map(items.map(item => [item.fish_slug, item.fish])).entries()];
const schema = water ? { "@context":"https://schema.org", "@type":"CollectionPage", name:`Что ловить на ${water.name_ru} в RF4`, url:`https://rf4spotter.ru/waterbodies/${water.slug}` } : null;
---
<Layout title={water ? `${water.name_ru} в RF4 — рыба и свежие точки` : "Водоём не найден — RF4 Spotter"} description={water ? `${water.name_ru} в Russian Fishing 4: свежие координаты, активные виды рыб, приманки и источники наблюдений.` : "Такого водоёма нет в каталоге RF4 Spotter."} noindex={!water} structuredData={schema}>
<a class="back" href="/waterbodies">← Все водоёмы</a>
<section class="catalog-hero content-grid"><span class="overline">Свежие данные за 72 часа</span><h1>{water?.name_ru ?? "Водоём не найден"}</h1>{water && <p>{items.length} {plural(items.length,["активная точка","активные точки","активных точек"])} для {fishes.length} {plural(fishes.length,["вида рыбы","видов рыб","видов рыб"])}.</p>}</section>
{unavailable ? <div class="state content-grid"><h2>Данные временно недоступны</h2></div> : !water ? <div class="state content-grid"><h2>Такого водоёма нет в справочнике</h2><a href="/waterbodies">Открыть каталог</a></div> : <section class="catalog-results content-grid"><aside><span class="overline">Рыбы</span>{fishes.length ? <nav>{fishes.map(([fishSlug,name]) => <a href={`/waterbodies/${water!.slug}/${fishSlug}`}>{name}</a>)}</nav> : <p>Свежих подтверждённых видов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <div class="state"><h2>Свежих точек пока нет</h2><p>Проверьте позже или посмотрите полевые сигналы на главной.</p></div>}</div></section>}
</Layout>
@@ -0,0 +1,20 @@
---
import ActivityCard from "../../../components/ActivityCard.astro";
import Layout from "../../../layouts/Layout.astro";
import { api, plural, type Activity, type DictionaryItem } from "../../../lib/api";
const { slug, fish: fishSlug } = Astro.params;
let water: DictionaryItem | undefined, fish: DictionaryItem | undefined, items: Activity[] = [], unavailable = false;
try {
const [waters, fishes] = await Promise.all([api<DictionaryItem[]>("/api/v1/waterbodies?limit=500"), api<DictionaryItem[]>("/api/v1/fishes?limit=500")]);
water = waters.find(item => item.slug === slug); fish = fishes.find(item => item.slug === fishSlug);
if (water && fish) items = await api<Activity[]>(`/api/v1/activity?hours=72&waterbody=${encodeURIComponent(water.slug)}&fish=${encodeURIComponent(fish.slug)}&limit=100`);
} catch { unavailable = true; }
if ((!water || !fish) && !unavailable) Astro.response.status = 404;
const valid = Boolean(water && fish);
const schema = valid ? { "@type":"CollectionPage", name:`${fish!.name_ru} на ${water!.name_ru} в RF4`, url:`https://rf4spotter.ru/waterbodies/${water!.slug}/${fish!.slug}` } : null;
---
<Layout title={valid ? `${fish!.name_ru} на ${water!.name_ru} в RF4 — точки и приманки` : "Страница не найдена — RF4 Spotter"} description={valid ? `Где ловить ${fish!.name_ru} на ${water!.name_ru} в Russian Fishing 4: свежие координаты, приманки, активность и источники.` : "Такого сочетания рыбы и водоёма нет в каталоге."} noindex={!valid} structuredData={schema}>
<a class="back" href={water ? `/waterbodies/${water.slug}` : "/waterbodies"}>← К водоёму</a>
<section class="catalog-hero content-grid"><span class="overline">{water?.name_ru ?? "Каталог RF4"}</span><h1>{fish?.name_ru ?? "Данные не найдены"}</h1>{valid && <p>{items.length} {plural(items.length,["свежая точка","свежие точки","свежих точек"])} за последние 72 часа.</p>}</section>
{unavailable ? <div class="state content-grid"><h2>Данные временно недоступны</h2></div> : !valid ? <div class="state content-grid"><h2>Такого сочетания нет в справочнике</h2><a href="/">Вернуться на главную</a></div> : <section class="catalog-results catalog-results--single content-grid"><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <div class="state"><h2>Свежих точек пока нет</h2><p>Данные появятся после новых подтверждённых наблюдений.</p></div>}</div></section>}
</Layout>
@@ -0,0 +1,10 @@
---
import Layout from "../../layouts/Layout.astro";
import { api, type DictionaryItem } from "../../lib/api";
let waters: DictionaryItem[] = [], unavailable = false;
try { waters = await api<DictionaryItem[]>("/api/v1/waterbodies?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="Водоёмы">{waters.map(water => <a href={`/waterbodies/${water.slug}`}><span>Водоём</span><strong>{water.name_ru}</strong><i>→</i></a>)}</nav>}
</Layout>
+1
View File
@@ -0,0 +1 @@
.catalog-hero{padding:68px 0 42px;border-bottom:1px solid #cbd6ce}.catalog-hero h1{margin:12px 0 10px;font:400 clamp(58px,7vw,94px)/.9 Georgia,serif;letter-spacing:-.055em}.catalog-hero p{color:#4d605d;font-size:16px}.catalog-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;padding:34px 0 100px}.catalog-grid>a{display:grid;grid-template-columns:1fr auto;gap:7px;padding:22px;border:1px solid #d3ddd5;border-radius:14px;background:#fbfcf9;text-decoration:none;transition:.18s ease}.catalog-grid>a:hover{transform:translateY(-2px);border-color:#81958a;box-shadow:0 14px 30px #14333812}.catalog-grid span{grid-column:1/-1;color:#657673;font-size:10px;text-transform:uppercase;letter-spacing:.1em}.catalog-grid strong{font:400 22px Georgia,serif}.catalog-grid i{font-style:normal;color:#6d8d2a}.catalog-results{display:grid;grid-template-columns:230px 1fr;gap:26px;padding:34px 0 100px}.catalog-results aside{padding:20px;background:#fff;border:1px solid #d6dfd7;border-radius:14px;align-self:start}.catalog-results aside nav{display:flex;flex-direction:column;margin-top:12px}.catalog-results aside a{padding:10px 0;border-bottom:1px solid #e1e8e2;color:#405552;font-size:13px}.catalog-results>div{display:grid;gap:12px}.catalog-results--single{display:block;max-width:960px}.catalog-results .spot-card{grid-template-columns:45px 1fr 190px}@media(max-width:900px){.catalog-grid{grid-template-columns:repeat(2,1fr)}.catalog-results{grid-template-columns:1fr}.catalog-results aside nav{display:grid;grid-template-columns:repeat(2,1fr);gap:0 16px}}@media(max-width:720px){.catalog-grid{grid-template-columns:1fr;padding-bottom:70px}.catalog-results{padding-bottom:70px}.catalog-results .spot-card{grid-template-columns:34px 1fr}.topbar nav{gap:8px}.topbar nav a{font-size:12px}}