diff --git a/.env.example b/.env.example index 8657ced..64ccfe2 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,8 @@ DATABASE_URL=postgresql+psycopg://rf4:rf4_local@localhost:5432/rf4_spotter PUBLIC_API_URL=http://localhost:8000 API_INTERNAL_URL=http://api:8000 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 diff --git a/README.md b/README.md index ebb59c1..00f3603 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,10 @@ docker compose up --build - сайт: ; - OpenAPI: ; -- проверка API: . +- проверка API: ; +- консоль MinIO: . -Контейнер 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/spots/{id}` и `/catches`; - справочники рыб, водоёмов и приманок; -- Astro SSR-интерфейс с адаптивной главной и страницей точки; +- Astro SSR-интерфейс с адаптивным дизайном из `design-reference` без переноса React/Vinext-стека; - объяснимые индексы активности и уверенности по формуле спецификации; - состояния «нет данных» и «источник недоступен»; - идемпотентный импорт официальных записей с журналом запусков; - публичная страница `/records` с источником и временем последнего импорта; - форма `/report`, защищённые admin API и журнал модерации; -- honeypot и базовый лимит отправок. +- honeypot и базовый лимит отправок; +- скриншоты уловов в MinIO/S3 с декодированием изображения, ограничением размера и очисткой метаданных. Все пользовательские ники и уловы в seed демонстрационные. @@ -98,7 +100,7 @@ curl -H "Authorization: Bearer change-me-in-production" \ "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. ## Исследовательский парсер официальных рекордов diff --git a/apps/api/alembic/versions/0004_screenshots.py b/apps/api/alembic/versions/0004_screenshots.py new file mode 100644 index 0000000..e1c16ff --- /dev/null +++ b/apps/api/alembic/versions/0004_screenshots.py @@ -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") diff --git a/apps/api/app/config.py b/apps/api/app/config.py index be59f6b..841028e 100644 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -4,6 +4,12 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): database_url: str = "postgresql+psycopg://rf4:rf4_local@localhost:5432/rf4_spotter" 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") diff --git a/apps/api/app/main.py b/apps/api/app/main.py index ee201f2..a748b64 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta, timezone from typing import Annotated, Literal 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 sqlalchemy import select from sqlalchemy.orm import Session, joinedload @@ -16,6 +16,7 @@ from .config import settings from .importer import normalize 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 .storage import ScreenshotError, signed_screenshot_url, upload_screenshot 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) +@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: if not authorization or authorization != f"Bearer {settings.admin_token}": 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]) 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))) - 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) diff --git a/apps/api/app/models.py b/apps/api/app/models.py index bae7b9d..f0e3aa0 100644 --- a/apps/api/app/models.py +++ b/apps/api/app/models.py @@ -91,6 +91,7 @@ class CatchReport(Base): source_external_id: Mapped[str | None] = mapped_column(String(64), unique=True) source_confidence: Mapped[int] moderation_status: Mapped[ModerationStatus] = mapped_column(Enum(ModerationStatus)) + screenshot_key: Mapped[str | None] = mapped_column(Text) raw_payload: Mapped[dict | None] = mapped_column(JSON) fish: Mapped[Fish] = relationship() spot: Mapped[Spot | None] = relationship() diff --git a/apps/api/app/schemas.py b/apps/api/app/schemas.py index 4c46c52..e4a71cc 100644 --- a/apps/api/app/schemas.py +++ b/apps/api/app/schemas.py @@ -142,6 +142,7 @@ class AdminCatchReportOut(BaseModel): reported_at: datetime moderation_status: str comment: str | None + screenshot_url: str | None class ModerationUpdate(BaseModel): diff --git a/apps/api/app/storage.py b/apps/api/app/storage.py new file mode 100644 index 0000000..e553169 --- /dev/null +++ b/apps/api/app/storage.py @@ -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) diff --git a/apps/api/requirements.txt b/apps/api/requirements.txt index f6bd8e6..8ed7559 100644 --- a/apps/api/requirements.txt +++ b/apps/api/requirements.txt @@ -1,9 +1,12 @@ alembic==1.16.5 beautifulsoup4==4.15.0 +boto3==1.40.35 fastapi==0.116.1 httpx==0.28.1 psycopg[binary]==3.2.10 pydantic-settings==2.10.1 +pillow==11.3.0 +python-multipart==0.0.20 pytest==8.4.2 sqlalchemy==2.0.43 uvicorn[standard]==0.35.0 diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index eb10487..86bdc98 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -85,3 +85,12 @@ def test_user_report_requires_moderation_before_activity() -> None: def test_admin_requires_token() -> None: 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 diff --git a/apps/api/tests/test_storage.py b/apps/api/tests/test_storage.py new file mode 100644 index 0000000..62da795 --- /dev/null +++ b/apps/api/tests/test_storage.py @@ -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") diff --git a/apps/web/.dockerignore b/apps/web/.dockerignore new file mode 100644 index 0000000..e817324 --- /dev/null +++ b/apps/web/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +test-results +playwright-report +.astro diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index fadf671..952fc8c 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -8,6 +8,8 @@ RUN npm run build FROM node:22-alpine WORKDIR /app 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 EXPOSE 4321 CMD ["node", "./dist/server/entry.mjs"] diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..d1a57a1 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/web/public/lake-dawn.png b/apps/web/public/lake-dawn.png new file mode 100644 index 0000000..b9bd972 Binary files /dev/null and b/apps/web/public/lake-dawn.png differ diff --git a/apps/web/src/components/ActivityCard.astro b/apps/web/src/components/ActivityCard.astro index 4cc9c2c..eb0ded1 100644 --- a/apps/web/src/components/ActivityCard.astro +++ b/apps/web/src/components/ActivityCard.astro @@ -2,12 +2,10 @@ import type { Activity } from "../lib/api"; import { ago, kg } from "../lib/api"; const { item } = Astro.props as { item: Activity }; +const level = item.activity_score >= 80 ? "Очень высокий" : item.activity_score >= 60 ? "Высокий" : item.activity_score >= 40 ? "Средний" : "Низкий"; --- -
-
{item.waterbody}

