Add user catch submission and moderation workflow
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
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
|
||||
|
||||
@@ -43,9 +43,11 @@ docker compose up --build
|
||||
- справочники рыб, водоёмов и приманок;
|
||||
- Astro SSR-интерфейс с адаптивной главной и страницей точки;
|
||||
- объяснимые индексы активности и уверенности по формуле спецификации;
|
||||
- состояния «нет данных» и «источник недоступен».
|
||||
- состояния «нет данных» и «источник недоступен»;
|
||||
- идемпотентный импорт официальных записей с журналом запусков;
|
||||
- публичная страница `/records` с источником и временем последнего импорта.
|
||||
- публичная страница `/records` с источником и временем последнего импорта;
|
||||
- форма `/report`, защищённые admin API и журнал модерации;
|
||||
- honeypot и базовый лимит отправок.
|
||||
|
||||
Все пользовательские ники и уловы в seed демонстрационные.
|
||||
|
||||
@@ -87,6 +89,17 @@ docker compose --profile tools run --rm importer
|
||||
|
||||
Импорт делает до трёх ограниченных попыток, проверяет DOM-контракт и не удаляет ранее сохранённые данные при сбое. Повторный запуск обновляет совпавшие записи по SHA-256 ключу и не создаёт дубликаты. Автоматическое расписание намеренно ещё не включено: сначала требуется согласовать допустимость регулярного опроса официального сайта.
|
||||
|
||||
## Пользовательские уловы и модерация
|
||||
|
||||
Новая запись из `/report` получает статус `pending` и не участвует в активности до одобрения. Административные методы требуют заголовок `Authorization: Bearer $ADMIN_TOKEN`:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer change-me-in-production" \
|
||||
"http://localhost:8000/api/v1/admin/catch-reports?status=pending"
|
||||
```
|
||||
|
||||
Перед внешним развёртыванием обязательно замените демонстрационный `ADMIN_TOKEN`. Скриншоты пока не принимаются: следующим шагом для них подключается MinIO/S3 с проверкой MIME, размера и очисткой EXIF.
|
||||
|
||||
## Исследовательский парсер официальных рекордов
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Moderation event journal."""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0003"
|
||||
down_revision = "0002"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
moderation = sa.Enum("pending", "approved", "rejected", name="moderationstatus", create_type=False)
|
||||
op.create_table(
|
||||
"moderation_event",
|
||||
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||
sa.Column("catch_report_id", sa.Uuid(), sa.ForeignKey("catch_report.id"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("previous_status", moderation, nullable=False),
|
||||
sa.Column("new_status", moderation, nullable=False),
|
||||
sa.Column("moderator", sa.String(100), nullable=False),
|
||||
sa.Column("reason", sa.Text()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("moderation_event")
|
||||
@@ -3,6 +3,7 @@ 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"
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
|
||||
|
||||
+67
-4
@@ -1,24 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from collections import Counter, defaultdict, deque
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from .activity import activity_rows
|
||||
from .database import get_session
|
||||
from .models import Bait, CatchReport, Fish, ModerationStatus, OfficialRecordImport, SourceType, Spot, Waterbody
|
||||
from .schemas import ActivityOut, BaitOut, CatchOut, FishOut, ImportRunOut, OfficialRecordOut, SpotOut, WaterbodyOut
|
||||
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
|
||||
|
||||
|
||||
app = FastAPI(title="RF4 Spotter API", version="0.1.0")
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["http://localhost:4321"], allow_methods=["GET"], allow_headers=["*"])
|
||||
Db = Annotated[Session, Depends(get_session)]
|
||||
_submissions: dict[str, deque[datetime]] = defaultdict(deque)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@@ -104,5 +107,65 @@ def imports(db: Db, limit: int = Query(20, ge=1, le=100)) -> list[OfficialRecord
|
||||
return list(db.scalars(select(OfficialRecordImport).order_by(OfficialRecordImport.started_at.desc()).limit(limit)))
|
||||
|
||||
|
||||
@app.post("/api/v1/catch-reports", response_model=CatchReportCreated, status_code=201)
|
||||
def create_catch_report(payload: CatchReportCreate, request: Request, db: Db) -> CatchReportCreated:
|
||||
if payload.website:
|
||||
raise HTTPException(status_code=400, detail="invalid submission")
|
||||
_check_rate_limit(request.client.host if request.client else "unknown")
|
||||
fish = db.scalar(select(Fish).where(Fish.slug == payload.fish_slug))
|
||||
waterbody = db.scalar(select(Waterbody).where(Waterbody.slug == payload.waterbody_slug))
|
||||
if fish is None or waterbody is None:
|
||||
raise HTTPException(status_code=422, detail="unknown fish or waterbody")
|
||||
spot = db.scalar(select(Spot).where(Spot.waterbody_id == waterbody.id, Spot.x == payload.x, Spot.y == payload.y))
|
||||
if spot is None:
|
||||
spot = Spot(waterbody=waterbody, x=payload.x, y=payload.y)
|
||||
db.add(spot)
|
||||
bait = None
|
||||
if payload.bait_name and payload.bait_name.strip():
|
||||
key = normalize(payload.bait_name)
|
||||
bait = db.scalar(select(Bait).where(Bait.normalized_name == key))
|
||||
if bait is None:
|
||||
bait = Bait(name=payload.bait_name.strip(), normalized_name=key, kind=BaitKind.unknown)
|
||||
db.add(bait)
|
||||
report = CatchReport(fish=fish, spot=spot, waterbody=waterbody, bait=bait, weight_g=payload.weight_g, fishing_method=payload.fishing_method, rig_type=payload.rig_type, retrieve_method=payload.retrieve_method, retrieve_speed=payload.retrieve_speed, caught_at=payload.caught_at, reported_at=datetime.now(timezone.utc), player_name=payload.player_name, source_type=SourceType.user, source_url=payload.source_url, source_confidence=60, moderation_status=ModerationStatus.pending, raw_payload={"comment": payload.comment} if payload.comment else None)
|
||||
db.add(report)
|
||||
db.commit()
|
||||
return CatchReportCreated(id=report.id, moderation_status=report.moderation_status.value)
|
||||
|
||||
|
||||
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"})
|
||||
return "admin"
|
||||
|
||||
|
||||
@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]
|
||||
|
||||
|
||||
@app.patch("/api/v1/admin/catch-reports/{report_id}", response_model=CatchReportCreated)
|
||||
def moderate_report(report_id: UUID, payload: ModerationUpdate, db: Db, moderator: Annotated[str, Depends(_admin)]) -> CatchReportCreated:
|
||||
report = db.get(CatchReport, report_id)
|
||||
if report is None or report.source_type != SourceType.user:
|
||||
raise HTTPException(status_code=404, detail="catch report not found")
|
||||
previous = report.moderation_status
|
||||
report.moderation_status = ModerationStatus(payload.status)
|
||||
db.add(ModerationEvent(catch_report=report, created_at=datetime.now(timezone.utc), previous_status=previous, new_status=report.moderation_status, moderator=moderator, reason=payload.reason))
|
||||
db.commit()
|
||||
return CatchReportCreated(id=report.id, moderation_status=report.moderation_status.value)
|
||||
|
||||
|
||||
def _check_rate_limit(client: str) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
recent = _submissions[client]
|
||||
while recent and recent[0] < now - timedelta(minutes=10):
|
||||
recent.popleft()
|
||||
if len(recent) >= 5:
|
||||
raise HTTPException(status_code=429, detail="too many submissions")
|
||||
recent.append(now)
|
||||
|
||||
|
||||
def _aware(value: datetime) -> datetime:
|
||||
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
|
||||
@@ -109,3 +109,15 @@ class OfficialRecordImport(Base):
|
||||
rows_created: Mapped[int] = mapped_column(default=0)
|
||||
rows_updated: Mapped[int] = mapped_column(default=0)
|
||||
error_summary: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
|
||||
class ModerationEvent(Base):
|
||||
__tablename__ = "moderation_event"
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
catch_report_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("catch_report.id"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
previous_status: Mapped[ModerationStatus] = mapped_column(Enum(ModerationStatus, name="moderationstatus", create_type=False))
|
||||
new_status: Mapped[ModerationStatus] = mapped_column(Enum(ModerationStatus, name="moderationstatus", create_type=False))
|
||||
moderator: Mapped[str] = mapped_column(String(100))
|
||||
reason: Mapped[str | None] = mapped_column(Text)
|
||||
catch_report: Mapped[CatchReport] = relationship()
|
||||
|
||||
+57
-1
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class FishOut(BaseModel):
|
||||
@@ -98,3 +98,59 @@ class ImportRunOut(BaseModel):
|
||||
rows_created: int
|
||||
rows_updated: int
|
||||
error_summary: str | None
|
||||
|
||||
|
||||
class CatchReportCreate(BaseModel):
|
||||
fish_slug: str
|
||||
waterbody_slug: str
|
||||
x: int = Field(ge=-10_000, le=10_000)
|
||||
y: int = Field(ge=-10_000, le=10_000)
|
||||
weight_g: int = Field(gt=0, le=3_000_000)
|
||||
bait_name: str | None = Field(default=None, max_length=200)
|
||||
fishing_method: str | None = Field(default=None, max_length=50)
|
||||
rig_type: str | None = Field(default=None, max_length=100)
|
||||
retrieve_method: str | None = Field(default=None, max_length=100)
|
||||
retrieve_speed: int | None = Field(default=None, ge=0, le=100)
|
||||
caught_at: datetime | None = None
|
||||
player_name: str | None = Field(default=None, max_length=100)
|
||||
source_url: str | None = Field(default=None, max_length=1000)
|
||||
comment: str | None = Field(default=None, max_length=1000)
|
||||
website: str = Field(default="", max_length=200)
|
||||
|
||||
@field_validator("fish_slug", "waterbody_slug")
|
||||
@classmethod
|
||||
def nonempty_slug(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("must not be empty")
|
||||
return value
|
||||
|
||||
|
||||
class CatchReportCreated(BaseModel):
|
||||
id: UUID
|
||||
moderation_status: str
|
||||
|
||||
|
||||
class AdminCatchReportOut(BaseModel):
|
||||
id: UUID
|
||||
fish: str
|
||||
waterbody: str
|
||||
coordinates: str
|
||||
weight_g: int
|
||||
bait: str | None
|
||||
player_name: str | None
|
||||
reported_at: datetime
|
||||
moderation_status: str
|
||||
comment: str | None
|
||||
|
||||
|
||||
class ModerationUpdate(BaseModel):
|
||||
status: str
|
||||
reason: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
@field_validator("status")
|
||||
@classmethod
|
||||
def known_status(cls, value: str) -> str:
|
||||
if value not in {"approved", "rejected", "pending"}:
|
||||
raise ValueError("unknown moderation status")
|
||||
return value
|
||||
|
||||
@@ -66,3 +66,22 @@ def test_records_list_is_empty_before_import() -> None:
|
||||
response = client.get("/api/v1/records")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
|
||||
def test_user_report_requires_moderation_before_activity() -> None:
|
||||
created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 77, "y": 88, "weight_g": 5500, "bait_name": "Новая приманка", "player_name": "Reporter"})
|
||||
assert created.status_code == 201
|
||||
assert created.json()["moderation_status"] == "pending"
|
||||
report_id = created.json()["id"]
|
||||
headers = {"Authorization": "Bearer change-me-in-production"}
|
||||
pending = client.get("/api/v1/admin/catch-reports", headers=headers)
|
||||
assert pending.status_code == 200
|
||||
assert any(item["id"] == report_id for item in pending.json())
|
||||
approved = client.patch(f"/api/v1/admin/catch-reports/{report_id}", headers=headers, json={"status": "approved", "reason": "fixture verified"})
|
||||
assert approved.status_code == 200
|
||||
activity = client.get("/api/v1/activity?waterbody=test-lake&fish=pike&hours=24").json()
|
||||
assert any(item["x"] == 77 and item["catches"] == 1 for item in activity)
|
||||
|
||||
|
||||
def test_admin_requires_token() -> None:
|
||||
assert client.get("/api/v1/admin/catch-reports").status_code == 401
|
||||
|
||||
@@ -6,7 +6,7 @@ const { title = "RF4 Spotter" } = Astro.props;
|
||||
<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>
|
||||
<body>
|
||||
<header class="site-header"><a href="/" class="brand"><span>RF4</span> Spotter</a><nav><a href="/">Активность</a><a href="/records">Рекорды</a></nav><p>Свежие точки без догадок</p></header>
|
||||
<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>
|
||||
<main><slot /></main>
|
||||
<footer>Неофициальный проект. Данные демонстрационные.</footer>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { APIRoute } from "astro";
|
||||
const base = 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); }
|
||||
catch { return redirect("/report?state=error", 303); }
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import { api, type DictionaryItem } from "../lib/api";
|
||||
let fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [], unavailable = false;
|
||||
try { [fishes, waterbodies] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]); } catch { unavailable = true; }
|
||||
const state = Astro.url.searchParams.get("state");
|
||||
---
|
||||
<Layout title="Добавить улов — RF4 Spotter">
|
||||
<section class="form-hero"><span class="eyebrow">Помогите другим игрокам</span><h1>Добавить улов</h1><p>До проверки модератором запись не влияет на публичный индекс клёва.</p></section>
|
||||
{state === "sent" && <div class="notice success">Улов отправлен на модерацию. Спасибо!</div>}
|
||||
{state === "error" && <div class="notice error">Не удалось отправить улов. Проверьте поля и повторите.</div>}
|
||||
{unavailable ? <div class="state"><h2>Форма временно недоступна</h2></div> : <form class="report-form" method="post" action="/api/report">
|
||||
<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>
|
||||
</form>}
|
||||
</Layout>
|
||||
@@ -20,3 +20,4 @@ label { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spa
|
||||
@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}}
|
||||
|
||||
@@ -17,6 +17,7 @@ services:
|
||||
build: ./apps/api
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://rf4:rf4_local@db:5432/rf4_spotter
|
||||
ADMIN_TOKEN: ${ADMIN_TOKEN:-change-me-in-production}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
Reference in New Issue
Block a user