feat: publish approved RF4 media catalog
This commit is contained in:
@@ -6,6 +6,7 @@ RUN pip install --no-cache-dir -r requirements-lock.txt
|
||||
RUN useradd --create-home --uid 10001 rf4
|
||||
COPY --chown=rf4:rf4 apps/api .
|
||||
COPY --chown=rf4:rf4 rf4_research ./rf4_research
|
||||
COPY --chown=rf4:rf4 data/media ./data/media
|
||||
USER rf4
|
||||
EXPOSE 8000
|
||||
CMD ["sh", "-c", "python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-access-log"]
|
||||
|
||||
@@ -16,6 +16,7 @@ from .readiness import readiness_report
|
||||
from .routers.activity import router as activity_router
|
||||
from .routers.admin import router as admin_router
|
||||
from .routers.catalog import router as catalog_router
|
||||
from .routers.media import router as media_router
|
||||
from .routers.public_data import router as public_data_router
|
||||
from .routers.submissions import router as submissions_router
|
||||
from .storage import client as storage_client
|
||||
@@ -87,6 +88,7 @@ def ready(db: Db) -> JSONResponse:
|
||||
|
||||
|
||||
app.include_router(catalog_router)
|
||||
app.include_router(media_router)
|
||||
app.include_router(activity_router)
|
||||
app.include_router(public_data_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MEDIA_ROOT = Path(os.environ.get("MEDIA_ROOT", "data/media")).resolve()
|
||||
|
||||
|
||||
def published_assets(entity_type: str | None = None) -> list[dict]:
|
||||
manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8"))
|
||||
result = []
|
||||
for item in manifest.get("assets", []):
|
||||
if item.get("status") != "approved" or not item.get("sha256") or not item.get("local_path"):
|
||||
continue
|
||||
if entity_type and item.get("entity_type") != entity_type:
|
||||
continue
|
||||
source_page = str(item.get("source_page") or "")
|
||||
source = "rf4db" if "rf4db.com" in source_page else "rf4map" if "rf4map.ru" in source_page else "rf4-official"
|
||||
result.append({
|
||||
"id": item["sha256"],
|
||||
"entity_type": item.get("entity_type"),
|
||||
"entity_key": item.get("entity_key"),
|
||||
"label": item.get("label"),
|
||||
"width": item.get("width"),
|
||||
"height": item.get("height"),
|
||||
"content_type": item.get("content_type"),
|
||||
"image_url": f"/api/v1/media/assets/{item['sha256']}",
|
||||
"source_system": source,
|
||||
"source_url": source_page,
|
||||
})
|
||||
return sorted(result, key=lambda item: (str(item["entity_type"]), str(item["label"] or "").casefold(), item["id"]))
|
||||
|
||||
|
||||
def published_file(digest: str) -> tuple[Path, str] | None:
|
||||
if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest):
|
||||
return None
|
||||
manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8"))
|
||||
item = next((row for row in manifest.get("assets", []) if row.get("status") == "approved" and row.get("sha256") == digest), None)
|
||||
if not item:
|
||||
return None
|
||||
target = (MEDIA_ROOT / item["local_path"]).resolve()
|
||||
if not target.is_relative_to(MEDIA_ROOT.resolve()) or not target.is_file():
|
||||
return None
|
||||
return target, str(item["content_type"])
|
||||
@@ -0,0 +1,24 @@
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from ..media_catalog import published_assets, published_file
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/v1/media/catalog")
|
||||
def media_catalog(entity_type: str | None = Query(None, pattern="^(fish|waterbody|tackle|reference)$")) -> list[dict]:
|
||||
return published_assets(entity_type)
|
||||
|
||||
|
||||
@router.get("/api/v1/media/assets/{digest}", response_class=FileResponse)
|
||||
def media_asset(digest: str) -> FileResponse:
|
||||
item = published_file(digest)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Media asset not found")
|
||||
path, media_type = item
|
||||
return FileResponse(path, media_type=media_type, headers={
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"ETag": f'"{digest}"',
|
||||
})
|
||||
@@ -3156,6 +3156,90 @@
|
||||
"summary": "Imports"
|
||||
}
|
||||
},
|
||||
"/api/v1/media/assets/{digest}": {
|
||||
"get": {
|
||||
"operationId": "media_asset_api_v1_media_assets__digest__get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "digest",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Digest",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Media Asset"
|
||||
}
|
||||
},
|
||||
"/api/v1/media/catalog": {
|
||||
"get": {
|
||||
"operationId": "media_catalog_api_v1_media_catalog_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "entity_type",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"pattern": "^(fish|waterbody|tackle|reference)$",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Entity Type"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
"title": "Response Media Catalog Api V1 Media Catalog Get",
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Media Catalog"
|
||||
}
|
||||
},
|
||||
"/api/v1/public-spot-pages": {
|
||||
"get": {
|
||||
"operationId": "public_spot_pages_api_v1_public_spot_pages_get",
|
||||
|
||||
@@ -61,6 +61,18 @@ def test_invalid_period_is_rejected() -> None:
|
||||
assert client.get("/api/v1/activity?sort=unknown").status_code == 422
|
||||
|
||||
|
||||
def test_published_media_catalog_and_content_addressed_file() -> None:
|
||||
catalog = client.get("/api/v1/media/catalog?entity_type=fish")
|
||||
assert catalog.status_code == 200
|
||||
assert catalog.json()
|
||||
item = catalog.json()[0]
|
||||
image = client.get(item["image_url"])
|
||||
assert image.status_code == 200
|
||||
assert image.headers["content-type"].startswith("image/")
|
||||
assert image.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
assert client.get("/api/v1/media/assets/not-a-hash").status_code == 404
|
||||
|
||||
|
||||
def test_review_queue_filters_before_pagination() -> None:
|
||||
with Session(engine) as db:
|
||||
stage_observations(db, [{
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
import SourceBadge from "./SourceBadge.astro";
|
||||
import type { MediaAsset } from "../lib/api";
|
||||
const { asset, compact = false, sourceLink = false } = Astro.props as { asset: MediaAsset; compact?: boolean; sourceLink?: boolean };
|
||||
---
|
||||
<figure class:list={["entity-media", { "entity-media--compact": compact }]}>
|
||||
<span class="entity-media__frame"><img src={asset.image_url} alt={asset.label ?? "Иллюстрация RF4"} width={asset.width} height={asset.height} loading="lazy" decoding="async" /></span>
|
||||
<figcaption><SourceBadge source={asset.source_system} href={sourceLink ? asset.source_url : undefined} /><span>{asset.label ?? "Справочный материал"}</span></figcaption>
|
||||
</figure>
|
||||
@@ -14,6 +14,7 @@ import "../styles/signal-pagination.css";
|
||||
import "../styles/dashboard-polish.css";
|
||||
import "../styles/loading-states.css";
|
||||
import "../styles/theme.css";
|
||||
import "../styles/media-catalog.css";
|
||||
import FishingIcon from "../components/FishingIcon.astro";
|
||||
import AlphaBanner from "../components/AlphaBanner.astro";
|
||||
const {
|
||||
@@ -114,7 +115,7 @@ Astro.response.headers.set("Content-Security-Policy", [
|
||||
<a class="skip-link" href="#main-content">Перейти к содержимому</a>
|
||||
<header class="topbar">
|
||||
<a href="/" class="brand"><span class="brand-mark" aria-hidden="true"><FishingIcon name="hook" size={24}/></span><span class="brand-name"><strong>RF4 Spotter</strong><span>Ни хвоста, ни чешуи</span></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("/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>
|
||||
<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("/media")}} aria-current={path.startsWith("/media") ? "page" : undefined} href="/media"><FishingIcon name="lure"/> <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>
|
||||
<div class="header-tools">
|
||||
<p class="live-badge"><span></span> Свежие данные и честная оценка</p>
|
||||
<div class="theme-switcher" role="group" aria-label="Цветовая тема">
|
||||
|
||||
@@ -26,6 +26,7 @@ export type PaginatedOfficialRecord = {
|
||||
export type PublicObservation = { id: string; source_system: string; source_name: string; source_url: string; fish_name: string; waterbody_name: string; x: number | null; y: number | null; weight_g: number | null; last_seen_at: string; missing_fields: string[]; quality: "incomplete" | "unverified" };
|
||||
export type ImportRun = { id: string; started_at: string; finished_at: string | null; status: string; source_url: string; rows_seen: number; rows_created: number; rows_updated: number; error_summary: string | null };
|
||||
export type SourceStatus = { source_system: string; name: string; status: "healthy" | "stale" | "temporarily_limited" | "source_changed" | "waiting" | "disabled"; last_started_at: string | null; last_success_at: string | null; observations: number };
|
||||
export type MediaAsset = { id: string; entity_type: "fish" | "waterbody" | "tackle" | "reference"; entity_key: string; label: string | null; width: number; height: number; content_type: string; image_url: string; source_system: string; source_url: string };
|
||||
|
||||
export const spotPath = (item: Pick<Activity, "waterbody_slug" | "x" | "y">) => `/spots/${item.waterbody_slug}-${item.x}x${item.y}`;
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { MediaAsset } from "./api";
|
||||
|
||||
const tokens = (value: string) => value
|
||||
.toLocaleLowerCase("ru")
|
||||
.replaceAll("ё", "е")
|
||||
.replace(/^(оз\.|р\.)\s*/, "")
|
||||
.replace(/[^a-zа-я0-9]+/giu, " ")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
|
||||
export const findMediaByLabel = (assets: MediaAsset[], label: string): MediaAsset | undefined => {
|
||||
const wanted = tokens(label);
|
||||
const exact = assets.filter((asset) => tokens(asset.label ?? "").join(" ") === wanted.join(" "));
|
||||
if (exact.length === 1) return exact[0];
|
||||
|
||||
const wantedSet = new Set(wanted);
|
||||
const candidates = assets.filter((asset) => {
|
||||
const available = new Set(tokens(asset.label ?? ""));
|
||||
return wanted.every((token) => available.has(token)) || [...available].every((token) => wantedSet.has(token));
|
||||
});
|
||||
return candidates.length === 1 ? candidates[0] : undefined;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { APIRoute } from "astro";
|
||||
|
||||
const apiBase = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
const digest = params.digest ?? "";
|
||||
if (!/^[a-f0-9]{64}$/.test(digest)) return new Response("Not found", { status: 404 });
|
||||
|
||||
try {
|
||||
const upstream = await fetch(`${apiBase}/api/v1/media/assets/${digest}`);
|
||||
if (!upstream.ok || !upstream.body) return new Response("Not found", { status: upstream.status === 404 ? 404 : 502 });
|
||||
const headers = new Headers();
|
||||
for (const name of ["content-type", "content-length", "cache-control", "etag"]) {
|
||||
const value = upstream.headers.get(name);
|
||||
if (value) headers.set(name, value);
|
||||
}
|
||||
return new Response(upstream.body, { status: 200, headers });
|
||||
} catch {
|
||||
return new Response("Media service unavailable", { status: 502 });
|
||||
}
|
||||
};
|
||||
@@ -1,15 +1,18 @@
|
||||
---
|
||||
import ActivityCard from "../../components/ActivityCard.astro";
|
||||
import EntityMedia from "../../components/EntityMedia.astro";
|
||||
import AtlasBreadcrumbs from "../../components/AtlasBreadcrumbs.astro";
|
||||
import AtlasEntityLink from "../../components/AtlasEntityLink.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";
|
||||
import { api, plural, type Activity, type DictionaryItem, type MediaAsset, type PaginatedActivity } from "../../lib/api";
|
||||
import { findMediaByLabel } from "../../lib/media";
|
||||
const { slug } = Astro.params;
|
||||
let fish: DictionaryItem | undefined, items: Activity[] = [], unavailable = false;
|
||||
let fish: DictionaryItem | undefined, items: Activity[] = [], media: MediaAsset[] = [], unavailable = false;
|
||||
try {
|
||||
const fishes = await api<DictionaryItem[]>("/api/v1/fishes?limit=500");
|
||||
const [fishes, fishMedia] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes?limit=500"), api<MediaAsset[]>("/api/v1/media/catalog?entity_type=fish")]);
|
||||
media = fishMedia;
|
||||
fish = fishes.find(item => item.slug === slug);
|
||||
if (fish) { const paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=72&fish=${encodeURIComponent(fish.slug)}&limit=100`); items = paginated.items; }
|
||||
} catch { unavailable = true; }
|
||||
@@ -20,10 +23,12 @@ if (unavailable) {
|
||||
}
|
||||
if (!fish && !unavailable) Astro.response.status = 404;
|
||||
const waters = [...new Map(items.map(item => [item.waterbody_slug, item.waterbody])).entries()];
|
||||
const image = fish ? findMediaByLabel(media, fish.name_ru) : undefined;
|
||||
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 || unavailable} structuredData={schema} errorPage={!fish || unavailable}>
|
||||
<AtlasBreadcrumbs items={[{ label: "Рыбы", href: "/fish" }, { label: fish?.name_ru ?? "Не найдено" }]} />
|
||||
<PageHero eyebrow="Свежие данные за 72 часа" title={fish?.name_ru ?? "Рыба не найдена"} description={fish ? `${items.length} ${plural(items.length,["активная точка","активные точки","активных точек"])} на ${waters.length} ${plural(waters.length,["водоёме","водоёмах","водоёмах"])}.` : undefined} variant={fish ? "fish" : undefined} identity={fish?.name_ru} />
|
||||
{image && <section class="entity-feature content-grid" aria-label={`Изображение: ${fish!.name_ru}`}><EntityMedia asset={image} sourceLink /><div><span class="overline">Визуальный справочник</span><h2>{fish!.name_ru}</h2><p>Изображение опубликовано с прямой ссылкой на источник. Оно помогает отличить вид, а актуальные точки ниже остаются отдельными наблюдениями игроков.</p></div></section>}
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !fish ? <StatePanel tone="error" title="Такой рыбы нет в справочнике" actionHref="/fish" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Водоёмы</span>{waters.length ? <nav>{waters.map(([waterSlug,name]) => <AtlasEntityLink href={`/waterbodies/${waterSlug}/${fish!.slug}`} label={name} kind="water" identity={waterSlug} />)}</nav> : <p>Свежих подтверждённых водоёмов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Проверьте позже или посмотрите полевые сигналы на главной." />}</div></section>}
|
||||
</Layout>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
---
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import FishSilhouette from "../../components/FishSilhouette.astro";
|
||||
import EntityMedia from "../../components/EntityMedia.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<DictionaryItem[]>("/api/v1/fishes?limit=500"); } catch { unavailable = true; }
|
||||
import { api, type DictionaryItem, type MediaAsset } from "../../lib/api";
|
||||
import { findMediaByLabel } from "../../lib/media";
|
||||
let fishes: DictionaryItem[] = [], media: MediaAsset[] = [], unavailable = false;
|
||||
try { [fishes, media] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes?limit=500"), api<MediaAsset[]>("/api/v1/media/catalog?entity_type=fish")]); } catch { unavailable = true; }
|
||||
if (unavailable) {
|
||||
Astro.response.status = 503;
|
||||
Astro.response.headers.set("Retry-After", "60");
|
||||
@@ -14,5 +16,5 @@ if (unavailable) {
|
||||
---
|
||||
<Layout title="Все виды рыб Russian Fishing 4 — RF4 Spotter" description="Каталог рыб RF4 со свежими точками, уловами, приманками и прозрачными источниками данных.">
|
||||
<PageHero eyebrow="Справочник RF4" title="Рыбы" description="Выберите вид, чтобы увидеть свежие подтверждённые точки и полевые сигналы." variant="fish" count={fishes.length} />
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенный список. Попробуйте обновить страницу позже." /> : <nav class="catalog-grid content-grid" aria-label="Виды рыб">{fishes.map((fish,index) => <a href={`/fish/${fish.slug}`}><span>Вид рыбы · {String(index + 1).padStart(2,"0")}</span><strong>{fish.name_ru}</strong><i>Открыть <b>→</b></i><FishSilhouette name={fish.name_ru} size={92}/></a>)}</nav>}
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенный список. Попробуйте обновить страницу позже." /> : <nav class="catalog-grid content-grid" aria-label="Виды рыб">{fishes.map((fish,index) => { const asset = findMediaByLabel(media, fish.name_ru); return <a href={`/fish/${fish.slug}`}><span>Вид рыбы · {String(index + 1).padStart(2,"0")}</span><strong>{fish.name_ru}</strong><i>Открыть <b>→</b></i>{asset ? <EntityMedia asset={asset} compact /> : <FishSilhouette name={fish.name_ru} size={92}/>}</a>; })}</nav>}
|
||||
</Layout>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import EntityMedia from "../components/EntityMedia.astro";
|
||||
import PageHero from "../components/PageHero.astro";
|
||||
import StatePanel from "../components/StatePanel.astro";
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import { api, type MediaAsset } from "../lib/api";
|
||||
const allowed = new Set(["fish", "waterbody", "tackle", "reference"]);
|
||||
const requested = Astro.url.searchParams.get("type") ?? "fish";
|
||||
const type = allowed.has(requested) ? requested : "fish";
|
||||
let assets: MediaAsset[] = [], unavailable = false;
|
||||
try { assets = await api<MediaAsset[]>(`/api/v1/media/catalog?entity_type=${type}`); } catch { unavailable = true; }
|
||||
const labels: Record<string,string> = {fish:"Рыбы",waterbody:"Водоёмы",tackle:"Снасти и приманки",reference:"Справочные материалы"};
|
||||
---
|
||||
<Layout title={`${labels[type]} RF4 — медиатека RF4 Spotter`} description="Изображения рыб, водоёмов и снастей Russian Fishing 4 с обязательной атрибуцией каждого источника.">
|
||||
<PageHero eyebrow="Визуальный справочник" title="Медиатека" description="Собранные материалы RF4 с источником у каждого изображения. Каталог пополняется по мере импорта и проверки." variant="fish" count={assets.length} />
|
||||
<section class="media-library content-grid">
|
||||
<nav class="media-library__intro" aria-label="Разделы медиатеки"><strong>{labels[type]} · {assets.length}</strong><span><a href="/media?type=fish">Рыбы</a> · <a href="/media?type=waterbody">Водоёмы</a> · <a href="/media?type=tackle">Снасти</a> · <a href="/media?type=reference">Справка</a></span></nav>
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Медиатека временно недоступна" /> : assets.length ? <div class="media-library__grid">{assets.map(asset => <article class="media-library__card"><EntityMedia asset={asset} sourceLink /><h2>{asset.label ?? "Без подписи"}</h2><span>{labels[asset.entity_type]}</span></article>)}</div> : <StatePanel title="В этом разделе пока нет изображений" description="Материалы появятся после следующего импорта." />}
|
||||
</section>
|
||||
</Layout>
|
||||
@@ -9,7 +9,7 @@ export const GET: APIRoute = async ({ site }) => {
|
||||
const output = (xml: string) => new Response(xml, { headers: { "Content-Type": "application/xml; charset=utf-8", "Cache-Control": "public, max-age=1800" } });
|
||||
if (lastGood?.origin === origin && Date.now() - lastGood.at < 1800000) return output(lastGood.xml);
|
||||
try {
|
||||
const paths = new Set(["/", "/records", "/report", "/status", "/rules", "/privacy", "/fish", "/waterbodies"]);
|
||||
const paths = new Set(["/", "/records", "/report", "/status", "/rules", "/privacy", "/fish", "/waterbodies", "/media"]);
|
||||
for (const [endpoint, prefix] of [["fishes", "fish"], ["waterbodies", "waterbodies"]]) {
|
||||
for (let offset = 0; ; offset += 500) {
|
||||
const rows = await api<DictionaryItem[]>(`/api/v1/${endpoint}?limit=500&offset=${offset}`);
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
---
|
||||
import ActivityCard from "../../components/ActivityCard.astro";
|
||||
import EntityMedia from "../../components/EntityMedia.astro";
|
||||
import AtlasBreadcrumbs from "../../components/AtlasBreadcrumbs.astro";
|
||||
import AtlasEntityLink from "../../components/AtlasEntityLink.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";
|
||||
import { api, plural, type Activity, type DictionaryItem, type MediaAsset, type PaginatedActivity } from "../../lib/api";
|
||||
import { findMediaByLabel } from "../../lib/media";
|
||||
const { slug } = Astro.params;
|
||||
let water: DictionaryItem | undefined, items: Activity[] = [], unavailable = false;
|
||||
let water: DictionaryItem | undefined, items: Activity[] = [], media: MediaAsset[] = [], unavailable = false;
|
||||
try {
|
||||
const waters = await api<DictionaryItem[]>("/api/v1/waterbodies?limit=500");
|
||||
const [waters, waterMedia] = await Promise.all([api<DictionaryItem[]>("/api/v1/waterbodies?limit=500"), api<MediaAsset[]>("/api/v1/media/catalog?entity_type=waterbody")]);
|
||||
media = waterMedia;
|
||||
water = waters.find(item => item.slug === slug);
|
||||
if (water) { const paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=72&waterbody=${encodeURIComponent(water.slug)}&limit=100`); items = paginated.items; }
|
||||
} catch { unavailable = true; }
|
||||
@@ -20,10 +23,12 @@ if (unavailable) {
|
||||
}
|
||||
if (!water && !unavailable) Astro.response.status = 404;
|
||||
const fishes = [...new Map(items.map(item => [item.fish_slug, item.fish])).entries()];
|
||||
const image = water ? findMediaByLabel(media, water.name_ru) : undefined;
|
||||
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 || unavailable} structuredData={schema} errorPage={!water || unavailable}>
|
||||
<AtlasBreadcrumbs items={[{ label: "Водоёмы", href: "/waterbodies" }, { label: water?.name_ru ?? "Не найдено" }]} />
|
||||
<PageHero eyebrow="Свежие данные за 72 часа" title={water?.name_ru ?? "Водоём не найден"} description={water ? `${items.length} ${plural(items.length,["активная точка","активные точки","активных точек"])} для ${fishes.length} ${plural(fishes.length,["вида рыбы","видов рыб","видов рыб"])}.` : undefined} variant={water ? "water" : undefined} identity={water?.slug} />
|
||||
{image && <section class="entity-feature content-grid" aria-label={`Изображение: ${water!.name_ru}`}><EntityMedia asset={image} sourceLink /><div><span class="overline">Карта и образ водоёма</span><h2>{water!.name_ru}</h2><p>Материал показан с прямой атрибуцией. Координаты активных точек ниже относятся к данным наблюдений, а не к геометрии изображения.</p></div></section>}
|
||||
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !water ? <StatePanel tone="error" title="Такого водоёма нет в справочнике" actionHref="/waterbodies" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Рыбы</span>{fishes.length ? <nav>{fishes.map(([fishSlug,name]) => <AtlasEntityLink href={`/waterbodies/${water!.slug}/${fishSlug}`} label={name} kind="fish" identity={name} />)}</nav> : <p>Свежих подтверждённых видов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Проверьте позже или посмотрите полевые сигналы на главной." />}</div></section>}
|
||||
</Layout>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
.entity-media{margin:0;min-width:0}.entity-media__frame{position:relative;display:grid;place-items:center;overflow:hidden;min-height:190px;padding:18px;border:1px solid var(--border-soft);border-radius:16px;background:radial-gradient(circle at 50% 46%,color-mix(in srgb,var(--lime) 15%,var(--surface)) 0 18%,var(--surface-soft) 62%)}.entity-media__frame:after{content:"";position:absolute;inset:12px;border:1px solid color-mix(in srgb,var(--border) 55%,transparent);border-radius:11px;pointer-events:none}.entity-media img{position:relative;z-index:1;display:block;width:100%;height:180px;object-fit:contain;filter:drop-shadow(0 12px 18px #08222624);image-rendering:auto}.entity-media figcaption{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:9px;color:var(--text-muted);font-size:11px}.entity-media figcaption>span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.entity-media--compact{grid-column:2;grid-row:1/4;width:118px}.entity-media--compact .entity-media__frame{min-height:88px;height:88px;padding:10px;border:0;background:color-mix(in srgb,var(--lime) 8%,var(--surface-soft))}.entity-media--compact img{height:72px}.entity-media--compact figcaption{justify-content:flex-end}.entity-media--compact figcaption>span:last-child{display:none}.entity-media--compact .source-chip{transform:scale(.9);transform-origin:right center}.media-library{padding:34px 0 100px}.media-library__intro{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:24px;color:var(--text-muted)}.media-library__grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}.media-library__card{padding:12px;border:1px solid var(--border-soft);border-radius:18px;background:var(--surface)}.media-library__card .entity-media__frame{min-height:170px}.media-library__card h2{margin:12px 3px 3px;font:400 19px Georgia,serif}.media-library__card>span{margin-left:3px;color:var(--text-subtle);font-size:10px;text-transform:uppercase;letter-spacing:.09em}@media(max-width:1050px){.media-library__grid{grid-template-columns:repeat(3,1fr)}}@media(max-width:720px){.media-library__grid{grid-template-columns:repeat(2,1fr)}.entity-media--compact{width:90px}.entity-media--compact .entity-media__frame{height:76px}.media-library__intro{display:block}}@media(max-width:440px){.media-library__grid{grid-template-columns:1fr}}
|
||||
.entity-feature{display:grid;grid-template-columns:minmax(280px,420px) 1fr;align-items:center;gap:48px;padding-block:36px;border-bottom:1px solid var(--border)}.entity-feature .entity-media__frame{min-height:260px}.entity-feature .entity-media img{height:230px}.entity-feature h2{margin:10px 0 12px;font:400 clamp(34px,4vw,58px)/.95 Georgia,serif}.entity-feature p{max-width:620px;color:var(--text-muted);line-height:1.6}@media(max-width:720px){.entity-feature{grid-template-columns:1fr;gap:24px;padding-block:26px}}
|
||||
Reference in New Issue
Block a user