{item.fish}

{item.activity_score}активность
- Точка {item.x}:{item.y} -
Рабочая приманка{item.best_bait ?? "не указана"}
-
Уловов
{item.catches}
Игроков
{item.unique_players}
Средний
{kg(item.average_weight_g)}
Максимум
{kg(item.max_weight_g)}
-

{item.explanation}

-
Обновлено {ago(item.last_confirmed_at)} · уверенность {item.confidence_score}/100
-
+ + {String(item.activity_score).padStart(2,"0")} +
{item.waterbody}{level}

{item.fish}

⌖ {item.x}:{item.y}◷ {ago(item.last_confirmed_at)}
Работает сейчас{item.best_bait ?? "не указана"}
+
{item.catches}уловов
{item.unique_players}игроков
{kg(item.average_weight_g)}средний вес
{item.confidence_score}%уверенность
+
diff --git a/apps/web/src/layouts/Layout.astro b/apps/web/src/layouts/Layout.astro index 20f4685..91b99fb 100644 --- a/apps/web/src/layouts/Layout.astro +++ b/apps/web/src/layouts/Layout.astro @@ -1,13 +1,18 @@ --- import "../styles/global.css"; const { title = "RF4 Spotter" } = Astro.props; +const path = Astro.url.pathname; --- - {title} + {title} - +
+ Ни хвоста,ни чешуи + +

Свежие данные и честная оценка

+
-
Неофициальный проект. Данные демонстрационные.
+
Ни хвоста,ни чешуи

Неофициальный проект для игроков Russian Fishing 4. Данные seed демонстрационные.

