Apply reference design to Astro and add screenshot storage

This commit is contained in:
ik
2026-09-03 07:53:53 +07:00
parent 6d536d0ade
commit c5242720b1
26 changed files with 253 additions and 59 deletions
+5
View File
@@ -2,3 +2,8 @@ DATABASE_URL=postgresql+psycopg://rf4:rf4_local@localhost:5432/rf4_spotter
PUBLIC_API_URL=http://localhost:8000 PUBLIC_API_URL=http://localhost:8000
API_INTERNAL_URL=http://api:8000 API_INTERNAL_URL=http://api:8000
ADMIN_TOKEN=change-me-in-production ADMIN_TOKEN=change-me-in-production
S3_ENDPOINT_URL=http://localhost:9000
S3_PUBLIC_ENDPOINT_URL=http://localhost:9000
S3_ACCESS_KEY=rf4-local
S3_SECRET_KEY=rf4-local-secret
S3_BUCKET=catch-screenshots
+7 -5
View File
@@ -14,9 +14,10 @@ docker compose up --build
- сайт: <http://localhost:4321>; - сайт: <http://localhost:4321>;
- OpenAPI: <http://localhost:8000/docs>; - OpenAPI: <http://localhost:8000/docs>;
- проверка API: <http://localhost:8000/health>. - проверка API: <http://localhost:8000/health>;
- консоль MinIO: <http://localhost:9001>.
Контейнер API сам выполняет `alembic upgrade head`, затем идемпотентный seed. PostgreSQL хранит данные в именованном volume `postgres_data`. Контейнер API сам выполняет `alembic upgrade head`, затем идемпотентный seed. PostgreSQL хранит данные в именованном volume `postgres_data`, а MinIO — в `minio_data`.
Остановка: Остановка:
@@ -41,13 +42,14 @@ docker compose up --build
- `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки; - `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки;
- `GET /api/v1/spots/{id}` и `/catches`; - `GET /api/v1/spots/{id}` и `/catches`;
- справочники рыб, водоёмов и приманок; - справочники рыб, водоёмов и приманок;
- Astro SSR-интерфейс с адаптивной главной и страницей точки; - Astro SSR-интерфейс с адаптивным дизайном из `design-reference` без переноса React/Vinext-стека;
- объяснимые индексы активности и уверенности по формуле спецификации; - объяснимые индексы активности и уверенности по формуле спецификации;
- состояния «нет данных» и «источник недоступен»; - состояния «нет данных» и «источник недоступен»;
- идемпотентный импорт официальных записей с журналом запусков; - идемпотентный импорт официальных записей с журналом запусков;
- публичная страница `/records` с источником и временем последнего импорта; - публичная страница `/records` с источником и временем последнего импорта;
- форма `/report`, защищённые admin API и журнал модерации; - форма `/report`, защищённые admin API и журнал модерации;
- honeypot и базовый лимит отправок. - honeypot и базовый лимит отправок;
- скриншоты уловов в MinIO/S3 с декодированием изображения, ограничением размера и очисткой метаданных.
Все пользовательские ники и уловы в seed демонстрационные. Все пользовательские ники и уловы в seed демонстрационные.
@@ -98,7 +100,7 @@ curl -H "Authorization: Bearer change-me-in-production" \
"http://localhost:8000/api/v1/admin/catch-reports?status=pending" "http://localhost:8000/api/v1/admin/catch-reports?status=pending"
``` ```
Перед внешним развёртыванием обязательно замените демонстрационный `ADMIN_TOKEN`. Скриншоты пока не принимаются: следующим шагом для них подключается MinIO/S3 с проверкой MIME, размера и очисткой EXIF. Перед внешним развёртыванием обязательно замените демонстрационный `ADMIN_TOKEN`, а также значения `S3_ACCESS_KEY` и `S3_SECRET_KEY`. Форма принимает JPEG, PNG и WebP до 8 МБ; API повторно кодирует изображение и удаляет EXIF перед сохранением в MinIO. Модератор получает временную подписанную ссылку через admin API.
## Исследовательский парсер официальных рекордов ## Исследовательский парсер официальных рекордов
@@ -0,0 +1,16 @@
"""Screenshot object key."""
from alembic import op
import sqlalchemy as sa
revision = "0004"
down_revision = "0003"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("catch_report", sa.Column("screenshot_key", sa.Text()))
def downgrade() -> None:
op.drop_column("catch_report", "screenshot_key")
+6
View File
@@ -4,6 +4,12 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
database_url: str = "postgresql+psycopg://rf4:rf4_local@localhost:5432/rf4_spotter" database_url: str = "postgresql+psycopg://rf4:rf4_local@localhost:5432/rf4_spotter"
admin_token: str = "change-me-in-production" admin_token: str = "change-me-in-production"
s3_endpoint_url: str = "http://localhost:9000"
s3_public_endpoint_url: str = "http://localhost:9000"
s3_access_key: str = "rf4-local"
s3_secret_key: str = "rf4-local-secret"
s3_bucket: str = "catch-screenshots"
screenshot_max_bytes: int = 8 * 1024 * 1024
model_config = SettingsConfigDict(env_file=".env", extra="ignore") model_config = SettingsConfigDict(env_file=".env", extra="ignore")
+19 -2
View File
@@ -5,7 +5,7 @@ from datetime import datetime, timedelta, timezone
from typing import Annotated, Literal from typing import Annotated, Literal
from uuid import UUID from uuid import UUID
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, Request, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
@@ -16,6 +16,7 @@ from .config import settings
from .importer import normalize from .importer import normalize
from .models import Bait, BaitKind, CatchReport, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody from .models import Bait, BaitKind, CatchReport, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody
from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportCreate, CatchReportCreated, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, SpotOut, WaterbodyOut from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportCreate, CatchReportCreated, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, SpotOut, WaterbodyOut
from .storage import ScreenshotError, signed_screenshot_url, upload_screenshot
app = FastAPI(title="RF4 Spotter API", version="0.1.0") app = FastAPI(title="RF4 Spotter API", version="0.1.0")
@@ -133,6 +134,22 @@ def create_catch_report(payload: CatchReportCreate, request: Request, db: Db) ->
return CatchReportCreated(id=report.id, moderation_status=report.moderation_status.value) return CatchReportCreated(id=report.id, moderation_status=report.moderation_status.value)
@app.post("/api/v1/catch-reports/{report_id}/screenshot", status_code=204, response_class=Response)
def add_screenshot(report_id: UUID, db: Db, screenshot: UploadFile = File()) -> Response:
report = db.get(CatchReport, report_id)
if report is None or report.source_type != SourceType.user or report.moderation_status != ModerationStatus.pending:
raise HTTPException(status_code=404, detail="pending catch report not found")
if report.screenshot_key:
raise HTTPException(status_code=409, detail="screenshot already uploaded")
raw = screenshot.file.read(settings.screenshot_max_bytes + 1)
try:
report.screenshot_key = upload_screenshot(raw)
except ScreenshotError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
db.commit()
return Response(status_code=204)
def _admin(authorization: Annotated[str | None, Header()] = None) -> str: def _admin(authorization: Annotated[str | None, Header()] = None) -> str:
if not authorization or authorization != f"Bearer {settings.admin_token}": if not authorization or authorization != f"Bearer {settings.admin_token}":
raise HTTPException(status_code=401, detail="invalid admin token", headers={"WWW-Authenticate": "Bearer"}) raise HTTPException(status_code=401, detail="invalid admin token", headers={"WWW-Authenticate": "Bearer"})
@@ -142,7 +159,7 @@ def _admin(authorization: Annotated[str | None, Header()] = None) -> str:
@app.get("/api/v1/admin/catch-reports", response_model=list[AdminCatchReportOut]) @app.get("/api/v1/admin/catch-reports", response_model=list[AdminCatchReportOut])
def admin_reports(db: Db, _: Annotated[str, Depends(_admin)], status: ModerationStatus = ModerationStatus.pending, limit: int = Query(50, ge=1, le=100)) -> list[AdminCatchReportOut]: def admin_reports(db: Db, _: Annotated[str, Depends(_admin)], status: ModerationStatus = ModerationStatus.pending, limit: int = Query(50, ge=1, le=100)) -> list[AdminCatchReportOut]:
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.spot), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.user, CatchReport.moderation_status == status).order_by(CatchReport.reported_at).limit(limit))) reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.waterbody), joinedload(CatchReport.spot), joinedload(CatchReport.bait)).where(CatchReport.source_type == SourceType.user, CatchReport.moderation_status == status).order_by(CatchReport.reported_at).limit(limit)))
return [AdminCatchReportOut(id=r.id, fish=r.fish.name_ru, waterbody=r.waterbody.name_ru, coordinates=f"{r.spot.x}:{r.spot.y}" if r.spot else "", weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, reported_at=r.reported_at, moderation_status=r.moderation_status.value, comment=(r.raw_payload or {}).get("comment")) for r in reports] return [AdminCatchReportOut(id=r.id, fish=r.fish.name_ru, waterbody=r.waterbody.name_ru, coordinates=f"{r.spot.x}:{r.spot.y}" if r.spot else "", weight_g=r.weight_g, bait=r.bait.name if r.bait else None, player_name=r.player_name, reported_at=r.reported_at, moderation_status=r.moderation_status.value, comment=(r.raw_payload or {}).get("comment"), screenshot_url=signed_screenshot_url(r.screenshot_key) if r.screenshot_key else None) for r in reports]
@app.patch("/api/v1/admin/catch-reports/{report_id}", response_model=CatchReportCreated) @app.patch("/api/v1/admin/catch-reports/{report_id}", response_model=CatchReportCreated)
+1
View File
@@ -91,6 +91,7 @@ class CatchReport(Base):
source_external_id: Mapped[str | None] = mapped_column(String(64), unique=True) source_external_id: Mapped[str | None] = mapped_column(String(64), unique=True)
source_confidence: Mapped[int] source_confidence: Mapped[int]
moderation_status: Mapped[ModerationStatus] = mapped_column(Enum(ModerationStatus)) moderation_status: Mapped[ModerationStatus] = mapped_column(Enum(ModerationStatus))
screenshot_key: Mapped[str | None] = mapped_column(Text)
raw_payload: Mapped[dict | None] = mapped_column(JSON) raw_payload: Mapped[dict | None] = mapped_column(JSON)
fish: Mapped[Fish] = relationship() fish: Mapped[Fish] = relationship()
spot: Mapped[Spot | None] = relationship() spot: Mapped[Spot | None] = relationship()
+1
View File
@@ -142,6 +142,7 @@ class AdminCatchReportOut(BaseModel):
reported_at: datetime reported_at: datetime
moderation_status: str moderation_status: str
comment: str | None comment: str | None
screenshot_url: str | None
class ModerationUpdate(BaseModel): class ModerationUpdate(BaseModel):
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
import io
import uuid
from functools import lru_cache
import boto3
from botocore.client import BaseClient
from botocore.exceptions import ClientError
from PIL import Image, UnidentifiedImageError
from .config import settings
class ScreenshotError(ValueError):
pass
ALLOWED_FORMATS = {"JPEG": ("jpg", "image/jpeg"), "PNG": ("png", "image/png"), "WEBP": ("webp", "image/webp")}
@lru_cache
def client() -> BaseClient:
return boto3.client("s3", endpoint_url=settings.s3_endpoint_url, aws_access_key_id=settings.s3_access_key, aws_secret_access_key=settings.s3_secret_key, region_name="us-east-1")
@lru_cache
def public_client() -> BaseClient:
return boto3.client("s3", endpoint_url=settings.s3_public_endpoint_url, aws_access_key_id=settings.s3_access_key, aws_secret_access_key=settings.s3_secret_key, region_name="us-east-1")
def prepare_image(raw: bytes) -> tuple[bytes, str, str]:
if not raw or len(raw) > settings.screenshot_max_bytes:
raise ScreenshotError("screenshot must be between 1 byte and 8 MB")
try:
with Image.open(io.BytesIO(raw)) as source:
source.verify()
with Image.open(io.BytesIO(raw)) as source:
fmt = (source.format or "").upper()
if fmt not in ALLOWED_FORMATS:
raise ScreenshotError("only JPEG, PNG and WebP images are accepted")
if source.width * source.height > 40_000_000:
raise ScreenshotError("image dimensions are too large")
extension, mime = ALLOWED_FORMATS[fmt]
clean = source.convert("RGB") if fmt in {"JPEG", "WEBP"} else source.convert("RGBA")
output = io.BytesIO()
clean.save(output, format=fmt, optimize=True)
return output.getvalue(), extension, mime
except (UnidentifiedImageError, OSError) as exc:
raise ScreenshotError("file is not a valid image") from exc
def upload_screenshot(raw: bytes) -> str:
body, extension, mime = prepare_image(raw)
key = f"reports/{uuid.uuid4()}.{extension}"
s3 = client()
try:
s3.head_bucket(Bucket=settings.s3_bucket)
except ClientError:
s3.create_bucket(Bucket=settings.s3_bucket)
s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=body, ContentType=mime)
return key
def signed_screenshot_url(key: str) -> str:
return public_client().generate_presigned_url("get_object", Params={"Bucket": settings.s3_bucket, "Key": key}, ExpiresIn=900)
+3
View File
@@ -1,9 +1,12 @@
alembic==1.16.5 alembic==1.16.5
beautifulsoup4==4.15.0 beautifulsoup4==4.15.0
boto3==1.40.35
fastapi==0.116.1 fastapi==0.116.1
httpx==0.28.1 httpx==0.28.1
psycopg[binary]==3.2.10 psycopg[binary]==3.2.10
pydantic-settings==2.10.1 pydantic-settings==2.10.1
pillow==11.3.0
python-multipart==0.0.20
pytest==8.4.2 pytest==8.4.2
sqlalchemy==2.0.43 sqlalchemy==2.0.43
uvicorn[standard]==0.35.0 uvicorn[standard]==0.35.0
+9
View File
@@ -85,3 +85,12 @@ def test_user_report_requires_moderation_before_activity() -> None:
def test_admin_requires_token() -> None: def test_admin_requires_token() -> None:
assert client.get("/api/v1/admin/catch-reports").status_code == 401 assert client.get("/api/v1/admin/catch-reports").status_code == 401
def test_pending_report_accepts_one_validated_screenshot(monkeypatch) -> None:
created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 91, "y": 92, "weight_g": 4200}).json()
monkeypatch.setattr("app.main.upload_screenshot", lambda raw: "reports/test.jpg" if raw == b"image-bytes" else "unexpected")
response = client.post(f"/api/v1/catch-reports/{created['id']}/screenshot", files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")})
assert response.status_code == 204
duplicate = client.post(f"/api/v1/catch-reports/{created['id']}/screenshot", files={"screenshot": ("catch.jpg", b"image-bytes", "image/jpeg")})
assert duplicate.status_code == 409
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
import io
import pytest
from PIL import Image
from app.storage import ScreenshotError, prepare_image
def test_prepare_image_removes_metadata() -> None:
source = Image.new("RGB", (32, 24), "#12383b")
exif = Image.Exif()
exif[0x010E] = "private note"
raw = io.BytesIO()
source.save(raw, format="JPEG", exif=exif)
cleaned, extension, mime = prepare_image(raw.getvalue())
with Image.open(io.BytesIO(cleaned)) as result:
assert result.getexif() == {}
assert result.size == (32, 24)
assert (extension, mime) == ("jpg", "image/jpeg")
def test_prepare_image_rejects_non_image() -> None:
with pytest.raises(ScreenshotError, match="valid image"):
prepare_image(b"not an image")
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
test-results
playwright-report
.astro
+2
View File
@@ -8,6 +8,8 @@ RUN npm run build
FROM node:22-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
ENV HOST=0.0.0.0 PORT=4321 NODE_ENV=production ENV HOST=0.0.0.0 PORT=4321 NODE_ENV=production
COPY --from=build /app/package*.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist COPY --from=build /app/dist ./dist
EXPOSE 4321 EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"] CMD ["node", "./dist/server/entry.mjs"]
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="9" fill="#082226"/>
<path d="M7 16c3.2-4.2 7-6 11.1-4.2 2 .9 3.5 2.4 4.9 4.2-1.4 1.8-2.9 3.3-4.9 4.2C14 22 10.2 20.2 7 16Z" fill="#c9f45b"/>
<circle cx="19" cy="14.5" r="1.1" fill="#082226"/>
<path d="m8 16-3.5-3.5v7L8 16Z" fill="#c9f45b"/>
</svg>

After

Width:  |  Height:  |  Size: 350 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+6 -8
View File
@@ -2,12 +2,10 @@
import type { Activity } from "../lib/api"; import type { Activity } from "../lib/api";
import { ago, kg } from "../lib/api"; import { ago, kg } from "../lib/api";
const { item } = Astro.props as { item: Activity }; const { item } = Astro.props as { item: Activity };
const level = item.activity_score >= 80 ? "Очень высокий" : item.activity_score >= 60 ? "Высокий" : item.activity_score >= 40 ? "Средний" : "Низкий";
--- ---
<article class="card"> <a class="spot-card" data-testid={`spot-${item.x}-${item.y}`} href={`/spots/${item.spot_id}`}>
<div class="card-top"><div><span class="eyebrow">{item.waterbody}</span><h2>{item.fish}</h2></div><div class="score" aria-label={`Активность ${item.activity_score} из 100`}><strong>{item.activity_score}</strong><span>активность</span></div></div> <span class="spot-rank">{String(item.activity_score).padStart(2,"0")}</span>
<a class="coordinates" href={`/spots/${item.spot_id}`}>Точка {item.x}:{item.y} <span></span></a> <div class="spot-main"><div class="spot-topline"><span>{item.waterbody}</span><span class="activity-pill"><i></i>{level}</span></div><h3>{item.fish}</h3><div class="spot-meta"><span>⌖ {item.x}:{item.y}</span><span>◷ {ago(item.last_confirmed_at)}</span></div><div class="bait-line"><span class="lure-dot"></span><div><span>Работает сейчас</span><strong>{item.best_bait ?? "не указана"}</strong></div></div></div>
<div class="bait"><span>Рабочая приманка</span><strong>{item.best_bait ?? "не указана"}</strong></div> <div class="spot-stats"><div><strong>{item.catches}</strong><span>уловов</span></div><div><strong>{item.unique_players}</strong><span>игроков</span></div><div><strong>{kg(item.average_weight_g)}</strong><span>средний вес</span></div><div><strong>{item.confidence_score}%</strong><span>уверенность</span></div></div><span class="card-arrow"></span>
<dl class="stats"><div><dt>Уловов</dt><dd>{item.catches}</dd></div><div><dt>Игроков</dt><dd>{item.unique_players}</dd></div><div><dt>Средний</dt><dd>{kg(item.average_weight_g)}</dd></div><div><dt>Максимум</dt><dd>{kg(item.max_weight_g)}</dd></div></dl> </a>
<p class="explanation">{item.explanation}</p>
<div class="fresh"><span class="pulse"></span> Обновлено {ago(item.last_confirmed_at)} · уверенность {item.confidence_score}/100</div>
</article>
+8 -3
View File
@@ -1,13 +1,18 @@
--- ---
import "../styles/global.css"; import "../styles/global.css";
const { title = "RF4 Spotter" } = Astro.props; const { title = "RF4 Spotter" } = Astro.props;
const path = Astro.url.pathname;
--- ---
<!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" /><title>{title}</title></head> <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>
<body> <body>
<header class="site-header"><a href="/" class="brand"><span>RF4</span> Spotter</a><nav><a href="/">Активность</a><a href="/records">Рекорды</a><a href="/report">Добавить улов</a></nav><p>Свежие точки без догадок</p></header> <header class="topbar">
<a href="/" class="brand" aria-label="Ни хвоста, ни чешуи"><span class="brand-mark" aria-hidden="true">⌁</span><span class="brand-name"><span>Ни хвоста,</span><strong>ни чешуи</strong></span></a>
<nav aria-label="Разделы сайта"><a class:list={{active:path === "/"}} href="/">≈ <span>Сейчас клюёт</span></a><a class:list={{active:path.startsWith("/records")}} href="/records">♜ <span>Рекорды</span></a><a class:list={{active:path.startsWith("/report")}} href="/report"> <span>Добавить улов</span></a></nav>
<p class="live-badge"><span></span> Свежие данные и честная оценка</p>
</header>
<main><slot /></main> <main><slot /></main>
<footer>Неофициальный проект. Данные демонстрационные.</footer> <footer><a href="/" class="brand"><span class="brand-mark">⌁</span><span class="brand-name"><span>Ни хвоста,</span><strong>ни чешуи</strong></span></a><p>Неофициальный проект для игроков Russian Fishing 4. Данные seed демонстрационные.</p><span>НХНЧ · 2026</span></footer>
</body> </body>
</html> </html>
+1 -1
View File
@@ -12,7 +12,7 @@ export type DictionaryItem = { id: string; slug: string; name_ru: string };
export type OfficialRecord = { id: string; fish: string; weight_g: number; waterbody: string; bait: string | null; player_name: string | null; record_date: string | null; category: string | null; region: string | null; source_url: string | null }; export type OfficialRecord = { id: string; fish: string; weight_g: number; waterbody: string; bait: string | null; player_name: string | null; record_date: string | null; category: string | null; region: string | null; source_url: string | null };
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 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 };
const base = import.meta.env.API_INTERNAL_URL || "http://localhost:8000"; const base = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
export async function api<T>(path: string): Promise<T> { export async function api<T>(path: string): Promise<T> {
const response = await fetch(`${base}${path}`); const response = await fetch(`${base}${path}`);
+13 -2
View File
@@ -1,10 +1,21 @@
import type { APIRoute } from "astro"; import type { APIRoute } from "astro";
const base = import.meta.env.API_INTERNAL_URL || "http://localhost:8000"; const base = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000";
export const POST: APIRoute = async ({ request, redirect }) => { export const POST: APIRoute = async ({ request, redirect }) => {
const form = await request.formData(); const form = await request.formData();
const text = (name: string) => String(form.get(name) || "").trim() || null; const text = (name: string) => String(form.get(name) || "").trim() || null;
const number = (name: string) => text(name) ? Number(text(name)) : null; const number = (name: string) => text(name) ? Number(text(name)) : null;
const payload = { fish_slug: String(form.get("fish_slug") || ""), waterbody_slug: String(form.get("waterbody_slug") || ""), x: Number(form.get("x")), y: Number(form.get("y")), weight_g: Number(form.get("weight_g")), bait_name: text("bait_name"), fishing_method: text("fishing_method"), retrieve_method: text("retrieve_method"), retrieve_speed: number("retrieve_speed"), player_name: text("player_name"), comment: text("comment"), website: String(form.get("website") || "") }; const payload = { fish_slug: String(form.get("fish_slug") || ""), waterbody_slug: String(form.get("waterbody_slug") || ""), x: Number(form.get("x")), y: Number(form.get("y")), weight_g: Number(form.get("weight_g")), bait_name: text("bait_name"), fishing_method: text("fishing_method"), retrieve_method: text("retrieve_method"), retrieve_speed: number("retrieve_speed"), player_name: text("player_name"), comment: text("comment"), website: String(form.get("website") || "") };
try { const response = await fetch(`${base}/api/v1/catch-reports`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) }); return redirect(response.ok ? "/report?state=sent" : "/report?state=error", 303); } try {
const response = await fetch(`${base}/api/v1/catch-reports`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) });
if (!response.ok) return redirect("/report?state=error", 303);
const created = await response.json();
const screenshot = form.get("screenshot");
if (screenshot instanceof File && screenshot.size > 0) {
const upload = new FormData(); upload.set("screenshot", screenshot);
const uploaded = await fetch(`${base}/api/v1/catch-reports/${created.id}/screenshot`, { method: "POST", body: upload });
if (!uploaded.ok) return redirect("/report?state=error", 303);
}
return redirect("/report?state=sent", 303);
}
catch { return redirect("/report?state=error", 303); } catch { return redirect("/report?state=error", 303); }
}; };
+9 -7
View File
@@ -1,7 +1,7 @@
--- ---
import Layout from "../layouts/Layout.astro"; import Layout from "../layouts/Layout.astro";
import ActivityCard from "../components/ActivityCard.astro"; import ActivityCard from "../components/ActivityCard.astro";
import { api, type Activity, type DictionaryItem } from "../lib/api"; import { ago, api, kg, type Activity, type DictionaryItem } from "../lib/api";
const params = Astro.url.searchParams; const params = Astro.url.searchParams;
const hours = params.get("hours") ?? "24"; const hours = params.get("hours") ?? "24";
@@ -18,14 +18,16 @@ try {
} catch { unavailable = true; } } catch { unavailable = true; }
--- ---
<Layout title="Что клюёт сейчас — RF4 Spotter"> <Layout title="Что клюёт сейчас — RF4 Spotter">
<section class="hero"><div><span class="eyebrow">Сводка активности</span><h1>Что клюёт<br/><em>прямо сейчас</em></h1></div><p>Свежие подтверждения, рабочие приманки и честная оценка надёжности данных.</p></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} точки показывают активность</strong></div><div class="pulse-orb"><span></span></div></div></div></section>
<form class="filters" method="get"> <section class="filters-wrap"><form class="filters content-grid" method="get">
<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>
<label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(x => <option value={x.slug} selected={fish === x.slug}>{x.name_ru}</option>)}</select></label> <label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(x => <option value={x.slug} selected={fish === x.slug}>{x.name_ru}</option>)}</select></label>
<label>Период<select name="hours"><option value="6" selected={hours === "6"}>6 часов</option><option value="12" selected={hours === "12"}>12 часов</option><option value="24" selected={hours === "24"}>24 часа</option><option value="72" selected={hours === "72"}>72 часа</option></select></label> <label>Период<select name="hours"><option value="6" selected={hours === "6"}>6 часов</option><option value="12" selected={hours === "12"}>12 часов</option><option value="24" selected={hours === "24"}>24 часа</option><option value="72" selected={hours === "72"}>72 часа</option></select></label>
<label>Сначала<select name="sort"><option value="activity" selected={sort === "activity"}>Активные</option><option value="confidence" selected={sort === "confidence"}>Надёжные</option><option value="freshness" selected={sort === "freshness"}>Свежие</option></select></label> <label>Сначала<select name="sort"><option value="activity" selected={sort === "activity"}>Активные</option><option value="confidence" selected={sort === "confidence"}>Надёжные</option><option value="freshness" selected={sort === "freshness"}>Свежие</option></select></label>
<button>Показать</button> <button>⌕ Найти клёв</button>
</form> </form></section>
<div class="section-heading"><h2>Активные точки</h2><span>{items.length} комбинации</span></div> <section class="dashboard content-grid"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} точки</span></div>{unavailable ? <div class="state"><h2>Источник временно недоступен</h2><p>Не показываем устаревшие догадки.</p></div> : items.length ? <div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div> : <div class="state"><h2>Пока нет свежих данных</h2><p>Попробуйте другой водоём или период.</p></div>}</div>
{unavailable ? <div class="state"><h2>Источник временно недоступен</h2><p>Не показываем устаревшие догадки. Попробуйте обновить страницу позже.</p></div> : items.length ? <div class="grid">{items.map(item => <ActivityCard item={item} />)}</div> : <div class="state"><h2>За этот период данных нет</h2><p>Измените фильтры или выберите более длинный период.</p></div>} {items[0] && <aside class="detail-card"><div class="detail-head"><div><span class="overline">Лидер активности</span><h2>{items[0].waterbody} <em>{items[0].x}:{items[0].y}</em></h2></div><a href={`/spots/${items[0].spot_id}`} aria-label="Открыть точку">↗</a></div><div class="detail-score"><div class="score-ring" style={`--score:${items[0].activity_score * 3.6}deg`}><div><strong>{items[0].activity_score}</strong><span>из 100</span></div></div><div><span>Индекс активности</span><strong>{items[0].activity_score >= 70 ? "Клёв отличный" : "Клёв хороший"}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span>◎</span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span>♟</span><small>Игроков</small><strong>{items[0].unique_players}</strong></div><div><span>◷</span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span>⌁</span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><i></i><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{items[0].catches} уловов</span></div></div><p class="confidence-note"><span>✓</span><span><strong>Оценка объяснима.</strong> Один игрок не может искусственно поднять уверенность.</span></p></aside>}
</section>
<section class="how-it-works content-grid"><div><span class="overline">Как читать данные</span><h2>Не обещаем рыбу.<br/>Показываем факты.</h2></div><div class="principles"><article><span>01</span><h3>Свежесть</h3><p>Чем старше сообщение, тем меньше оно влияет на активность.</p></article><article><span>02</span><h3>Разные игроки</h3><p>Десять уловов одного человека не равны десяти подтверждениям.</p></article><article><span>03</span><h3>Уверенность</h3><p>Каждая оценка объясняет, сколько данных за ней стоит.</p></article></div></section>
</Layout> </Layout>
+2 -2
View File
@@ -11,7 +11,7 @@ const last = runs[0];
<Layout title="Официальные рекорды — RF4 Spotter"> <Layout title="Официальные рекорды — RF4 Spotter">
<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>Slug рыбы<input name="fish" value={fish} placeholder="pike" /></label><label>Slug водоёма<input name="waterbody" value={waterbody} placeholder="vyunok" /></label><button>Фильтровать</button></form> <form class="record-filters" method="get"><label>Slug рыбы<input name="fish" value={fish} placeholder="pike" /></label><label>Slug водоёма<input name="waterbody" value={waterbody} placeholder="vyunok" /></label><button>Фильтровать</button></form>
<div class="section-heading"><h2>Последние записи</h2><span>{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>
{unavailable ? <div class="state"><h2>Источник временно недоступен</h2></div> : records.length ? <div class="record-table"><div class="record-row record-head"><span>Рыба</span><span>Вес</span><span>Водоём</span><span>Приманка</span><span>Игрок</span><span>Дата</span></div>{records.map(record => <article class="record-row"><strong>{record.fish}</strong><strong>{kg(record.weight_g)}</strong><span>{record.waterbody}</span><span>{record.bait ?? "—"}</span><span>{record.player_name ?? "—"}</span><time>{record.record_date ? new Date(record.record_date).toLocaleDateString("ru-RU") : "—"}</time></article>)}</div> : <div class="state"><h2>Рекорды ещё не импортированы</h2><p>Запустите <code>python -m app.cli import-records</code>. Пустой результат не подменяется демоданными.</p></div>} {unavailable ? <div class="state content-grid"><h2>Источник временно недоступен</h2></div> : records.length ? <div class="record-table"><div class="record-row record-head"><span>Рыба</span><span>Вес</span><span>Водоём</span><span>Приманка</span><span>Игрок</span><span>Дата</span></div>{records.map(record => <article class="record-row"><strong>{record.fish}</strong><strong>{kg(record.weight_g)}</strong><span>{record.waterbody}</span><span>{record.bait ?? "—"}</span><span>{record.player_name ?? "—"}</span><time>{record.record_date ? new Date(record.record_date).toLocaleDateString("ru-RU") : "—"}</time></article>)}</div> : <div class="state content-grid"><h2>Рекорды ещё не импортированы</h2><p>Запустите <code>python -m app.cli import-records</code>. Пустой результат не подменяется демоданными.</p></div>}
<p class="official-note">Источник: <a href="https://rf4game.de/records/region/RU/" rel="noreferrer">официальный сайт Russian Fishing 4</a>. Координаты в официальных таблицах отсутствуют.</p> <p class="official-note">Источник: <a href="https://rf4game.de/records/region/RU/" rel="noreferrer">официальный сайт Russian Fishing 4</a>. Координаты в официальных таблицах отсутствуют.</p>
</Layout> </Layout>
+3 -3
View File
@@ -6,11 +6,11 @@ try { [fishes, waterbodies] = await Promise.all([api<DictionaryItem[]>("/api/v1/
const state = Astro.url.searchParams.get("state"); const state = Astro.url.searchParams.get("state");
--- ---
<Layout title="Добавить улов — RF4 Spotter"> <Layout title="Добавить улов — RF4 Spotter">
<section class="form-hero"><span class="eyebrow">Помогите другим игрокам</span><h1>Добавить улов</h1><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 === "error" && <div class="notice error">Не удалось отправить улов. Проверьте поля и повторите.</div>} {state === "error" && <div class="notice error">Не удалось отправить улов. Проверьте поля и повторите.</div>}
{unavailable ? <div class="state"><h2>Форма временно недоступна</h2></div> : <form class="report-form" method="post" action="/api/report"> {unavailable ? <div class="state"><h2>Форма временно недоступна</h2></div> : <form class="report-form" method="post" action="/api/report" enctype="multipart/form-data">
<div class="form-grid"><label>Рыба *<select name="fish_slug" required>{fishes.map(x => <option value={x.slug}>{x.name_ru}</option>)}</select></label><label>Водоём *<select name="waterbody_slug" required>{waterbodies.map(x => <option value={x.slug}>{x.name_ru}</option>)}</select></label><label>Координата X *<input name="x" type="number" min="-10000" max="10000" required /></label><label>Координата Y *<input name="y" type="number" min="-10000" max="10000" required /></label><label>Вес, граммы *<input name="weight_g" type="number" min="1" max="3000000" required /></label><label>Приманка<input name="bait_name" maxlength="200" /></label><label>Способ ловли<select name="fishing_method"><option value="">Не указан</option><option value="spinning">Спиннинг</option><option value="bottom">Донная</option><option value="float">Поплавочная</option></select></label><label>Проводка<input name="retrieve_method" maxlength="100" /></label><label>Скорость проводки<input name="retrieve_speed" type="number" min="0" max="100" /></label><label>Ник игрока<input name="player_name" maxlength="100" /></label></div> <div class="form-grid"><label>Рыба *<select name="fish_slug" required>{fishes.map(x => <option value={x.slug}>{x.name_ru}</option>)}</select></label><label>Водоём *<select name="waterbody_slug" required>{waterbodies.map(x => <option value={x.slug}>{x.name_ru}</option>)}</select></label><label>Координата X *<input name="x" type="number" min="-10000" max="10000" required /></label><label>Координата Y *<input name="y" type="number" min="-10000" max="10000" required /></label><label>Вес, граммы *<input name="weight_g" type="number" min="1" max="3000000" required /></label><label>Приманка<input name="bait_name" maxlength="200" /></label><label>Способ ловли<select name="fishing_method"><option value="">Не указан</option><option value="spinning">Спиннинг</option><option value="bottom">Донная</option><option value="float">Поплавочная</option></select></label><label>Проводка<input name="retrieve_method" maxlength="100" /></label><label>Скорость проводки<input name="retrieve_speed" type="number" min="0" max="100" /></label><label>Ник игрока<input name="player_name" maxlength="100" /></label></div>
<label class="wide">Комментарий<textarea name="comment" maxlength="1000" rows="4"></textarea></label><label class="honeypot" aria-hidden="true">Сайт<input name="website" tabindex="-1" autocomplete="off" /></label><p class="privacy">Ник необязателен. Не отправляйте личные данные. Скриншоты будут добавлены после подключения объектного хранилища.</p><button type="submit">Отправить на проверку</button> <label class="wide">Комментарий<textarea name="comment" maxlength="1000" rows="4"></textarea></label><label class="wide">Скриншот, JPEG/PNG/WebP до 8 МБ<input name="screenshot" type="file" accept="image/jpeg,image/png,image/webp" /></label><label class="honeypot" aria-hidden="true">Сайт<input name="website" tabindex="-1" autocomplete="off" /></label><p class="privacy">Ник и скриншот необязательны. Из изображения удаляются EXIF и прочие метаданные.</p><button type="submit">Отправить на проверку</button>
</form>} </form>}
</Layout> </Layout>
+14 -23
View File
@@ -1,23 +1,14 @@
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&family=Unbounded:wght@600;700&display=swap'); :root{--paper:#f2f5ee;--deep:#082226;--teal:#12383b;--lime:#c9f45b;--ink:#092226;--muted:#647472;--line:#cbd6ce;--white:#fff;--orange:#ffb65c;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--ink);background:var(--paper)}
:root { color-scheme: dark; --bg:#07110f; --panel:#0d1c18; --line:#20332d; --ink:#f2f4e9; --muted:#93a69f; --accent:#d7f45b; --orange:#ff985a; font-family:Manrope,system-ui,sans-serif; } *{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;min-width:320px;background:var(--paper);color:var(--ink)}a{color:inherit}button,input,select,textarea{font:inherit}button{cursor:pointer}.content-grid{width:min(1360px,calc(100% - 64px));margin-inline:auto}main{min-height:calc(100vh - 204px)}
* { box-sizing:border-box; } .topbar{height:86px;width:min(1480px,calc(100% - 48px));margin:auto;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:28px}.brand{display:flex;align-items:center;gap:12px;text-decoration:none;min-width:max-content}.brand-mark{width:42px;height:42px;display:grid;place-items:center;border:1px solid #9eb0a7;border-radius:50%;background:var(--deep);color:var(--lime);font-size:28px}.brand-name{display:flex;flex-direction:column;font:17px/.95 Georgia,serif}.brand-name strong{font-size:20px;font-style:italic}.topbar nav{height:100%;display:flex;gap:23px;align-items:center}.topbar nav a{height:100%;display:flex;align-items:center;gap:7px;padding:0 8px;text-decoration:none;color:#526461;font-size:14px;border-bottom:3px solid transparent}.topbar nav a.active{color:var(--deep);border-color:var(--deep)}.live-badge{justify-self:end;display:flex;align-items:center;gap:9px;color:#60716e;font-size:13px}.live-badge>span{width:8px;height:8px;border-radius:50%;background:#83b83a;box-shadow:0 0 0 5px #83b83a20}
body { margin:0; background:radial-gradient(circle at 15% -10%,#173b30 0,transparent 30rem),var(--bg); color:var(--ink); min-height:100vh; } .eyebrow,.overline{text-transform:uppercase;letter-spacing:.14em;font-size:12px;font-weight:750;color:#657673}.eyebrow{display:flex;align-items:center;gap:10px}.eyebrow b{display:grid;place-items:center;min-width:41px;height:25px;padding:0 8px;border:1px solid #9aaa9f;border-radius:20px;color:var(--deep);letter-spacing:.05em}.intro{padding:74px 0 42px;display:grid;grid-template-columns:.92fr 1.08fr;align-items:end;gap:70px}.intro h1,.records-hero h1,.form-hero h1,.spot-hero h1{margin:18px 0 22px;font:400 clamp(58px,6.3vw,102px)/.87 Georgia,"Times New Roman",serif;letter-spacing:-.065em}.intro h1 em,.records-hero h1 em,.form-hero h1 em{color:#497074;font-weight:400}.intro-copy>p{font-size:18px;line-height:1.55;color:#586a68;max-width:560px}.lake-card{position:relative;border-radius:18px;overflow:hidden;height:326px;box-shadow:0 24px 70px #173c3f24}.lake-card img{width:100%;height:100%;object-fit:cover;display:block}.lake-card:after{content:"";position:absolute;inset:0;background:linear-gradient(180deg,transparent 35%,#041c20d9)}.lake-overlay{position:absolute;z-index:2;inset:auto 24px 22px 26px;display:flex;justify-content:space-between;align-items:end;color:#fff}.lake-overlay .overline{color:#d2dedb;display:block;margin-bottom:7px}.lake-overlay strong{font:400 21px Georgia,serif}.pulse-orb{width:45px;height:45px;border:1px solid #ffffff7d;border-radius:50%;display:grid;place-items:center}.pulse-orb span{width:9px;height:9px;border-radius:50%;background:var(--lime);box-shadow:0 0 0 8px #c9f45b25}
a { color:inherit; } .filters-wrap{background:var(--deep);padding:24px 0;position:sticky;top:0;z-index:20;box-shadow:0 10px 35px #08222620}.filters{display:grid;grid-template-columns:repeat(4,1fr) auto;gap:12px;align-items:end}.filters label,.report-form label,.record-filters label{color:#9fb0ad;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.filters select{display:block;width:100%;height:48px;margin-top:7px;padding:0 13px;border:1px solid #ffffff29;border-radius:10px;background:#ffffff0c;color:#f5f8f3}.filters option{color:var(--ink)}.filters button,.report-form button,.record-filters button{height:48px;padding:0 24px;border:0;border-radius:10px;background:var(--lime);color:var(--deep);font-weight:750}
.site-header, main, footer { width:min(1180px,calc(100% - 40px)); margin:auto; } .dashboard{display:grid;grid-template-columns:minmax(0,1.45fr) minmax(360px,.75fr);gap:26px;padding:66px 0 104px;align-items:start}.section-heading{display:flex;justify-content:space-between;align-items:end;margin:0 0 23px}.section-heading h2,.detail-head h2,.how-it-works h2{font:400 38px Georgia,serif;margin:6px 0 0;letter-spacing:-.04em}.result-count{border:1px solid #b9c6bf;color:#667875;font-size:13px;padding:7px 11px;border-radius:20px}.spot-list{display:flex;flex-direction:column;gap:12px}.spot-card{position:relative;display:grid;grid-template-columns:45px 1fr 190px;gap:18px;padding:22px 48px 22px 20px;border:1px solid #d5ded7;border-radius:16px;background:#fbfcf9;text-decoration:none;transition:.22s ease}.spot-card:hover{transform:translateY(-2px);border-color:#7d9488;box-shadow:0 18px 40px #14333812;background:#fff}.spot-rank{width:36px;height:36px;border:1px solid #cad4cd;border-radius:50%;display:grid;place-items:center;font:italic 14px Georgia,serif;color:#788985}.spot-topline{display:flex;align-items:center;gap:12px;color:#687a77;font-size:13px}.activity-pill{display:inline-flex;align-items:center;gap:6px;padding:5px 9px;border-radius:30px;background:#e6f7c3;color:#426315;font-size:11px;font-weight:750}.activity-pill i{width:6px;height:6px;border-radius:50%;background:#6d9f32}.spot-main h3{font:400 25px Georgia,serif;margin:8px 0}.spot-meta{display:flex;gap:16px;color:#71817f;font-size:13px}.bait-line{display:flex;gap:11px;align-items:center;margin-top:18px;padding-top:15px;border-top:1px solid #e2e8e2}.lure-dot,.best-lure i{display:block;width:12px;height:28px;border-radius:50% 50% 43% 43%;background:var(--orange);transform:rotate(22deg);box-shadow:inset -4px 0 #091e2250}.bait-line div{display:flex;flex-direction:column;gap:2px}.bait-line div span{color:#82908e;font-size:11px;text-transform:uppercase;letter-spacing:.09em}.bait-line strong{font-size:14px}.spot-stats{border-left:1px solid #e2e8e2;padding-left:18px;display:grid;grid-template-columns:1fr 1fr;gap:18px 10px;align-content:center}.spot-stats div{display:flex;flex-direction:column}.spot-stats strong{font:400 20px Georgia,serif}.spot-stats span{font-size:9px;text-transform:uppercase;letter-spacing:.08em;color:#7c8d89}.card-arrow{position:absolute;right:18px;top:50%;transform:translateY(-50%);font-size:28px;color:#91a09c}
.site-header { height:86px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); } .detail-card{position:sticky;top:116px;margin:0;background:var(--deep);color:#f5f8f3;border:0;border-radius:18px;padding:27px;overflow:hidden}.detail-card:before{content:"";position:absolute;width:420px;height:420px;border:1px solid #ffffff0f;border-radius:50%;left:48%;top:-210px;box-shadow:0 0 0 48px #ffffff08,0 0 0 96px #ffffff05}.detail-card>*{position:relative;z-index:1}.detail-head{display:flex;justify-content:space-between}.detail-head .overline,.best-lure .overline{color:#a7b6b3}.detail-head h2{font-size:30px}.detail-head h2 em{color:var(--lime);font-size:22px}.detail-head>a{width:40px;height:40px;display:grid;place-items:center;border:1px solid #ffffff34;border-radius:50%;text-decoration:none}.detail-score{display:grid;grid-template-columns:126px 1fr;gap:20px;align-items:center;padding:34px 0 29px}.score-ring{width:122px;height:122px;display:grid;place-items:center;border-radius:50%;background:conic-gradient(var(--lime) var(--score),#ffffff16 0);position:relative}.score-ring:after{content:"";position:absolute;inset:8px;background:var(--deep);border-radius:50%}.score-ring>div{z-index:1;text-align:center;display:flex;flex-direction:column}.score-ring strong{font:400 39px/1 Georgia,serif}.score-ring span{color:#a9b8b5;font-size:10px;text-transform:uppercase}.detail-score>div>span{color:#a9b8b5;font-size:11px;text-transform:uppercase;letter-spacing:.1em}.detail-score>div>strong{display:block;margin:5px 0 8px;font:400 24px Georgia,serif;color:var(--lime)}.detail-score p{color:#b7c3c0;font-size:13px;line-height:1.5;margin:0}.metric-grid{display:grid;grid-template-columns:1fr 1fr;border:1px solid #ffffff1c;border-radius:12px;overflow:hidden}.metric-grid>div{min-height:91px;padding:15px;display:grid;grid-template-columns:23px 1fr;gap:3px 8px;border-bottom:1px solid #ffffff1c}.metric-grid>div:nth-child(odd){border-right:1px solid #ffffff1c}.metric-grid>div:nth-last-child(-n+2){border-bottom:0}.metric-grid>div>span{grid-row:1/3;color:var(--lime)}.metric-grid small{color:#9cadaa;text-transform:uppercase;font-size:9px}.metric-grid strong{font:400 17px Georgia,serif}.best-lure{margin-top:28px}.best-lure>div{display:grid;grid-template-columns:18px 1fr auto;gap:11px;align-items:center;padding:15px 0;border-bottom:1px solid #ffffff17}.best-lure>div span{color:var(--lime);font-size:12px}.confidence-note{display:flex;gap:10px;padding:16px;background:#ffffff0a;border-radius:10px;margin-top:22px;font-size:12px;line-height:1.45;color:#aebcba}.confidence-note>span:first-child{color:var(--lime)}.confidence-note strong{color:#fff}
.brand { text-decoration:none; font-family:Unbounded,sans-serif; font-weight:700; font-size:19px; }.brand span{color:var(--accent)} .state{min-height:240px;display:grid;place-items:center;align-content:center;text-align:center;border:1px dashed #b9c7bf;border-radius:16px;color:#71817f}.state h2{font:400 28px Georgia,serif;color:var(--deep);margin:0}.how-it-works{border-top:1px solid #cfd8d1;padding:85px 0 105px;display:grid;grid-template-columns:.8fr 1.2fr;gap:70px}.how-it-works h2{font-size:47px;line-height:1.05}.principles{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}.principles article{padding-top:24px;border-top:2px solid #294b4e}.principles article>span{font:italic 16px Georgia,serif;color:#82928f}.principles h3{margin:28px 0 8px;font:400 22px Georgia,serif}.principles p{font-size:14px;line-height:1.55;color:#657572}
.site-header nav{display:flex;gap:24px}.site-header nav a{font-size:13px;color:#bdcbc6;text-decoration:none}.site-header nav a:hover{color:var(--accent)} .records-hero,.form-hero{width:min(1360px,calc(100% - 64px));margin:auto;padding:76px 0 48px;display:flex;align-items:end;justify-content:space-between;gap:70px}.records-hero h1,.form-hero h1{font-size:clamp(58px,7vw,108px);margin-bottom:0}.records-hero>div:last-child,.form-hero>p{max-width:500px;color:#627370;line-height:1.65}.source-status{display:grid;grid-template-columns:auto 1fr;gap:4px 9px}.source-status small{grid-column:2}.status-dot{width:9px;height:9px;border-radius:50%;background:#82928f}.status-dot.success{background:#83b83a}.record-filters,.report-form{width:min(1360px,calc(100% - 64px));margin:0 auto 55px;padding:25px;background:#fff;border:1px solid #d4ddd6;border-radius:18px;box-shadow:0 30px 80px #16383c12}.record-filters{display:flex;gap:14px;background:var(--deep)}.record-filters label{flex:1}.record-filters input,.report-form input,.report-form textarea,.report-form select{display:block;width:100%;margin-top:7px;padding:13px 14px;border:1px solid #d6dfd7;border-radius:9px;background:#f3f6f1;color:var(--deep)}.record-table{width:min(1360px,calc(100% - 64px));margin:auto;background:#fff;border-radius:16px;overflow:hidden}.record-row{display:grid;grid-template-columns:1.2fr .65fr 1.1fr 1.5fr 1fr .75fr;gap:14px;padding:20px;border-top:1px solid #e3e9e3}.record-head{background:#e4ebe3;border:0}.record-head span{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:#647572}.record-row>span,.record-row>time{font-size:13px;color:#60716e}.official-note{width:min(1360px,calc(100% - 64px));margin:20px auto 100px;color:#647572;font-size:12px}.form-hero{align-items:center}.form-hero>div{}.form-hero>p{font-size:17px}.notice{width:min(1360px,calc(100% - 64px));margin:0 auto 18px;padding:16px;border-radius:10px}.notice.success{background:#dff0c3;color:#426315}.notice.error{background:#f5d8d2;color:#8a3025}.report-form{max-width:900px;padding:34px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px 16px}.report-form label{display:block;color:#60716e}.report-form .wide{display:block;margin-top:20px}.report-form input:focus,.report-form textarea:focus,.report-form select:focus{outline:none;border-color:#839d4b;box-shadow:0 0 0 3px #c9f45b45}.privacy{font-size:12px;color:#647572}.honeypot{position:absolute;left:-9999px}.back{display:inline-block;margin:40px max(32px,calc((100% - 1360px)/2)) 10px;color:#60716e}.spot-hero,.periods,.detail-grid{width:min(1180px,calc(100% - 64px));margin-inline:auto}.spot-hero{padding:50px;background:var(--deep);color:#fff;border-radius:18px;display:flex;justify-content:space-between}.spot-hero h1{font-size:70px}.spot-hero p{color:#a9b8b5}.pin{width:160px;height:160px;border:1px solid #ffffff34;border-radius:50%;display:grid;place-items:center;color:var(--lime);font:400 35px Georgia}.periods{display:grid;grid-template-columns:repeat(3,1fr);margin-top:20px;background:#fff;border-radius:14px;overflow:hidden}.periods>div{text-align:center;padding:25px;border-right:1px solid #e2e8e2}.periods strong{display:block;font:400 32px Georgia;color:#537014}.periods span{font-size:12px;color:#647572}.detail-grid{display:grid;grid-template-columns:2fr 1fr;gap:24px;padding-bottom:80px}.catch-list article{display:flex;justify-content:space-between;padding:16px 0;border-bottom:1px solid #d5ded7}.catch-list article>div{display:flex;flex-direction:column}.catch-list article>div:last-child{text-align:right}.catch-list span{font-size:12px;color:#647572}.detail-grid aside{margin-top:50px;padding:25px;background:#fff;border-radius:14px}.detail-grid aside li{padding:10px 0;border-bottom:1px solid #e2e8e2}.note{font-size:12px;color:#647572}
.site-header p, footer { color:var(--muted); font-size:13px; } footer{padding:45px 0 30px} footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32px,calc((100vw - 1360px)/2));display:grid;grid-template-columns:1fr 1fr auto;align-items:center;gap:28px}footer .brand-mark{border-color:#ffffff32}footer p{font-size:12px;color:#92a4a0}footer>span{font:italic 16px Georgia;color:var(--lime)}
.hero { padding:78px 0 55px; display:grid; grid-template-columns:1.4fr .6fr; align-items:end; gap:30px; } @media(max-width:1040px){.content-grid{width:min(100% - 36px,900px)}.topbar{width:calc(100% - 36px);grid-template-columns:1fr auto}.live-badge{display:none}.intro{grid-template-columns:1fr;gap:34px;padding-top:54px}.lake-card{height:280px}.dashboard{grid-template-columns:1fr}.detail-card{position:relative;top:auto}.how-it-works{grid-template-columns:1fr}.records-hero,.form-hero{display:block}.records-hero>div:last-child,.form-hero>p{margin-top:25px}}
.hero h1,.spot-hero h1 { font:700 clamp(42px,7vw,86px)/.98 Unbounded,sans-serif; letter-spacing:-.055em; margin:14px 0 0; }.hero h1 em{color:var(--accent);font-style:normal}.hero>p{font-size:18px;line-height:1.65;color:#b9c6c1;max-width:420px} @media(max-width:720px){.content-grid,.records-hero,.form-hero,.record-filters,.record-table,.official-note,.report-form,.spot-hero,.periods,.detail-grid{width:calc(100% - 28px)}.topbar{width:100%;padding:13px 14px 0;display:flex;flex-wrap:wrap;height:auto}.topbar .brand{flex:1}.topbar nav{order:2;width:100%;height:46px;overflow-x:auto}.topbar nav a{flex:0 0 auto;font-size:13px}.intro h1,.records-hero h1,.form-hero h1{font-size:55px}.intro{padding-top:48px}.lake-card{height:225px}.filters-wrap{position:relative}.filters{grid-template-columns:1fr 1fr}.dashboard{padding:45px 0 74px}.spot-card{grid-template-columns:34px 1fr;padding:18px 18px 18px 14px;gap:10px}.spot-stats{grid-column:2;border:0;border-top:1px solid #e2e8e2;padding:13px 0 0;grid-template-columns:repeat(4,1fr)}.card-arrow{display:none}.detail-score{grid-template-columns:105px 1fr}.score-ring{width:100px;height:100px}.principles{grid-template-columns:1fr}.record-filters{display:grid}.record-row{grid-template-columns:1fr 1fr}.record-head{display:none}.record-row>*:nth-child(even){text-align:right}.form-grid,.detail-grid{grid-template-columns:1fr}.spot-hero{padding:28px}.spot-hero h1{font-size:48px}.pin{display:none}footer{grid-template-columns:1fr auto;padding:30px 20px}footer p{grid-column:1/-1;order:3}}
.eyebrow { color:var(--accent); font-size:11px; font-weight:800; letter-spacing:.14em; text-transform:uppercase; } @media(max-width:480px){.filters{grid-template-columns:1fr}.spot-stats strong{font-size:16px}.topbar nav{gap:4px}.topbar nav a{padding:0 5px}.brand-name{font-size:14px}.brand-name strong{font-size:17px}}
.filters { padding:18px; background:#0b1915cc; border:1px solid var(--line); display:grid; grid-template-columns:repeat(4,1fr) auto; gap:12px; border-radius:16px; position:sticky; top:10px; z-index:2; backdrop-filter:blur(16px); } @media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition-duration:.01ms!important}}
label { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.08em; } select { display:block; width:100%; margin-top:7px; border:0; color:var(--ink); background:#14251f; padding:12px; border-radius:8px; font:600 14px Manrope; } button{align-self:end;border:0;border-radius:8px;background:var(--accent);color:#102015;font-weight:800;padding:13px 24px;cursor:pointer}
.section-heading { display:flex; align-items:center; justify-content:space-between; margin:50px 0 20px; }.section-heading h2{font:600 22px Unbounded;margin:0}.section-heading span{color:var(--muted);font-size:13px}
.grid { display:grid; grid-template-columns:repeat(2,1fr); gap:18px; }.card{padding:28px;background:linear-gradient(145deg,#10231dcf,#0a1714);border:1px solid var(--line);border-radius:18px}.card-top{display:flex;justify-content:space-between;gap:20px}.card h2{font:700 30px Unbounded;margin:8px 0}.score{text-align:center;background:#182b22;border-radius:50%;width:82px;height:82px;display:flex;flex-direction:column;justify-content:center;flex:none}.score strong{font:700 27px Unbounded;color:var(--accent)}.score span{font-size:8px;text-transform:uppercase;color:var(--muted)}
.coordinates{display:flex;justify-content:space-between;background:var(--accent);color:#0b1713;padding:14px 17px;border-radius:9px;text-decoration:none;font-weight:800;margin:20px 0}.bait{display:flex;flex-direction:column;gap:5px}.bait span,.fresh{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.07em}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;border-block:1px solid var(--line);padding:18px 0;margin:20px 0}.stats div{display:flex;flex-direction:column-reverse}.stats dt{font-size:10px;color:var(--muted)}.stats dd{font-weight:700;margin:0 0 3px}.explanation{min-height:48px;color:#bac8c3;font-size:13px;line-height:1.6}.pulse{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--accent);margin-right:5px}.state{padding:50px;border:1px dashed #385148;text-align:center;border-radius:16px;color:var(--muted)}.state h1,.state h2{color:var(--ink)}
.back{display:inline-block;margin:45px 0 25px;color:var(--muted);text-decoration:none}.spot-hero{display:flex;justify-content:space-between;align-items:center;padding:40px;background:linear-gradient(130deg,#142c24,#0b1714);border:1px solid var(--line);border-radius:20px}.spot-hero h1{font-size:clamp(36px,6vw,72px)}.spot-hero p{color:var(--muted)}.pin{font:700 40px Unbounded;color:var(--accent);border:1px solid #38523e;border-radius:50%;width:170px;height:170px;display:grid;place-content:center}.pin span{color:var(--orange)}.periods{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--line);border:1px solid var(--line);margin:20px 0;border-radius:14px;overflow:hidden}.periods div{background:var(--panel);padding:25px;text-align:center}.periods strong{display:block;font:700 32px Unbounded;color:var(--accent)}.periods span{font-size:12px;color:var(--muted)}.detail-grid{display:grid;grid-template-columns:2fr 1fr;gap:22px}.catch-list article{display:flex;justify-content:space-between;border-bottom:1px solid var(--line);padding:16px 2px}.catch-list article>div{display:flex;flex-direction:column}.catch-list article>div:last-child{text-align:right}.catch-list span{color:var(--muted);font-size:12px;margin-top:4px}aside{background:var(--panel);border:1px solid var(--line);border-radius:15px;padding:25px;margin-top:50px}aside li{padding:11px 0;border-bottom:1px solid var(--line)}.note{font-size:12px;line-height:1.6;color:var(--muted)}
@media(max-width:800px){.site-header p{display:none}.hero{grid-template-columns:1fr;padding-top:50px}.filters{position:static;grid-template-columns:1fr 1fr}.filters button{grid-column:1/-1}.grid,.detail-grid{grid-template-columns:1fr}.stats{grid-template-columns:1fr 1fr}.spot-hero{padding:25px}.pin{display:none}.periods div{padding:18px 8px}.periods strong{font-size:24px}}
@media(max-width:480px){.site-header,main,footer{width:min(100% - 24px,1180px)}.filters{grid-template-columns:1fr}.card{padding:20px}.hero h1{font-size:40px}.periods span{font-size:10px}}
.records-hero{padding:70px 0 45px;display:flex;align-items:end;justify-content:space-between;gap:30px}.records-hero h1{font:700 clamp(42px,7vw,78px)/1 Unbounded;margin:15px 0 0;letter-spacing:-.05em}.records-hero h1 em{color:var(--accent);font-style:normal}.source-status{display:grid;grid-template-columns:auto 1fr;gap:4px 9px;align-items:center;color:#c6d1cd}.source-status small{grid-column:2;color:var(--muted)}.status-dot{width:9px;height:9px;border-radius:50%;background:#66736e}.status-dot.success{background:var(--accent)}.status-dot.failed{background:#ff6f61}.record-filters{display:flex;gap:12px;padding:18px;border:1px solid var(--line);background:var(--panel);border-radius:14px}.record-filters label{flex:1}.record-filters input{display:block;width:100%;margin-top:7px;padding:12px;border:0;border-radius:8px;background:#14251f;color:var(--ink)}.record-table{border:1px solid var(--line);border-radius:14px;overflow:hidden}.record-row{display:grid;grid-template-columns:1.2fr .65fr 1.1fr 1.5fr 1fr .75fr;gap:14px;padding:16px 18px;border-bottom:1px solid var(--line);align-items:center}.record-row:last-child{border:0}.record-row span,.record-row time{font-size:13px;color:#aebdb7}.record-head{background:#14251f;text-transform:uppercase;letter-spacing:.07em}.record-head span{font-size:10px;color:var(--muted)}.official-note{color:var(--muted);font-size:12px;margin-top:20px}.official-note a{color:#bccf61}@media(max-width:800px){.site-header nav{gap:12px}.records-hero{display:block}.source-status{margin-top:30px}.record-filters{display:grid}.record-row{grid-template-columns:1fr 1fr}.record-head{display:none}.record-row>*:nth-child(even){text-align:right}}
.form-hero{padding:65px 0 35px}.form-hero h1{font:700 clamp(40px,7vw,74px)/1 Unbounded;margin:14px 0;letter-spacing:-.05em}.form-hero p{color:var(--muted)}.report-form{padding:28px;border:1px solid var(--line);background:var(--panel);border-radius:18px}.form-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:18px}.report-form input,.report-form textarea{display:block;width:100%;margin-top:7px;padding:12px;border:1px solid #223a32;border-radius:8px;background:#14251f;color:var(--ink);font:14px Manrope}.report-form .wide{display:block;margin-top:18px}.privacy{font-size:12px;color:var(--muted);line-height:1.6}.honeypot{position:absolute;left:-9999px}.notice{padding:15px 18px;border-radius:10px;margin-bottom:18px}.notice.success{background:#1d3726;color:#d7f45b}.notice.error{background:#44201d;color:#ffafa6}@media(max-width:650px){.site-header nav a:first-child{display:none}.form-grid{grid-template-columns:1fr}.report-form{padding:20px}}
+2 -2
View File
@@ -2,9 +2,9 @@ import { expect, test } from "@playwright/test";
test("player can open an active spot", async ({ page }) => { test("player can open an active spot", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await expect(page.getByRole("heading", { name: "Что клюёт прямо сейчас" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Выбирай место, пока клюёт." })).toBeVisible();
await expect(page.getByRole("heading", { name: "Щука" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Щука" })).toBeVisible();
await page.getByRole("link", { name: /Точка 110:103/ }).click(); await page.getByTestId("spot-110-103").click();
await expect(page.getByRole("heading", { name: "Точка 110:103" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Точка 110:103" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Последние уловы" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Последние уловы" })).toBeVisible();
}); });
+20
View File
@@ -13,14 +13,33 @@ services:
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
minio:
image: minio/minio:RELEASE.2025-07-23T15-54-02Z
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY:-rf4-local}
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:-rf4-local-secret}
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
api: api:
build: ./apps/api build: ./apps/api
environment: environment:
DATABASE_URL: postgresql+psycopg://rf4:rf4_local@db:5432/rf4_spotter DATABASE_URL: postgresql+psycopg://rf4:rf4_local@db:5432/rf4_spotter
ADMIN_TOKEN: ${ADMIN_TOKEN:-change-me-in-production} ADMIN_TOKEN: ${ADMIN_TOKEN:-change-me-in-production}
S3_ENDPOINT_URL: http://minio:9000
S3_PUBLIC_ENDPOINT_URL: ${S3_PUBLIC_ENDPOINT_URL:-http://localhost:9000}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-rf4-local}
S3_SECRET_KEY: ${S3_SECRET_KEY:-rf4-local-secret}
S3_BUCKET: ${S3_BUCKET:-catch-screenshots}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
minio:
condition: service_started
ports: ports:
- "8000:8000" - "8000:8000"
healthcheck: healthcheck:
@@ -52,3 +71,4 @@ services:
volumes: volumes:
postgres_data: postgres_data:
minio_data:
+1 -1
View File
@@ -12,5 +12,5 @@ build-backend = "setuptools.build_meta"
include = ["rf4_research*"] include = ["rf4_research*"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests", "apps/api/tests"]
pythonpath = [".", "apps/api"] pythonpath = [".", "apps/api"]