НХНЧ · 2026
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 7d17bd7..9029703 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -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 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(path: string): Promise { const response = await fetch(`${base}${path}`); diff --git a/apps/web/src/pages/api/report.ts b/apps/web/src/pages/api/report.ts index ff312d3..2ed9bfc 100644 --- a/apps/web/src/pages/api/report.ts +++ b/apps/web/src/pages/api/report.ts @@ -1,10 +1,21 @@ 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 }) => { const form = await request.formData(); const text = (name: string) => String(form.get(name) || "").trim() || 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") || "") }; - 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); } }; diff --git a/apps/web/src/pages/index.astro b/apps/web/src/pages/index.astro index 3d5e49c..7d6d448 100644 --- a/apps/web/src/pages/index.astro +++ b/apps/web/src/pages/index.astro @@ -1,7 +1,7 @@ --- import Layout from "../layouts/Layout.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 hours = params.get("hours") ?? "24"; @@ -18,14 +18,16 @@ try { } catch { unavailable = true; } --- -
Сводка активности

Что клюёт
прямо сейчас

Свежие подтверждения, рабочие приманки и честная оценка надёжности данных.

-
+
RF4 Живая карта клёва

Выбирай место,
пока клюёт.

Свежие точки, рабочие приманки и честная оценка данных от игроков.

Туманное озеро на рассвете
Пульс водоёмов{items.length} точки показывают активность
+
- - -

Активные точки

{items.length} комбинации
- {unavailable ?

Источник временно недоступен

Не показываем устаревшие догадки. Попробуйте обновить страницу позже.

: items.length ?
{items.map(item => )}
:

За этот период данных нет

Измените фильтры или выберите более длинный период.

} + +
+
За выбранный период

Горячие точки

{items.length} точки
{unavailable ?

Источник временно недоступен

Не показываем устаревшие догадки.

: items.length ?
{items.map(item => )}
:

Пока нет свежих данных

Попробуйте другой водоём или период.

}
+ {items[0] && } +
+
Как читать данные

Не обещаем рыбу.
Показываем факты.

01

Свежесть

Чем старше сообщение, тем меньше оно влияет на активность.

02

Разные игроки

Десять уловов одного человека не равны десяти подтверждениям.

03

Уверенность

Каждая оценка объясняет, сколько данных за ней стоит.

diff --git a/apps/web/src/pages/records.astro b/apps/web/src/pages/records.astro index 8546f4c..236b1c6 100644 --- a/apps/web/src/pages/records.astro +++ b/apps/web/src/pages/records.astro @@ -11,7 +11,7 @@ const last = runs[0];
Публичные данные RF4

Официальные
рекорды

{last ? `Импорт: ${last.status}` : "Импорт ещё не запускался"}{last?.finished_at && {new Date(last.finished_at).toLocaleString("ru-RU")} · {last.rows_seen} строк}
-

Последние записи

{records.length} показано
- {unavailable ?

Источник временно недоступен

: records.length ?
РыбаВесВодоёмПриманкаИгрокДата
{records.map(record =>
{record.fish}{kg(record.weight_g)}{record.waterbody}{record.bait ?? "—"}{record.player_name ?? "—"}
)}
:

Рекорды ещё не импортированы

Запустите python -m app.cli import-records. Пустой результат не подменяется демоданными.

} +
Официальный источник

Последние записи

{records.length} показано
+ {unavailable ?

Источник временно недоступен

: records.length ?
РыбаВесВодоёмПриманкаИгрокДата
{records.map(record =>
{record.fish}{kg(record.weight_g)}{record.waterbody}{record.bait ?? "—"}{record.player_name ?? "—"}
)}
:

Рекорды ещё не импортированы

Запустите python -m app.cli import-records. Пустой результат не подменяется демоданными.

}

Источник: официальный сайт Russian Fishing 4. Координаты в официальных таблицах отсутствуют.

diff --git a/apps/web/src/pages/report.astro b/apps/web/src/pages/report.astro index e87173c..76f6d6b 100644 --- a/apps/web/src/pages/report.astro +++ b/apps/web/src/pages/report.astro @@ -6,11 +6,11 @@ try { [fishes, waterbodies] = await Promise.all([api("/api/v1/ const state = Astro.url.searchParams.get("state"); --- -
Помогите другим игрокам

Добавить улов

До проверки модератором запись не влияет на публичный индекс клёва.

+
+1 Помочь сообществу

Добавить
свой улов

Полминуты — и рабочая точка появится в общей статистике после проверки модератором.

{state === "sent" &&
Улов отправлен на модерацию. Спасибо!
} {state === "error" &&
Не удалось отправить улов. Проверьте поля и повторите.
} - {unavailable ?

Форма временно недоступна

:
+ {unavailable ?

Форма временно недоступна

:
-

Ник необязателен. Не отправляйте личные данные. Скриншоты будут добавлены после подключения объектного хранилища.

+

Ник и скриншот необязательны. Из изображения удаляются EXIF и прочие метаданные.

}
diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index b8a109b..386fd41 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -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 { 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; } -body { margin:0; background:radial-gradient(circle at 15% -10%,#173b30 0,transparent 30rem),var(--bg); color:var(--ink); min-height:100vh; } -a { color:inherit; } -.site-header, main, footer { width:min(1180px,calc(100% - 40px)); margin:auto; } -.site-header { height:86px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); } -.brand { text-decoration:none; font-family:Unbounded,sans-serif; font-weight:700; font-size:19px; }.brand span{color:var(--accent)} -.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)} -.site-header p, footer { color:var(--muted); font-size:13px; } footer{padding:45px 0 30px} -.hero { padding:78px 0 55px; display:grid; grid-template-columns:1.4fr .6fr; align-items:end; gap:30px; } -.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} -.eyebrow { color:var(--accent); font-size:11px; font-weight:800; letter-spacing:.14em; text-transform:uppercase; } -.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); } -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}} +: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)} +*{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)} +.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} +.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} +.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} +.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} +.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} +.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} +.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} +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)} +@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}} +@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}} +@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}} +@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition-duration:.01ms!important}} diff --git a/apps/web/tests/smoke.spec.ts b/apps/web/tests/smoke.spec.ts index de770c9..731146f 100644 --- a/apps/web/tests/smoke.spec.ts +++ b/apps/web/tests/smoke.spec.ts @@ -2,9 +2,9 @@ import { expect, test } from "@playwright/test"; test("player can open an active spot", async ({ page }) => { 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 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: "Последние уловы" })).toBeVisible(); }); diff --git a/compose.yaml b/compose.yaml index a8048c1..384ef7c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -13,14 +13,33 @@ services: volumes: - 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: build: ./apps/api environment: DATABASE_URL: postgresql+psycopg://rf4:rf4_local@db:5432/rf4_spotter 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: db: condition: service_healthy + minio: + condition: service_started ports: - "8000:8000" healthcheck: @@ -52,3 +71,4 @@ services: volumes: postgres_data: + minio_data: diff --git a/pyproject.toml b/pyproject.toml index 11043f0..d3ade4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,5 +12,5 @@ build-backend = "setuptools.build_meta" include = ["rf4_research*"] [tool.pytest.ini_options] -testpaths = ["tests"] +testpaths = ["tests", "apps/api/tests"] pythonpath = [".", "apps/api"]