Compare commits
140
Commits
4303b145b0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59bf85ac23 | ||
|
|
b713284bab | ||
|
|
8089570d56 | ||
|
|
504a8ce552 | ||
|
|
a46d0e0eac | ||
|
|
6a3c6c54de | ||
|
|
c54cb71720 | ||
|
|
b68826836e | ||
|
|
00f91dbef8 | ||
|
|
ad69f90e43 | ||
|
|
8f03eec76a | ||
|
|
78eaf57189 | ||
|
|
191f94b834 | ||
|
|
fe3ca11d8f | ||
|
|
86c399f2da | ||
|
|
2a845e8b8d | ||
|
|
97742d4716 | ||
|
|
e47db12e4d | ||
|
|
bed156ae94 | ||
|
|
8e84f41924 | ||
|
|
167f23a99f | ||
|
|
ec46655e13 | ||
|
|
28a361314a | ||
|
|
bc02d28169 | ||
|
|
5ffe05206c | ||
|
|
1dbda03209 | ||
|
|
1ac27986cb | ||
|
|
21c0ffbe75 | ||
|
|
e74929e7f7 | ||
|
|
2fc4b88a80 | ||
|
|
55e5810e65 | ||
|
|
f711b9ac0b | ||
|
|
4f1381cec8 | ||
|
|
c38097767f | ||
|
|
26ad9b5194 | ||
|
|
2f3f6acd78 | ||
|
|
0fed0f1bad | ||
|
|
4100691268 | ||
|
|
558e284745 | ||
|
|
3246350319 | ||
|
|
9dfad82dd0 | ||
|
|
060aacff92 | ||
|
|
9c49a0850d | ||
|
|
4c4849ad2c | ||
|
|
1c4065a26c | ||
|
|
7350880736 | ||
|
|
ebc4b0e65c | ||
|
|
9fc9c820c4 | ||
|
|
f3a9f34ff3 | ||
|
|
c5c356f948 | ||
|
|
f3cf706ede | ||
|
|
34a5224e78 | ||
|
|
be59d76791 | ||
|
|
bf0f16642a | ||
|
|
524dad42ec | ||
|
|
206343895e | ||
|
|
f0b396b598 | ||
|
|
4d6ec85173 | ||
|
|
6fa669b4ef | ||
|
|
b7d69619fa | ||
|
|
b4585cee7e | ||
|
|
5c1a7ffa01 | ||
|
|
b9aecb75f5 | ||
|
|
3fa97047f2 | ||
|
|
cf8e211176 | ||
|
|
7688df8a5a | ||
|
|
b91380b5c5 | ||
|
|
ed9ec4bf2b | ||
|
|
5d9ffa2e59 | ||
|
|
cd0f6fa5a2 | ||
|
|
4972b11fb0 | ||
|
|
0b830a9978 | ||
|
|
a701397e41 | ||
|
|
11e54a7842 | ||
|
|
f3eb54083f | ||
|
|
b0421c11f5 | ||
|
|
99ca887f77 | ||
|
|
d32a1973b2 | ||
|
|
2d56a67461 | ||
|
|
675cd8ba4e | ||
|
|
31b720f7b3 | ||
|
|
4f847db38a | ||
|
|
d32796277c | ||
|
|
e4e10bb4e9 | ||
|
|
91019cbf3a | ||
|
|
de2470010c | ||
|
|
2729e7a035 | ||
|
|
4052350632 | ||
|
|
852e68d9a5 | ||
|
|
c5f9aa85a0 | ||
|
|
c84ec1eced | ||
|
|
87321d8384 | ||
|
|
8d7538d423 | ||
|
|
86515a9da1 | ||
|
|
81af8a02b6 | ||
|
|
a8c01f01e9 | ||
|
|
f0b63df803 | ||
|
|
94f9e408fb | ||
|
|
9c521514fc | ||
|
|
b1a2eb5318 | ||
|
|
eeef4bb536 | ||
|
|
10d43013f7 | ||
|
|
2ed28266df | ||
|
|
3b4557b549 | ||
|
|
36d704d854 | ||
|
|
bbed832eb8 | ||
|
|
6ce9784d0a | ||
|
|
92bb878ced | ||
|
|
3260f91495 | ||
|
|
346ecd2dbe | ||
|
|
196185dc97 | ||
|
|
7a8e49dde3 | ||
|
|
5a8174b11e | ||
|
|
1dfe1e8ba4 | ||
|
|
9569f4768a | ||
|
|
5335255c5c | ||
|
|
3f0a02a9b9 | ||
|
|
e31fbe996d | ||
|
|
3a8d3565a1 | ||
|
|
7efdb1a16a | ||
|
|
d1a5ad1b22 | ||
|
|
0ac9830c84 | ||
|
|
4bcf301289 | ||
|
|
72ae157ec3 | ||
|
|
25514d9377 | ||
|
|
f02cb247a3 | ||
|
|
d541443a1a | ||
|
|
46251c635f | ||
|
|
a547d09fcd | ||
|
|
e94d247096 | ||
|
|
4f0c2d23de | ||
|
|
4e9895fbf4 | ||
|
|
b0edae98c6 | ||
|
|
d438c40542 | ||
|
|
722c88d436 | ||
|
|
5221442aeb | ||
|
|
85d81aa996 | ||
|
|
53e7b0e4ae | ||
|
|
d63eedf41b | ||
|
|
28fffb3acb |
@@ -0,0 +1,51 @@
|
|||||||
|
# План работы RF4 Spotter — Регрессионный аудит
|
||||||
|
|
||||||
|
На основе: [REGRESSION_AUDIT_2026-09-09.md](../docs/REGRESSION_AUDIT_2026-09-09.md)
|
||||||
|
Дата: 2026-09-09
|
||||||
|
|
||||||
|
## Выполнено
|
||||||
|
|
||||||
|
### R01 ✅ — Caddy body_limit → request_body max_size
|
||||||
|
- **Файл**: `deploy/Caddyfile`
|
||||||
|
- **Проблема**: `body_limit 10M` не поддерживается в Caddy 2.10.2
|
||||||
|
- **Решение**: `request_body { max_size 10M }`
|
||||||
|
- **Верификация**: `caddy adapt` проходит без ошибок
|
||||||
|
|
||||||
|
### R02 ✅ — Activity API contract regression
|
||||||
|
- **API**: `main.py` — `/api/v1/activity` возвращает `PaginatedActivityOut`
|
||||||
|
- **Frontend**: все 4 потребителя обновлены:
|
||||||
|
- `index.astro` ✅ (из предыдущего коммита)
|
||||||
|
- `fish/[slug].astro` ✅
|
||||||
|
- `waterbodies/[slug].astro` ✅
|
||||||
|
- `waterbodies/[slug]/[fish].astro` ✅
|
||||||
|
- `spots/[id].astro` ✅
|
||||||
|
- **Тесты**: 4 теста обновлены под новый контракт
|
||||||
|
- **Результат**: 76 passed, 1 skipped, 0 failures
|
||||||
|
|
||||||
|
### R14 ✅ — Registry источников больше не зависит от БД при парсинге CLI
|
||||||
|
- **Файл**: `community_scheduler.py`
|
||||||
|
- **Решение**: `_static_registry()` — без БД; `configured_sources(enabled_keys)` — с опциональной БД
|
||||||
|
- **Тесты**: scheduler unit-тесты проходят без PostgreSQL
|
||||||
|
|
||||||
|
## Итоги тестов
|
||||||
|
- **Python**: 76 passed, 1 skipped ✅
|
||||||
|
- **Astro check**: 0 errors, 0 warnings, 0 hints ✅
|
||||||
|
- **Caddy adapt**: passes ✅
|
||||||
|
|
||||||
|
## Коммиты
|
||||||
|
1. `a37f9c4` — R01 Caddy body_limit → request_body
|
||||||
|
2. `d962ba2` — R02 activity API contract + R14 static registry
|
||||||
|
|
||||||
|
## Осталось из регрессий (R03-R15)
|
||||||
|
- R03: Research CLI cooldown broken (fcntl `r`/`r+` + encoding)
|
||||||
|
- R04: Disabled-фильтрация обходит site cooldown
|
||||||
|
- R05: Bootstrap небезопасен для источников
|
||||||
|
- R06: Фильтры сигналов сравнивают slug с названием
|
||||||
|
- R07: Ошибка главной остаётся HTTP 200
|
||||||
|
- R08: Фильтры UI не доведены до responsive-состояния
|
||||||
|
- R09: Пагинация (частично исправлено в U03)
|
||||||
|
- R10: Ошибки формы теряют черновик
|
||||||
|
- R11: Проверка URL после сетевого обращения
|
||||||
|
- R12: Мониторинг не сигнализирует о зависшем импорте
|
||||||
|
- R13: Доверие к IP клиента не ограничено proxy
|
||||||
|
- R15: D04/D06/D07/D08 выполнены не полностью
|
||||||
File diff suppressed because it is too large
Load Diff
Generated
+5
@@ -0,0 +1,5 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Environment-dependent path to Maven home directory
|
||||||
|
/mavenHomeManager.xml
|
||||||
Generated
+266
@@ -0,0 +1,266 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GigaCodeAgentSettings">
|
||||||
|
<option name="autoApproveEdits" value="true" />
|
||||||
|
<option name="autoApprovedCommands">
|
||||||
|
<list>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="mkdir -p /home/ik/git/rf4-help/.gigacode/plans" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="cd *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="ls *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="pip install *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="tail *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python pytest *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python -c " import ast import sys files = [ 'apps/api/app/main.py', 'apps/api/app/readiness.py', 'apps/api/app/schemas.py', 'apps/api/app/activity.py', 'apps/api/app/community_scheduler.py', 'apps/api/app/community_importer.py', 'rf4_research/community_cli.py', ] for f in files: try: with open(f) as fh: ast.parse(fh.read()) print(f'OK: {f}') except SyntaxError as e: print(f'ERROR: {f}: {e}') sys.exit(1) print('All Python files syntax OK') "" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="npm run *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="head *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="npm install *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="git add *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="git reset *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="git diff *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="git log *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="git commit *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="git status" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="echo *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="printf 'localhost {\n body_limit 10M\n}\n' > /tmp/test-caddyfile" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="cp /home/ik/git/rf4-help/deploy/Caddyfile /tmp/Caddyfile" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="printf 'example.com {\n body_limit 10M\n}\n' > /tmp/Caddyfile" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="printf 'example.com {\n request_body {\n max_size 10M\n }\n}\n' > /tmp/Caddyfile" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="docker run *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="sed 's/email {$ACME_EMAIL}/email test@test.com/' /home/ik/git/rf4-help/deploy/Caddyfile > /tmp/Caddyfile" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="grep *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="printf 'example.com {\n request_body {\n max_size 10M\n }\n handle {\n respond \"ok\"\n }\n}\n' > /tmp/Caddyfile" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python -c "import ast; ast.parse(open('rf4_research/community_cli.py').read()); print('OK')"" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python -c "import ast; ast.parse(open('apps/api/app/community_scheduler.py').read()); print('OK')"" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python -c "import ast; ast.parse(open('apps/api/app/main.py').read()); print('OK')"" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="node -e "try { fetch('http://localhost:1', { signal: AbortSignal.timeout(100) }); } catch(e) { console.log(e.constructor.name, e.message); }" 2>&1" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="node -e "fetch('http://httpbin.org/delay/10', { signal: AbortSignal.timeout(200) }).catch(e => console.log(e.constructor.name, e.message));" 2>&1" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="pip-compile requirements.txt --output-file requirements-lock.txt -q 2>&1" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="pip-compile requirements-dev.txt --output-file requirements-dev-lock.txt -q 2>&1" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="wc *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="alembic revision *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python -m app.cli --help 2>&1" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python -m rf4_research.community_cli --help 2>&1" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="alembic heads *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 -c " from urllib.request import Request, urlopen from urllib.error import HTTPError # Test with a URL that redirects try: req = Request('https://httpbin.org/redirect/1', headers={'User-Agent': 'test'}) resp = urlopen(req, timeout=5) print(f'Final URL: {resp.url}') print(f'Response URL: {resp.url}') except Exception as e: print(f'Error: {type(e).__name__}: {e}') " 2>&1" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="pwd" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 -c "from rf4_research import community_cli; print('Syntax OK')"" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/append_tests.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 pytest *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_redirect_test.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a04.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a04_v2.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="git show *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="git branch *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/test_double_reservation_bug.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a02_double_reservation.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/test_debug.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/test_a02_final.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/test_a02_v2.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/test_a02_code.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a04_pagination.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a01_monitoring.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a01_tests.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="*" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a01_tests_v2.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a04_filters.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a05_sessionstorage.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a06_tests.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a07_review_note.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a08_errorpage.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a08_index.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a11_pip_audit.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a04_pagination_final.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a08_index_unavailable.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a08_spots.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a08_records.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a01_vars.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a01_test.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a07_auto_publish.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a07_review_note_auto.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 /tmp/fix_a09_cli.py" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python3 -c "from app.cli import main; import sys; sys.argv = ['cli', '--help']; main()" 2>&1" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="find *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="git push *" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python -c " from sqlalchemy import create_engine, select from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool from app.database import Base from app.models import SubmissionAttempt import hashlib, hmac engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool) Base.metadata.create_all(engine) with Session(engine) as db: # Simulate first request key = 'idem-test-key-001' key_hash = hmac.new('change-rate-limit-secret'.encode(), key.encode(), hashlib.sha256).hexdigest() print(f'Key hash: {key_hash}') # Check before storing existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash)) print(f'Found before: {existing}') # Store from datetime import datetime, timezone db.add(SubmissionAttempt(client_hash='test', idempotency_key=key_hash, created_at=datetime.now(timezone.utc))) db.commit() # Check after storing existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash)) print(f'Found after: {existing}') if existing: print(f' idempotency_key: {existing.idempotency_key}') "" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python -c " from sqlalchemy import create_engine, select from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool from app.database import Base from app.models import SubmissionAttempt import hashlib, hmac engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool) Base.metadata.create_all(engine) with Session(engine) as db: key = 'idem-test-key-001' key_hash = hmac.new('change-rate-limit-secret'.encode(), key.encode(), hashlib.sha256).hexdigest() print(f'Key hash: {key_hash}') existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash)) print(f'Found before: {existing}') from datetime import datetime, timezone db.add(SubmissionAttempt(client_hash='test', idempotency_key=key_hash, created_at=datetime.now(timezone.utc))) db.commit() existing = db.scalar(select(SubmissionAttempt).where(SubmissionAttempt.idempotency_key == key_hash)) print(f'Found after: {existing}') if existing: print(f' idempotency_key: {existing.idempotency_key}') "" />
|
||||||
|
</Execute>
|
||||||
|
<Execute>
|
||||||
|
<option name="pattern" value="python -c " from sqlalchemy import create_engine, inspect, text from sqlalchemy.pool import StaticPool from app.database import Base from app.models import SubmissionAttempt engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool) Base.metadata.create_all(engine) inspector = inspect(engine) columns = inspector.get_columns('submission_attempt') print('Columns:', [c['name'] for c in columns]) "" />
|
||||||
|
</Execute>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
<option name="autoApprovedReads">
|
||||||
|
<list>
|
||||||
|
<Read>
|
||||||
|
<option name="pattern" value="/path/to/apps/web/src/pages/index.astro" />
|
||||||
|
</Read>
|
||||||
|
<Read>
|
||||||
|
<option name="pattern" value="/path/to/apps/web/src/lib/api.ts" />
|
||||||
|
</Read>
|
||||||
|
<Read>
|
||||||
|
<option name="pattern" value="/path/to/rf4_spotter/rf4_research/community_cli.py" />
|
||||||
|
</Read>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectRootManager">
|
||||||
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/rf4-help.iml" filepath="$PROJECT_DIR$/.idea/rf4-help.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+9
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="JAVA_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||||
|
<exclude-output />
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
|
||||||
|
RF4 Spotter is an Astro/FastAPI/PostgreSQL application with an offline
|
||||||
|
research and media-ingestion toolkit. The main areas are:
|
||||||
|
|
||||||
|
- `apps/api/` — FastAPI application, routers, models, migrations, and API tests.
|
||||||
|
- `apps/web/` — Astro pages, components, styles, unit tests, and Playwright tests.
|
||||||
|
- `rf4_research/` — source parsers, media manifest tooling, and CLI commands.
|
||||||
|
- `tests/` — Python research/tooling tests and fixtures.
|
||||||
|
- `data/media/` — versioned manifest and content-addressed local media files.
|
||||||
|
- `docs/` — specification, roadmap, ADRs, runbooks, and acceptance procedures.
|
||||||
|
- `compose.yaml` — local PostgreSQL, API, web, and supporting services.
|
||||||
|
|
||||||
|
Keep generated reports and temporary downloads outside committed paths unless a
|
||||||
|
task explicitly requires versioning them.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/pytest -q # Python suites
|
||||||
|
npm --prefix apps/web run check # Astro type/template checks
|
||||||
|
npm --prefix apps/web run build # Check and production build
|
||||||
|
npm --prefix apps/web run test:unit # Web unit tests
|
||||||
|
WEB_URL=http://127.0.0.1:4321 npm --prefix apps/web run test:e2e
|
||||||
|
docker compose up --build # Full local stack
|
||||||
|
```
|
||||||
|
|
||||||
|
For media work, use `.venv/bin/python -m rf4_research.media_cli --audit` and
|
||||||
|
respect the manifest’s cooldown and approval states. Do not hotlink or replace
|
||||||
|
approved media without explicit review.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
|
Use four-space indentation for Python and two spaces for Astro/TypeScript.
|
||||||
|
Prefer typed Python functions, `snake_case` for Python identifiers, and
|
||||||
|
`camelCase` for TypeScript variables/functions. Astro components use
|
||||||
|
`PascalCase.astro`; tests use descriptive `test_*.py` or `*.test.ts` names.
|
||||||
|
Keep UI text and data-source labels explicit and accessible; run `astro check`
|
||||||
|
before committing web changes.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
|
||||||
|
Add focused regression tests beside the affected suite. Python tests use
|
||||||
|
pytest; web behavior uses Node unit tests and Playwright. Run the smallest
|
||||||
|
relevant test first, then the full suite before handoff. Never use live external
|
||||||
|
sources in tests; use fixtures or isolated Docker services.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
|
||||||
|
Use imperative, concise commit subjects with the repository’s existing scope
|
||||||
|
style, such as `feat:`, `fix:`, `data:`, or `chore:`. Keep commits focused and
|
||||||
|
exclude unrelated user files. Pull requests should describe behavior changes,
|
||||||
|
verification commands, migration or configuration impact, and screenshots for
|
||||||
|
visual/UI work. Call out any media provenance, approval, or rollback decision.
|
||||||
|
|
||||||
|
## Security & Configuration Tips
|
||||||
|
|
||||||
|
Do not commit secrets, production `.env` files, tokens, or private payloads.
|
||||||
|
Use local fixtures and documented environment variables. Preserve the strict
|
||||||
|
CSP, admin authentication barriers, source attribution, and media provenance
|
||||||
|
when changing application code.
|
||||||
@@ -6,7 +6,7 @@ RF4 Spotter — неофициальный сервис свежих точек
|
|||||||
|
|
||||||
## Статус разработки
|
## Статус разработки
|
||||||
|
|
||||||
**Проверка 13 сентября 2026 (`787a506`): локальный контур готов к развёртыванию открытой альфы, внешний запуск ждёт сервер и его настройки.** Пакет восстановления A01–A13 закрыт. Python: **157 passed, 1 skipped**; Astro check/build, web unit и API-тесты проходят. Граф миграций имеет единственную голову `0016`; последний полный production bootstrap подтвердил Caddy, scheduler и браузерный сценарий отправки/модерации на предыдущей голове, а актуальная голова проверяется CI на чистой PostgreSQL. Реальные источники во время приёмки не опрашивались.
|
**Проверка 20 сентября 2026: локальный кодовый контур проходит сборочные и тестовые gates, внешний запуск ждёт сервер и его настройки.** Пакет восстановления A01–A13 закрыт. Python: **191 passed, 1 skipped**; Astro check/build и web unit проходят. Граф миграций имеет единственную голову `0020`; OpenAPI artifact синхронизирован с FastAPI (`36 paths`). Изолированный production bootstrap с чистыми томами повторно пройден; живая БД, реальные секреты и импорт внешних источников по-прежнему требуют отдельной инфраструктурной/разрешённой приёмки.
|
||||||
|
|
||||||
Актуальные следующие задачи находятся только в [ROADMAP](docs/ROADMAP.md). Старые планы и аудиты сохранены как история и больше не задают порядок работ. До внешнего запуска нужны сервер, DNS/TLS, production-секреты, публичные контакты, внешний backup и канал уведомлений.
|
Актуальные следующие задачи находятся только в [ROADMAP](docs/ROADMAP.md). Старые планы и аудиты сохранены как история и больше не задают порядок работ. До внешнего запуска нужны сервер, DNS/TLS, production-секреты, публичные контакты, внешний backup и канал уведомлений.
|
||||||
|
|
||||||
@@ -46,9 +46,11 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
|
|||||||
|
|
||||||
Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. После явного разрешения владельца от 14 сентября все скачанные и целостные материалы опубликованы в `/media`; публичный API отдаёт Git-копии по content-addressed URL, а каждая карточка показывает плашку и прямую ссылку на источник. Будущие загрузки по-прежнему не одобряются автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов.
|
Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. После явного разрешения владельца от 14 сентября все скачанные и целостные материалы опубликованы в `/media`; публичный API отдаёт Git-копии по content-addressed URL, а каждая карточка показывает плашку и прямую ссылку на источник. Будущие загрузки по-прежнему не одобряются автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов.
|
||||||
|
|
||||||
`python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает уникальные нормализованные подписи и кандидатов без подписи, поэтому альтернативные URL не завышают покрытие. На 14.09.2026 manifest содержит 704 записи: 456 approved, 227 duplicate, 10 queued и 11 invalid. Опубликованы 243 уникально подписанные рыбы из 252, все 149 найденных изображений снастей/приманок и 64 справочных материала; 10 оставшихся RF4DB URL будут загружены после cooldown. Карты 19 водоёмов ещё предстоит проиндексировать, а общий target снастей остаётся `null` до проверяемого полного счётчика.
|
Актуальный offline-срез от 20.09.2026: 704 записи manifest, 466 `approved`, 226 `superseded`, 1 `duplicate` и 11 `invalid`; audit проходит без ошибок и orphan-файлов. У 252 из 253 рыбных изображений есть 1024×1024 WebP, один 48×48 fallback сохранён из-за отсутствия проверенной альтернативы. Производные WebP/AVIF и provenance хранятся в Git; визуальная browser-приёмка остаётся отдельным пунктом B25.
|
||||||
|
|
||||||
`python -m rf4_research.media_cli --quality-report` выполняет offline-проверку разрешения опубликованных рыб. Текущий отчёт выявляет 227 PNG RF4MAP размером 48×48, которые увеличиваются в карточках до 180 px, и 16 WebP 1024×1024; для всех низких версий уже известны альтернативные RF4DB URL. План загрузки, сравнения, безопасного переключения и генерации производных размеров ведётся в B20–B25 ROADMAP.
|
`python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает уникальные нормализованные подписи и кандидатов без подписи, поэтому альтернативные URL не завышают покрытие. Актуальный manifest содержит 704 записи: 466 approved, 226 superseded, 1 duplicate и 11 invalid; queue-представления больше нет. Опубликованы 253 fish-файла, все 149 найденных изображений снастей/приманок и 64 справочных материала; общий target снастей остаётся `null` до проверяемого полного счётчика. Водоёмы имеют отдельный canonical index из 19 карточек; detail-наполнение остаётся W02–W08.
|
||||||
|
|
||||||
|
`python -m rf4_research.media_cli --quality-report` выполняет offline-проверку разрешения опубликованных рыб. Контур quality-upgrade обработал 226 прямых RF4DB-альтернатив: 252 из 253 рыбных изображений теперь имеют 1024×1024 WebP, один 48×48 fallback сохранён из-за отсутствия проверенной альтернативы. `--queue-quality-upgrades` по-прежнему переводит только прямые альтернативы низкоразрешённых published-файлов в безопасную очередь, не снимая текущую версию с публикации; B21/B22 завершены, а browser-приёмка остаётся отдельным пунктом B25.
|
||||||
|
|
||||||
`python -m rf4_research.media_cli --queue-plan` без сетевых запросов объединяет manifest с общим cooldown-state: показывает queued-состав каждого домена, оставшееся время и наиболее полезный следующий asset с приоритетом водоёмов и рыб. Разрешённое media-окно загружается командой `--download-batch --batch-limit 40`: до 40 assets на домен под одной резервацией, затем 30 минут до нового batch. Блокировка/rate limit/сетевая ошибка останавливает домен сразу, три последовательных невалидных ответа — досрочно. Все файлы остаются в карантине до ручного review. Точный поимённый список отсутствующих сущностей появится только после получения канонического перечня; разница между двумя несогласованными каталогами не выдаётся за доказанный gap.
|
`python -m rf4_research.media_cli --queue-plan` без сетевых запросов объединяет manifest с общим cooldown-state: показывает queued-состав каждого домена, оставшееся время и наиболее полезный следующий asset с приоритетом водоёмов и рыб. Разрешённое media-окно загружается командой `--download-batch --batch-limit 40`: до 40 assets на домен под одной резервацией, затем 30 минут до нового batch. Блокировка/rate limit/сетевая ошибка останавливает домен сразу, три последовательных невалидных ответа — досрочно. Все файлы остаются в карантине до ручного review. Точный поимённый список отсутствующих сущностей появится только после получения канонического перечня; разница между двумя несогласованными каталогами не выдаётся за доказанный gap.
|
||||||
|
|
||||||
@@ -146,7 +148,7 @@ docker compose up --build
|
|||||||
- readiness PostgreSQL, MinIO и импорта с версией/revision сборки: <http://localhost:8000/ready>;
|
- readiness PostgreSQL, MinIO и импорта с версией/revision сборки: <http://localhost:8000/ready>;
|
||||||
- консоль MinIO: <http://localhost:9001>.
|
- консоль MinIO: <http://localhost:9001>.
|
||||||
|
|
||||||
Контейнер API сам выполняет `alembic upgrade head`, затем идемпотентный seed. PostgreSQL хранит данные в именованном volume `postgres_data`, а MinIO — в `minio_data`. Compose ожидает readiness PostgreSQL и MinIO перед API, а API-контейнер проверяет `/ready`. Версия и commit SHA задаются через `APP_VERSION`/`APP_REVISION`; те же значения доступны администратору в `/api/v1/admin/diagnostics`. Официальный импорт по умолчанию необязателен; при включённом scheduler установите `OFFICIAL_IMPORT_REQUIRED=true`, тогда отсутствующий, неуспешный или просроченный запуск сделает readiness отрицательным.
|
Одноразовые контейнеры `migrate` и `minio-init` перед запуском API соответственно применяют `alembic upgrade head` и идемпотентно создают локальный `S3_BUCKET`; приложение само не создаёт схему или bucket. PostgreSQL хранит данные в именованном volume `postgres_data`, а MinIO — в `minio_data`. Compose ожидает readiness PostgreSQL, MinIO и успешное завершение обоих init-контейнеров перед API, а API-контейнер проверяет `/ready`. Версия и commit SHA задаются через `APP_VERSION`/`APP_REVISION`; те же значения доступны администратору в `/api/v1/admin/diagnostics`. Здоровье импортов диагностическое и не мешает API или scheduler восстановиться после сбоя.
|
||||||
|
|
||||||
API и scheduler пишут по одной JSON-записи на событие. HTTP-лог содержит только сгенерированный `request_id`, метод, путь без query string, статус и длительность; IP, заголовок авторизации и пользовательский payload не журналируются. `X-Request-ID` возвращается клиенту. Стандартный access-log Uvicorn отключён. Уровень управляется `LOG_LEVEL`. Публичный агрегат активности кэшируется в памяти процесса на 20 секунд (до 128 ключей) и очищается после публикации, модерации или удаления через этот процесс API; изменения scheduler видны после TTL; `X-Cache` показывает `HIT`/`MISS`. Защищённый `/api/v1/admin/diagnostics` скачивает JSON только с идентификатором сборки и агрегированными счётчиками, без имён игроков, исходных URL, payload и ошибок парсеров.
|
API и scheduler пишут по одной JSON-записи на событие. HTTP-лог содержит только сгенерированный `request_id`, метод, путь без query string, статус и длительность; IP, заголовок авторизации и пользовательский payload не журналируются. `X-Request-ID` возвращается клиенту. Стандартный access-log Uvicorn отключён. Уровень управляется `LOG_LEVEL`. Публичный агрегат активности кэшируется в памяти процесса на 20 секунд (до 128 ключей) и очищается после публикации, модерации или удаления через этот процесс API; изменения scheduler видны после TTL; `X-Cache` показывает `HIT`/`MISS`. Защищённый `/api/v1/admin/diagnostics` скачивает JSON только с идентификатором сборки и агрегированными счётчиками, без имён игроков, исходных URL, payload и ошибок парсеров.
|
||||||
|
|
||||||
@@ -182,7 +184,7 @@ docker compose up --build
|
|||||||
## Что реализовано
|
## Что реализовано
|
||||||
|
|
||||||
- FastAPI и SQLAlchemy 2;
|
- FastAPI и SQLAlchemy 2;
|
||||||
- PostgreSQL 17 и линейные миграции Alembic до `0016`;
|
- PostgreSQL 17 и линейные миграции Alembic до `0020`;
|
||||||
- идемпотентный seed с двумя точками и свежими демо-уловами;
|
- идемпотентный seed с двумя точками и свежими демо-уловами;
|
||||||
- `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки;
|
- `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки;
|
||||||
- `GET /api/v1/spots/{id}` и `/catches`;
|
- `GET /api/v1/spots/{id}` и `/catches`;
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""add canonical waterbody provenance fields
|
||||||
|
|
||||||
|
Revision ID: 0017
|
||||||
|
Revises: 0016
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0017"
|
||||||
|
down_revision = "0016"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("waterbody", sa.Column("source_system", sa.String(length=50), nullable=True))
|
||||||
|
op.add_column("waterbody", sa.Column("source_external_id", sa.String(length=200), nullable=True))
|
||||||
|
op.add_column("waterbody", sa.Column("source_url", sa.Text(), nullable=True))
|
||||||
|
op.add_column("waterbody", sa.Column("description", sa.Text(), nullable=True))
|
||||||
|
op.add_column("waterbody", sa.Column("source_checked_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.create_index(
|
||||||
|
"uq_waterbody_source_identity",
|
||||||
|
"waterbody",
|
||||||
|
["source_system", "source_external_id"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("uq_waterbody_source_identity", table_name="waterbody")
|
||||||
|
op.drop_column("waterbody", "source_checked_at")
|
||||||
|
op.drop_column("waterbody", "description")
|
||||||
|
op.drop_column("waterbody", "source_url")
|
||||||
|
op.drop_column("waterbody", "source_external_id")
|
||||||
|
op.drop_column("waterbody", "source_system")
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""store source coordinate text and precision
|
||||||
|
|
||||||
|
Revision ID: 0018
|
||||||
|
Revises: 0017
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0018"
|
||||||
|
down_revision = "0017"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("external_observation", sa.Column("coordinate_raw", sa.String(length=200), nullable=True))
|
||||||
|
op.add_column("external_observation", sa.Column("coordinate_precision", sa.String(length=20), nullable=True))
|
||||||
|
op.execute("UPDATE external_observation SET coordinate_precision = CASE WHEN x IS NOT NULL AND y IS NOT NULL THEN 'exact' ELSE 'missing' END")
|
||||||
|
op.alter_column("external_observation", "coordinate_precision", nullable=False, server_default="missing")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("external_observation", "coordinate_precision")
|
||||||
|
op.drop_column("external_observation", "coordinate_raw")
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""store canonical waterbody fish count
|
||||||
|
|
||||||
|
Revision ID: 0019
|
||||||
|
Revises: 0018
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0019"
|
||||||
|
down_revision = "0018"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("waterbody", sa.Column("fish_species_count", sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("waterbody", "fish_species_count")
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""store canonical waterbody detail facts
|
||||||
|
|
||||||
|
Revision ID: 0020
|
||||||
|
Revises: 0019
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0020"
|
||||||
|
down_revision = "0019"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for name in ("source_aliases", "source_fish_species", "source_image_urls", "source_point_urls"):
|
||||||
|
op.add_column("waterbody", sa.Column(name, sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for name in ("source_point_urls", "source_image_urls", "source_fish_species", "source_aliases"):
|
||||||
|
op.drop_column("waterbody", name)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""add canonical tackle items and rig components
|
||||||
|
|
||||||
|
Revision ID: 0021
|
||||||
|
Revises: 0020
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0021"
|
||||||
|
down_revision = "0020"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"tackle_item",
|
||||||
|
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(200), nullable=False),
|
||||||
|
sa.Column("normalized_name", sa.String(200), nullable=False, unique=True),
|
||||||
|
sa.Column("category", sa.String(20), nullable=False),
|
||||||
|
sa.Column("subcategory", sa.String(100)),
|
||||||
|
sa.Column("brand", sa.String(100)),
|
||||||
|
sa.Column("family", sa.String(100)),
|
||||||
|
sa.Column("unlock_level", sa.Integer()),
|
||||||
|
sa.Column("source_system", sa.String(50)),
|
||||||
|
sa.Column("source_external_id", sa.String(200)),
|
||||||
|
sa.Column("source_url", sa.Text()),
|
||||||
|
sa.Column("source_checked_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("raw_payload", sa.JSON()),
|
||||||
|
sa.UniqueConstraint("source_system", "source_external_id"),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"category IN ('bait', 'lure', 'rod', 'reel', 'line', 'hook', 'rig', 'float', 'sinker', 'other')",
|
||||||
|
name="ck_tackle_item_category",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"rig",
|
||||||
|
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(200), nullable=False),
|
||||||
|
sa.Column("normalized_name", sa.String(200), nullable=False, unique=True),
|
||||||
|
sa.Column("source_system", sa.String(50)),
|
||||||
|
sa.Column("source_external_id", sa.String(200)),
|
||||||
|
sa.Column("source_url", sa.Text()),
|
||||||
|
sa.Column("source_checked_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("raw_payload", sa.JSON()),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"rig_component",
|
||||||
|
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||||
|
sa.Column("rig_id", sa.Uuid(), sa.ForeignKey("rig.id"), nullable=False),
|
||||||
|
sa.Column("tackle_item_id", sa.Uuid(), sa.ForeignKey("tackle_item.id")),
|
||||||
|
sa.Column("role", sa.String(50), nullable=False),
|
||||||
|
sa.Column("position", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("raw_value", sa.String(200)),
|
||||||
|
sa.UniqueConstraint("rig_id", "position"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("rig_component")
|
||||||
|
op.drop_table("rig")
|
||||||
|
op.drop_table("tackle_item")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""preserve ordered gear evidence on catches
|
||||||
|
|
||||||
|
Revision ID: 0022
|
||||||
|
Revises: 0021
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0022"
|
||||||
|
down_revision = "0021"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"catch_tackle_component",
|
||||||
|
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||||
|
sa.Column("catch_report_id", sa.Uuid(), sa.ForeignKey("catch_report.id"), nullable=False),
|
||||||
|
sa.Column("tackle_item_id", sa.Uuid(), sa.ForeignKey("tackle_item.id")),
|
||||||
|
sa.Column("rig_id", sa.Uuid(), sa.ForeignKey("rig.id")),
|
||||||
|
sa.Column("role", sa.String(50), nullable=False),
|
||||||
|
sa.Column("position", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("raw_value", sa.String(200), nullable=False),
|
||||||
|
sa.Column("source_system", sa.String(50)),
|
||||||
|
sa.Column("source_external_id", sa.String(200)),
|
||||||
|
sa.Column("source_url", sa.Text()),
|
||||||
|
sa.Column("raw_payload", sa.JSON()),
|
||||||
|
sa.UniqueConstraint("catch_report_id", "position"),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"NOT (tackle_item_id IS NOT NULL AND rig_id IS NOT NULL)",
|
||||||
|
name="ck_catch_tackle_one_canonical_target",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("catch_tackle_component")
|
||||||
@@ -61,6 +61,9 @@ def activity_rows(
|
|||||||
confidence = min(confidence, 50)
|
confidence = min(confidence, 50)
|
||||||
elif len(players) == 2:
|
elif len(players) == 2:
|
||||||
confidence = min(confidence, 65)
|
confidence = min(confidence, 65)
|
||||||
|
coordinate_precisions = {_coordinate_precision(item) for item in items}
|
||||||
|
coordinate_precision = max(coordinate_precisions, key=_precision_rank)
|
||||||
|
coordinate_sources = sorted({_source_system(item) for item in items})
|
||||||
latest = max(_aware(r.reported_at) for r in items)
|
latest = max(_aware(r.reported_at) for r in items)
|
||||||
baits = Counter(r.bait.name for r in items if r.bait)
|
baits = Counter(r.bait.name for r in items if r.bait)
|
||||||
freshness_text = _freshness_text(now - latest)
|
freshness_text = _freshness_text(now - latest)
|
||||||
@@ -74,7 +77,10 @@ def activity_rows(
|
|||||||
max_weight_g=max(r.weight_g for r in items), last_confirmed_at=latest,
|
max_weight_g=max(r.weight_g for r in items), last_confirmed_at=latest,
|
||||||
activity_score=activity, confidence_score=confidence,
|
activity_score=activity, confidence_score=confidence,
|
||||||
explanation=_explanation(len(items), len(players), freshness_text, activity, confidence),
|
explanation=_explanation(len(items), len(players), freshness_text, activity, confidence),
|
||||||
sources=sorted({_source_system(item) for item in items}),
|
sources=coordinate_sources,
|
||||||
|
coordinate_precision=coordinate_precision,
|
||||||
|
coordinate_sources=coordinate_sources,
|
||||||
|
source_conflicts=_source_conflicts(items),
|
||||||
))
|
))
|
||||||
return sorted(result, key=lambda row: (row.activity_score, row.last_confirmed_at), reverse=True)
|
return sorted(result, key=lambda row: (row.activity_score, row.last_confirmed_at), reverse=True)
|
||||||
|
|
||||||
@@ -90,6 +96,26 @@ def _source_system(report: CatchReport) -> str:
|
|||||||
return "manual-import"
|
return "manual-import"
|
||||||
|
|
||||||
|
|
||||||
|
def _source_conflicts(reports: list[CatchReport]) -> list[str]:
|
||||||
|
conflicts: set[str] = set()
|
||||||
|
for report in reports:
|
||||||
|
provenance = (report.raw_payload or {}).get("provenance", {})
|
||||||
|
values = provenance.get("conflicts", []) if isinstance(provenance, dict) else []
|
||||||
|
if isinstance(values, list):
|
||||||
|
conflicts.update(str(value).strip() for value in values if str(value).strip())
|
||||||
|
return sorted(conflicts)
|
||||||
|
|
||||||
|
|
||||||
|
def _coordinate_precision(report: CatchReport) -> str:
|
||||||
|
provenance = (report.raw_payload or {}).get("provenance", {})
|
||||||
|
value = provenance.get("coordinate_precision") if isinstance(provenance, dict) else None
|
||||||
|
return value if value in {"exact", "approximate", "area", "missing"} else "exact"
|
||||||
|
|
||||||
|
|
||||||
|
def _precision_rank(value: str) -> int:
|
||||||
|
return {"exact": 0, "approximate": 1, "area": 2, "missing": 3}[value]
|
||||||
|
|
||||||
|
|
||||||
def _aware(value: datetime) -> datetime:
|
def _aware(value: datetime) -> datetime:
|
||||||
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|||||||
@@ -15,3 +15,28 @@ def audit_catalog(db: Session) -> dict[str, int]:
|
|||||||
"incomplete_published_staging": db.scalar(select(func.count()).select_from(ExternalObservation).where(ExternalObservation.status == "published", or_(ExternalObservation.fish_id.is_(None), ExternalObservation.waterbody_id.is_(None), ExternalObservation.x.is_(None), ExternalObservation.y.is_(None), ExternalObservation.weight_g.is_(None), ExternalObservation.catch_report_id.is_(None)))) or 0,
|
"incomplete_published_staging": db.scalar(select(func.count()).select_from(ExternalObservation).where(ExternalObservation.status == "published", or_(ExternalObservation.fish_id.is_(None), ExternalObservation.waterbody_id.is_(None), ExternalObservation.x.is_(None), ExternalObservation.y.is_(None), ExternalObservation.weight_g.is_(None), ExternalObservation.catch_report_id.is_(None)))) or 0,
|
||||||
}
|
}
|
||||||
return {"fishes": count(Fish), "waterbodies": count(Waterbody), "reports": count(CatchReport), "staging": count(ExternalObservation), **failures, "failures": sum(failures.values())}
|
return {"fishes": count(Fish), "waterbodies": count(Waterbody), "reports": count(CatchReport), "staging": count(ExternalObservation), **failures, "failures": sum(failures.values())}
|
||||||
|
|
||||||
|
|
||||||
|
def audit_waterbody_catalog(db: Session, expected_ids: set[str]) -> dict:
|
||||||
|
"""Check a verified RF4DB snapshot without withdrawing legacy rows."""
|
||||||
|
rows = list(db.scalars(select(Waterbody).where(Waterbody.source_system == "rf4db")))
|
||||||
|
observed_ids = [str(row.source_external_id) for row in rows if row.source_external_id]
|
||||||
|
observed = set(observed_ids)
|
||||||
|
duplicate_ids = sorted({item for item in observed_ids if observed_ids.count(item) > 1})
|
||||||
|
missing = sorted(expected_ids - observed)
|
||||||
|
unexpected = sorted(observed - expected_ids)
|
||||||
|
provenance_issues = sorted(
|
||||||
|
str(row.source_external_id)
|
||||||
|
for row in rows
|
||||||
|
if not row.source_external_id or not row.source_url or not row.source_checked_at
|
||||||
|
)
|
||||||
|
failures = len(missing) + len(duplicate_ids) + len(provenance_issues)
|
||||||
|
return {
|
||||||
|
"expected": len(expected_ids),
|
||||||
|
"observed": len(observed),
|
||||||
|
"missing_source_external_ids": missing,
|
||||||
|
"unexpected_source_external_ids": unexpected,
|
||||||
|
"duplicate_source_external_ids": duplicate_ids,
|
||||||
|
"provenance_issues": provenance_issues,
|
||||||
|
"failures": failures,
|
||||||
|
}
|
||||||
|
|||||||
+66
-2
@@ -8,10 +8,10 @@ from dataclasses import asdict
|
|||||||
from .config import settings
|
from .config import settings
|
||||||
from .database import SessionLocal
|
from .database import SessionLocal
|
||||||
from .importer import import_records
|
from .importer import import_records
|
||||||
from .community_importer import stage_observations
|
from .community_importer import stage_observations, update_waterbody_detail, update_waterbody_details, upsert_waterbody_catalog
|
||||||
from .retention import RetentionPolicy, apply_retention
|
from .retention import RetentionPolicy, apply_retention
|
||||||
from .storage import delete_screenshot
|
from .storage import delete_screenshot
|
||||||
from .catalog_audit import audit_catalog
|
from .catalog_audit import audit_catalog, audit_waterbody_catalog
|
||||||
from .community_scheduler import run_source, configured_sources
|
from .community_scheduler import run_source, configured_sources
|
||||||
|
|
||||||
# Static registry for argparse choices — no DB required for --help
|
# Static registry for argparse choices — no DB required for --help
|
||||||
@@ -34,11 +34,20 @@ def main() -> int:
|
|||||||
community = sub.add_parser("stage-community-json")
|
community = sub.add_parser("stage-community-json")
|
||||||
community.add_argument("--input", default="-", help="JSON array path or - for stdin")
|
community.add_argument("--input", default="-", help="JSON array path or - for stdin")
|
||||||
community.add_argument("--limit", type=int, default=500)
|
community.add_argument("--limit", type=int, default=500)
|
||||||
|
waterbodies = sub.add_parser("import-waterbody-catalog")
|
||||||
|
waterbodies.add_argument("--input", required=True, help="JSON snapshot path or - for stdin")
|
||||||
|
waterbodies.add_argument("--limit", type=int, default=100)
|
||||||
|
detail = sub.add_parser("import-waterbody-detail")
|
||||||
|
detail.add_argument("--input", required=True, help="JSON detail snapshot path")
|
||||||
|
details = sub.add_parser("import-waterbody-details")
|
||||||
|
details.add_argument("--input", required=True, help="JSON array of detail snapshots")
|
||||||
fetch_community = sub.add_parser("fetch-community")
|
fetch_community = sub.add_parser("fetch-community")
|
||||||
fetch_community.add_argument("source", choices=STATIC_SOURCE_CHOICES)
|
fetch_community.add_argument("source", choices=STATIC_SOURCE_CHOICES)
|
||||||
cleanup = sub.add_parser("cleanup-retention")
|
cleanup = sub.add_parser("cleanup-retention")
|
||||||
cleanup.add_argument("--apply", action="store_true", help="apply changes; default is dry-run")
|
cleanup.add_argument("--apply", action="store_true", help="apply changes; default is dry-run")
|
||||||
sub.add_parser("audit-catalog")
|
sub.add_parser("audit-catalog")
|
||||||
|
waterbody_audit = sub.add_parser("audit-waterbody-catalog")
|
||||||
|
waterbody_audit.add_argument("--input", required=True, help="JSON snapshot path")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
with SessionLocal() as session:
|
with SessionLocal() as session:
|
||||||
if args.command == "import-records":
|
if args.command == "import-records":
|
||||||
@@ -57,6 +66,43 @@ def main() -> int:
|
|||||||
parser.error("input must be a JSON array")
|
parser.error("input must be a JSON array")
|
||||||
created, updated = stage_observations(session, payload[:args.limit])
|
created, updated = stage_observations(session, payload[:args.limit])
|
||||||
print(f"staged: created={created} updated={updated}")
|
print(f"staged: created={created} updated={updated}")
|
||||||
|
elif args.command == "import-waterbody-catalog":
|
||||||
|
if not 1 <= args.limit <= 500:
|
||||||
|
parser.error("--limit must be between 1 and 500")
|
||||||
|
stream = sys.stdin if args.input == "-" else open(args.input, encoding="utf-8")
|
||||||
|
try:
|
||||||
|
snapshot = json.load(stream)
|
||||||
|
finally:
|
||||||
|
if stream is not sys.stdin:
|
||||||
|
stream.close()
|
||||||
|
if isinstance(snapshot, dict):
|
||||||
|
payload = snapshot.get("items")
|
||||||
|
source_system = snapshot.get("source_system")
|
||||||
|
if isinstance(payload, list) and isinstance(source_system, str):
|
||||||
|
payload = [
|
||||||
|
{"source_system": source_system, **item}
|
||||||
|
for item in payload if isinstance(item, dict)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
payload = snapshot
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
parser.error("input must be a JSON array or an object with an items array")
|
||||||
|
created, updated = upsert_waterbody_catalog(session, payload[:args.limit])
|
||||||
|
print(f"waterbodies: created={created} updated={updated}")
|
||||||
|
elif args.command == "import-waterbody-detail":
|
||||||
|
with open(args.input, encoding="utf-8") as stream:
|
||||||
|
payload = json.load(stream)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
parser.error("input must be a JSON object")
|
||||||
|
update_waterbody_detail(session, payload)
|
||||||
|
print(f"waterbody detail: updated={payload.get('source_external_id', 'unknown')}")
|
||||||
|
elif args.command == "import-waterbody-details":
|
||||||
|
with open(args.input, encoding="utf-8") as stream:
|
||||||
|
payload = json.load(stream)
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
parser.error("input must be a JSON array")
|
||||||
|
created, updated = update_waterbody_details(session, payload)
|
||||||
|
print(f"waterbody details: created={created} updated={updated}")
|
||||||
elif args.command == "fetch-community":
|
elif args.command == "fetch-community":
|
||||||
# A09: Verify source is enabled at runtime (not just in static choices)
|
# A09: Verify source is enabled at runtime (not just in static choices)
|
||||||
enabled = configured_sources()
|
enabled = configured_sources()
|
||||||
@@ -76,6 +122,24 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
counts = apply_retention(session, policy=policy, dry_run=not args.apply, delete_object=delete_screenshot)
|
counts = apply_retention(session, policy=policy, dry_run=not args.apply, delete_object=delete_screenshot)
|
||||||
print(json.dumps({"mode": "apply" if args.apply else "dry-run", "policy": asdict(policy), "counts": counts}, ensure_ascii=False))
|
print(json.dumps({"mode": "apply" if args.apply else "dry-run", "policy": asdict(policy), "counts": counts}, ensure_ascii=False))
|
||||||
|
elif args.command == "audit-waterbody-catalog":
|
||||||
|
stream = sys.stdin if args.input == "-" else open(args.input, encoding="utf-8")
|
||||||
|
try:
|
||||||
|
snapshot = json.load(stream)
|
||||||
|
finally:
|
||||||
|
if stream is not sys.stdin:
|
||||||
|
stream.close()
|
||||||
|
items = snapshot.get("items") if isinstance(snapshot, dict) else snapshot
|
||||||
|
if not isinstance(items, list):
|
||||||
|
parser.error("input must be a JSON array or an object with an items array")
|
||||||
|
expected_ids = {
|
||||||
|
str(item["source_external_id"])
|
||||||
|
for item in items
|
||||||
|
if isinstance(item, dict) and item.get("source_external_id")
|
||||||
|
}
|
||||||
|
result = audit_waterbody_catalog(session, expected_ids)
|
||||||
|
print(json.dumps(result, ensure_ascii=False))
|
||||||
|
return 1 if result["failures"] else 0
|
||||||
else:
|
else:
|
||||||
result = audit_catalog(session)
|
result = audit_catalog(session)
|
||||||
print(json.dumps(result, ensure_ascii=False))
|
print(json.dumps(result, ensure_ascii=False))
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -26,12 +28,153 @@ SOURCE_HOSTS = {
|
|||||||
"rf4map": {"rf4map.ru"},
|
"rf4map": {"rf4map.ru"},
|
||||||
"rf4posts-spot": {"rf4-posts.com"},
|
"rf4posts-spot": {"rf4-posts.com"},
|
||||||
}
|
}
|
||||||
|
COORDINATE_PRECISIONS = frozenset({"exact", "approximate", "area", "missing"})
|
||||||
|
|
||||||
|
|
||||||
class CommunityImportError(ValueError):
|
class CommunityImportError(ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_waterbody_catalog(
|
||||||
|
session: Session, rows: Iterable[dict[str, Any]], *, fetched_at: datetime | None = None,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""Apply a validated canonical waterbody snapshot without destructive sync.
|
||||||
|
|
||||||
|
Rows are matched by the RF4DB source identity first and by an exact existing
|
||||||
|
name second. Missing rows are deliberately left untouched: an incomplete
|
||||||
|
response must never withdraw a previously known waterbody.
|
||||||
|
"""
|
||||||
|
fetched_at = fetched_at or datetime.now(timezone.utc)
|
||||||
|
created = updated = 0
|
||||||
|
for raw in rows:
|
||||||
|
payload = _json_payload(raw)
|
||||||
|
if payload.get("source_system") != "rf4db":
|
||||||
|
raise CommunityImportError("waterbody catalog requires source_system=rf4db")
|
||||||
|
external_id = _required(payload, "source_external_id", 200)
|
||||||
|
name = _required(payload, "name", 200)
|
||||||
|
source_url = _required(payload, "source_url", 2000)
|
||||||
|
parsed_url = urlparse(source_url)
|
||||||
|
if parsed_url.scheme != "https" or parsed_url.hostname not in {"rf4db.com", "www.rf4db.com"}:
|
||||||
|
raise CommunityImportError("waterbody source_url does not match rf4db")
|
||||||
|
unlock_level = _integer(payload.get("unlock_level"), minimum=0, maximum=1_000)
|
||||||
|
unlock_label = _required(payload, "unlock_label", 50)
|
||||||
|
fish_species_count = _integer(payload.get("fish_species_count"), minimum=0, maximum=10_000)
|
||||||
|
if fish_species_count is None:
|
||||||
|
raise CommunityImportError("invalid fish_species_count")
|
||||||
|
|
||||||
|
item = session.scalar(select(Waterbody).where(
|
||||||
|
Waterbody.source_system == "rf4db",
|
||||||
|
Waterbody.source_external_id == external_id,
|
||||||
|
))
|
||||||
|
if item is None:
|
||||||
|
item = session.scalar(select(Waterbody).where(Waterbody.name_ru == name))
|
||||||
|
if item is None:
|
||||||
|
item = Waterbody(
|
||||||
|
slug=_catalog_slug(session, name, external_id),
|
||||||
|
name_ru=name,
|
||||||
|
unlock_level=unlock_level,
|
||||||
|
)
|
||||||
|
session.add(item)
|
||||||
|
created += 1
|
||||||
|
else:
|
||||||
|
updated += 1
|
||||||
|
item.name_ru = name
|
||||||
|
item.unlock_level = unlock_level
|
||||||
|
item.fish_species_count = fish_species_count
|
||||||
|
item.source_system = "rf4db"
|
||||||
|
item.source_external_id = external_id
|
||||||
|
item.source_url = source_url
|
||||||
|
item.source_checked_at = fetched_at
|
||||||
|
session.commit()
|
||||||
|
return created, updated
|
||||||
|
|
||||||
|
|
||||||
|
def update_waterbody_detail(
|
||||||
|
session: Session, detail: dict[str, Any], *, fetched_at: datetime | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Persist one complete RF4DB detail snapshot without assigning media roles."""
|
||||||
|
fetched_at = fetched_at or datetime.now(timezone.utc)
|
||||||
|
payload = _validate_waterbody_detail(detail)
|
||||||
|
_apply_waterbody_detail(session, payload, fetched_at=fetched_at)
|
||||||
|
session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def update_waterbody_details(
|
||||||
|
session: Session, details: Iterable[dict[str, Any]], *, fetched_at: datetime | None = None,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""Validate and apply a detail batch in one transaction."""
|
||||||
|
fetched_at = fetched_at or datetime.now(timezone.utc)
|
||||||
|
payloads = [_validate_waterbody_detail(detail) for detail in details]
|
||||||
|
external_ids = [str(payload["source_external_id"]) for payload in payloads]
|
||||||
|
if len(external_ids) != len(set(external_ids)):
|
||||||
|
raise CommunityImportError("waterbody detail batch contains duplicate source identities")
|
||||||
|
updated = 0
|
||||||
|
for payload in payloads:
|
||||||
|
_apply_waterbody_detail(session, payload, fetched_at=fetched_at)
|
||||||
|
updated += 1
|
||||||
|
session.commit()
|
||||||
|
return 0, updated
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_waterbody_detail(detail: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = _json_payload(detail)
|
||||||
|
if payload.get("source_system") != "rf4db":
|
||||||
|
raise CommunityImportError("waterbody detail requires source_system=rf4db")
|
||||||
|
external_id = _required(payload, "source_external_id", 200)
|
||||||
|
source_url = _required(payload, "source_url", 2000)
|
||||||
|
parsed_url = urlparse(source_url)
|
||||||
|
if parsed_url.scheme != "https" or parsed_url.hostname not in {"rf4db.com", "www.rf4db.com", "download.rf4db.com"}:
|
||||||
|
raise CommunityImportError("waterbody detail source_url does not match rf4db")
|
||||||
|
_required(payload, "name", 200)
|
||||||
|
_optional(payload, "description", 20_000)
|
||||||
|
_string_list(payload, "aliases", 100, 200)
|
||||||
|
_string_list(payload, "fish_species", 10_000, 200)
|
||||||
|
_string_list(payload, "image_urls", 100, 2_000)
|
||||||
|
_string_list(payload, "point_urls", 10_000, 2_000)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_waterbody_detail(session: Session, payload: dict[str, Any], *, fetched_at: datetime) -> None:
|
||||||
|
external_id = str(payload["source_external_id"])
|
||||||
|
source_url = str(payload["source_url"])
|
||||||
|
item = session.scalar(select(Waterbody).where(
|
||||||
|
Waterbody.source_system == "rf4db", Waterbody.source_external_id == external_id,
|
||||||
|
))
|
||||||
|
if item is None:
|
||||||
|
raise CommunityImportError("waterbody detail has no imported catalog identity")
|
||||||
|
item.description = _optional(payload, "description", 20_000)
|
||||||
|
item.source_aliases = _string_list(payload, "aliases", 100, 200)
|
||||||
|
item.source_fish_species = _string_list(payload, "fish_species", 10_000, 200)
|
||||||
|
item.source_image_urls = _string_list(payload, "image_urls", 100, 2_000)
|
||||||
|
item.source_point_urls = _string_list(payload, "point_urls", 10_000, 2_000)
|
||||||
|
item.source_url = source_url
|
||||||
|
item.source_checked_at = fetched_at
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_slug(session: Session, name: str, external_id: str) -> str:
|
||||||
|
base = re.sub(r"[^a-z0-9а-яё]+", "-", name.casefold(), flags=re.IGNORECASE).strip("-")
|
||||||
|
base = base or "waterbody"
|
||||||
|
candidate = base[:100]
|
||||||
|
if session.scalar(select(Waterbody.id).where(Waterbody.slug == candidate)) is None:
|
||||||
|
return candidate
|
||||||
|
suffix = hashlib.sha256(external_id.encode()).hexdigest()[:10]
|
||||||
|
return f"{base[:89]}-{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _string_list(payload: dict[str, Any], key: str, max_items: int, max_length: int) -> list[str]:
|
||||||
|
value = payload.get(key)
|
||||||
|
if not isinstance(value, list) or len(value) > max_items:
|
||||||
|
raise CommunityImportError(f"invalid {key}")
|
||||||
|
result = []
|
||||||
|
for item in value:
|
||||||
|
text = str(item).strip()
|
||||||
|
if not text or len(text) > max_length:
|
||||||
|
raise CommunityImportError(f"invalid {key}")
|
||||||
|
result.append(text)
|
||||||
|
return list(dict.fromkeys(result))
|
||||||
|
|
||||||
|
|
||||||
def stage_observations(
|
def stage_observations(
|
||||||
session: Session, records: Iterable[dict[str, Any]], *, fetched_at: datetime | None = None,
|
session: Session, records: Iterable[dict[str, Any]], *, fetched_at: datetime | None = None,
|
||||||
) -> tuple[int, int]:
|
) -> tuple[int, int]:
|
||||||
@@ -65,6 +208,8 @@ def stage_observations(
|
|||||||
"waterbody_external_id": _optional(payload, "waterbody_external_id", 200),
|
"waterbody_external_id": _optional(payload, "waterbody_external_id", 200),
|
||||||
"x": _integer(payload.get("x"), maximum=10_000),
|
"x": _integer(payload.get("x"), maximum=10_000),
|
||||||
"y": _integer(payload.get("y"), maximum=10_000),
|
"y": _integer(payload.get("y"), maximum=10_000),
|
||||||
|
"coordinate_raw": _coordinate_raw(payload),
|
||||||
|
"coordinate_precision": _coordinate_precision(payload),
|
||||||
"weight_g": _integer(payload.get("weight_g"), minimum=1, maximum=3_000_000),
|
"weight_g": _integer(payload.get("weight_g"), minimum=1, maximum=3_000_000),
|
||||||
"published_at": _datetime(payload.get("published_at")),
|
"published_at": _datetime(payload.get("published_at")),
|
||||||
"last_seen_at": fetched_at, "payload": payload,
|
"last_seen_at": fetched_at, "payload": payload,
|
||||||
@@ -83,6 +228,7 @@ def stage_observations(
|
|||||||
changed = any(getattr(observation, key) != values[key] for key in (
|
changed = any(getattr(observation, key) != values[key] for key in (
|
||||||
"source_url", "fish_name", "fish_external_id", "waterbody_name",
|
"source_url", "fish_name", "fish_external_id", "waterbody_name",
|
||||||
"waterbody_external_id", "x", "y", "weight_g",
|
"waterbody_external_id", "x", "y", "weight_g",
|
||||||
|
"coordinate_raw", "coordinate_precision",
|
||||||
)) or observation.payload != payload
|
)) or observation.payload != payload
|
||||||
if observation.status != "rejected" and changed and observation.catch_report is not None:
|
if observation.status != "rejected" and changed and observation.catch_report is not None:
|
||||||
observation.catch_report.moderation_status = ModerationStatus.pending
|
observation.catch_report.moderation_status = ModerationStatus.pending
|
||||||
@@ -186,6 +332,27 @@ def _optional(payload: dict[str, Any], key: str, limit: int) -> str | None:
|
|||||||
return value or None
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def _coordinate_raw(payload: dict[str, Any]) -> str | None:
|
||||||
|
value = str(payload.get("coordinate_raw") or "").strip()
|
||||||
|
if len(value) > 200:
|
||||||
|
raise CommunityImportError("invalid coordinate_raw")
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
x, y = payload.get("x"), payload.get("y")
|
||||||
|
return f"{x}:{y}" if isinstance(x, int) and isinstance(y, int) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _coordinate_precision(payload: dict[str, Any]) -> str:
|
||||||
|
value = str(payload.get("coordinate_precision") or "").strip().casefold()
|
||||||
|
if not value:
|
||||||
|
return "exact" if isinstance(payload.get("x"), int) and isinstance(payload.get("y"), int) else "missing"
|
||||||
|
if value not in COORDINATE_PRECISIONS:
|
||||||
|
raise CommunityImportError("invalid coordinate_precision")
|
||||||
|
if value == "exact" and (not isinstance(payload.get("x"), int) or not isinstance(payload.get("y"), int)):
|
||||||
|
raise CommunityImportError("exact coordinates require x and y")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _integer(value: Any, *, minimum: int = -10_000, maximum: int) -> int | None:
|
def _integer(value: Any, *, minimum: int = -10_000, maximum: int) -> int | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ from .models import (
|
|||||||
Bait, BaitKind, CatchReport, ExternalEntityAlias, ExternalObservation,
|
Bait, BaitKind, CatchReport, ExternalEntityAlias, ExternalObservation,
|
||||||
Fish, ModerationStatus, SourceType, Spot, Waterbody,
|
Fish, ModerationStatus, SourceType, Spot, Waterbody,
|
||||||
)
|
)
|
||||||
|
from .tackle_components import replace_tackle_components
|
||||||
|
from rf4_research.gear_components import from_catch_fields
|
||||||
|
|
||||||
|
|
||||||
class ExternalReviewError(ValueError):
|
class ExternalReviewError(ValueError):
|
||||||
@@ -19,7 +21,7 @@ class ExternalReviewError(ValueError):
|
|||||||
|
|
||||||
def map_observation(
|
def map_observation(
|
||||||
session: Session, observation: ExternalObservation, fish: Fish, waterbody: Waterbody,
|
session: Session, observation: ExternalObservation, fish: Fish, waterbody: Waterbody,
|
||||||
*, note: str | None = None,
|
*, note: str | None = None, commit: bool = True,
|
||||||
) -> ExternalObservation:
|
) -> ExternalObservation:
|
||||||
if observation.status == "published":
|
if observation.status == "published":
|
||||||
raise ExternalReviewError("published observation cannot be remapped")
|
raise ExternalReviewError("published observation cannot be remapped")
|
||||||
@@ -41,7 +43,8 @@ def map_observation(
|
|||||||
observation.status = "ready" if _complete(observation) else "mapped"
|
observation.status = "ready" if _complete(observation) else "mapped"
|
||||||
_save_alias(session, observation, "fish", observation.fish_external_id or observation.fish_name, fish=fish)
|
_save_alias(session, observation, "fish", observation.fish_external_id or observation.fish_name, fish=fish)
|
||||||
_save_alias(session, observation, "waterbody", observation.waterbody_external_id or observation.waterbody_name, waterbody=waterbody)
|
_save_alias(session, observation, "waterbody", observation.waterbody_external_id or observation.waterbody_name, waterbody=waterbody)
|
||||||
session.commit()
|
if commit:
|
||||||
|
session.commit()
|
||||||
return observation
|
return observation
|
||||||
|
|
||||||
|
|
||||||
@@ -59,17 +62,22 @@ def suggest_aliases(session: Session, observation: ExternalObservation) -> tuple
|
|||||||
return (fish_alias.fish if fish_alias else None, waterbody_alias.waterbody if waterbody_alias else None)
|
return (fish_alias.fish if fish_alias else None, waterbody_alias.waterbody if waterbody_alias else None)
|
||||||
|
|
||||||
|
|
||||||
def reject_observation(session: Session, observation: ExternalObservation, *, reason: str) -> ExternalObservation:
|
def reject_observation(
|
||||||
|
session: Session, observation: ExternalObservation, *, reason: str, commit: bool = True,
|
||||||
|
) -> ExternalObservation:
|
||||||
if observation.status == "published":
|
if observation.status == "published":
|
||||||
raise ExternalReviewError("published observation cannot be rejected")
|
raise ExternalReviewError("published observation cannot be rejected")
|
||||||
observation.status = "rejected"
|
observation.status = "rejected"
|
||||||
observation.review_note = reason
|
observation.review_note = reason
|
||||||
observation.reviewed_at = datetime.now(timezone.utc)
|
observation.reviewed_at = datetime.now(timezone.utc)
|
||||||
session.commit()
|
if commit:
|
||||||
|
session.commit()
|
||||||
return observation
|
return observation
|
||||||
|
|
||||||
|
|
||||||
def publish_observation(session: Session, observation: ExternalObservation) -> CatchReport:
|
def publish_observation(
|
||||||
|
session: Session, observation: ExternalObservation, *, commit: bool = True,
|
||||||
|
) -> CatchReport:
|
||||||
if observation.catch_report is not None and observation.status == "published":
|
if observation.catch_report is not None and observation.status == "published":
|
||||||
return observation.catch_report
|
return observation.catch_report
|
||||||
if observation.status == "rejected":
|
if observation.status == "rejected":
|
||||||
@@ -104,6 +112,8 @@ def publish_observation(session: Session, observation: ExternalObservation) -> C
|
|||||||
"external_observation_id": str(observation.id),
|
"external_observation_id": str(observation.id),
|
||||||
"source_system": observation.source_system,
|
"source_system": observation.source_system,
|
||||||
"source_external_id": observation.source_external_id,
|
"source_external_id": observation.source_external_id,
|
||||||
|
"coordinate_raw": observation.coordinate_raw,
|
||||||
|
"coordinate_precision": observation.coordinate_precision,
|
||||||
},
|
},
|
||||||
"original": observation.payload,
|
"original": observation.payload,
|
||||||
},
|
},
|
||||||
@@ -116,10 +126,22 @@ def publish_observation(session: Session, observation: ExternalObservation) -> C
|
|||||||
setattr(report, key, value)
|
setattr(report, key, value)
|
||||||
session.add(report)
|
session.add(report)
|
||||||
session.flush()
|
session.flush()
|
||||||
|
replace_tackle_components(
|
||||||
|
session,
|
||||||
|
report,
|
||||||
|
from_catch_fields(
|
||||||
|
bait=observation.payload.get("bait"),
|
||||||
|
rig_type=observation.payload.get("rig_type"),
|
||||||
|
),
|
||||||
|
source_system=observation.source_system,
|
||||||
|
source_url=observation.source_url,
|
||||||
|
raw_payload={"origin": "community_observation", "observation_id": str(observation.id)},
|
||||||
|
)
|
||||||
observation.catch_report = report
|
observation.catch_report = report
|
||||||
observation.status = "published"
|
observation.status = "published"
|
||||||
observation.reviewed_at = now
|
observation.reviewed_at = now
|
||||||
session.commit()
|
if commit:
|
||||||
|
session.commit()
|
||||||
return report
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ from .models import (
|
|||||||
Bait, BaitKind, CatchReport, Fish, ImportRecordEvent, ImportStatus, ModerationStatus,
|
Bait, BaitKind, CatchReport, Fish, ImportRecordEvent, ImportStatus, ModerationStatus,
|
||||||
OfficialRecordImport, SourceType, Waterbody,
|
OfficialRecordImport, SourceType, Waterbody,
|
||||||
)
|
)
|
||||||
|
from .tackle_components import replace_tackle_components
|
||||||
|
from rf4_research.gear_components import from_catch_fields
|
||||||
|
|
||||||
|
|
||||||
USER_AGENT = "RF4-Spotter/0.1 (public records importer)"
|
USER_AGENT = "RF4-Spotter/0.1 (public records importer)"
|
||||||
@@ -222,6 +224,14 @@ def _import_records_locked(session: Session, *, url: str, region: str, category:
|
|||||||
provenance={"source_system": "rf4-official", "source_url": url, "source_external_id": key},
|
provenance={"source_system": "rf4-official", "source_url": url, "source_external_id": key},
|
||||||
))
|
))
|
||||||
run.rows_updated += 1
|
run.rows_updated += 1
|
||||||
|
replace_tackle_components(
|
||||||
|
session,
|
||||||
|
report,
|
||||||
|
from_catch_fields(bait=raw.bait, rig_type=None),
|
||||||
|
source_system="rf4-official",
|
||||||
|
source_url=url,
|
||||||
|
raw_payload={"origin": "official_record", "source_external_id": key},
|
||||||
|
)
|
||||||
run.status = ImportStatus.success
|
run.status = ImportStatus.success
|
||||||
run.finished_at = datetime.now(timezone.utc)
|
run.finished_at = datetime.now(timezone.utc)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from .dependencies import Db
|
|||||||
from .logging_config import configure_logging
|
from .logging_config import configure_logging
|
||||||
from .readiness import readiness_report
|
from .readiness import readiness_report
|
||||||
from .routers.activity import router as activity_router
|
from .routers.activity import router as activity_router
|
||||||
|
from .routers.analytics import router as analytics_router
|
||||||
from .routers.admin import router as admin_router
|
from .routers.admin import router as admin_router
|
||||||
from .routers.catalog import router as catalog_router
|
from .routers.catalog import router as catalog_router
|
||||||
from .routers.media import router as media_router
|
from .routers.media import router as media_router
|
||||||
@@ -90,6 +91,7 @@ def ready(db: Db) -> JSONResponse:
|
|||||||
app.include_router(catalog_router)
|
app.include_router(catalog_router)
|
||||||
app.include_router(media_router)
|
app.include_router(media_router)
|
||||||
app.include_router(activity_router)
|
app.include_router(activity_router)
|
||||||
|
app.include_router(analytics_router)
|
||||||
app.include_router(public_data_router)
|
app.include_router(public_data_router)
|
||||||
app.include_router(admin_router)
|
app.include_router(admin_router)
|
||||||
app.include_router(submissions_router)
|
app.include_router(submissions_router)
|
||||||
|
|||||||
@@ -6,9 +6,25 @@ from pathlib import Path
|
|||||||
|
|
||||||
|
|
||||||
MEDIA_ROOT = Path(os.environ.get("MEDIA_ROOT", "data/media")).resolve()
|
MEDIA_ROOT = Path(os.environ.get("MEDIA_ROOT", "data/media")).resolve()
|
||||||
|
WATERBODY_MEDIA_ROLES = {"waterbody_cover", "waterbody_map", "waterbody_depth_map", "waterbody_screenshot"}
|
||||||
|
TACKLE_MEDIA_ROLES = {"tackle_card", "tackle_detail", "rig_diagram", "tackle_screenshot"}
|
||||||
|
KNOWN_MEDIA_ROLES = WATERBODY_MEDIA_ROLES | TACKLE_MEDIA_ROLES
|
||||||
|
MEDIA_ROLES_BY_ENTITY = {"waterbody": WATERBODY_MEDIA_ROLES, "tackle": TACKLE_MEDIA_ROLES}
|
||||||
|
|
||||||
|
|
||||||
def published_assets(entity_type: str | None = None) -> list[dict]:
|
def _public_role_allowed(entity_type: str | None, role: object) -> bool:
|
||||||
|
if role is None:
|
||||||
|
return True
|
||||||
|
if not isinstance(role, str) or role not in KNOWN_MEDIA_ROLES:
|
||||||
|
return False
|
||||||
|
if entity_type == "waterbody":
|
||||||
|
return role in WATERBODY_MEDIA_ROLES
|
||||||
|
if entity_type == "tackle":
|
||||||
|
return role in TACKLE_MEDIA_ROLES
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def published_assets(entity_type: str | None = None, media_role: str | None = None) -> list[dict]:
|
||||||
manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8"))
|
manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8"))
|
||||||
result = []
|
result = []
|
||||||
for item in manifest.get("assets", []):
|
for item in manifest.get("assets", []):
|
||||||
@@ -16,12 +32,17 @@ def published_assets(entity_type: str | None = None) -> list[dict]:
|
|||||||
continue
|
continue
|
||||||
if entity_type and item.get("entity_type") != entity_type:
|
if entity_type and item.get("entity_type") != entity_type:
|
||||||
continue
|
continue
|
||||||
|
if media_role and item.get("media_role") != media_role:
|
||||||
|
continue
|
||||||
|
if not _public_role_allowed(item.get("entity_type"), item.get("media_role")):
|
||||||
|
continue
|
||||||
source_page = str(item.get("source_page") or "")
|
source_page = str(item.get("source_page") or "")
|
||||||
source = "rf4db" if "rf4db.com" in source_page else "rf4map" if "rf4map.ru" in source_page else "rf4-official"
|
source = "rf4db" if "rf4db.com" in source_page else "rf4map" if "rf4map.ru" in source_page else "rf4-official"
|
||||||
result.append({
|
result.append({
|
||||||
"id": item["sha256"],
|
"id": item["sha256"],
|
||||||
"entity_type": item.get("entity_type"),
|
"entity_type": item.get("entity_type"),
|
||||||
"entity_key": item.get("entity_key"),
|
"entity_key": item.get("entity_key"),
|
||||||
|
"media_role": item.get("media_role"),
|
||||||
"label": item.get("label"),
|
"label": item.get("label"),
|
||||||
"width": item.get("width"),
|
"width": item.get("width"),
|
||||||
"height": item.get("height"),
|
"height": item.get("height"),
|
||||||
@@ -49,15 +70,32 @@ def published_file(digest: str) -> tuple[Path, str] | None:
|
|||||||
return None
|
return None
|
||||||
manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8"))
|
manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8"))
|
||||||
item = next((row for row in manifest.get("assets", []) if row.get("status") == "approved" and row.get("sha256") == digest), None)
|
item = next((row for row in manifest.get("assets", []) if row.get("status") == "approved" and row.get("sha256") == digest), None)
|
||||||
if not item:
|
media_type = None
|
||||||
|
local_path = None
|
||||||
|
if item:
|
||||||
|
media_type = item.get("content_type")
|
||||||
|
local_path = item.get("local_path")
|
||||||
|
else:
|
||||||
|
for row in manifest.get("assets", []):
|
||||||
|
if row.get("status") != "approved":
|
||||||
|
continue
|
||||||
|
variant = next((candidate for candidate in row.get("derivatives", []) if candidate.get("sha256") == digest), None)
|
||||||
|
if variant:
|
||||||
|
media_type = variant.get("content_type")
|
||||||
|
local_path = variant.get("local_path")
|
||||||
|
break
|
||||||
|
if not local_path:
|
||||||
return None
|
return None
|
||||||
target = (MEDIA_ROOT / item["local_path"]).resolve()
|
target = (MEDIA_ROOT / local_path).resolve()
|
||||||
if not target.is_relative_to(MEDIA_ROOT.resolve()) or not target.is_file():
|
if not target.is_relative_to(MEDIA_ROOT.resolve()) or not target.is_file():
|
||||||
return None
|
return None
|
||||||
return target, str(item["content_type"])
|
return target, str(media_type or "application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
def review_assets(entity_type: str | None = None, status: str | None = None) -> list[dict]:
|
def review_assets(
|
||||||
|
entity_type: str | None = None, status: str | None = None, media_role: str | None = None,
|
||||||
|
query: str | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8"))
|
manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8"))
|
||||||
result = []
|
result = []
|
||||||
for item in manifest.get("assets", []):
|
for item in manifest.get("assets", []):
|
||||||
@@ -66,6 +104,10 @@ def review_assets(entity_type: str | None = None, status: str | None = None) ->
|
|||||||
continue
|
continue
|
||||||
if entity_type and item.get("entity_type") != entity_type:
|
if entity_type and item.get("entity_type") != entity_type:
|
||||||
continue
|
continue
|
||||||
|
if media_role and item.get("media_role") != media_role:
|
||||||
|
continue
|
||||||
|
if query and query.casefold() not in str(item.get("label") or "").casefold():
|
||||||
|
continue
|
||||||
digest = str(item.get("sha256") or "")
|
digest = str(item.get("sha256") or "")
|
||||||
if len(digest) != 64 or not item.get("local_path"):
|
if len(digest) != 64 or not item.get("local_path"):
|
||||||
continue
|
continue
|
||||||
@@ -81,9 +123,11 @@ def review_assets(entity_type: str | None = None, status: str | None = None) ->
|
|||||||
"height": item.get("height"),
|
"height": item.get("height"),
|
||||||
"content_type": item.get("content_type"),
|
"content_type": item.get("content_type"),
|
||||||
"image_url": f"/api/v1/admin/media/assets/{digest}",
|
"image_url": f"/api/v1/admin/media/assets/{digest}",
|
||||||
|
"asset_url": item.get("asset_url", ""),
|
||||||
"source_system": source,
|
"source_system": source,
|
||||||
"source_url": source_page,
|
"source_url": source_page,
|
||||||
"duplicate_of": item.get("duplicate_of"),
|
"duplicate_of": item.get("duplicate_of"),
|
||||||
|
"supersedes": item.get("supersedes"),
|
||||||
"derivatives": [{
|
"derivatives": [{
|
||||||
"role": variant.get("role"), "format": variant.get("format"),
|
"role": variant.get("role"), "format": variant.get("format"),
|
||||||
"width": variant.get("width"), "height": variant.get("height"),
|
"width": variant.get("width"), "height": variant.get("height"),
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ class Waterbody(Base):
|
|||||||
slug: Mapped[str] = mapped_column(String(100), unique=True)
|
slug: Mapped[str] = mapped_column(String(100), unique=True)
|
||||||
name_ru: Mapped[str] = mapped_column(String(200), unique=True)
|
name_ru: Mapped[str] = mapped_column(String(200), unique=True)
|
||||||
unlock_level: Mapped[int | None]
|
unlock_level: Mapped[int | None]
|
||||||
|
fish_species_count: Mapped[int | None]
|
||||||
|
source_system: Mapped[str | None] = mapped_column(String(50))
|
||||||
|
source_external_id: Mapped[str | None] = mapped_column(String(200))
|
||||||
|
source_url: Mapped[str | None] = mapped_column(Text)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
source_aliases: Mapped[list[str] | None] = mapped_column(JSON)
|
||||||
|
source_fish_species: Mapped[list[str] | None] = mapped_column(JSON)
|
||||||
|
source_image_urls: Mapped[list[str] | None] = mapped_column(JSON)
|
||||||
|
source_point_urls: Mapped[list[str] | None] = mapped_column(JSON)
|
||||||
|
source_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
|
||||||
class Bait(Base):
|
class Bait(Base):
|
||||||
@@ -59,6 +69,61 @@ class Bait(Base):
|
|||||||
kind: Mapped[BaitKind] = mapped_column(Enum(BaitKind))
|
kind: Mapped[BaitKind] = mapped_column(Enum(BaitKind))
|
||||||
|
|
||||||
|
|
||||||
|
class TackleItem(Base):
|
||||||
|
"""Canonical gear item; legacy Bait rows remain source-compatible."""
|
||||||
|
|
||||||
|
__tablename__ = "tackle_item"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("source_system", "source_external_id"),
|
||||||
|
CheckConstraint(
|
||||||
|
"category IN ('bait', 'lure', 'rod', 'reel', 'line', 'hook', 'rig', 'float', 'sinker', 'other')",
|
||||||
|
name="ck_tackle_item_category",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
name: Mapped[str] = mapped_column(String(200))
|
||||||
|
normalized_name: Mapped[str] = mapped_column(String(200), unique=True)
|
||||||
|
category: Mapped[str] = mapped_column(String(20))
|
||||||
|
subcategory: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
brand: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
family: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
unlock_level: Mapped[int | None]
|
||||||
|
source_system: Mapped[str | None] = mapped_column(String(50))
|
||||||
|
source_external_id: Mapped[str | None] = mapped_column(String(200))
|
||||||
|
source_url: Mapped[str | None] = mapped_column(Text)
|
||||||
|
source_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
raw_payload: Mapped[dict | None] = mapped_column(JSON)
|
||||||
|
rig_components: Mapped[list["RigComponent"]] = relationship(back_populates="tackle_item")
|
||||||
|
|
||||||
|
|
||||||
|
class Rig(Base):
|
||||||
|
"""A named rig/setup kept separate from individual tackle items."""
|
||||||
|
|
||||||
|
__tablename__ = "rig"
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
name: Mapped[str] = mapped_column(String(200))
|
||||||
|
normalized_name: Mapped[str] = mapped_column(String(200), unique=True)
|
||||||
|
source_system: Mapped[str | None] = mapped_column(String(50))
|
||||||
|
source_external_id: Mapped[str | None] = mapped_column(String(200))
|
||||||
|
source_url: Mapped[str | None] = mapped_column(Text)
|
||||||
|
source_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
raw_payload: Mapped[dict | None] = mapped_column(JSON)
|
||||||
|
components: Mapped[list["RigComponent"]] = relationship(back_populates="rig")
|
||||||
|
|
||||||
|
|
||||||
|
class RigComponent(Base):
|
||||||
|
__tablename__ = "rig_component"
|
||||||
|
__table_args__ = (UniqueConstraint("rig_id", "position"),)
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
rig_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("rig.id"))
|
||||||
|
tackle_item_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("tackle_item.id"))
|
||||||
|
role: Mapped[str] = mapped_column(String(50))
|
||||||
|
position: Mapped[int] = mapped_column(Integer)
|
||||||
|
raw_value: Mapped[str | None] = mapped_column(String(200))
|
||||||
|
rig: Mapped[Rig] = relationship(back_populates="components")
|
||||||
|
tackle_item: Mapped[TackleItem | None] = relationship(back_populates="rig_components")
|
||||||
|
|
||||||
|
|
||||||
class Spot(Base):
|
class Spot(Base):
|
||||||
__tablename__ = "spot"
|
__tablename__ = "spot"
|
||||||
__table_args__ = (UniqueConstraint("waterbody_id", "x", "y"),)
|
__table_args__ = (UniqueConstraint("waterbody_id", "x", "y"),)
|
||||||
@@ -100,6 +165,34 @@ class CatchReport(Base):
|
|||||||
spot: Mapped[Spot | None] = relationship()
|
spot: Mapped[Spot | None] = relationship()
|
||||||
waterbody: Mapped[Waterbody] = relationship()
|
waterbody: Mapped[Waterbody] = relationship()
|
||||||
bait: Mapped[Bait | None] = relationship()
|
bait: Mapped[Bait | None] = relationship()
|
||||||
|
tackle_components: Mapped[list["CatchTackleComponent"]] = relationship(back_populates="catch_report")
|
||||||
|
|
||||||
|
|
||||||
|
class CatchTackleComponent(Base):
|
||||||
|
"""Ordered gear evidence; unresolved raw values are valid and preserved."""
|
||||||
|
|
||||||
|
__tablename__ = "catch_tackle_component"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("catch_report_id", "position"),
|
||||||
|
CheckConstraint(
|
||||||
|
"NOT (tackle_item_id IS NOT NULL AND rig_id IS NOT NULL)",
|
||||||
|
name="ck_catch_tackle_one_canonical_target",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
catch_report_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("catch_report.id"))
|
||||||
|
tackle_item_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("tackle_item.id"))
|
||||||
|
rig_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("rig.id"))
|
||||||
|
role: Mapped[str] = mapped_column(String(50))
|
||||||
|
position: Mapped[int] = mapped_column(Integer)
|
||||||
|
raw_value: Mapped[str] = mapped_column(String(200))
|
||||||
|
source_system: Mapped[str | None] = mapped_column(String(50))
|
||||||
|
source_external_id: Mapped[str | None] = mapped_column(String(200))
|
||||||
|
source_url: Mapped[str | None] = mapped_column(Text)
|
||||||
|
raw_payload: Mapped[dict | None] = mapped_column(JSON)
|
||||||
|
catch_report: Mapped[CatchReport] = relationship(back_populates="tackle_components")
|
||||||
|
tackle_item: Mapped[TackleItem | None] = relationship()
|
||||||
|
rig: Mapped[Rig | None] = relationship()
|
||||||
|
|
||||||
|
|
||||||
class OfficialRecordImport(Base):
|
class OfficialRecordImport(Base):
|
||||||
@@ -187,6 +280,8 @@ class ExternalObservation(Base):
|
|||||||
waterbody_external_id: Mapped[str | None] = mapped_column(String(200))
|
waterbody_external_id: Mapped[str | None] = mapped_column(String(200))
|
||||||
x: Mapped[int | None]
|
x: Mapped[int | None]
|
||||||
y: Mapped[int | None]
|
y: Mapped[int | None]
|
||||||
|
coordinate_raw: Mapped[str | None] = mapped_column(String(200))
|
||||||
|
coordinate_precision: Mapped[str] = mapped_column(String(20), default="missing")
|
||||||
weight_g: Mapped[int | None]
|
weight_g: Mapped[int | None]
|
||||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ from uuid import UUID
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Response
|
from fastapi import APIRouter, HTTPException, Query, Response
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload, selectinload
|
||||||
|
|
||||||
from ..activity import activity_rows
|
from ..activity import _source_conflicts, activity_rows
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from ..dependencies import Db
|
from ..dependencies import Db
|
||||||
from ..models import CatchReport, ModerationStatus, SourceType, Spot, Waterbody
|
from ..models import CatchReport, ModerationStatus, SourceType, Spot, Waterbody
|
||||||
@@ -68,7 +68,15 @@ def spot_detail(spot_id: UUID, db: Db) -> SpotOut:
|
|||||||
def count_since(delta: timedelta) -> int:
|
def count_since(delta: timedelta) -> int:
|
||||||
return sum(aware(report.reported_at) >= now - delta for report in reports)
|
return sum(aware(report.reported_at) >= now - delta for report in reports)
|
||||||
|
|
||||||
return SpotOut(id=spot.id, waterbody_slug=spot.waterbody.slug, waterbody=spot.waterbody.name_ru, x=spot.x, y=spot.y, description=spot.description, catches_24h=count_since(timedelta(hours=24)), catches_3d=count_since(timedelta(days=3)), catches_7d=count_since(timedelta(days=7)), top_baits=[name for name, _ in bait_counts.most_common(5)])
|
provenance = [
|
||||||
|
(report.raw_payload or {}).get("provenance", {})
|
||||||
|
for report in reports
|
||||||
|
if isinstance((report.raw_payload or {}).get("provenance", {}), dict)
|
||||||
|
]
|
||||||
|
precisions = [item.get("coordinate_precision") for item in provenance]
|
||||||
|
precision = max((value for value in precisions if value in {"exact", "approximate", "area", "missing"}), key={"exact": 0, "approximate": 1, "area": 2, "missing": 3}.get, default="exact")
|
||||||
|
sources = sorted({str(item.get("source_system")) for item in provenance if item.get("source_system")}) or ["players"]
|
||||||
|
return SpotOut(id=spot.id, waterbody_slug=spot.waterbody.slug, waterbody=spot.waterbody.name_ru, x=spot.x, y=spot.y, description=spot.description, catches_24h=count_since(timedelta(hours=24)), catches_3d=count_since(timedelta(days=3)), catches_7d=count_since(timedelta(days=7)), top_baits=[name for name, _ in bait_counts.most_common(5)], coordinate_precision=precision, coordinate_sources=sources, source_conflicts=_source_conflicts(reports))
|
||||||
|
|
||||||
|
|
||||||
def _report_source(report: CatchReport) -> str:
|
def _report_source(report: CatchReport) -> str:
|
||||||
@@ -83,8 +91,13 @@ def _report_source(report: CatchReport) -> str:
|
|||||||
@router.get("/api/v1/spots/{spot_id}/catches", response_model=list[CatchOut])
|
@router.get("/api/v1/spots/{spot_id}/catches", response_model=list[CatchOut])
|
||||||
def spot_catches(spot_id: UUID, db: Db, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[CatchOut]:
|
def spot_catches(spot_id: UUID, db: Db, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[CatchOut]:
|
||||||
_spot_or_404(db, spot_id)
|
_spot_or_404(db, spot_id)
|
||||||
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc(), CatchReport.id.desc()).offset(offset).limit(limit)))
|
reports = list(db.scalars(select(CatchReport).options(joinedload(CatchReport.fish), joinedload(CatchReport.bait), selectinload(CatchReport.tackle_components)).where(CatchReport.spot_id == spot_id, CatchReport.moderation_status == ModerationStatus.approved, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at.desc(), CatchReport.id.desc()).offset(offset).limit(limit)))
|
||||||
return [CatchOut(id=report.id, fish=report.fish.name_ru, weight_g=report.weight_g, bait=report.bait.name if report.bait else None, player_name=report.player_name, caught_at=report.caught_at, reported_at=report.reported_at, retrieve_method=report.retrieve_method, retrieve_speed=report.retrieve_speed, source_system=_report_source(report), source_url=report.source_url) for report in reports]
|
return [CatchOut(id=report.id, fish=report.fish.name_ru, weight_g=report.weight_g, bait=report.bait.name if report.bait else None, player_name=report.player_name, caught_at=report.caught_at, reported_at=report.reported_at, fishing_method=report.fishing_method, retrieve_method=report.retrieve_method, retrieve_speed=report.retrieve_speed, source_system=_report_source(report), source_url=report.source_url, tackle_components=[{
|
||||||
|
"id": component.id, "role": component.role, "position": component.position,
|
||||||
|
"raw_value": component.raw_value, "tackle_item_id": component.tackle_item_id,
|
||||||
|
"rig_id": component.rig_id, "source_system": component.source_system,
|
||||||
|
"source_url": component.source_url,
|
||||||
|
} for component in sorted(report.tackle_components, key=lambda value: value.position)]) for report in reports]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/spots/{spot_id}/timeline")
|
@router.get("/api/v1/spots/{spot_id}/timeline")
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ from ..community_review import ExternalReviewError, map_observation, publish_obs
|
|||||||
from ..config import settings
|
from ..config import settings
|
||||||
from ..dependencies import Db
|
from ..dependencies import Db
|
||||||
from ..importer import ImportAlreadyRunning, ImportSourceError, import_records
|
from ..importer import ImportAlreadyRunning, ImportSourceError, import_records
|
||||||
from ..media_catalog import review_assets, review_file
|
from rf4_research.media_assets import publish_quality_upgrades, rollback_quality_upgrade
|
||||||
|
|
||||||
|
from ..media_catalog import KNOWN_MEDIA_ROLES, MEDIA_ROLES_BY_ENTITY, MEDIA_ROOT, review_assets, review_file
|
||||||
from ..models import CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Waterbody
|
from ..models import CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Waterbody
|
||||||
from ..public_cache import public_cache
|
from ..public_cache import public_cache
|
||||||
from ..schemas import AdminCatchReportOut, AdminMediaReviewOut, AdminModerationHistoryOut, AdminSourceStatusOut, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationAction, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationUpdate
|
from ..schemas import AdminCatchReportOut, AdminMediaDecision, AdminMediaReviewOut, AdminMediaRollback, AdminModerationHistoryOut, AdminSourceStatusOut, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationAction, ExternalObservationBulkAction, ExternalObservationBulkDecision, ExternalObservationBulkMapping, ExternalObservationBulkResult, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, ImportRunOut, ModerationBulkResult, ModerationBulkUpdate, ModerationUpdate
|
||||||
from ..storage import delete_screenshot, signed_screenshot_url
|
from ..storage import delete_screenshot, signed_screenshot_url
|
||||||
from ..time_utils import aware
|
from ..time_utils import aware
|
||||||
|
|
||||||
@@ -35,10 +37,16 @@ def admin_media_catalog(
|
|||||||
_: Annotated[str, Depends(_admin)],
|
_: Annotated[str, Depends(_admin)],
|
||||||
entity_type: str | None = Query(None, pattern="^(fish|waterbody|tackle|reference)$"),
|
entity_type: str | None = Query(None, pattern="^(fish|waterbody|tackle|reference)$"),
|
||||||
status: str | None = Query(None, pattern="^(approved|upgrade_queued|upgrade_stored)$"),
|
status: str | None = Query(None, pattern="^(approved|upgrade_queued|upgrade_stored)$"),
|
||||||
|
media_role: str | None = Query(None, pattern="^(waterbody_cover|waterbody_map|waterbody_depth_map|waterbody_screenshot|tackle_card|tackle_detail|rig_diagram|tackle_screenshot)$"),
|
||||||
|
q: str | None = Query(None, max_length=100),
|
||||||
limit: int = Query(50, ge=1, le=100),
|
limit: int = Query(50, ge=1, le=100),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
) -> list[AdminMediaReviewOut]:
|
) -> list[AdminMediaReviewOut]:
|
||||||
return review_assets(entity_type, status)[offset:offset + limit]
|
if media_role and media_role not in KNOWN_MEDIA_ROLES:
|
||||||
|
raise HTTPException(status_code=422, detail="unknown media role")
|
||||||
|
if media_role and (not entity_type or media_role not in MEDIA_ROLES_BY_ENTITY.get(entity_type, set())):
|
||||||
|
raise HTTPException(status_code=422, detail="media role is incompatible with entity type")
|
||||||
|
return review_assets(entity_type, status, media_role, q.strip() if q else None)[offset:offset + limit]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/admin/media/assets/{digest}", response_class=FileResponse)
|
@router.get("/api/v1/admin/media/assets/{digest}", response_class=FileResponse)
|
||||||
@@ -50,6 +58,32 @@ def admin_media_asset(digest: str, _: Annotated[str, Depends(_admin)]) -> FileRe
|
|||||||
return FileResponse(path, media_type=media_type, headers={"Cache-Control": "private, no-store"})
|
return FileResponse(path, media_type=media_type, headers={"Cache-Control": "private, no-store"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/admin/media/upgrades/publish")
|
||||||
|
def admin_publish_media_upgrades(
|
||||||
|
payload: AdminMediaDecision,
|
||||||
|
_: Annotated[str, Depends(_admin)],
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Atomically publish all stored quality upgrades after an explicit decision."""
|
||||||
|
try:
|
||||||
|
return publish_quality_upgrades(MEDIA_ROOT / "manifest.json", note=payload.note)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/admin/media/upgrades/rollback")
|
||||||
|
def admin_rollback_media_upgrade(
|
||||||
|
payload: AdminMediaRollback,
|
||||||
|
_: Annotated[str, Depends(_admin)],
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Restore one superseded fallback while retaining the reviewed candidate."""
|
||||||
|
try:
|
||||||
|
return rollback_quality_upgrade(
|
||||||
|
MEDIA_ROOT / "manifest.json", asset_url=payload.asset_url, note=payload.note,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/admin/diagnostics")
|
@router.get("/api/v1/admin/diagnostics")
|
||||||
def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONResponse:
|
def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONResponse:
|
||||||
report_counts = {status.value: count for status, count in db.execute(
|
report_counts = {status.value: count for status, count in db.execute(
|
||||||
@@ -315,6 +349,34 @@ def admin_map_external_observation(
|
|||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/api/v1/admin/external-observations/bulk-mapping", response_model=ExternalObservationBulkResult)
|
||||||
|
def admin_map_external_observations_bulk(
|
||||||
|
payload: ExternalObservationBulkMapping, db: Db, _: Annotated[str, Depends(_admin)],
|
||||||
|
) -> ExternalObservationBulkResult:
|
||||||
|
ids = [item.id for item in payload.items]
|
||||||
|
observations = list(db.scalars(select(ExternalObservation).where(ExternalObservation.id.in_(ids)).with_for_update()))
|
||||||
|
by_id = {item.id: item for item in observations}
|
||||||
|
expected = {item.id: item.expected_version for item in payload.items}
|
||||||
|
if len(by_id) != len(ids) or any(
|
||||||
|
item.status in {"published", "withdrawn"} or item.moderation_version != expected[item.id]
|
||||||
|
for item in observations
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=409, detail="one or more observations changed; reload the queue")
|
||||||
|
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")
|
||||||
|
try:
|
||||||
|
for item in observations:
|
||||||
|
item.moderation_version += 1
|
||||||
|
map_observation(db, item, fish, waterbody, note=payload.note, commit=False)
|
||||||
|
db.commit()
|
||||||
|
except ExternalReviewError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
return ExternalObservationBulkResult(updated=len(observations))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/v1/admin/external-observations/{observation_id}/publish", response_model=ExternalObservationPublished)
|
@router.post("/api/v1/admin/external-observations/{observation_id}/publish", response_model=ExternalObservationPublished)
|
||||||
def admin_publish_external_observation(
|
def admin_publish_external_observation(
|
||||||
observation_id: UUID, payload: ExternalObservationAction, db: Db, _: Annotated[str, Depends(_admin)],
|
observation_id: UUID, payload: ExternalObservationAction, db: Db, _: Annotated[str, Depends(_admin)],
|
||||||
@@ -333,6 +395,30 @@ def admin_publish_external_observation(
|
|||||||
return ExternalObservationPublished(observation_id=observation.id, catch_report_id=report.id, status=observation.status)
|
return ExternalObservationPublished(observation_id=observation.id, catch_report_id=report.id, status=observation.status)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/admin/external-observations/bulk-publish", response_model=ExternalObservationBulkResult)
|
||||||
|
def admin_publish_external_observations_bulk(
|
||||||
|
payload: ExternalObservationBulkAction, db: Db, _: Annotated[str, Depends(_admin)],
|
||||||
|
) -> ExternalObservationBulkResult:
|
||||||
|
ids = [item.id for item in payload.items]
|
||||||
|
observations = list(db.scalars(select(ExternalObservation).where(ExternalObservation.id.in_(ids)).with_for_update()))
|
||||||
|
expected = {item.id: item.expected_version for item in payload.items}
|
||||||
|
if len({item.id for item in observations}) != len(ids) or any(
|
||||||
|
item.status in {"rejected", "withdrawn"} or item.moderation_version != expected[item.id]
|
||||||
|
for item in observations
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=409, detail="one or more observations changed; reload the queue")
|
||||||
|
try:
|
||||||
|
for item in observations:
|
||||||
|
item.moderation_version += 1
|
||||||
|
publish_observation(db, item, commit=False)
|
||||||
|
db.commit()
|
||||||
|
except ExternalReviewError as exc:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
public_cache.invalidate()
|
||||||
|
return ExternalObservationBulkResult(updated=len(observations))
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/api/v1/admin/external-observations/{observation_id}/reject", response_model=ExternalObservationOut)
|
@router.patch("/api/v1/admin/external-observations/{observation_id}/reject", response_model=ExternalObservationOut)
|
||||||
def admin_reject_external_observation(
|
def admin_reject_external_observation(
|
||||||
observation_id: UUID, payload: ExternalObservationDecision, db: Db,
|
observation_id: UUID, payload: ExternalObservationDecision, db: Db,
|
||||||
@@ -350,6 +436,21 @@ def admin_reject_external_observation(
|
|||||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/api/v1/admin/external-observations/bulk-reject", response_model=ExternalObservationBulkResult)
|
||||||
|
def admin_reject_external_observations_bulk(payload: ExternalObservationBulkDecision, db: Db, _: Annotated[str, Depends(_admin)]) -> ExternalObservationBulkResult:
|
||||||
|
ids = [item.id for item in payload.items]
|
||||||
|
observations = list(db.scalars(select(ExternalObservation).where(ExternalObservation.id.in_(ids)).with_for_update()))
|
||||||
|
by_id = {item.id: item for item in observations}
|
||||||
|
expected = {item.id: item.expected_version for item in payload.items}
|
||||||
|
if len(by_id) != len(ids) or any(item.status in {"published", "rejected", "withdrawn"} or item.moderation_version != expected[item.id] for item in observations):
|
||||||
|
raise HTTPException(status_code=409, detail="one or more observations changed; reload the queue")
|
||||||
|
for item in observations:
|
||||||
|
item.moderation_version += 1
|
||||||
|
reject_observation(db, item, reason=payload.reason, commit=False)
|
||||||
|
db.commit()
|
||||||
|
return ExternalObservationBulkResult(updated=len(observations))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/admin/catch-reports", response_model=list[AdminCatchReportOut])
|
@router.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), offset: int = Query(0, ge=0)) -> list[AdminCatchReportOut]:
|
def admin_reports(db: Db, _: Annotated[str, Depends(_admin)], status: ModerationStatus = ModerationStatus.pending, limit: int = Query(50, ge=1, le=100), offset: int = Query(0, ge=0)) -> 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, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at, CatchReport.id).offset(offset).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, CatchReport.deleted_at.is_(None)).order_by(CatchReport.reported_at, CatchReport.id).offset(offset).limit(limit)))
|
||||||
@@ -372,6 +473,27 @@ def moderate_report(report_id: UUID, payload: ModerationUpdate, db: Db, moderato
|
|||||||
return CatchReportCreated(id=report.id, moderation_status=report.moderation_status.value)
|
return CatchReportCreated(id=report.id, moderation_status=report.moderation_status.value)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/admin/catch-reports/bulk", response_model=ModerationBulkResult)
|
||||||
|
def moderate_reports_bulk(payload: ModerationBulkUpdate, db: Db, moderator: Annotated[str, Depends(_admin)]) -> ModerationBulkResult:
|
||||||
|
if payload.status == "rejected" and not (payload.reason or "").strip():
|
||||||
|
raise HTTPException(status_code=422, detail="rejection reason required")
|
||||||
|
ids = [item.id for item in payload.items]
|
||||||
|
reports = list(db.scalars(select(CatchReport).where(CatchReport.id.in_(ids)).with_for_update()))
|
||||||
|
by_id = {report.id: report for report in reports}
|
||||||
|
if len(by_id) != len(ids) or any(report.source_type != SourceType.user or report.deleted_at is not None or report.moderation_status != ModerationStatus.pending for report in reports):
|
||||||
|
raise HTTPException(status_code=409, detail="one or more reports are no longer pending")
|
||||||
|
expected = {item.id: item.expected_version for item in payload.items}
|
||||||
|
if any(report.moderation_version != expected[report.id] for report in reports):
|
||||||
|
raise HTTPException(status_code=409, detail="one or more reports changed; reload the queue")
|
||||||
|
for report in reports:
|
||||||
|
previous = report.moderation_status
|
||||||
|
report.moderation_status = ModerationStatus(payload.status)
|
||||||
|
report.moderation_version += 1
|
||||||
|
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(); public_cache.invalidate()
|
||||||
|
return ModerationBulkResult(updated=len(reports))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/v1/admin/catch-reports/{report_id}", status_code=204, response_class=Response)
|
@router.delete("/api/v1/admin/catch-reports/{report_id}", status_code=204, response_class=Response)
|
||||||
def delete_report(report_id: UUID, db: Db, moderator: Annotated[str, Depends(_admin)], expected_version: int = Query(ge=0)) -> Response:
|
def delete_report(report_id: UUID, db: Db, moderator: Annotated[str, Depends(_admin)], expected_version: int = Query(ge=0)) -> Response:
|
||||||
report = db.scalar(select(CatchReport).where(CatchReport.id == report_id).with_for_update())
|
report = db.scalar(select(CatchReport).where(CatchReport.id == report_id).with_for_update())
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from math import exp, log
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session, joinedload, selectinload
|
||||||
|
|
||||||
|
from ..dependencies import Db
|
||||||
|
from ..models import CatchReport, Fish, ModerationStatus, Spot, Waterbody
|
||||||
|
from ..schemas import TackleCombinationOut
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
FRESHNESS_HALF_LIFE_HOURS = 12.5
|
||||||
|
|
||||||
|
|
||||||
|
def _age_hours(value: datetime, now: datetime) -> float:
|
||||||
|
observed_at = value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
|
||||||
|
return max(0.0, (now - observed_at).total_seconds() / 3600)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/analytics/tackle", response_model=list[TackleCombinationOut])
|
||||||
|
def tackle_combinations(
|
||||||
|
db: Db,
|
||||||
|
waterbody: str | None = None,
|
||||||
|
fish: str | None = None,
|
||||||
|
method: str | None = None,
|
||||||
|
role: str | None = None,
|
||||||
|
hours: int = Query(72, ge=24, le=168),
|
||||||
|
min_samples: int = Query(3, ge=1, le=100),
|
||||||
|
min_players: int = Query(2, ge=1, le=100),
|
||||||
|
) -> list[TackleCombinationOut]:
|
||||||
|
if role is not None and role not in {"lure", "bait", "rig", "rod", "reel", "line", "hook", "float", "sinker"}:
|
||||||
|
raise HTTPException(status_code=422, detail="invalid tackle role")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
query = select(CatchReport).options(
|
||||||
|
joinedload(CatchReport.fish), joinedload(CatchReport.waterbody),
|
||||||
|
selectinload(CatchReport.tackle_components),
|
||||||
|
).where(
|
||||||
|
CatchReport.moderation_status == ModerationStatus.approved,
|
||||||
|
CatchReport.deleted_at.is_(None),
|
||||||
|
CatchReport.reported_at >= now - timedelta(hours=hours),
|
||||||
|
)
|
||||||
|
if waterbody:
|
||||||
|
query = query.join(Waterbody, CatchReport.waterbody_id == Waterbody.id).where(Waterbody.slug == waterbody)
|
||||||
|
if fish:
|
||||||
|
query = query.join(Fish, CatchReport.fish_id == Fish.id).where(Fish.slug == fish)
|
||||||
|
if method:
|
||||||
|
query = query.where(CatchReport.fishing_method == method)
|
||||||
|
|
||||||
|
groups: dict[tuple[str, str], list[CatchReport]] = defaultdict(list)
|
||||||
|
for report in db.scalars(query):
|
||||||
|
for component in report.tackle_components:
|
||||||
|
if role and component.role != role:
|
||||||
|
continue
|
||||||
|
if component.raw_value.strip():
|
||||||
|
groups[(component.role, component.raw_value.strip())].append(report)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for (component_role, value), reports in groups.items():
|
||||||
|
unique_reports = {report.id: report for report in reports}
|
||||||
|
item_ids = {
|
||||||
|
component.tackle_item_id
|
||||||
|
for report in unique_reports.values()
|
||||||
|
for component in report.tackle_components
|
||||||
|
if component.role == component_role and component.raw_value.strip() == value and component.tackle_item_id is not None
|
||||||
|
}
|
||||||
|
rig_ids = {
|
||||||
|
component.rig_id
|
||||||
|
for report in unique_reports.values()
|
||||||
|
for component in report.tackle_components
|
||||||
|
if component.role == component_role and component.raw_value.strip() == value and component.rig_id is not None
|
||||||
|
}
|
||||||
|
canonical_item_id = next(iter(item_ids)) if len(item_ids) == 1 and not rig_ids else None
|
||||||
|
canonical_rig_id = next(iter(rig_ids)) if len(rig_ids) == 1 and not item_ids else None
|
||||||
|
players = {report.player_name.strip().casefold() for report in unique_reports.values() if report.player_name and report.player_name.strip()}
|
||||||
|
catches = len(unique_reports)
|
||||||
|
unique_players = len(players)
|
||||||
|
last_seen = max(report.reported_at for report in unique_reports.values())
|
||||||
|
freshness_score = round(sum(
|
||||||
|
exp(-_age_hours(report.reported_at, now) * log(2) / FRESHNESS_HALF_LIFE_HOURS)
|
||||||
|
for report in unique_reports.values()
|
||||||
|
) / catches * 100)
|
||||||
|
enough = catches >= min_samples and unique_players >= min_players
|
||||||
|
result.append(TackleCombinationOut(
|
||||||
|
role=component_role, value=value, tackle_item_id=canonical_item_id, rig_id=canonical_rig_id,
|
||||||
|
catches=catches, unique_players=unique_players,
|
||||||
|
last_seen_at=last_seen, freshness_score=freshness_score,
|
||||||
|
status="recommendation" if enough else "insufficient_data",
|
||||||
|
explanation=(
|
||||||
|
"Достаточно независимых наблюдений для рекомендации."
|
||||||
|
if enough else
|
||||||
|
f"Данных мало: нужно минимум {min_samples} наблюдения и {min_players} независимых игрока."
|
||||||
|
),
|
||||||
|
))
|
||||||
|
return sorted(result, key=lambda item: (item.status != "recommendation", -item.freshness_score, -item.catches, -item.unique_players, item.role, item.value))
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
from fastapi import APIRouter, Query
|
from uuid import UUID
|
||||||
from sqlalchemy import select
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from ..dependencies import Db
|
from ..dependencies import Db
|
||||||
from ..models import Bait, CatchReport, Fish, ModerationStatus, Spot, Waterbody
|
from ..models import Bait, CatchReport, Fish, ModerationStatus, Rig, RigComponent, Spot, TackleItem, Waterbody
|
||||||
from ..schemas import BaitOut, FishOut, WaterbodyOut
|
from ..schemas import BaitOut, FishOut, PaginatedRigOut, PaginatedTackleItemOut, RigOut, RigSummaryOut, TackleItemOut, TackleItemRigOut, WaterbodyOut
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -23,6 +26,107 @@ def baits(db: Db, limit: int = Query(200, ge=1, le=500), offset: int = Query(0,
|
|||||||
return list(db.scalars(select(Bait).order_by(Bait.name, Bait.id).offset(offset).limit(limit)))
|
return list(db.scalars(select(Bait).order_by(Bait.name, Bait.id).offset(offset).limit(limit)))
|
||||||
|
|
||||||
|
|
||||||
|
def _item_missing_fields(item: TackleItem) -> list[str]:
|
||||||
|
return [field for field, value in (
|
||||||
|
("subcategory", item.subcategory), ("brand", item.brand),
|
||||||
|
("family", item.family), ("unlock_level", item.unlock_level),
|
||||||
|
("source_url", item.source_url), ("source_checked_at", item.source_checked_at),
|
||||||
|
) if value is None]
|
||||||
|
|
||||||
|
|
||||||
|
def _rig_missing_fields(rig: Rig) -> list[str]:
|
||||||
|
return [field for field, value in (
|
||||||
|
("source_url", rig.source_url), ("source_checked_at", rig.source_checked_at),
|
||||||
|
) if value is None]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/tackle/items", response_model=PaginatedTackleItemOut)
|
||||||
|
def tackle_items(
|
||||||
|
db: Db,
|
||||||
|
category: str | None = Query(None, pattern="^(bait|lure|rod|reel|line|hook|rig|float|sinker|other)$"),
|
||||||
|
q: str | None = None,
|
||||||
|
brand: str | None = None,
|
||||||
|
family: str | None = None,
|
||||||
|
unlock_level: int | None = Query(None, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=100),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
) -> PaginatedTackleItemOut:
|
||||||
|
if q is not None and len(q) > 100:
|
||||||
|
raise HTTPException(status_code=422, detail="search query is too long")
|
||||||
|
query = select(TackleItem)
|
||||||
|
if q and q.strip():
|
||||||
|
query = query.where(TackleItem.name.ilike(f"%{q.strip()}%"))
|
||||||
|
if category:
|
||||||
|
query = query.where(TackleItem.category == category)
|
||||||
|
if brand:
|
||||||
|
query = query.where(TackleItem.brand == brand)
|
||||||
|
if family:
|
||||||
|
query = query.where(TackleItem.family == family)
|
||||||
|
if unlock_level is not None:
|
||||||
|
query = query.where(TackleItem.unlock_level == unlock_level)
|
||||||
|
total = db.scalar(query.with_only_columns(func.count(TackleItem.id), maintain_column_froms=True).order_by(None)) or 0
|
||||||
|
items = list(db.scalars(query.order_by(TackleItem.name, TackleItem.id).offset(offset).limit(limit)))
|
||||||
|
return PaginatedTackleItemOut(
|
||||||
|
items=[TackleItemOut.model_validate(item).model_copy(update={"missing_fields": _item_missing_fields(item)}) for item in items],
|
||||||
|
total=total, limit=limit, offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/tackle/items/{item_id}", response_model=TackleItemOut)
|
||||||
|
def tackle_item(item_id: UUID, db: Db) -> TackleItemOut:
|
||||||
|
item = db.get(TackleItem, item_id)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status_code=404, detail="tackle item not found")
|
||||||
|
rigs = list(db.scalars(select(Rig).join(RigComponent, RigComponent.rig_id == Rig.id).where(RigComponent.tackle_item_id == item_id).options(selectinload(Rig.components)).distinct().order_by(Rig.name, Rig.id)))
|
||||||
|
related = [TackleItemRigOut(id=rig.id, name=rig.name, source_system=rig.source_system, source_checked_at=rig.source_checked_at, component_count=len(rig.components)) for rig in rigs]
|
||||||
|
return TackleItemOut.model_validate(item).model_copy(update={"missing_fields": _item_missing_fields(item), "related_rigs": related})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/tackle/rigs", response_model=PaginatedRigOut)
|
||||||
|
def tackle_rigs(
|
||||||
|
db: Db,
|
||||||
|
q: str | None = None,
|
||||||
|
limit: int = Query(48, ge=1, le=100),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
) -> PaginatedRigOut:
|
||||||
|
if q is not None and len(q) > 100:
|
||||||
|
raise HTTPException(status_code=422, detail="search query is too long")
|
||||||
|
query = select(Rig).options(selectinload(Rig.components))
|
||||||
|
if q and q.strip():
|
||||||
|
query = query.where(Rig.name.ilike(f"%{q.strip()}%"))
|
||||||
|
total_query = select(func.count(Rig.id))
|
||||||
|
if q and q.strip():
|
||||||
|
total_query = total_query.where(Rig.name.ilike(f"%{q.strip()}%"))
|
||||||
|
total = db.scalar(total_query) or 0
|
||||||
|
rigs = list(db.scalars(query.order_by(Rig.name, Rig.id).offset(offset).limit(limit)))
|
||||||
|
return PaginatedRigOut(
|
||||||
|
items=[RigSummaryOut(
|
||||||
|
id=rig.id, name=rig.name, source_system=rig.source_system,
|
||||||
|
source_external_id=rig.source_external_id, source_url=rig.source_url,
|
||||||
|
source_checked_at=rig.source_checked_at, missing_fields=_rig_missing_fields(rig),
|
||||||
|
component_count=len(rig.components),
|
||||||
|
) for rig in rigs],
|
||||||
|
total=total, limit=limit, offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/tackle/rigs/{rig_id}", response_model=RigOut)
|
||||||
|
def rig_detail(rig_id: UUID, db: Db) -> RigOut:
|
||||||
|
rig = db.scalar(select(Rig).options(selectinload(Rig.components)).where(Rig.id == rig_id))
|
||||||
|
if rig is None:
|
||||||
|
raise HTTPException(status_code=404, detail="rig not found")
|
||||||
|
missing = _rig_missing_fields(rig)
|
||||||
|
return RigOut(
|
||||||
|
id=rig.id, name=rig.name, source_system=rig.source_system,
|
||||||
|
source_external_id=rig.source_external_id, source_url=rig.source_url,
|
||||||
|
source_checked_at=rig.source_checked_at, missing_fields=missing,
|
||||||
|
components=[{
|
||||||
|
"id": component.id, "role": component.role, "position": component.position,
|
||||||
|
"raw_value": component.raw_value, "tackle_item_id": component.tackle_item_id,
|
||||||
|
} for component in sorted(rig.components, key=lambda value: value.position)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/public-spot-pages")
|
@router.get("/api/v1/public-spot-pages")
|
||||||
def public_spot_pages(db: Db, limit: int = Query(500, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[str]:
|
def public_spot_pages(db: Db, limit: int = Query(500, ge=1, le=500), offset: int = Query(0, ge=0)) -> list[str]:
|
||||||
rows = db.execute(select(Waterbody.slug, Spot.x, Spot.y, Fish.slug)
|
rows = db.execute(select(Waterbody.slug, Spot.x, Spot.y, Fish.slug)
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
from ..media_catalog import published_assets, published_file
|
from ..media_catalog import KNOWN_MEDIA_ROLES, published_assets, published_file
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/media/catalog")
|
@router.get("/api/v1/media/catalog")
|
||||||
def media_catalog(entity_type: str | None = Query(None, pattern="^(fish|waterbody|tackle|reference)$")) -> list[dict]:
|
def media_catalog(
|
||||||
return published_assets(entity_type)
|
entity_type: str | None = Query(None, pattern="^(fish|waterbody|tackle|reference)$"),
|
||||||
|
media_role: str | None = Query(None, pattern="^(waterbody_cover|waterbody_map|waterbody_depth_map|waterbody_screenshot|tackle_card|tackle_detail|rig_diagram|tackle_screenshot)$"),
|
||||||
|
) -> list[dict]:
|
||||||
|
if media_role and entity_type not in {"waterbody", "tackle"}:
|
||||||
|
raise HTTPException(status_code=422, detail="media_role requires waterbody or tackle entity_type")
|
||||||
|
if media_role and media_role not in KNOWN_MEDIA_ROLES:
|
||||||
|
raise HTTPException(status_code=422, detail="unknown media role")
|
||||||
|
return published_assets(entity_type, media_role)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/v1/media/assets/{digest}", response_class=FileResponse)
|
@router.get("/api/v1/media/assets/{digest}", response_class=FileResponse)
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from ..dependencies import Db
|
from ..dependencies import Db
|
||||||
from ..models import (
|
from ..models import (
|
||||||
CatchReport,
|
CatchReport,
|
||||||
|
Bait,
|
||||||
CommunityImportRun,
|
CommunityImportRun,
|
||||||
DataSource,
|
DataSource,
|
||||||
ExternalObservation,
|
ExternalObservation,
|
||||||
@@ -117,9 +118,12 @@ def records(
|
|||||||
fish: str | None = None,
|
fish: str | None = None,
|
||||||
waterbody: str | None = None,
|
waterbody: str | None = None,
|
||||||
category: str | None = None,
|
category: str | None = None,
|
||||||
|
q: str | None = None,
|
||||||
limit: int = Query(50, ge=1, le=100),
|
limit: int = Query(50, ge=1, le=100),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
) -> PaginatedOfficialRecordOut:
|
) -> PaginatedOfficialRecordOut:
|
||||||
|
if q is not None and len(q) > 100:
|
||||||
|
raise HTTPException(status_code=422, detail="record search query is too long")
|
||||||
query = select(CatchReport).options(
|
query = select(CatchReport).options(
|
||||||
joinedload(CatchReport.fish),
|
joinedload(CatchReport.fish),
|
||||||
joinedload(CatchReport.waterbody),
|
joinedload(CatchReport.waterbody),
|
||||||
@@ -131,6 +135,9 @@ def records(
|
|||||||
query = query.join(CatchReport.waterbody).where(Waterbody.slug == waterbody)
|
query = query.join(CatchReport.waterbody).where(Waterbody.slug == waterbody)
|
||||||
if category:
|
if category:
|
||||||
query = query.where(CatchReport.raw_payload["category"].as_string() == category)
|
query = query.where(CatchReport.raw_payload["category"].as_string() == category)
|
||||||
|
if q and q.strip():
|
||||||
|
needle = f"%{q.strip()}%"
|
||||||
|
query = query.outerjoin(Bait, CatchReport.bait_id == Bait.id).where(or_(CatchReport.player_name.ilike(needle), Bait.name.ilike(needle)))
|
||||||
total = db.scalar(
|
total = db.scalar(
|
||||||
query.with_only_columns(func.count(CatchReport.id), maintain_column_froms=True).order_by(None)
|
query.with_only_columns(func.count(CatchReport.id), maintain_column_froms=True).order_by(None)
|
||||||
) or 0
|
) or 0
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ from ..models import Bait, BaitKind, CatchReport, Fish, ModerationStatus, Source
|
|||||||
from ..schemas import CatchReportAccepted, CatchReportCreate
|
from ..schemas import CatchReportAccepted, CatchReportCreate
|
||||||
from ..storage import ScreenshotError, upload_screenshot
|
from ..storage import ScreenshotError, upload_screenshot
|
||||||
from ..submission_security import check_rate_limit
|
from ..submission_security import check_rate_limit
|
||||||
|
from ..tackle_components import replace_tackle_components
|
||||||
|
from rf4_research.gear_components import from_catch_fields
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger("rf4.api.submissions")
|
logger = logging.getLogger("rf4.api.submissions")
|
||||||
@@ -60,6 +62,14 @@ def create_catch_report(payload: CatchReportCreate, request: Request, db: Db, id
|
|||||||
upload_token = _replay_token(key_hash) if key_hash else secrets.token_urlsafe(32)
|
upload_token = _replay_token(key_hash) if key_hash else secrets.token_urlsafe(32)
|
||||||
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, screenshot_upload_token_hash=hashlib.sha256(upload_token.encode()).hexdigest())
|
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, screenshot_upload_token_hash=hashlib.sha256(upload_token.encode()).hexdigest())
|
||||||
db.add(report)
|
db.add(report)
|
||||||
|
replace_tackle_components(
|
||||||
|
db,
|
||||||
|
report,
|
||||||
|
from_catch_fields(bait=payload.bait_name, rig_type=payload.rig_type),
|
||||||
|
source_system="user",
|
||||||
|
source_url=payload.source_url,
|
||||||
|
raw_payload={"origin": "user_submission"},
|
||||||
|
)
|
||||||
if key_hash:
|
if key_hash:
|
||||||
db.add(SubmissionAttempt(client_hash="", idempotency_key=key_hash, catch_report=report, payload_hash=payload_hash, created_at=datetime.now(timezone.utc)))
|
db.add(SubmissionAttempt(client_hash="", idempotency_key=key_hash, catch_report=report, payload_hash=payload_hash, created_at=datetime.now(timezone.utc)))
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ class WaterbodyOut(BaseModel):
|
|||||||
slug: str
|
slug: str
|
||||||
name_ru: str
|
name_ru: str
|
||||||
unlock_level: int | None
|
unlock_level: int | None
|
||||||
|
fish_species_count: int | None
|
||||||
|
source_system: str | None
|
||||||
|
source_external_id: str | None
|
||||||
|
source_url: str | None
|
||||||
|
description: str | None
|
||||||
|
source_aliases: list[str] | None
|
||||||
|
source_fish_species: list[str] | None
|
||||||
|
source_image_urls: list[str] | None
|
||||||
|
source_point_urls: list[str] | None
|
||||||
|
source_checked_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
class BaitOut(BaseModel):
|
class BaitOut(BaseModel):
|
||||||
@@ -30,6 +40,75 @@ class BaitOut(BaseModel):
|
|||||||
kind: str
|
kind: str
|
||||||
|
|
||||||
|
|
||||||
|
class TackleItemRigOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
source_system: str | None
|
||||||
|
source_checked_at: datetime | None
|
||||||
|
component_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class TackleItemOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
category: str
|
||||||
|
subcategory: str | None
|
||||||
|
brand: str | None
|
||||||
|
family: str | None
|
||||||
|
unlock_level: int | None
|
||||||
|
source_system: str | None
|
||||||
|
source_external_id: str | None
|
||||||
|
source_url: str | None
|
||||||
|
source_checked_at: datetime | None
|
||||||
|
missing_fields: list[str] = Field(default_factory=list)
|
||||||
|
related_rigs: list[TackleItemRigOut] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedTackleItemOut(BaseModel):
|
||||||
|
items: list[TackleItemOut]
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
class RigSummaryOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
source_system: str | None
|
||||||
|
source_external_id: str | None
|
||||||
|
source_url: str | None
|
||||||
|
source_checked_at: datetime | None
|
||||||
|
missing_fields: list[str] = Field(default_factory=list)
|
||||||
|
component_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class PaginatedRigOut(BaseModel):
|
||||||
|
items: list[RigSummaryOut]
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
class RigComponentOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
role: str
|
||||||
|
position: int
|
||||||
|
raw_value: str | None
|
||||||
|
tackle_item_id: UUID | None
|
||||||
|
|
||||||
|
|
||||||
|
class RigOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
source_system: str | None
|
||||||
|
source_external_id: str | None
|
||||||
|
source_url: str | None
|
||||||
|
source_checked_at: datetime | None
|
||||||
|
missing_fields: list[str] = Field(default_factory=list)
|
||||||
|
components: list[RigComponentOut]
|
||||||
|
|
||||||
|
|
||||||
class ActivityOut(BaseModel):
|
class ActivityOut(BaseModel):
|
||||||
spot_id: UUID
|
spot_id: UUID
|
||||||
waterbody_slug: str
|
waterbody_slug: str
|
||||||
@@ -48,6 +127,9 @@ class ActivityOut(BaseModel):
|
|||||||
confidence_score: int
|
confidence_score: int
|
||||||
explanation: str
|
explanation: str
|
||||||
sources: list[str]
|
sources: list[str]
|
||||||
|
coordinate_precision: str
|
||||||
|
coordinate_sources: list[str]
|
||||||
|
source_conflicts: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class PaginatedActivityOut(BaseModel):
|
class PaginatedActivityOut(BaseModel):
|
||||||
@@ -57,6 +139,19 @@ class PaginatedActivityOut(BaseModel):
|
|||||||
offset: int
|
offset: int
|
||||||
|
|
||||||
|
|
||||||
|
class TackleCombinationOut(BaseModel):
|
||||||
|
role: str
|
||||||
|
value: str
|
||||||
|
tackle_item_id: UUID | None = None
|
||||||
|
rig_id: UUID | None = None
|
||||||
|
catches: int
|
||||||
|
unique_players: int
|
||||||
|
last_seen_at: datetime
|
||||||
|
freshness_score: int = Field(ge=0, le=100)
|
||||||
|
status: str
|
||||||
|
explanation: str
|
||||||
|
|
||||||
|
|
||||||
class CatchOut(BaseModel):
|
class CatchOut(BaseModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
fish: str
|
fish: str
|
||||||
@@ -65,10 +160,23 @@ class CatchOut(BaseModel):
|
|||||||
player_name: str | None
|
player_name: str | None
|
||||||
caught_at: datetime | None
|
caught_at: datetime | None
|
||||||
reported_at: datetime
|
reported_at: datetime
|
||||||
|
fishing_method: str | None
|
||||||
retrieve_method: str | None
|
retrieve_method: str | None
|
||||||
retrieve_speed: int | None
|
retrieve_speed: int | None
|
||||||
source_system: str
|
source_system: str
|
||||||
source_url: str | None
|
source_url: str | None
|
||||||
|
tackle_components: list["CatchTackleComponentOut"]
|
||||||
|
|
||||||
|
|
||||||
|
class CatchTackleComponentOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
role: str
|
||||||
|
position: int
|
||||||
|
raw_value: str
|
||||||
|
tackle_item_id: UUID | None
|
||||||
|
rig_id: UUID | None
|
||||||
|
source_system: str | None
|
||||||
|
source_url: str | None
|
||||||
|
|
||||||
|
|
||||||
class SpotOut(BaseModel):
|
class SpotOut(BaseModel):
|
||||||
@@ -82,6 +190,9 @@ class SpotOut(BaseModel):
|
|||||||
catches_3d: int
|
catches_3d: int
|
||||||
catches_7d: int
|
catches_7d: int
|
||||||
top_baits: list[str]
|
top_baits: list[str]
|
||||||
|
coordinate_precision: str
|
||||||
|
coordinate_sources: list[str]
|
||||||
|
source_conflicts: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class OfficialRecordOut(BaseModel):
|
class OfficialRecordOut(BaseModel):
|
||||||
@@ -215,6 +326,28 @@ class ModerationUpdate(BaseModel):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class ModerationBulkItem(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
expected_version: int = Field(ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ModerationBulkUpdate(BaseModel):
|
||||||
|
status: str
|
||||||
|
reason: str | None = Field(default=None, max_length=1000)
|
||||||
|
items: list[ModerationBulkItem] = Field(min_length=1, max_length=50)
|
||||||
|
|
||||||
|
@field_validator("status")
|
||||||
|
@classmethod
|
||||||
|
def known_bulk_status(cls, value: str) -> str:
|
||||||
|
if value not in {"approved", "rejected", "pending"}:
|
||||||
|
raise ValueError("unknown moderation status")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class ModerationBulkResult(BaseModel):
|
||||||
|
updated: int
|
||||||
|
|
||||||
|
|
||||||
class ExternalObservationOut(BaseModel):
|
class ExternalObservationOut(BaseModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
source_system: str
|
source_system: str
|
||||||
@@ -270,6 +403,31 @@ class ExternalObservationDecision(BaseModel):
|
|||||||
expected_version: int = Field(ge=0)
|
expected_version: int = Field(ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalObservationBulkItem(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
expected_version: int = Field(ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalObservationBulkDecision(BaseModel):
|
||||||
|
reason: str = Field(min_length=1, max_length=1000)
|
||||||
|
items: list[ExternalObservationBulkItem] = Field(min_length=1, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalObservationBulkAction(BaseModel):
|
||||||
|
items: list[ExternalObservationBulkItem] = Field(min_length=1, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalObservationBulkMapping(BaseModel):
|
||||||
|
fish_slug: str
|
||||||
|
waterbody_slug: str
|
||||||
|
note: str | None = Field(default=None, max_length=1000)
|
||||||
|
items: list[ExternalObservationBulkItem] = Field(min_length=1, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalObservationBulkResult(BaseModel):
|
||||||
|
updated: int
|
||||||
|
|
||||||
|
|
||||||
class ExternalObservationAction(BaseModel):
|
class ExternalObservationAction(BaseModel):
|
||||||
expected_version: int = Field(ge=0)
|
expected_version: int = Field(ge=0)
|
||||||
|
|
||||||
@@ -318,7 +476,17 @@ class AdminMediaReviewOut(BaseModel):
|
|||||||
height: int | None
|
height: int | None
|
||||||
content_type: str | None
|
content_type: str | None
|
||||||
image_url: str
|
image_url: str
|
||||||
|
asset_url: str
|
||||||
source_system: str
|
source_system: str
|
||||||
source_url: str
|
source_url: str
|
||||||
duplicate_of: str | None
|
duplicate_of: str | None
|
||||||
|
supersedes: str | None
|
||||||
derivatives: list[AdminMediaDerivativeOut]
|
derivatives: list[AdminMediaDerivativeOut]
|
||||||
|
|
||||||
|
|
||||||
|
class AdminMediaDecision(BaseModel):
|
||||||
|
note: str = Field(min_length=1, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminMediaRollback(AdminMediaDecision):
|
||||||
|
asset_url: str = Field(min_length=1, max_length=2000)
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
from sqlalchemy import delete
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from rf4_research.gear_components import GearComponentIdentity
|
||||||
|
|
||||||
|
from .models import CatchReport, CatchTackleComponent
|
||||||
|
|
||||||
|
|
||||||
|
def replace_tackle_components(
|
||||||
|
session: Session,
|
||||||
|
report: CatchReport,
|
||||||
|
components: Iterable[GearComponentIdentity],
|
||||||
|
*,
|
||||||
|
source_system: str | None,
|
||||||
|
source_url: str | None = None,
|
||||||
|
raw_payload: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Replace the ordered evidence for a report while keeping imports idempotent."""
|
||||||
|
session.flush()
|
||||||
|
session.execute(
|
||||||
|
delete(CatchTackleComponent).where(CatchTackleComponent.catch_report_id == report.id)
|
||||||
|
)
|
||||||
|
session.add_all(
|
||||||
|
CatchTackleComponent(
|
||||||
|
catch_report_id=report.id,
|
||||||
|
role=component.role,
|
||||||
|
position=component.position,
|
||||||
|
raw_value=component.raw_value,
|
||||||
|
source_system=source_system,
|
||||||
|
source_external_id=component.source_external_id,
|
||||||
|
source_url=source_url,
|
||||||
|
raw_payload=raw_payload,
|
||||||
|
)
|
||||||
|
for component in components
|
||||||
|
)
|
||||||
+1716
-1
File diff suppressed because it is too large
Load Diff
@@ -131,6 +131,17 @@ def test_reports_without_coordinates_do_not_create_activity_group(db: Session) -
|
|||||||
assert activity_rows(db, hours=72, now=NOW) == []
|
assert activity_rows(db, hours=72, now=NOW) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("precision", ["exact", "approximate", "area", "missing"])
|
||||||
|
def test_coordinate_precision_is_preserved_in_activity_evidence(db: Session, precision: str) -> None:
|
||||||
|
report = add_report(db, age_hours=1)
|
||||||
|
report.raw_payload = {"provenance": {"coordinate_precision": precision}}
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
row = activity_rows(db, hours=24, now=NOW)[0]
|
||||||
|
|
||||||
|
assert row.coordinate_precision == precision
|
||||||
|
|
||||||
|
|
||||||
def test_confidence_capped_at_50_with_single_player() -> None:
|
def test_confidence_capped_at_50_with_single_player() -> None:
|
||||||
"""D06: One player cannot artificially inflate confidence above 50%."""
|
"""D06: One player cannot artificially inflate confidence above 50%."""
|
||||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
from app.models import CatchReport, CatchTackleComponent, Fish, ModerationStatus, SourceType, Spot, Waterbody
|
||||||
|
from app.routers.analytics import tackle_combinations
|
||||||
|
from app.schemas import TackleCombinationOut
|
||||||
|
|
||||||
|
|
||||||
|
def test_tackle_recommendation_requires_samples_and_independent_players() -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as db:
|
||||||
|
waterbody = Waterbody(slug="lake", name_ru="Озеро", unlock_level=1)
|
||||||
|
fish = Fish(slug="pike", name_ru="Щука", trophy_weight_g=10_000)
|
||||||
|
spot = Spot(waterbody=waterbody, x=10, y=20)
|
||||||
|
db.add_all([waterbody, fish, spot])
|
||||||
|
db.flush()
|
||||||
|
for index, player in enumerate(("One", "One", "Two")):
|
||||||
|
report = CatchReport(
|
||||||
|
fish=fish, waterbody=waterbody, spot=spot, weight_g=1000,
|
||||||
|
caught_at=now - timedelta(hours=1), reported_at=now - timedelta(hours=1),
|
||||||
|
player_name=player, source_type=SourceType.user, source_confidence=80,
|
||||||
|
moderation_status=ModerationStatus.approved,
|
||||||
|
)
|
||||||
|
report.tackle_components.append(CatchTackleComponent(role="lure", position=0, raw_value="Spinner #1"))
|
||||||
|
db.add(report)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
rows = tackle_combinations(db, waterbody="lake", fish="pike", method=None, hours=72, min_samples=3, min_players=2)
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert (rows[0].status, rows[0].catches, rows[0].unique_players) == ("recommendation", 3, 2)
|
||||||
|
|
||||||
|
rows = tackle_combinations(db, waterbody="lake", fish="pike", method=None, hours=72, min_samples=3, min_players=3)
|
||||||
|
assert rows[0].status == "insufficient_data"
|
||||||
|
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_tackle_analytics_handles_empty_and_multicomponent_observations() -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as db:
|
||||||
|
waterbody = Waterbody(slug="empty-check", name_ru="Проверка", unlock_level=1)
|
||||||
|
fish = Fish(slug="perch", name_ru="Окунь", trophy_weight_g=5_000)
|
||||||
|
spot = Spot(waterbody=waterbody, x=1, y=2)
|
||||||
|
report = CatchReport(
|
||||||
|
fish=fish, waterbody=waterbody, spot=spot, weight_g=500,
|
||||||
|
caught_at=now, reported_at=now, player_name="Player",
|
||||||
|
source_type=SourceType.user, source_confidence=80,
|
||||||
|
moderation_status=ModerationStatus.approved,
|
||||||
|
)
|
||||||
|
report.tackle_components.extend([
|
||||||
|
CatchTackleComponent(role="lure", position=0, raw_value="Spinner #1"),
|
||||||
|
CatchTackleComponent(role="rig", position=1, raw_value="Rig #1"),
|
||||||
|
CatchTackleComponent(role="lure", position=2, raw_value="Spinner #1"),
|
||||||
|
CatchTackleComponent(role="lure", position=3, raw_value=" "),
|
||||||
|
])
|
||||||
|
db.add(report)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
rows = tackle_combinations(db, waterbody="empty-check", fish="perch", method=None, hours=72, min_samples=1, min_players=1)
|
||||||
|
assert {(row.role, row.value, row.catches) for row in rows} == {
|
||||||
|
("lure", "Spinner #1", 1), ("rig", "Rig #1", 1),
|
||||||
|
}
|
||||||
|
assert tackle_combinations(db, waterbody="missing", fish=None, method=None, hours=72) == []
|
||||||
|
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_tackle_analytics_exposes_decay_and_prefers_fresher_equal_samples() -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as db:
|
||||||
|
waterbody = Waterbody(slug="decay-check", name_ru="Свежесть", unlock_level=1)
|
||||||
|
fish = Fish(slug="pike", name_ru="Щука", trophy_weight_g=10_000)
|
||||||
|
spot = Spot(waterbody=waterbody, x=3, y=4)
|
||||||
|
db.add_all([waterbody, fish, spot])
|
||||||
|
db.flush()
|
||||||
|
for value, age in (("Fresh spinner", 1), ("Old spinner", 48)):
|
||||||
|
for index, player in enumerate(("One", "One", "Two")):
|
||||||
|
report = CatchReport(
|
||||||
|
fish=fish, waterbody=waterbody, spot=spot, weight_g=1000,
|
||||||
|
caught_at=now - timedelta(hours=age), reported_at=now - timedelta(hours=age),
|
||||||
|
player_name=player, source_type=SourceType.user, source_confidence=80,
|
||||||
|
moderation_status=ModerationStatus.approved,
|
||||||
|
)
|
||||||
|
report.tackle_components.append(CatchTackleComponent(role="lure", position=index, raw_value=value))
|
||||||
|
db.add(report)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
rows = tackle_combinations(db, waterbody="decay-check", fish="pike", method=None, hours=72, min_samples=3, min_players=2)
|
||||||
|
assert [row.value for row in rows] == ["Fresh spinner", "Old spinner"]
|
||||||
|
assert rows[0].freshness_score > rows[1].freshness_score
|
||||||
|
assert rows[0].freshness_score <= 100
|
||||||
|
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_tackle_freshness_score_contract_rejects_out_of_range_values() -> None:
|
||||||
|
base = {
|
||||||
|
"role": "lure", "value": "Spinner", "catches": 3, "unique_players": 2,
|
||||||
|
"last_seen_at": datetime.now(timezone.utc), "status": "recommendation", "explanation": "ok",
|
||||||
|
}
|
||||||
|
assert TackleCombinationOut(**base, freshness_score=0).freshness_score == 0
|
||||||
|
assert TackleCombinationOut(**base, freshness_score=100).freshness_score == 100
|
||||||
|
for value in (-1, 101):
|
||||||
|
try:
|
||||||
|
TackleCombinationOut(**base, freshness_score=value)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
raise AssertionError(f"freshness score {value} was accepted")
|
||||||
@@ -12,7 +12,8 @@ from app.database import Base, get_session
|
|||||||
from app.community_importer import stage_observations
|
from app.community_importer import stage_observations
|
||||||
from app.importer import ImportAlreadyRunning
|
from app.importer import ImportAlreadyRunning
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models import Bait, BaitKind, CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
from app.models import Bait, BaitKind, CatchReport, CatchTackleComponent, DataSource, ExternalEntityAlias, ExternalObservation, Fish, ImportStatus, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody
|
||||||
|
from app.routers import admin as admin_router
|
||||||
|
|
||||||
|
|
||||||
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||||
@@ -54,6 +55,20 @@ def test_activity_filters_and_explains_score() -> None:
|
|||||||
assert payload["items"][0]["unique_players"] == 3
|
assert payload["items"][0]["unique_players"] == 3
|
||||||
assert "3 свежих улова" in payload["items"][0]["explanation"]
|
assert "3 свежих улова" in payload["items"][0]["explanation"]
|
||||||
assert payload["items"][0]["sources"] == ["manual-import"]
|
assert payload["items"][0]["sources"] == ["manual-import"]
|
||||||
|
assert payload["items"][0]["source_conflicts"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_source_conflict_is_exposed_on_activity_and_spot() -> None:
|
||||||
|
with Session(engine) as db:
|
||||||
|
report = db.scalar(select(CatchReport).where(CatchReport.waterbody.has(slug="test-lake")))
|
||||||
|
assert report is not None
|
||||||
|
report.raw_payload = {"provenance": {"conflicts": ["координаты: 10:20 · 11:21"]}}
|
||||||
|
spot_id = report.spot_id
|
||||||
|
db.commit()
|
||||||
|
activity = client.get("/api/v1/activity?waterbody=test-lake&fish=pike&hours=6").json()
|
||||||
|
assert activity["items"][0]["source_conflicts"] == ["координаты: 10:20 · 11:21"]
|
||||||
|
spot = client.get(f"/api/v1/spots/{spot_id}").json()
|
||||||
|
assert spot["source_conflicts"] == ["координаты: 10:20 · 11:21"]
|
||||||
|
|
||||||
|
|
||||||
def test_waterbody_catalog_exposes_nullable_source_provenance() -> None:
|
def test_waterbody_catalog_exposes_nullable_source_provenance() -> None:
|
||||||
@@ -81,9 +96,18 @@ def test_published_media_catalog_and_content_addressed_file() -> None:
|
|||||||
assert image.status_code == 200
|
assert image.status_code == 200
|
||||||
assert image.headers["content-type"].startswith("image/")
|
assert image.headers["content-type"].startswith("image/")
|
||||||
assert image.headers["cache-control"] == "public, max-age=31536000, immutable"
|
assert image.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||||
|
variant = client.get(item["variants"][0]["url"])
|
||||||
|
assert variant.status_code == 200
|
||||||
|
assert variant.headers["content-type"].startswith("image/")
|
||||||
assert client.get("/api/v1/media/assets/not-a-hash").status_code == 404
|
assert client.get("/api/v1/media/assets/not-a-hash").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_published_media_catalog_exposes_reviewed_media_role() -> None:
|
||||||
|
catalog = client.get("/api/v1/media/catalog?entity_type=fish")
|
||||||
|
assert catalog.status_code == 200
|
||||||
|
assert all("media_role" in item for item in catalog.json())
|
||||||
|
|
||||||
|
|
||||||
def test_review_queue_filters_before_pagination() -> None:
|
def test_review_queue_filters_before_pagination() -> None:
|
||||||
with Session(engine) as db:
|
with Session(engine) as db:
|
||||||
stage_observations(db, [{
|
stage_observations(db, [{
|
||||||
@@ -182,6 +206,34 @@ def test_admin_media_review_requires_auth() -> None:
|
|||||||
assert all({"role", "format", "width", "height"} <= set(derivative) for derivative in response.json()[0]["derivatives"])
|
assert all({"role", "format", "width", "height"} <= set(derivative) for derivative in response.json()[0]["derivatives"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_media_decisions_require_auth_and_note(monkeypatch) -> None:
|
||||||
|
assert client.post("/api/v1/admin/media/upgrades/publish", json={"note": "publish"}).status_code == 401
|
||||||
|
assert client.post("/api/v1/admin/media/upgrades/rollback", json={"asset_url": "https://example.test/a", "note": "rollback"}).status_code == 401
|
||||||
|
|
||||||
|
monkeypatch.setattr(admin_router, "publish_quality_upgrades", lambda path, note: {"published": 2, "retained_fallbacks": 2})
|
||||||
|
publish = client.post(
|
||||||
|
"/api/v1/admin/media/upgrades/publish",
|
||||||
|
json={"note": "visual review complete"},
|
||||||
|
headers={"Authorization": "Bearer change-me-in-production"},
|
||||||
|
)
|
||||||
|
assert publish.status_code == 200
|
||||||
|
assert publish.json() == {"published": 2, "retained_fallbacks": 2}
|
||||||
|
|
||||||
|
monkeypatch.setattr(admin_router, "rollback_quality_upgrade", lambda path, asset_url, note: {"rolled_back": asset_url, "restored": "https://example.test/fallback"})
|
||||||
|
rollback = client.post(
|
||||||
|
"/api/v1/admin/media/upgrades/rollback",
|
||||||
|
json={"asset_url": "https://example.test/a", "note": "fallback is preferred"},
|
||||||
|
headers={"Authorization": "Bearer change-me-in-production"},
|
||||||
|
)
|
||||||
|
assert rollback.status_code == 200
|
||||||
|
assert rollback.json()["rolled_back"] == "https://example.test/a"
|
||||||
|
assert client.post(
|
||||||
|
"/api/v1/admin/media/upgrades/publish",
|
||||||
|
json={"note": ""},
|
||||||
|
headers={"Authorization": "Bearer change-me-in-production"},
|
||||||
|
).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
def test_liveness_does_not_probe_dependencies() -> None:
|
def test_liveness_does_not_probe_dependencies() -> None:
|
||||||
response = client.get("/health?token=must-not-be-logged")
|
response = client.get("/health?token=must-not-be-logged")
|
||||||
assert response.json() == {"status": "ok"}
|
assert response.json() == {"status": "ok"}
|
||||||
@@ -223,6 +275,12 @@ def test_spot_detail_and_catches() -> None:
|
|||||||
assert catches.status_code == 200
|
assert catches.status_code == 200
|
||||||
assert len(catches.json()) == 3
|
assert len(catches.json()) == 3
|
||||||
assert catches.json()[0]["source_system"] == "manual-import"
|
assert catches.json()[0]["source_system"] == "manual-import"
|
||||||
|
assert catches.json()[0]["fishing_method"] == "spinning"
|
||||||
|
assert catches.json()[0]["tackle_components"] == []
|
||||||
|
fixture = client.get("/api/v1/spots/resolve?waterbody=test-lake&x=10&y=20")
|
||||||
|
assert fixture.status_code == 200
|
||||||
|
fixture_catches = client.get(f"/api/v1/spots/{fixture.json()['id']}/catches")
|
||||||
|
assert fixture_catches.json()[0]["fishing_method"] == "spinning"
|
||||||
resolved = client.get("/api/v1/spots/resolve?waterbody=test-lake&x=10&y=20")
|
resolved = client.get("/api/v1/spots/resolve?waterbody=test-lake&x=10&y=20")
|
||||||
assert resolved.status_code == 200
|
assert resolved.status_code == 200
|
||||||
assert resolved.json()["id"] == spot_id
|
assert resolved.json()["id"] == spot_id
|
||||||
@@ -319,11 +377,14 @@ def test_records_pagination_returns_correct_total_and_offset() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_user_report_requires_moderation_before_activity() -> None:
|
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"})
|
created = client.post("/api/v1/catch-reports", json={"fish_slug": "pike", "waterbody_slug": "test-lake", "x": 77, "y": 88, "weight_g": 5500, "bait_name": "Новая приманка", "rig_type": "Спиннинг", "player_name": "Reporter"})
|
||||||
assert created.status_code == 201
|
assert created.status_code == 201
|
||||||
assert created.headers["Cache-Control"] == "no-store"
|
assert created.headers["Cache-Control"] == "no-store"
|
||||||
assert created.json()["moderation_status"] == "pending"
|
assert created.json()["moderation_status"] == "pending"
|
||||||
report_id = created.json()["id"]
|
report_id = created.json()["id"]
|
||||||
|
with Session(engine) as db:
|
||||||
|
components = db.scalars(select(CatchTackleComponent).where(CatchTackleComponent.catch_report_id == UUID(report_id)).order_by(CatchTackleComponent.position)).all()
|
||||||
|
assert [(component.role, component.raw_value) for component in components] == [("lure", "Новая приманка"), ("rig", "Спиннинг")]
|
||||||
headers = {"Authorization": "Bearer change-me-in-production"}
|
headers = {"Authorization": "Bearer change-me-in-production"}
|
||||||
pending = client.get("/api/v1/admin/catch-reports", headers=headers)
|
pending = client.get("/api/v1/admin/catch-reports", headers=headers)
|
||||||
assert pending.status_code == 200
|
assert pending.status_code == 200
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from datetime import datetime, timezone
|
|||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.catalog_audit import audit_catalog
|
from app.catalog_audit import audit_catalog, audit_waterbody_catalog
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
from app.models import CatchReport, Fish, ModerationStatus, SourceType, Spot, Waterbody
|
from app.models import CatchReport, Fish, ModerationStatus, SourceType, Spot, Waterbody
|
||||||
|
|
||||||
@@ -24,3 +24,24 @@ def test_catalog_audit_checks_the_whole_catalog() -> None:
|
|||||||
assert result["reports"] == 1
|
assert result["reports"] == 1
|
||||||
assert result["invalid_coordinates"] == 1
|
assert result["invalid_coordinates"] == 1
|
||||||
assert result["failures"] == 1
|
assert result["failures"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_waterbody_catalog_audit_reports_snapshot_gaps_without_legacy_rows() -> None:
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as db:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
db.add_all([
|
||||||
|
Waterbody(
|
||||||
|
slug="lake", name_ru="Озеро", source_system="rf4db",
|
||||||
|
source_external_id="level_001_lake", source_url="https://rf4db.com/ru/maps/level_001_lake",
|
||||||
|
source_checked_at=now,
|
||||||
|
),
|
||||||
|
Waterbody(slug="legacy", name_ru="Старое озеро"),
|
||||||
|
])
|
||||||
|
db.commit()
|
||||||
|
result = audit_waterbody_catalog(db, {"level_001_lake", "level_002_river"})
|
||||||
|
assert result["expected"] == 2
|
||||||
|
assert result["observed"] == 1
|
||||||
|
assert result["missing_source_external_ids"] == ["level_002_river"]
|
||||||
|
assert result["failures"] == 1
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
from app.models import CatchReport, CatchTackleComponent, Fish, ModerationStatus, SourceType, Waterbody
|
||||||
|
|
||||||
|
|
||||||
|
def test_catch_keeps_ordered_unresolved_gear_evidence() -> None:
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as session:
|
||||||
|
fish = Fish(slug="pike", name_ru="Щука")
|
||||||
|
waterbody = Waterbody(slug="lake", name_ru="Озеро")
|
||||||
|
report = CatchReport(
|
||||||
|
fish=fish, waterbody=waterbody, weight_g=1000,
|
||||||
|
reported_at=datetime(2026, 9, 20, tzinfo=timezone.utc), source_type=SourceType.manual_import,
|
||||||
|
source_confidence=50, moderation_status=ModerationStatus.pending,
|
||||||
|
)
|
||||||
|
report.tackle_components.extend([
|
||||||
|
CatchTackleComponent(role="lure", position=0, raw_value="Spiker #2"),
|
||||||
|
CatchTackleComponent(role="rig", position=1, raw_value="Method Popup"),
|
||||||
|
])
|
||||||
|
session.add(report)
|
||||||
|
session.commit()
|
||||||
|
saved = session.get(CatchReport, report.id)
|
||||||
|
assert saved is not None
|
||||||
|
assert [(row.position, row.role, row.raw_value) for row in saved.tackle_components] == [
|
||||||
|
(0, "lure", "Spiker #2"), (1, "rig", "Method Popup"),
|
||||||
|
]
|
||||||
@@ -7,11 +7,11 @@ import pytest
|
|||||||
from sqlalchemy import create_engine, func, select
|
from sqlalchemy import create_engine, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.community_importer import CommunityImportError, stage_observations
|
from app.community_importer import CommunityImportError, stage_observations, update_waterbody_detail, update_waterbody_details, upsert_waterbody_catalog
|
||||||
from app.community_review import ExternalReviewError, map_observation, publish_observation, suggest_aliases
|
from app.community_review import ExternalReviewError, map_observation, publish_observation, suggest_aliases
|
||||||
from app.source_lifecycle import record_scheduled_source_check, record_source_check
|
from app.source_lifecycle import record_scheduled_source_check, record_source_check
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
from app.models import CatchReport, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody
|
from app.models import CatchReport, CatchTackleComponent, DataSource, ExternalEntityAlias, ExternalObservation, Fish, Waterbody
|
||||||
from rf4_research.community_sources import parse_rf4db_catches, parse_rf4map_point, parse_rf4posts_spot
|
from rf4_research.community_sources import parse_rf4db_catches, parse_rf4map_point, parse_rf4posts_spot
|
||||||
|
|
||||||
|
|
||||||
@@ -42,6 +42,20 @@ def record(source: str = "rf4db", external_id: str = "catch-1") -> dict[str, obj
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def waterbody_row(**overrides: object) -> dict[str, object]:
|
||||||
|
row: dict[str, object] = {
|
||||||
|
"source_system": "rf4db",
|
||||||
|
"source_external_id": "level_001_mosquito",
|
||||||
|
"source_url": "https://rf4db.com/ru/maps/level_001_mosquito",
|
||||||
|
"name": "оз. Комариное",
|
||||||
|
"unlock_level": 1,
|
||||||
|
"unlock_label": "1",
|
||||||
|
"fish_species_count": 20,
|
||||||
|
}
|
||||||
|
row.update(overrides)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def db() -> Session:
|
def db() -> Session:
|
||||||
engine = create_engine("sqlite://")
|
engine = create_engine("sqlite://")
|
||||||
@@ -69,6 +83,77 @@ def test_staging_is_idempotent_and_preserves_first_seen(db: Session) -> None:
|
|||||||
assert source is not None and source.enabled is True
|
assert source is not None and source.enabled is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_waterbody_catalog_upsert_is_idempotent_and_non_destructive(db: Session) -> None:
|
||||||
|
first = datetime(2026, 9, 16, 10, tzinfo=timezone.utc)
|
||||||
|
assert upsert_waterbody_catalog(db, [waterbody_row()], fetched_at=first) == (1, 0)
|
||||||
|
item = db.scalar(select(Waterbody).where(Waterbody.source_external_id == "level_001_mosquito"))
|
||||||
|
assert item is not None
|
||||||
|
assert item.slug == "оз-комариное"
|
||||||
|
assert item.source_checked_at.replace(tzinfo=timezone.utc) == first
|
||||||
|
assert item.fish_species_count == 20
|
||||||
|
|
||||||
|
assert upsert_waterbody_catalog(db, [waterbody_row(name="Озеро Комариное", unlock_level=2)], fetched_at=first) == (0, 1)
|
||||||
|
item = db.scalar(select(Waterbody).where(Waterbody.source_external_id == "level_001_mosquito"))
|
||||||
|
assert item is not None
|
||||||
|
assert (item.name_ru, item.unlock_level, item.fish_species_count) == ("Озеро Комариное", 2, 20)
|
||||||
|
assert db.scalar(select(Waterbody).where(Waterbody.name_ru == "оз. Комариное")) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_waterbody_catalog_rejects_untrusted_source(db: Session) -> None:
|
||||||
|
with pytest.raises(CommunityImportError, match="source_url"):
|
||||||
|
upsert_waterbody_catalog(db, [waterbody_row(source_url="https://example.test/map")])
|
||||||
|
|
||||||
|
|
||||||
|
def test_waterbody_detail_updates_only_imported_identity_without_media_roles(db: Session) -> None:
|
||||||
|
upsert_waterbody_catalog(db, [waterbody_row()])
|
||||||
|
assert update_waterbody_detail(db, {
|
||||||
|
"source_system": "rf4db",
|
||||||
|
"source_external_id": "level_001_mosquito",
|
||||||
|
"source_url": "https://rf4db.com/ru/maps/level_001_mosquito",
|
||||||
|
"name": "оз. Комариное",
|
||||||
|
"description": "Каменистые берега.",
|
||||||
|
"aliases": ["Комариное", "Комариное"],
|
||||||
|
"fish_species": ["Щука", "Окунь"],
|
||||||
|
"image_urls": ["https://oss.rf4db.com/map.webp"],
|
||||||
|
"point_urls": ["https://rf4db.com/ru/maps/level_001_mosquito/spots/12-34"],
|
||||||
|
}) is True
|
||||||
|
item = db.scalar(select(Waterbody).where(Waterbody.source_external_id == "level_001_mosquito"))
|
||||||
|
assert item is not None
|
||||||
|
assert item.source_aliases == ["Комариное"]
|
||||||
|
assert item.source_fish_species == ["Щука", "Окунь"]
|
||||||
|
assert item.source_image_urls == ["https://oss.rf4db.com/map.webp"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_waterbody_detail_accepts_authorized_download_subdomain(db: Session) -> None:
|
||||||
|
upsert_waterbody_catalog(db, [waterbody_row()])
|
||||||
|
assert update_waterbody_detail(db, {
|
||||||
|
"source_system": "rf4db",
|
||||||
|
"source_external_id": "level_001_mosquito",
|
||||||
|
"source_url": "https://download.rf4db.com/ru/maps/level_001_mosquito",
|
||||||
|
"name": "оз. Комариное",
|
||||||
|
"description": None,
|
||||||
|
"aliases": [],
|
||||||
|
"fish_species": ["Щука"],
|
||||||
|
"image_urls": [],
|
||||||
|
"point_urls": [],
|
||||||
|
}) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_waterbody_detail_batch_validates_before_writing(db: Session) -> None:
|
||||||
|
upsert_waterbody_catalog(db, [waterbody_row()])
|
||||||
|
valid = {
|
||||||
|
"source_system": "rf4db", "source_external_id": "level_001_mosquito",
|
||||||
|
"source_url": "https://rf4db.com/ru/maps/level_001_mosquito", "name": "оз. Комариное",
|
||||||
|
"description": "Описание", "aliases": [], "fish_species": ["Щука"],
|
||||||
|
"image_urls": [], "point_urls": [],
|
||||||
|
}
|
||||||
|
invalid = valid | {"source_external_id": "unknown", "source_url": "https://example.test/map"}
|
||||||
|
with pytest.raises(CommunityImportError, match="source_url"):
|
||||||
|
update_waterbody_details(db, [valid, invalid])
|
||||||
|
item = db.scalar(select(Waterbody).where(Waterbody.source_external_id == "level_001_mosquito"))
|
||||||
|
assert item is not None and item.description is None
|
||||||
|
|
||||||
|
|
||||||
def test_external_ids_are_isolated_by_source(db: Session) -> None:
|
def test_external_ids_are_isolated_by_source(db: Session) -> None:
|
||||||
created, updated = stage_observations(db, [record("rf4db"), record("rf4stat-fishing")])
|
created, updated = stage_observations(db, [record("rf4db"), record("rf4stat-fishing")])
|
||||||
|
|
||||||
@@ -104,14 +189,28 @@ def test_complete_observation_with_reviewed_aliases_is_published(db: Session) ->
|
|||||||
assert item.catch_report is not None
|
assert item.catch_report is not None
|
||||||
assert item.catch_report.fish_id == fish.id
|
assert item.catch_report.fish_id == fish.id
|
||||||
assert item.catch_report.waterbody_id == waterbody.id
|
assert item.catch_report.waterbody_id == waterbody.id
|
||||||
assert db.scalar(select(func.count()).select_from(CatchReport)) == 1
|
|
||||||
|
|
||||||
assert stage_observations(db, [record() | {"weight_g": 5_000}]) == (0, 1)
|
assert stage_observations(db, [record() | {"weight_g": 5_000}]) == (0, 1)
|
||||||
db.refresh(item)
|
db.refresh(item)
|
||||||
assert item.status == "published"
|
assert item.status == "published"
|
||||||
assert db.scalar(select(func.count()).select_from(CatchReport)) == 1
|
assert db.scalar(select(func.count()).select_from(CatchReport)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_observation_preserves_coordinate_text_and_precision(db: Session) -> None:
|
||||||
|
stage_observations(db, [record() | {
|
||||||
|
"source_external_id": "coordinate-area",
|
||||||
|
"x": None, "y": None, "coordinate_raw": "северная бухта",
|
||||||
|
"coordinate_precision": "area", "weight_g": None,
|
||||||
|
}])
|
||||||
|
item = db.scalar(select(ExternalObservation).where(ExternalObservation.source_external_id == "coordinate-area"))
|
||||||
|
assert item is not None
|
||||||
|
assert (item.coordinate_raw, item.coordinate_precision, item.x, item.y) == ("северная бухта", "area", None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_coordinate_precision_rejects_unknown_value(db: Session) -> None:
|
||||||
|
with pytest.raises(CommunityImportError, match="invalid coordinate_precision"):
|
||||||
|
stage_observations(db, [record() | {"coordinate_precision": "guess"}])
|
||||||
|
|
||||||
|
|
||||||
def test_changed_published_record_requires_review_and_reuses_report(db: Session) -> None:
|
def test_changed_published_record_requires_review_and_reuses_report(db: Session) -> None:
|
||||||
fish = Fish(slug="pike", name_ru="Щука")
|
fish = Fish(slug="pike", name_ru="Щука")
|
||||||
water = Waterbody(slug="test-lake", name_ru="Тестовое озеро")
|
water = Waterbody(slug="test-lake", name_ru="Тестовое озеро")
|
||||||
@@ -140,6 +239,8 @@ def test_changed_published_record_requires_review_and_reuses_report(db: Session)
|
|||||||
assert updated.id == report_id
|
assert updated.id == report_id
|
||||||
assert updated.weight_g == 6000
|
assert updated.weight_g == 6000
|
||||||
assert updated.moderation_status.value == "approved"
|
assert updated.moderation_status.value == "approved"
|
||||||
|
components = db.scalars(select(CatchTackleComponent).order_by(CatchTackleComponent.position)).all()
|
||||||
|
assert [(component.position, component.raw_value) for component in components] == [(0, "Приманка")]
|
||||||
assert db.scalar(select(func.count()).select_from(CatchReport)) == 1
|
assert db.scalar(select(func.count()).select_from(CatchReport)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
from app.importer import FetchResult, ImportAlreadyRunning, ImportSourceError, _lock_key, _official_import_lock, import_records, parse_html
|
from app.importer import FetchResult, ImportAlreadyRunning, ImportSourceError, _lock_key, _official_import_lock, import_records, parse_html
|
||||||
from app.models import CatchReport, ImportStatus, OfficialRecordImport, SourceType
|
from app.models import CatchReport, CatchTackleComponent, ImportStatus, OfficialRecordImport, SourceType
|
||||||
|
|
||||||
|
|
||||||
FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "records_ru_sample.html"
|
FIXTURE = Path(__file__).parents[3] / "tests" / "fixtures" / "records_ru_sample.html"
|
||||||
@@ -50,6 +50,7 @@ def test_parser_and_import_are_idempotent() -> None:
|
|||||||
# A12: Second import of identical data creates no events (no fields changed)
|
# A12: Second import of identical data creates no events (no fields changed)
|
||||||
assert (second.rows_created, second.rows_updated) == (0, 0)
|
assert (second.rows_created, second.rows_updated) == (0, 0)
|
||||||
assert db.scalar(select(func.count()).select_from(CatchReport).where(CatchReport.source_type == SourceType.official_record)) == 2
|
assert db.scalar(select(func.count()).select_from(CatchReport).where(CatchReport.source_type == SourceType.official_record)) == 2
|
||||||
|
assert db.scalar(select(func.count()).select_from(CatchTackleComponent)) == 2
|
||||||
assert db.scalar(select(func.count()).select_from(OfficialRecordImport)) == 2
|
assert db.scalar(select(func.count()).select_from(OfficialRecordImport)) == 2
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from app import media_catalog
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_media_catalog_rejects_unknown_and_cross_entity_roles(tmp_path, monkeypatch) -> None:
|
||||||
|
manifest = {
|
||||||
|
"assets": [
|
||||||
|
{"status": "approved", "sha256": "a" * 64, "local_path": "water.webp", "entity_type": "waterbody", "entity_key": "kuori", "media_role": "waterbody_map"},
|
||||||
|
{"status": "approved", "sha256": "b" * 64, "local_path": "wrong.webp", "entity_type": "waterbody", "entity_key": "kuori", "media_role": "tackle_card"},
|
||||||
|
{"status": "approved", "sha256": "c" * 64, "local_path": "unknown.webp", "entity_type": "tackle", "entity_key": "spiker", "media_role": "future_role"},
|
||||||
|
{"status": "approved", "sha256": "d" * 64, "local_path": "tackle.webp", "entity_type": "tackle", "entity_key": "spiker", "media_role": "tackle_card"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
(tmp_path / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||||
|
monkeypatch.setattr(media_catalog, "MEDIA_ROOT", tmp_path)
|
||||||
|
|
||||||
|
rows = media_catalog.published_assets()
|
||||||
|
|
||||||
|
assert [row["id"] for row in rows] == ["d" * 64, "a" * 64]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
from app.models import Rig, RigComponent, TackleItem
|
||||||
|
from app.routers.catalog import rig_detail, tackle_item, tackle_items
|
||||||
|
|
||||||
|
|
||||||
|
def test_tackle_catalog_filters_details_and_missing_fields() -> None:
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as db:
|
||||||
|
item = TackleItem(
|
||||||
|
name="API тестовая блесна", normalized_name="api тестовая блесна",
|
||||||
|
category="lure", subcategory="spinner", brand="RF4", family=None,
|
||||||
|
unlock_level=0, source_system="fixture", source_external_id="api-lure-1",
|
||||||
|
source_url="https://example.test/lure/api-lure-1",
|
||||||
|
)
|
||||||
|
rig = Rig(name="API тестовый монтаж", normalized_name="api тестовый монтаж", source_system="fixture")
|
||||||
|
rig.components.append(RigComponent(role="lure", position=0, tackle_item=item, raw_value=item.name))
|
||||||
|
db.add(rig)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
page = tackle_items(db, category="lure", brand="RF4", family=None, unlock_level=None, limit=10, offset=0)
|
||||||
|
assert page.total == 1
|
||||||
|
assert page.items[0].missing_fields == ["family", "source_checked_at"]
|
||||||
|
assert tackle_item(item.id, db).name == item.name
|
||||||
|
details = rig_detail(rig.id, db)
|
||||||
|
assert details.components[0].raw_value == item.name
|
||||||
|
assert details.missing_fields == ["source_url", "source_checked_at"]
|
||||||
|
|
||||||
|
engine.dispose()
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
from app.models import Rig, RigComponent, TackleItem
|
||||||
|
|
||||||
|
|
||||||
|
def test_tackle_item_and_rig_components_keep_provenance_and_legacy_independence() -> None:
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
item_id = uuid.uuid4()
|
||||||
|
rig_id = uuid.uuid4()
|
||||||
|
with Session(engine) as session:
|
||||||
|
item = TackleItem(
|
||||||
|
id=item_id, name="Spiker #2", normalized_name="spiker #2",
|
||||||
|
category="lure", subcategory="spinner", brand="RF4", family="spoon",
|
||||||
|
unlock_level=0, source_system="rf4db", source_external_id="spiker-2",
|
||||||
|
source_url="https://rf4db.com/ru/wiki/lures/spiker-2",
|
||||||
|
raw_payload={"weight": {"state": "value", "value": 0}},
|
||||||
|
)
|
||||||
|
rig = Rig(
|
||||||
|
id=rig_id, name="Method Popup", normalized_name="method popup",
|
||||||
|
source_system="rf4db", source_external_id="method-popup",
|
||||||
|
)
|
||||||
|
rig.components.append(RigComponent(role="lure", position=0, tackle_item=item, raw_value="Spiker #2"))
|
||||||
|
session.add(rig)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
saved = session.get(TackleItem, item_id)
|
||||||
|
assert saved is not None
|
||||||
|
assert saved.unlock_level == 0
|
||||||
|
assert saved.raw_payload == {"weight": {"state": "value", "value": 0}}
|
||||||
|
assert saved.rig_components[0].rig_id == rig_id
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
---
|
---
|
||||||
import type { Activity } from "../lib/api";
|
import type { Activity } from "../lib/api";
|
||||||
import { activityLevel, ago, kg, plural, spotPath } from "../lib/api";
|
import { activityLevel, ago, coordinatePrecisionLabel, kg, spotPath } from "../lib/api";
|
||||||
import FishingIcon from "./FishingIcon.astro";
|
import FishingIcon from "./FishingIcon.astro";
|
||||||
import DataPassport from "./DataPassport.astro";
|
import DataPassport from "./DataPassport.astro";
|
||||||
import FishSilhouette from "./FishSilhouette.astro";
|
import FishSilhouette from "./FishSilhouette.astro";
|
||||||
import TackleGlyph from "./TackleGlyph.astro";
|
import TackleGlyph from "./TackleGlyph.astro";
|
||||||
const { item } = Astro.props as { item: Activity };
|
const { item, periodLabel = null } = Astro.props as { item: Activity; periodLabel?: string | null };
|
||||||
const level = activityLevel(item.activity_score);
|
const level = activityLevel(item.activity_score);
|
||||||
const limited = item.catches < 3;
|
const limited = item.catches < 3;
|
||||||
|
const precision = coordinatePrecisionLabel(item.coordinate_precision);
|
||||||
---
|
---
|
||||||
<a class="spot-card" data-testid={`spot-${item.x}-${item.y}`} href={spotPath(item)}>
|
<a class="spot-card" data-testid={`spot-${item.x}-${item.y}`} href={spotPath(item)}>
|
||||||
<span class="spot-rank">{String(item.activity_score).padStart(2,"0")}</span>
|
<span class="spot-rank">{String(item.activity_score).padStart(2,"0")}</span>
|
||||||
@@ -15,10 +16,11 @@ const limited = item.catches < 3;
|
|||||||
<div class="spot-topline"><span>{item.waterbody}</span><span class="activity-pill" data-activity-level={level.short}><i></i>{level.short}</span>{limited && <span class="data-quality">Данных мало</span>}</div>
|
<div class="spot-topline"><span>{item.waterbody}</span><span class="activity-pill" data-activity-level={level.short}><i></i>{level.short}</span>{limited && <span class="data-quality">Данных мало</span>}</div>
|
||||||
<h3>{item.fish}</h3>
|
<h3>{item.fish}</h3>
|
||||||
<FishSilhouette name={item.fish}/>
|
<FishSilhouette name={item.fish}/>
|
||||||
<div class="spot-meta"><span><FishingIcon name="pin" size={14}/> {item.x}:{item.y}</span><span><FishingIcon name="clock" size={14}/> {ago(item.last_confirmed_at)}</span></div>
|
<div class="spot-meta"><span><FishingIcon name="pin" size={14}/> {item.x}:{item.y} · {precision}</span><span><FishingIcon name="clock" size={14}/> {ago(item.last_confirmed_at)}</span></div>
|
||||||
<p class="data-note">{item.explanation}</p>
|
<p class="data-note">{item.explanation}</p>
|
||||||
<DataPassport sources={item.sources} observedAt={item.last_confirmed_at} confidence={item.confidence_score}/>
|
<DataPassport sources={item.sources} observedAt={item.last_confirmed_at} periodLabel={periodLabel} confidence={item.confidence_score} sampleSize={item.catches} independentPlayers={item.unique_players} coordinatePrecision={item.coordinate_precision} coordinateSources={item.coordinate_sources} sourceConflicts={item.source_conflicts} status={limited ? "insufficient" : "verified"}/>
|
||||||
<div class="bait-line"><TackleGlyph name={item.best_bait}/><div><span>Работает сейчас</span><strong>{item.best_bait ?? "не указана"}</strong></div></div>
|
<div class="bait-line"><TackleGlyph name={item.best_bait}/><div><span>{limited ? "Нужно ещё подтверждений" : item.best_bait ? "Работает сейчас" : "Наживка не указана"}</span><strong>{item.best_bait ?? "не указана"}</strong></div></div>
|
||||||
|
<span class="card-cta">Открыть точку <span aria-hidden="true">→</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="spot-stats"><div><strong>{item.catches}</strong><span>{plural(item.catches, ["улов", "улова", "уловов"])}</span></div><div><strong>{item.unique_players}</strong><span>{plural(item.unique_players, ["игрок", "игрока", "игроков"])}</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"><FishingIcon name="arrow" size={22}/></span>
|
<div class="spot-stats"><div><strong>{kg(item.average_weight_g)}</strong><span>средний вес</span></div><div><strong>{kg(item.max_weight_g)}</strong><span>макс. вес</span></div></div><span class="card-arrow"><FishingIcon name="arrow" size={22}/></span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
import DataPassport from "./DataPassport.astro";
|
||||||
|
import type { Activity } from "../lib/api";
|
||||||
|
const { items, totalPoints, periodLabel } = Astro.props as { items: Activity[]; totalPoints: number; periodLabel: string };
|
||||||
|
const sources = [...new Set(items.flatMap(item => item.sources))];
|
||||||
|
const conflicts = [...new Set(items.flatMap(item => item.source_conflicts))];
|
||||||
|
const observations = items.reduce((sum, item) => sum + item.catches, 0);
|
||||||
|
const confidence = items.length ? Math.round(items.reduce((sum, item) => sum + item.confidence_score, 0) / items.length) : null;
|
||||||
|
const observedAt = items.map(item => item.last_confirmed_at).filter(Boolean).sort().at(-1) ?? null;
|
||||||
|
const status = observations < 3 ? "insufficient" : "verified";
|
||||||
|
---
|
||||||
|
<section class="activity-evidence content-grid" aria-labelledby="activity-evidence-title">
|
||||||
|
<div>
|
||||||
|
<span class="overline">Доверие к выборке</span>
|
||||||
|
<h2 id="activity-evidence-title">Паспорт активности</h2>
|
||||||
|
<p>{totalPoints} {totalPoints === 1 ? "точка" : totalPoints < 5 ? "точки" : "точек"} за {periodLabel}. Сводка построена по подтверждённым карточкам этого списка.</p>
|
||||||
|
</div>
|
||||||
|
<DataPassport sources={sources} observedAt={observedAt} periodLabel={periodLabel} confidence={confidence} sampleSize={observations} sourceConflicts={conflicts} status={status} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.activity-evidence { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, 1.1fr); gap: 24px; align-items: start; margin-block: 26px; }
|
||||||
|
.activity-evidence h2 { margin: 6px 0 8px; }
|
||||||
|
.activity-evidence p { max-width: 470px; margin: 0; color: var(--text-muted); line-height: 1.6; }
|
||||||
|
@media (max-width: 720px) { .activity-evidence { grid-template-columns: 1fr; } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
import { spotPath, type Activity } from "../lib/api";
|
||||||
|
|
||||||
|
const { items } = Astro.props as { items: Activity[] };
|
||||||
|
const xValues = items.map((item) => item.x);
|
||||||
|
const yValues = items.map((item) => item.y);
|
||||||
|
const minX = Math.min(...xValues, 0);
|
||||||
|
const maxX = Math.max(...xValues, 1);
|
||||||
|
const minY = Math.min(...yValues, 0);
|
||||||
|
const maxY = Math.max(...yValues, 1);
|
||||||
|
const xRange = Math.max(1, maxX - minX);
|
||||||
|
const yRange = Math.max(1, maxY - minY);
|
||||||
|
const column = (value: number) => Math.min(20, Math.max(1, Math.round(((value - minX) / xRange) * 18) + 1));
|
||||||
|
const row = (value: number) => Math.min(20, Math.max(1, Math.round((1 - (value - minY) / yRange) * 18) + 1));
|
||||||
|
---
|
||||||
|
<section class="activity-map" aria-label="Схема координат горячих точек">
|
||||||
|
<header class="activity-map__header">
|
||||||
|
<div><span class="overline">Режим схемы</span><h3>Горячие точки на координатной сетке</h3></div>
|
||||||
|
<p>Показаны подтверждённые координаты текущей выборки. Это схема `x:y`, а не географическая карта водоёма.</p>
|
||||||
|
</header>
|
||||||
|
<div class="activity-map__plot" role="group" aria-label={`Координатная схема, точек: ${items.length}`}>
|
||||||
|
<span class="activity-map__axis activity-map__axis--x" aria-hidden="true">x →</span>
|
||||||
|
<span class="activity-map__axis activity-map__axis--y" aria-hidden="true">y ↑</span>
|
||||||
|
{items.map((item) => <a class:list={["activity-map__point", `activity-map__point--col-${column(item.x)}`, `activity-map__point--row-${row(item.y)}`]} href={spotPath(item)} aria-label={`${item.waterbody}, ${item.x}:${item.y}, ${item.fish}`}><span>{item.x}:{item.y}</span><strong>{item.fish}</strong></a>)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.activity-map { display: grid; gap: 18px; }
|
||||||
|
.activity-map__header { display: flex; justify-content: space-between; gap: 22px; align-items: end; }
|
||||||
|
.activity-map__header h3 { margin: 6px 0 0; font: 400 27px Georgia, serif; letter-spacing: -.03em; }
|
||||||
|
.activity-map__header p { max-width: 330px; margin: 0; color: var(--text-muted); font-size: 12px; line-height: 1.5; }
|
||||||
|
.activity-map__plot { position: relative; display: grid; grid-template-columns: repeat(20, minmax(0, 1fr)); grid-template-rows: repeat(20, minmax(0, 1fr)); min-height: 470px; overflow: hidden; border: 1px solid var(--border); border-radius: 20px; background: linear-gradient(90deg, color-mix(in srgb, var(--accent-muted) 8%, transparent) 1px, transparent 1px), linear-gradient(color-mix(in srgb, var(--accent-muted) 8%, transparent) 1px, transparent 1px), var(--surface-soft); background-size: 10% 10%; }
|
||||||
|
.activity-map__plot::after { content: ""; position: absolute; inset: 8%; border: 1px dashed color-mix(in srgb, var(--accent-muted) 40%, transparent); border-radius: 12px; pointer-events: none; }
|
||||||
|
.activity-map__axis { position: absolute; z-index: 1; color: var(--text-subtle); font-size: 10px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
|
||||||
|
.activity-map__axis--x { right: 5%; bottom: 3%; }
|
||||||
|
.activity-map__axis--y { left: 2%; top: 4%; writing-mode: vertical-rl; transform: rotate(180deg); }
|
||||||
|
.activity-map__point { position: relative; z-index: 2; display: grid; align-self: center; justify-self: center; gap: 3px; min-width: 74px; margin: 5px; padding: 8px 10px; border: 1px solid color-mix(in srgb, var(--accent-muted) 45%, var(--border)); border-radius: 12px; background: var(--surface); color: var(--text); text-decoration: none; box-shadow: 0 8px 18px color-mix(in srgb, var(--deep) 11%, transparent); }
|
||||||
|
.activity-map__point::before { content: ""; position: absolute; left: 50%; bottom: -6px; width: 10px; height: 10px; border-right: 1px solid color-mix(in srgb, var(--accent-muted) 45%, var(--border)); border-bottom: 1px solid color-mix(in srgb, var(--accent-muted) 45%, var(--border)); background: var(--surface); transform: translateX(-50%) rotate(45deg); }
|
||||||
|
.activity-map__point:hover, .activity-map__point:focus-visible { border-color: var(--focus); transform: scale(1.03); }
|
||||||
|
.activity-map__point span { color: var(--text-muted); font-size: 10px; font-weight: 750; }
|
||||||
|
.activity-map__point strong { max-width: 130px; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
:global(.activity-map__point--col-1) { grid-column: 1; } :global(.activity-map__point--col-2) { grid-column: 2; } :global(.activity-map__point--col-3) { grid-column: 3; } :global(.activity-map__point--col-4) { grid-column: 4; } :global(.activity-map__point--col-5) { grid-column: 5; } :global(.activity-map__point--col-6) { grid-column: 6; } :global(.activity-map__point--col-7) { grid-column: 7; } :global(.activity-map__point--col-8) { grid-column: 8; } :global(.activity-map__point--col-9) { grid-column: 9; } :global(.activity-map__point--col-10) { grid-column: 10; } :global(.activity-map__point--col-11) { grid-column: 11; } :global(.activity-map__point--col-12) { grid-column: 12; } :global(.activity-map__point--col-13) { grid-column: 13; } :global(.activity-map__point--col-14) { grid-column: 14; } :global(.activity-map__point--col-15) { grid-column: 15; } :global(.activity-map__point--col-16) { grid-column: 16; } :global(.activity-map__point--col-17) { grid-column: 17; } :global(.activity-map__point--col-18) { grid-column: 18; } :global(.activity-map__point--col-19) { grid-column: 19; } :global(.activity-map__point--col-20) { grid-column: 20; }
|
||||||
|
:global(.activity-map__point--row-1) { grid-row: 1; } :global(.activity-map__point--row-2) { grid-row: 2; } :global(.activity-map__point--row-3) { grid-row: 3; } :global(.activity-map__point--row-4) { grid-row: 4; } :global(.activity-map__point--row-5) { grid-row: 5; } :global(.activity-map__point--row-6) { grid-row: 6; } :global(.activity-map__point--row-7) { grid-row: 7; } :global(.activity-map__point--row-8) { grid-row: 8; } :global(.activity-map__point--row-9) { grid-row: 9; } :global(.activity-map__point--row-10) { grid-row: 10; } :global(.activity-map__point--row-11) { grid-row: 11; } :global(.activity-map__point--row-12) { grid-row: 12; } :global(.activity-map__point--row-13) { grid-row: 13; } :global(.activity-map__point--row-14) { grid-row: 14; } :global(.activity-map__point--row-15) { grid-row: 15; } :global(.activity-map__point--row-16) { grid-row: 16; } :global(.activity-map__point--row-17) { grid-row: 17; } :global(.activity-map__point--row-18) { grid-row: 18; } :global(.activity-map__point--row-19) { grid-row: 19; } :global(.activity-map__point--row-20) { grid-row: 20; }
|
||||||
|
@media (max-width: 720px) { .activity-map__header { display: grid; gap: 8px; align-items: start; } .activity-map__plot { min-height: 390px; } .activity-map__point { min-width: 62px; padding: 7px 8px; } .activity-map__point strong { max-width: 90px; } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
const { hours = "72" } = Astro.props as { hours?: string };
|
||||||
|
const options = [["24", "24 часа"], ["72", "72 часа"], ["168", "7 дней"]] as const;
|
||||||
|
---
|
||||||
|
<form class="record-filters activity-period-filter" method="get" aria-label="Период активности">
|
||||||
|
<label>Период активности<select name="hours">{options.map(([value, label]) => <option value={value} selected={hours === value}>{label}</option>)}</select></label>
|
||||||
|
<button data-action="secondary">Обновить</button>
|
||||||
|
</form>
|
||||||
@@ -3,16 +3,18 @@ import SourceBadge from "./SourceBadge.astro";
|
|||||||
import { ago, kg, type Catch } from "../lib/api";
|
import { ago, kg, type Catch } from "../lib/api";
|
||||||
import TackleGlyph from "./TackleGlyph.astro";
|
import TackleGlyph from "./TackleGlyph.astro";
|
||||||
const { catches } = Astro.props as { catches: Catch[] };
|
const { catches } = Astro.props as { catches: Catch[] };
|
||||||
|
const methodLabels: Record<string, string> = { spinning: "Спиннинг", bottom: "Донная", float: "Поплавочная" };
|
||||||
|
const roleLabels: Record<string, string> = { lure: "Приманка", bait: "Наживка", rig: "Сборка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", float: "Поплавок", sinker: "Груз" };
|
||||||
---
|
---
|
||||||
<div class="catch-list">
|
<div class="catch-list">
|
||||||
{catches.map(item => {
|
{catches.map(item => {
|
||||||
const timestamp = item.caught_at ?? item.reported_at;
|
const timestamp = item.caught_at ?? item.reported_at;
|
||||||
return <article>
|
return <article>
|
||||||
<div><strong>{item.fish}</strong><span class="tackle-label"><TackleGlyph name={item.bait} size={24}/><span>{item.bait ?? "Приманка не указана"}</span></span><SourceBadge source={item.source_system} href={item.source_url}/></div>
|
<div><strong>{item.fish}</strong><span class="tackle-label"><TackleGlyph name={item.bait} size={24}/><span>{item.bait ?? "Приманка не указана"}</span></span>{item.fishing_method && <span>Метод: {methodLabels[item.fishing_method] ?? item.fishing_method}</span>}{item.retrieve_method && <span>Проводка: {item.retrieve_method}{item.retrieve_speed != null ? ` · скорость ${item.retrieve_speed}` : ""}</span>}{item.tackle_components?.length > 0 && <details class="catch-components"><summary>Комплект снастей ({item.tackle_components.length})</summary><ul>{item.tackle_components.map(component => <li><span>{roleLabels[component.role] ?? component.role}</span>{component.rig_id ? <a href={`/tackle/rigs/${component.rig_id}`}>{component.raw_value}</a> : component.tackle_item_id ? <a href={`/tackle/items/${component.tackle_item_id}`}>{component.raw_value}</a> : <span>{component.raw_value}</span>}</li>)}</ul></details>}<SourceBadge source={item.source_system} href={item.source_url}/></div>
|
||||||
<div><strong>{kg(item.weight_g)}</strong><span>{item.player_name ?? "Анонимно"}</span><span>{item.caught_at ? "Время улова" : "Получено · время улова неизвестно"}</span><time datetime={timestamp}>{ago(timestamp)} · {new Date(timestamp).toLocaleString("ru-RU", { timeZone: "UTC" })} UTC</time></div>
|
<div><strong>{kg(item.weight_g)}</strong><span>{item.player_name ?? "Анонимно"}</span><span>{item.caught_at ? "Время улова" : "Получено · время улова неизвестно"}</span><time datetime={timestamp}>{ago(timestamp)} · {new Date(timestamp).toLocaleString("ru-RU", { timeZone: "UTC" })} UTC</time></div>
|
||||||
</article>;
|
</article>;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<style>
|
<style>
|
||||||
time{margin-top:7px;color:var(--text-secondary);font-size:10px;font-weight:750}
|
time{margin-top:7px;color:var(--text-secondary);font-size:10px;font-weight:750}.catch-components{margin-top:8px}.catch-components summary{cursor:pointer;color:var(--text-secondary);font-size:11px;font-weight:750}.catch-components ul{display:grid;gap:4px;margin:8px 0 0;padding:0;list-style:none;color:var(--text-secondary);font-size:11px}.catch-components li{display:flex;gap:6px;flex-wrap:wrap}.catch-components li span:first-child{color:var(--text-subtle)}.catch-components a{color:inherit;text-underline-offset:3px}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
---
|
---
|
||||||
import SourceBadge from "./SourceBadge.astro";
|
import SourceBadge from "./SourceBadge.astro";
|
||||||
import { ago } from "../lib/api";
|
import { ago, coordinatePrecisionLabel, freshnessStatus, passportDisplayStatus } from "../lib/api";
|
||||||
type Props = { sources: string[]; sourceUrl?: string | null; observedAt: string; completeness?: number | null; confidence?: number | null; status?: "verified" | "unverified" | "incomplete" };
|
type Props = { sources: string[]; sourceUrl?: string | null; observedAt?: string | null; periodLabel?: string | null; completeness?: number | null; confidence?: number | null; sampleSize?: number | null; independentPlayers?: number | null; coordinatePrecision?: "exact" | "approximate" | "area" | "missing" | null; coordinateSources?: string[]; sourceConflicts?: string[]; status?: "verified" | "unverified" | "incomplete" | "insufficient" | "blocked" };
|
||||||
const { sources, sourceUrl, observedAt, completeness = null, confidence = null, status = "verified" } = Astro.props;
|
const { sources, sourceUrl, observedAt, periodLabel = null, completeness = null, confidence = null, sampleSize = null, independentPlayers = null, coordinatePrecision = null, coordinateSources = [], sourceConflicts = [], status = "verified" } = Astro.props as Props;
|
||||||
const statusLabels = { verified: "Учтено", unverified: "Ждёт проверки", incomplete: "Неполные данные" };
|
const freshness = freshnessStatus(observedAt);
|
||||||
|
const displayStatus = passportDisplayStatus(status, freshness, sourceConflicts.length > 0);
|
||||||
|
const statusLabels = { verified: "Учтено", unverified: "Ждёт проверки", incomplete: "Неполные данные", insufficient: "Недостаточно данных", blocked: "Источник ограничен", conflict: "Источники расходятся", stale: "Данные устарели" };
|
||||||
const completenessLabel = completeness == null ? "Не рассчитана" : `${Math.min(100, Math.max(0, completeness))}% полей`;
|
const completenessLabel = completeness == null ? "Не рассчитана" : `${Math.min(100, Math.max(0, completeness))}% полей`;
|
||||||
---
|
---
|
||||||
<section class="data-passport" aria-label="Паспорт данных">
|
<section class="data-passport" aria-label="Паспорт данных">
|
||||||
<header><span>Паспорт данных</span><strong data-passport-status={status}>{statusLabels[status]}</strong></header>
|
<header><span>Паспорт данных</span><strong data-passport-status={displayStatus}>{statusLabels[displayStatus]}</strong></header>
|
||||||
<div class="data-passport__sources">{sources.map(source => <SourceBadge source={source} href={sources.length === 1 ? sourceUrl : null}/>)}</div>
|
<div class="data-passport__sources">{sources.map(source => <SourceBadge source={source} href={sources.length === 1 ? sourceUrl : null}/>)}</div>
|
||||||
<dl><div><dt>Свежесть</dt><dd>{ago(observedAt)}</dd></div><div><dt>Полнота</dt><dd>{completenessLabel}</dd></div><div><dt>Доверие</dt><dd>{confidence == null ? "После проверки" : `${confidence}%`}</dd></div></dl>
|
{sourceConflicts.length > 0 && <p class="data-passport__conflict"><strong>Источники расходятся:</strong> {sourceConflicts.join(" · ")}</p>}
|
||||||
|
{coordinateSources.length > 0 && <details class="data-passport__coordinate-sources"><summary>Источники координат</summary><div>{coordinateSources.map(source => <SourceBadge source={source}/>)}</div></details>}
|
||||||
|
<dl><div><dt>Свежесть</dt><dd data-freshness={freshness}>{freshness === "stale" ? "Устарело" : freshness === "fresh" ? "Свежо" : "Не указана"}{observedAt && ` · ${ago(observedAt)}`}</dd></div>{periodLabel && <div><dt>Период</dt><dd>{periodLabel}</dd></div>}<div><dt>Полнота</dt><dd>{completenessLabel}</dd></div><div><dt>Доверие</dt><dd>{confidence == null ? "После проверки" : `${confidence}%`}</dd></div>{sampleSize != null && <div><dt>Наблюдения</dt><dd>{sampleSize}</dd></div>}{independentPlayers != null && <div><dt>Игроки</dt><dd>{independentPlayers}</dd></div>}{coordinatePrecision && <div><dt>Координаты</dt><dd>{coordinatePrecisionLabel(coordinatePrecision)}</dd></div>}</dl>
|
||||||
|
{sampleSize != null && sampleSize < 3 && <p class="data-passport__minimum">Минимум для рекомендации: 3 наблюдения.</p>}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
---
|
---
|
||||||
import SourceBadge from "./SourceBadge.astro";
|
import SourceBadge from "./SourceBadge.astro";
|
||||||
import type { MediaAsset } from "../lib/api";
|
import type { MediaAsset } from "../lib/api";
|
||||||
|
import { mediaRoleLabel } from "../lib/media";
|
||||||
const { asset, compact = false, sourceLink = false } = Astro.props as { asset: MediaAsset; compact?: boolean; sourceLink?: boolean };
|
const { asset, compact = false, sourceLink = false } = Astro.props as { asset: MediaAsset; compact?: boolean; sourceLink?: boolean };
|
||||||
---
|
---
|
||||||
<figure class:list={["entity-media", { "entity-media--compact": compact }]}>
|
<figure class:list={["entity-media", { "entity-media--compact": compact }]}>
|
||||||
<span class="entity-media__frame"><img src={asset.image_url} alt={asset.label ?? "Иллюстрация RF4"} width={asset.width} height={asset.height} loading="lazy" decoding="async" /></span>
|
<span class="entity-media__frame"><picture>
|
||||||
<figcaption><SourceBadge source={asset.source_system} href={sourceLink ? asset.source_url : undefined} /><span>{asset.label ?? "Справочный материал"}</span></figcaption>
|
{(["avif", "webp"] as const).map(format => {
|
||||||
|
const variants = (asset.variants ?? []).filter(item => item.format === format);
|
||||||
|
return variants.length ? <source type={`image/${format}`} srcset={variants.map(item => `${item.url} ${item.width}w`).join(", ")} sizes={compact ? "180px" : "(max-width: 720px) 100vw, 720px"} /> : null;
|
||||||
|
})}
|
||||||
|
<img src={asset.image_url} alt={asset.label ?? "Иллюстрация RF4"} width={asset.width} height={asset.height} loading="lazy" decoding="async" />
|
||||||
|
</picture></span>
|
||||||
|
<figcaption><SourceBadge source={asset.source_system} href={sourceLink ? asset.source_url : undefined} /><span>{asset.label ?? "Справочный материал"}</span>{asset.media_role && <small data-media-role={asset.media_role}>{mediaRoleLabel(asset.media_role)}</small>}</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
import { pageHref, pageWindow } from "../lib/pagination";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
path: string;
|
||||||
|
params: URLSearchParams;
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
anchor?: string;
|
||||||
|
itemLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { path, params, total, limit, offset, anchor = "", itemLabel = "записей" } = Astro.props;
|
||||||
|
const state = pageWindow(total, limit, offset);
|
||||||
|
---
|
||||||
|
{total > limit && <nav class="pagination" aria-label="Пагинация">
|
||||||
|
<span>{state.start}–{state.end} из {total} {itemLabel} · страница {state.page} из {state.pages}</span>
|
||||||
|
<div>
|
||||||
|
{state.previousOffset !== null
|
||||||
|
? <a data-action="secondary" rel="prev" href={pageHref(path, params, state.previousOffset, anchor)}>← Предыдущая</a>
|
||||||
|
: <span class="pagination__disabled" aria-disabled="true">← Предыдущая</span>}
|
||||||
|
{state.nextOffset !== null
|
||||||
|
? <a data-action="secondary" rel="next" href={pageHref(path, params, state.nextOffset, anchor)}>Следующая →</a>
|
||||||
|
: <span class="pagination__disabled" aria-disabled="true">Следующая →</span>}
|
||||||
|
</div>
|
||||||
|
</nav>}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
---
|
---
|
||||||
|
import { safeHttpUrl } from "../lib/urls";
|
||||||
const { source, href, tone = "source" } = Astro.props as { source: string; href?: string | null; tone?: "source" | "incomplete" | "verified" };
|
const { source, href, tone = "source" } = Astro.props as { source: string; href?: string | null; tone?: "source" | "incomplete" | "verified" };
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
"rf4-official": "RF4 · официальный",
|
"rf4-official": "RF4 · официальный",
|
||||||
@@ -12,5 +13,6 @@ const labels: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
const label = labels[source] ?? source;
|
const label = labels[source] ?? source;
|
||||||
const mark = source === "players" ? "♟" : source === "rf4-official" ? "★" : "↗";
|
const mark = source === "players" ? "♟" : source === "rf4-official" ? "★" : "↗";
|
||||||
|
const safeHref = safeHttpUrl(href);
|
||||||
---
|
---
|
||||||
{href ? <a class="source-chip" data-source={source} data-tone={tone} href={href} target="_blank" rel="noreferrer" title={`Открыть источник: ${label}`}><i>{mark}</i><span>{label}</span></a> : <span class="source-chip" data-source={source} data-tone={tone}><i>{mark}</i><span>{label}</span></span>}
|
{safeHref ? <a class="source-chip" data-source={source} data-tone={tone} href={safeHref} target="_blank" rel="noreferrer" title={`Открыть источник: ${label}`}><i>{mark}</i><span>{label}</span></a> : <span class="source-chip" data-source={source} data-tone={tone}><i>{mark}</i><span>{label}</span></span>}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import "../styles/signal-pagination.css";
|
|||||||
import "../styles/pagination.css";
|
import "../styles/pagination.css";
|
||||||
import "../styles/dashboard-polish.css";
|
import "../styles/dashboard-polish.css";
|
||||||
import "../styles/loading-states.css";
|
import "../styles/loading-states.css";
|
||||||
|
import "../styles/query-context.css";
|
||||||
|
import "../styles/plan.css";
|
||||||
import "../styles/theme.css";
|
import "../styles/theme.css";
|
||||||
import "../styles/media-catalog.css";
|
import "../styles/media-catalog.css";
|
||||||
import FishingIcon from "../components/FishingIcon.astro";
|
import FishingIcon from "../components/FishingIcon.astro";
|
||||||
@@ -121,7 +123,7 @@ Astro.response.headers.set("Content-Security-Policy", [
|
|||||||
<a class="skip-link" href="#main-content">Перейти к содержимому</a>
|
<a class="skip-link" href="#main-content">Перейти к содержимому</a>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<a href="/" class="brand"><span class="brand-mark" aria-hidden="true"><FishingIcon name="hook" size={24}/></span><span class="brand-name"><strong>RF4 Spotter</strong><span>Ни хвоста, ни чешуи</span></span></a>
|
<a href="/" class="brand"><span class="brand-mark" aria-hidden="true"><FishingIcon name="hook" size={24}/></span><span class="brand-name"><strong>RF4 Spotter</strong><span>Ни хвоста, ни чешуи</span></span></a>
|
||||||
<nav aria-label="Разделы сайта"><a class:list={{active:path === "/"}} aria-current={path === "/" ? "page" : undefined} href="/"><FishingIcon name="float"/> <span>Сейчас клюёт</span></a><a class:list={{active:path.startsWith("/waterbodies") || path.startsWith("/fish")}} aria-current={path.startsWith("/waterbodies") || path.startsWith("/fish") ? "page" : undefined} href="/waterbodies"><FishingIcon name="ripple"/> <span>Каталог</span></a><a class:list={{active:path.startsWith("/media")}} aria-current={path.startsWith("/media") ? "page" : undefined} href="/media"><FishingIcon name="lure"/> <span>Медиатека</span></a><a class:list={{active:path.startsWith("/records")}} aria-current={path.startsWith("/records") ? "page" : undefined} href="/records"><FishingIcon name="trophy"/> <span>Рекорды</span></a><a class:list={{active:path.startsWith("/report")}} aria-current={path.startsWith("/report") ? "page" : undefined} href="/report"><FishingIcon name="plus"/> <span>Добавить улов</span></a></nav>
|
<nav aria-label="Разделы сайта"><a class:list={{active:path === "/"}} aria-current={path === "/" ? "page" : undefined} href="/"><FishingIcon name="float"/> <span>Сейчас клюёт</span></a><a class:list={{active:path.startsWith("/waterbodies") || path.startsWith("/fish")}} aria-current={path.startsWith("/waterbodies") || path.startsWith("/fish") ? "page" : undefined} href="/waterbodies"><FishingIcon name="ripple"/> <span>Каталог</span></a><a class:list={{active:path === "/plan"}} aria-current={path === "/plan" ? "page" : undefined} href="/plan"><FishingIcon name="pin"/> <span>Мой план</span></a><a class:list={{active:path.startsWith("/media") || path.startsWith("/admin/media")}} aria-current={path.startsWith("/media") || path.startsWith("/admin/media") ? "page" : undefined} href="/media"><FishingIcon name="lure"/> <span>Медиатека</span></a><a class:list={{active:path.startsWith("/records")}} aria-current={path.startsWith("/records") ? "page" : undefined} href="/records"><FishingIcon name="trophy"/> <span>Рекорды</span></a><a class:list={{active:path.startsWith("/report")}} aria-current={path.startsWith("/report") ? "page" : undefined} href="/report"><FishingIcon name="plus"/> <span>Добавить улов</span></a></nav>
|
||||||
<div class="header-tools">
|
<div class="header-tools">
|
||||||
<p class="live-badge"><span></span> Свежие данные и честная оценка</p>
|
<p class="live-badge"><span></span> Свежие данные и честная оценка</p>
|
||||||
<div class="theme-switcher" role="group" aria-label="Цветовая тема">
|
<div class="theme-switcher" role="group" aria-label="Цветовая тема">
|
||||||
|
|||||||
+16
-5
@@ -4,6 +4,7 @@ export type Activity = {
|
|||||||
unique_players: number; average_weight_g: number; max_weight_g: number;
|
unique_players: number; average_weight_g: number; max_weight_g: number;
|
||||||
last_confirmed_at: string; activity_score: number; confidence_score: number;
|
last_confirmed_at: string; activity_score: number; confidence_score: number;
|
||||||
explanation: string; sources: string[];
|
explanation: string; sources: string[];
|
||||||
|
coordinate_precision: "exact" | "approximate" | "area" | "missing"; coordinate_sources: string[]; source_conflicts: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PaginatedActivity = {
|
export type PaginatedActivity = {
|
||||||
@@ -13,9 +14,17 @@ export type PaginatedActivity = {
|
|||||||
offset: number;
|
offset: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Spot = { id: string; waterbody_slug: string; waterbody: string; x: number; y: number; description: string | null; catches_24h: number; catches_3d: number; catches_7d: number; top_baits: string[] };
|
export type Spot = { id: string; waterbody_slug: string; waterbody: string; x: number; y: number; description: string | null; catches_24h: number; catches_3d: number; catches_7d: number; top_baits: string[]; coordinate_precision: "exact" | "approximate" | "area" | "missing"; coordinate_sources: string[]; source_conflicts: string[] };
|
||||||
export type Catch = { id: string; fish: string; weight_g: number; bait: string | null; player_name: string | null; caught_at: string | null; reported_at: string; retrieve_method: string | null; retrieve_speed: number | null; source_system: string; source_url: string | null };
|
export type CatchTackleComponent = { id: string; role: string; position: number; raw_value: string; tackle_item_id: string | null; rig_id: string | null; source_system: string | null; source_url: string | null };
|
||||||
export type DictionaryItem = { id: string; slug: string; name_ru: string };
|
export type RigComponent = { id: string; role: string; position: number; raw_value: string | null; tackle_item_id: string | null };
|
||||||
|
export type Rig = { id: string; name: string; source_system: string | null; source_external_id: string | null; source_url: string | null; source_checked_at: string | null; missing_fields: string[]; components: RigComponent[] };
|
||||||
|
export type Catch = { id: string; fish: string; weight_g: number; bait: string | null; player_name: string | null; caught_at: string | null; reported_at: string; fishing_method: string | null; retrieve_method: string | null; retrieve_speed: number | null; source_system: string; source_url: string | null; tackle_components: CatchTackleComponent[] };
|
||||||
|
export type DictionaryItem = { id: string; slug: string; name_ru: string; unlock_level?: number | null; fish_species_count?: number | null; source_system?: string | null; source_external_id?: string | null; source_url?: string | null; description?: string | null; source_aliases?: string[] | null; source_fish_species?: string[] | null; source_image_urls?: string[] | null; source_point_urls?: string[] | null; source_checked_at?: string | null };
|
||||||
|
export type TackleItem = { id: string; name: string; category: string; subcategory: string | null; brand: string | null; family: string | null; unlock_level: number | null; source_system: string | null; source_external_id: string | null; source_url: string | null; source_checked_at: string | null; missing_fields: string[]; related_rigs: { id: string; name: string; source_system: string | null; source_checked_at: string | null; component_count: number }[] };
|
||||||
|
export type PaginatedTackleItems = { items: TackleItem[]; total: number; limit: number; offset: number };
|
||||||
|
export type RigSummary = { id: string; name: string; source_system: string | null; source_external_id: string | null; source_url: string | null; source_checked_at: string | null; missing_fields: string[]; component_count: number };
|
||||||
|
export type PaginatedRigs = { items: RigSummary[]; total: number; limit: number; offset: number };
|
||||||
|
export type TackleCombination = { role: string; value: string; tackle_item_id: string | null; rig_id: string | null; catches: number; unique_players: number; last_seen_at: string; freshness_score: number; status: "recommendation" | "insufficient_data"; explanation: 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; source_system: 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; source_system: string };
|
||||||
export type PaginatedOfficialRecord = {
|
export type PaginatedOfficialRecord = {
|
||||||
items: OfficialRecord[];
|
items: OfficialRecord[];
|
||||||
@@ -26,11 +35,13 @@ export type PaginatedOfficialRecord = {
|
|||||||
export type PublicObservation = { id: string; source_system: string; source_name: string; source_url: string; fish_name: string; waterbody_name: string; x: number | null; y: number | null; weight_g: number | null; last_seen_at: string; missing_fields: string[]; quality: "incomplete" | "unverified" };
|
export type PublicObservation = { id: string; source_system: string; source_name: string; source_url: string; fish_name: string; waterbody_name: string; x: number | null; y: number | null; weight_g: number | null; last_seen_at: string; missing_fields: string[]; quality: "incomplete" | "unverified" };
|
||||||
export type ImportRun = { id: string; started_at: string; finished_at: string | null; status: string; source_url: string; rows_seen: number; rows_created: number; rows_updated: number; error_summary: string | null };
|
export type ImportRun = { id: string; started_at: string; finished_at: string | null; status: string; source_url: string; rows_seen: number; rows_created: number; rows_updated: number; error_summary: string | null };
|
||||||
export type SourceStatus = { source_system: string; name: string; status: "healthy" | "stale" | "temporarily_limited" | "source_changed" | "waiting" | "disabled"; last_started_at: string | null; last_success_at: string | null; observations: number };
|
export type SourceStatus = { source_system: string; name: string; status: "healthy" | "stale" | "temporarily_limited" | "source_changed" | "waiting" | "disabled"; last_started_at: string | null; last_success_at: string | null; observations: number };
|
||||||
export type MediaAsset = { id: string; entity_type: "fish" | "waterbody" | "tackle" | "reference"; entity_key: string; label: string | null; width: number; height: number; content_type: string; image_url: string; source_system: string; source_url: string };
|
export type MediaVariant = { role: "card" | "detail"; format: "webp" | "avif"; width: number; height: number; url: string };
|
||||||
|
export type MediaRole = "waterbody_cover" | "waterbody_map" | "waterbody_depth_map" | "waterbody_screenshot" | "tackle_card" | "tackle_detail" | "rig_diagram" | "tackle_screenshot";
|
||||||
|
export type MediaAsset = { id: string; entity_type: "fish" | "waterbody" | "tackle" | "reference"; entity_key: string; media_role?: MediaRole | null; label: string | null; width: number; height: number; content_type: string; image_url: string; source_system: string; source_url: string; variants?: MediaVariant[] };
|
||||||
|
|
||||||
export const spotPath = (item: Pick<Activity, "waterbody_slug" | "x" | "y">) => `/spots/${item.waterbody_slug}-${item.x}x${item.y}`;
|
export const spotPath = (item: Pick<Activity, "waterbody_slug" | "x" | "y">) => `/spots/${item.waterbody_slug}-${item.x}x${item.y}`;
|
||||||
|
|
||||||
export { activityLevel, ago, kg, plural } from "./presentation";
|
export { activityLevel, ago, coordinatePrecisionLabel, freshnessStatus, kg, passportDisplayStatus, plural, sourceStatusLabel } from "./presentation";
|
||||||
|
|
||||||
const base = process.env.API_INTERNAL_URL || 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";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,17 @@
|
|||||||
import type { MediaAsset } from "./api";
|
import type { MediaAsset, MediaRole } from "./api";
|
||||||
|
|
||||||
|
const mediaRoleLabels: Record<MediaRole, string> = {
|
||||||
|
waterbody_cover: "Заставка водоёма",
|
||||||
|
waterbody_map: "Карта водоёма",
|
||||||
|
waterbody_depth_map: "Карта глубин",
|
||||||
|
waterbody_screenshot: "Скриншот водоёма",
|
||||||
|
tackle_card: "Карточка снасти",
|
||||||
|
tackle_detail: "Деталь снасти",
|
||||||
|
rig_diagram: "Схема сборки",
|
||||||
|
tackle_screenshot: "Скриншот снасти",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mediaRoleLabel = (role: string): string => mediaRoleLabels[role as MediaRole] ?? role;
|
||||||
|
|
||||||
const tokens = (value: string) => value
|
const tokens = (value: string) => value
|
||||||
.toLocaleLowerCase("ru")
|
.toLocaleLowerCase("ru")
|
||||||
@@ -9,13 +22,14 @@ const tokens = (value: string) => value
|
|||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
export const findMediaByLabel = (assets: MediaAsset[], label: string): MediaAsset | undefined => {
|
export const findMediaByLabel = (assets: MediaAsset[], label: string, allowedRoles?: readonly MediaRole[]): MediaAsset | undefined => {
|
||||||
|
const filtered = allowedRoles ? assets.filter((asset) => asset.media_role && allowedRoles.includes(asset.media_role)) : assets;
|
||||||
const wanted = tokens(label);
|
const wanted = tokens(label);
|
||||||
const exact = assets.filter((asset) => tokens(asset.label ?? "").join(" ") === wanted.join(" "));
|
const exact = filtered.filter((asset) => tokens(asset.label ?? "").join(" ") === wanted.join(" "));
|
||||||
if (exact.length === 1) return exact[0];
|
if (exact.length === 1) return exact[0];
|
||||||
|
|
||||||
const wantedSet = new Set(wanted);
|
const wantedSet = new Set(wanted);
|
||||||
const candidates = assets.filter((asset) => {
|
const candidates = filtered.filter((asset) => {
|
||||||
const available = new Set(tokens(asset.label ?? ""));
|
const available = new Set(tokens(asset.label ?? ""));
|
||||||
return wanted.every((token) => available.has(token)) || [...available].every((token) => wantedSet.has(token));
|
return wanted.every((token) => available.has(token)) || [...available].every((token) => wantedSet.has(token));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export type PageWindow = {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
page: number;
|
||||||
|
pages: number;
|
||||||
|
previousOffset: number | null;
|
||||||
|
nextOffset: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pageWindow = (total: number, limit: number, offset: number): PageWindow => {
|
||||||
|
const safeTotal = Math.max(0, Math.trunc(total));
|
||||||
|
const safeLimit = Math.max(1, Math.trunc(limit));
|
||||||
|
const safeOffset = Math.max(0, Math.trunc(offset));
|
||||||
|
const end = Math.min(safeOffset + safeLimit, safeTotal);
|
||||||
|
return {
|
||||||
|
start: safeTotal > 0 && safeOffset < safeTotal ? safeOffset + 1 : 0,
|
||||||
|
end,
|
||||||
|
page: Math.floor(safeOffset / safeLimit) + 1,
|
||||||
|
pages: Math.max(1, Math.ceil(safeTotal / safeLimit)),
|
||||||
|
previousOffset: safeOffset > 0 ? Math.max(0, safeOffset - safeLimit) : null,
|
||||||
|
nextOffset: end < safeTotal ? safeOffset + safeLimit : null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pageHref = (path: string, search: URLSearchParams, offset: number, anchor = "") => {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
if (offset > 0) params.set("offset", String(offset));
|
||||||
|
else params.delete("offset");
|
||||||
|
const query = params.toString();
|
||||||
|
return `${path}${query ? `?${query}` : ""}${anchor}`;
|
||||||
|
};
|
||||||
@@ -20,3 +20,33 @@ export function ago(value: string) {
|
|||||||
const minutes = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 60000));
|
const minutes = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 60000));
|
||||||
return minutes < 60 ? `${minutes} мин назад` : `${Math.floor(minutes / 60)} ч назад`;
|
return minutes < 60 ? `${minutes} мин назад` : `${Math.floor(minutes / 60)} ч назад`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function freshnessStatus(value: string | null | undefined, now = Date.now()): "fresh" | "stale" | "unknown" {
|
||||||
|
if (!value) return "unknown";
|
||||||
|
const timestamp = new Date(value).getTime();
|
||||||
|
if (!Number.isFinite(timestamp)) return "unknown";
|
||||||
|
return now - timestamp > 48 * 60 * 60 * 1000 ? "stale" : "fresh";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CoordinatePrecision = "exact" | "approximate" | "area" | "missing";
|
||||||
|
|
||||||
|
export function coordinatePrecisionLabel(value: CoordinatePrecision | string | null | undefined): string {
|
||||||
|
return ({ exact: "точные", approximate: "приблизительные", area: "район", missing: "не указаны" } as Record<string, string>)[value ?? ""] ?? "не указаны";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PassportStatus = "verified" | "unverified" | "incomplete" | "insufficient" | "blocked";
|
||||||
|
export type PassportDisplayStatus = PassportStatus | "conflict" | "stale";
|
||||||
|
|
||||||
|
export function passportDisplayStatus(status: PassportStatus, freshness: "fresh" | "stale" | "unknown", hasConflicts: boolean): PassportDisplayStatus {
|
||||||
|
if (hasConflicts) return "conflict";
|
||||||
|
if (status === "verified" && freshness === "stale") return "stale";
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sourceStatusLabel(status: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
healthy: "Актуален", stale: "Данные устарели", temporarily_limited: "Источник временно ограничен",
|
||||||
|
source_changed: "Источник изменился", waiting: "Ожидает запуска", disabled: "Выключен",
|
||||||
|
};
|
||||||
|
return labels[status] ?? "Состояние не определено";
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export function safeHttpUrl(value: string | null | undefined): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password || !url.hostname) return null;
|
||||||
|
return url.href;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
<label>Порядок<select name="order"><option value="risk">Сначала требуют внимания</option><option value="newest">Сначала новые</option><option value="oldest">Сначала старые</option></select></label>
|
<label>Порядок<select name="order"><option value="risk">Сначала требуют внимания</option><option value="newest">Сначала новые</option><option value="oldest">Сначала старые</option></select></label>
|
||||||
<button data-action="primary" type="submit">Применить</button>
|
<button data-action="primary" type="submit">Применить</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><div class="moderation-list" data-external-list aria-live="polite"></div>
|
<div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><label><input type="checkbox" data-external-select-all> Выбрать все записи на странице</label><div data-external-bulk hidden><strong data-external-bulk-count>0 выбрано</strong><label>Рыба<select data-external-bulk-fish><option value="">Выберите…</option></select></label><label>Водоём<select data-external-bulk-waterbody><option value="">Выберите…</option></select></label><textarea data-external-bulk-reason rows="1" maxlength="1000" placeholder="Примечание или причина отклонения"></textarea><button data-action="secondary" type="button" data-external-bulk-map>Сопоставить выбранные</button><button data-action="primary" type="button" data-external-bulk-publish>Опубликовать выбранные</button><button data-action="danger" type="button" data-external-bulk-reject>Отклонить выбранные</button></div><div class="moderation-list" data-external-list aria-live="polite"></div>
|
||||||
<nav data-pages aria-label="Страницы очереди" hidden>
|
<nav data-pages aria-label="Страницы очереди" hidden>
|
||||||
<button data-action="secondary" type="button" data-previous>Предыдущая</button>
|
<button data-action="secondary" type="button" data-previous>Предыдущая</button>
|
||||||
<span data-page-number aria-live="polite"></span>
|
<span data-page-number aria-live="polite"></span>
|
||||||
@@ -40,17 +40,28 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
|
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
|
||||||
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
|
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
|
||||||
const filters = document.querySelector<HTMLFormElement>(".admin-queue-filters");
|
const filters = document.querySelector<HTMLFormElement>(".admin-queue-filters");
|
||||||
|
const bulk = document.querySelector<HTMLElement>("[data-external-bulk]");
|
||||||
|
const selectAll = document.querySelector<HTMLInputElement>("[data-external-select-all]");
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
let token = ""; let fishes: Record<string, string>[] = []; let waters: Record<string, string>[] = [];
|
let token = ""; let fishes: Record<string, string>[] = []; let waters: Record<string, string>[] = [];
|
||||||
let sessionTimer: ReturnType<typeof setTimeout> | undefined;
|
let sessionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
const loadingCards = () => `<div class="loading-grid" aria-hidden="true">${Array.from({length:2}, () => '<div class="loading-card"><span class="loading-line loading-line--label"></span><span class="loading-line loading-line--title"></span><span class="loading-line"></span><span class="loading-line loading-line--short"></span></div>').join("")}</div><span class="sr-only">Загружаем внешние наблюдения</span>`;
|
const loadingCards = () => `<div class="loading-grid" aria-hidden="true">${Array.from({length:2}, () => '<div class="loading-card"><span class="loading-line loading-line--label"></span><span class="loading-line loading-line--title"></span><span class="loading-line"></span><span class="loading-line loading-line--short"></span></div>').join("")}</div><span class="sr-only">Загружаем внешние наблюдения</span>`;
|
||||||
const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading));
|
const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading));
|
||||||
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
||||||
const safeHttpUrl = (value: unknown) => { try { const url = new URL(String(value)); return url.protocol === "http:" || url.protocol === "https:" ? esc(url.href) : ""; } catch { return ""; } };
|
const safeHttpUrl = (value: unknown) => { try { const url = new URL(String(value)); return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password && url.hostname ? esc(url.href) : ""; } catch { return ""; } };
|
||||||
const fail = (message: string) => { if (status) status.hidden = true; if (error) { error.textContent = message; error.hidden = false; } };
|
const fail = (message: string) => { if (status) status.hidden = true; if (error) { error.textContent = message; error.hidden = false; } };
|
||||||
const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } };
|
const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } };
|
||||||
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (filters) filters.hidden = true; if (list) list.innerHTML = ""; pages?.setAttribute("hidden", ""); if (message) fail(message); };
|
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (filters) filters.hidden = true; if (list) list.innerHTML = ""; if (selectAll) { selectAll.checked = false; selectAll.indeterminate = false; } syncBulk(); pages?.setAttribute("hidden", ""); if (message) fail(message); };
|
||||||
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
|
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
|
||||||
|
const syncBulk = () => { const inputs = [...(list?.querySelectorAll<HTMLInputElement>("[data-external-select]") ?? [])]; const selected = inputs.filter(input => input.checked).length; if (bulk) bulk.hidden = selected === 0; if (selectAll) { selectAll.checked = inputs.length > 0 && selected === inputs.length; selectAll.indeterminate = selected > 0 && selected < inputs.length; } const count = bulk?.querySelector<HTMLElement>("[data-external-bulk-count]"); if (count) count.textContent = `${selected} выбрано`; };
|
||||||
|
const hydrateBulk = () => { list?.querySelectorAll<HTMLElement>("[data-observation-id]").forEach(card => { if (card.querySelector("[data-external-select]")) return; const label = document.createElement("label"); label.innerHTML = `<input type="checkbox" data-external-select aria-label="Выбрать наблюдение">`; card.prepend(label); }); const fishSelect = bulk?.querySelector<HTMLSelectElement>("[data-external-bulk-fish]"); const waterSelect = bulk?.querySelector<HTMLSelectElement>("[data-external-bulk-waterbody]"); if (fishSelect && fishSelect.options.length === 1 && fishes.length) fishSelect.innerHTML = '<option value="">Выберите…</option>' + options(fishes); if (waterSelect && waterSelect.options.length === 1 && waters.length) waterSelect.innerHTML = '<option value="">Выберите…</option>' + options(waters); syncBulk(); };
|
||||||
|
const selectedCards = () => [...(list?.querySelectorAll<HTMLInputElement>("[data-external-select]:checked") ?? [])].map(input => input.closest<HTMLElement>("[data-observation-id]")).filter((card): card is HTMLElement => Boolean(card));
|
||||||
|
const setBulkBusy = (busy: boolean) => bulk?.querySelectorAll<HTMLButtonElement>("button").forEach(button => { button.disabled = busy; });
|
||||||
|
bulk?.querySelector<HTMLButtonElement>("[data-external-bulk-map]")?.addEventListener("click", async () => { const cards = selectedCards(); const fish_slug = bulk.querySelector<HTMLSelectElement>("[data-external-bulk-fish]")?.value; const waterbody_slug = bulk.querySelector<HTMLSelectElement>("[data-external-bulk-waterbody]")?.value; if (!cards.length) return; if (!fish_slug || !waterbody_slug) { fail("Выберите каноническую рыбу и водоём."); return; } setBulkBusy(true); try { await json(`${root?.dataset.apiUrl}/api/v1/admin/external-observations/bulk-mapping`, {method:"PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:JSON.stringify({fish_slug,waterbody_slug,note:bulk.querySelector<HTMLTextAreaElement>("[data-external-bulk-reason]")?.value.trim() || null,items:cards.map(card => ({id:card.dataset.observationId,expected_version:Number(card.dataset.version)}))})}); await loadQueue(); succeed(`Сопоставлено наблюдений: ${cards.length}.`); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка пакетного сопоставления."); } finally { setBulkBusy(false); } });
|
||||||
|
bulk?.querySelector<HTMLButtonElement>("[data-external-bulk-publish]")?.addEventListener("click", async () => { const cards = selectedCards(); if (!cards.length) return; setBulkBusy(true); try { await json(`${root?.dataset.apiUrl}/api/v1/admin/external-observations/bulk-publish`, {method:"POST",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:JSON.stringify({items:cards.map(card => ({id:card.dataset.observationId,expected_version:Number(card.dataset.version)}))})}); await loadQueue(); succeed(`Опубликовано наблюдений: ${cards.length}.`); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка пакетной публикации."); } finally { setBulkBusy(false); } });
|
||||||
|
bulk?.querySelector<HTMLButtonElement>("[data-external-bulk-reject]")?.addEventListener("click", async () => { const cards = selectedCards(); const reason = bulk.querySelector<HTMLTextAreaElement>("[data-external-bulk-reason]")?.value.trim(); if (!cards.length) return; if (!reason) { fail("Укажите причину отклонения."); return; } setBulkBusy(true); try { await json(`${root?.dataset.apiUrl}/api/v1/admin/external-observations/bulk-reject`, {method:"PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:JSON.stringify({reason,items:cards.map(card => ({id:card.dataset.observationId,expected_version:Number(card.dataset.version)}))})}); await loadQueue(); succeed(`Отклонено наблюдений: ${cards.length}.`); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка пакетного отклонения."); } finally { setBulkBusy(false); } });
|
||||||
|
list?.addEventListener("change", event => { if ((event.target as HTMLElement).matches("[data-external-select]")) syncBulk(); });
|
||||||
|
selectAll?.addEventListener("change", () => { list?.querySelectorAll<HTMLInputElement>("[data-external-select]").forEach(input => input.checked = selectAll.checked); syncBulk(); });
|
||||||
const options = (items: Record<string, string>[], selected?: unknown) => items.map(item => `<option value="${esc(item.slug)}" ${item.slug === selected ? "selected" : ""}>${esc(item.name_ru)}</option>`).join("");
|
const options = (items: Record<string, string>[], selected?: unknown) => items.map(item => `<option value="${esc(item.slug)}" ${item.slug === selected ? "selected" : ""}>${esc(item.name_ru)}</option>`).join("");
|
||||||
async function json(url: string, init: RequestInit = {}) { const response = await fetch(url, init); if (response.status === 409) { await loadQueue(); throw new Error("Запись уже изменена в другой вкладке. Очередь обновлена."); } if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, `Запрос завершился ошибкой ${response.status}.`)); } if ((init.headers as Record<string, string> | undefined)?.Authorization) keepSession(); return response.json(); }
|
async function json(url: string, init: RequestInit = {}) { const response = await fetch(url, init); if (response.status === 409) { await loadQueue(); throw new Error("Запись уже изменена в другой вкладке. Очередь обновлена."); } if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, `Запрос завершился ошибкой ${response.status}.`)); } if ((init.headers as Record<string, string> | undefined)?.Authorization) keepSession(); return response.json(); }
|
||||||
async function loadQueue() {
|
async function loadQueue() {
|
||||||
@@ -68,6 +79,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`;
|
if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`;
|
||||||
if (!pending.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все внешние записи обработаны.</p></div>'; return; }
|
if (!pending.length) { list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все внешние записи обработаны.</p></div>'; return; }
|
||||||
list.innerHTML = pending.map((row: Record<string, unknown>) => { const complete = row.x != null && row.y != null && row.weight_g != null; const sourceUrl = safeHttpUrl(row.source_url); return `<article class="moderation-card" data-observation-id="${esc(row.id)}" data-version="${esc(row.moderation_version)}"><div class="moderation-summary"><span class="activity-pill"><i></i>${esc(row.source_system)} · ${esc(row.status)}</span><h2>${esc(row.fish_name)}</h2><p>${esc(row.waterbody_name)}</p><dl><div><dt>Координаты</dt><dd>${row.x == null || row.y == null ? "нет" : `${esc(row.x)}:${esc(row.y)}`}</dd></div><div><dt>Вес</dt><dd>${row.weight_g == null ? "нет" : `${esc(row.weight_g)} г`}</dd></div><div><dt>ID источника</dt><dd>${esc(row.source_external_id)}</dd></div><div><dt>Состояние</dt><dd>${complete ? "полная запись" : "неполная"}</dd></div></dl>${sourceUrl ? `<a href="${sourceUrl}" target="_blank" rel="noreferrer">Открыть первоисточник</a>` : '<span class="privacy">Ссылка источника имеет небезопасный формат</span>'}</div><div class="moderation-actions"><label>Каноническая рыба<select name="fish" required><option value="">Выберите…</option>${options(fishes, row.fish_slug)}</select></label><label>Канонический водоём<select name="waterbody" required><option value="">Выберите…</option>${options(waters, row.waterbody_slug)}</select></label><label>Примечание<textarea name="note" rows="2" maxlength="1000">${esc(row.review_note ?? "")}</textarea></label><p data-alias-message role="status"></p><div><button data-action="secondary" type="button" data-suggest>Подсказать соответствия</button><button data-action="secondary" type="button" data-map>Сопоставить</button><button data-action="primary" type="button" data-publish ${complete && row.status === "ready" ? "" : "disabled"}>Опубликовать</button><button data-action="danger" type="button" data-reject>Отклонить</button></div></div></article>`; }).join("");
|
list.innerHTML = pending.map((row: Record<string, unknown>) => { const complete = row.x != null && row.y != null && row.weight_g != null; const sourceUrl = safeHttpUrl(row.source_url); return `<article class="moderation-card" data-observation-id="${esc(row.id)}" data-version="${esc(row.moderation_version)}"><div class="moderation-summary"><span class="activity-pill"><i></i>${esc(row.source_system)} · ${esc(row.status)}</span><h2>${esc(row.fish_name)}</h2><p>${esc(row.waterbody_name)}</p><dl><div><dt>Координаты</dt><dd>${row.x == null || row.y == null ? "нет" : `${esc(row.x)}:${esc(row.y)}`}</dd></div><div><dt>Вес</dt><dd>${row.weight_g == null ? "нет" : `${esc(row.weight_g)} г`}</dd></div><div><dt>ID источника</dt><dd>${esc(row.source_external_id)}</dd></div><div><dt>Состояние</dt><dd>${complete ? "полная запись" : "неполная"}</dd></div></dl>${sourceUrl ? `<a href="${sourceUrl}" target="_blank" rel="noreferrer">Открыть первоисточник</a>` : '<span class="privacy">Ссылка источника имеет небезопасный формат</span>'}</div><div class="moderation-actions"><label>Каноническая рыба<select name="fish" required><option value="">Выберите…</option>${options(fishes, row.fish_slug)}</select></label><label>Канонический водоём<select name="waterbody" required><option value="">Выберите…</option>${options(waters, row.waterbody_slug)}</select></label><label>Примечание<textarea name="note" rows="2" maxlength="1000">${esc(row.review_note ?? "")}</textarea></label><p data-alias-message role="status"></p><div><button data-action="secondary" type="button" data-suggest>Подсказать соответствия</button><button data-action="secondary" type="button" data-map>Сопоставить</button><button data-action="primary" type="button" data-publish ${complete && row.status === "ready" ? "" : "disabled"}>Опубликовать</button><button data-action="danger" type="button" data-reject>Отклонить</button></div></div></article>`; }).join("");
|
||||||
|
hydrateBulk();
|
||||||
const payloadLabels: Record<string, string> = {bait:"Приманка",fishing_method:"Метод ловли",rig_type:"Оснастка",retrieve_method:"Проводка",retrieve_speed:"Скорость проводки",player_name:"Игрок",published_at:"Опубликовано источником",region:"Регион",category:"Категория"};
|
const payloadLabels: Record<string, string> = {bait:"Приманка",fishing_method:"Метод ловли",rig_type:"Оснастка",retrieve_method:"Проводка",retrieve_speed:"Скорость проводки",player_name:"Игрок",published_at:"Опубликовано источником",region:"Регион",category:"Категория"};
|
||||||
const missingLabels: Record<string, string> = {coordinates:"координаты",weight_g:"вес"};
|
const missingLabels: Record<string, string> = {coordinates:"координаты",weight_g:"вес"};
|
||||||
pending.forEach((row: Record<string, unknown>, index: number) => {
|
pending.forEach((row: Record<string, unknown>, index: number) => {
|
||||||
|
|||||||
@@ -25,20 +25,32 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
|
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
|
||||||
let token = "";
|
let token = "";
|
||||||
let sessionTimer: ReturnType<typeof setTimeout> | undefined;
|
let sessionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let importCooldownUntil = 0;
|
||||||
|
let importCooldownTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
let importHistoryOffset = 5;
|
||||||
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
||||||
const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } };
|
const fail = (message: string) => { if (error) { error.textContent = message; error.hidden = false; } };
|
||||||
const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } };
|
const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } };
|
||||||
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (content) content.innerHTML = ""; if (message) fail(message); };
|
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (content) content.innerHTML = ""; if (message) fail(message); };
|
||||||
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
|
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
|
||||||
|
const syncImportButton = () => { const button = content?.querySelector<HTMLButtonElement>("[data-official-import]"); if (!button) return; const seconds = Math.max(0, Math.ceil((importCooldownUntil - Date.now()) / 1000)); button.disabled = seconds > 0; button.textContent = seconds > 0 ? `Повторить через ${seconds < 60 ? `${seconds} с` : `${Math.ceil(seconds / 60)} мин`}` : "Запустить импорт"; button.setAttribute("aria-disabled", String(seconds > 0)); };
|
||||||
|
const startImportCooldown = (seconds: number) => { if (!Number.isFinite(seconds) || seconds <= 0) return; importCooldownUntil = Math.max(importCooldownUntil, Date.now() + Math.ceil(seconds) * 1000); if (importCooldownTimer) clearInterval(importCooldownTimer); syncImportButton(); importCooldownTimer = setInterval(() => { syncImportButton(); if (Date.now() >= importCooldownUntil && importCooldownTimer) { clearInterval(importCooldownTimer); importCooldownTimer = undefined; } }, 1000); };
|
||||||
|
const importStatusLabels: Record<string, string> = {running:"Выполняется",success:"Успешно",partial:"Частично",failed:"Ошибка"};
|
||||||
|
const importRow = (run: Record<string, unknown>) => { const state = String(run.status); const rows = Number(run.rows_seen ?? 0); const created = Number(run.rows_created ?? 0); const updated = Number(run.rows_updated ?? 0); const result = rows ? ` · ${rows} строк · +${created}/↻${updated}` : ""; const finished = run.finished_at ? ` · завершён ${new Date(String(run.finished_at)).toLocaleString("ru-RU")}` : ""; return `<li><span>${esc(importStatusLabels[state] ?? state)}<small>${esc(result)}</small></span><time>${esc(new Date(String(run.started_at)).toLocaleString("ru-RU"))}<small>${esc(finished)}</small></time></li>`; };
|
||||||
|
const ensureImportMoreButton = () => { const section = content?.querySelector<HTMLElement>(".admin-dashboard-grid section:nth-child(2)"); const list = section?.querySelector<HTMLUListElement>("ul"); if (!section || !list || list.children.length < 5 || section.querySelector("[data-import-more]")) return; const button = document.createElement("button"); button.type = "button"; button.dataset.action = "quiet"; button.dataset.importMore = "true"; button.textContent = "Показать ещё импорты"; list.after(button); };
|
||||||
|
window.setInterval(ensureImportMoreButton, 1000);
|
||||||
async function authorizedJson(path: string) { const response = await fetch(`${root?.dataset.apiUrl}${path}`, {headers:{Authorization:`Bearer ${token}`}}); if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось загрузить административные данные.")); } keepSession(); return response.json(); }
|
async function authorizedJson(path: string) { const response = await fetch(`${root?.dataset.apiUrl}${path}`, {headers:{Authorization:`Bearer ${token}`}}); if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось загрузить административные данные.")); } keepSession(); return response.json(); }
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
|
importHistoryOffset = 5;
|
||||||
error?.setAttribute("hidden", ""); status?.setAttribute("hidden", ""); content.setAttribute("aria-busy", "true"); content.innerHTML = '<div class="loading-grid" aria-hidden="true"><div class="loading-card"></div><div class="loading-card"></div></div>';
|
error?.setAttribute("hidden", ""); status?.setAttribute("hidden", ""); content.setAttribute("aria-busy", "true"); content.innerHTML = '<div class="loading-grid" aria-hidden="true"><div class="loading-card"></div><div class="loading-card"></div></div>';
|
||||||
const diagnostics = await authorizedJson("/api/v1/admin/diagnostics");
|
const diagnostics = await authorizedJson("/api/v1/admin/diagnostics");
|
||||||
const [imports, sources, history] = await Promise.all([authorizedJson("/api/v1/admin/imports?limit=5"), authorizedJson("/api/v1/admin/source-status"), authorizedJson("/api/v1/admin/moderation-history?limit=8")]);
|
const [imports, sources, history] = await Promise.all([authorizedJson("/api/v1/admin/imports?limit=5"), authorizedJson("/api/v1/admin/source-status"), authorizedJson("/api/v1/admin/moderation-history?limit=8")]);
|
||||||
const reports = diagnostics.counts?.catch_reports ?? {}; const observations = diagnostics.counts?.external_observations ?? {};
|
const reports = diagnostics.counts?.catch_reports ?? {}; const observations = diagnostics.counts?.external_observations ?? {};
|
||||||
const sourceLabels: Record<string, string> = {healthy:"Работает",waiting:"Ожидает",stale:"Устарел",disabled:"Отключён",source_changed:"Изменился",temporarily_limited:"Временно недоступен"};
|
const sourceLabels: Record<string, string> = {healthy:"Работает",waiting:"Ожидает",stale:"Устарел",disabled:"Отключён",source_changed:"Изменился",temporarily_limited:"Временно недоступен"};
|
||||||
const sourceRows = (sources as Record<string, unknown>[]).map(source => { const state = String(source.status); const safeState = Object.hasOwn(sourceLabels, state) ? state : "waiting"; const cooldown = Number(source.cooldown_seconds ?? 0); const detail = cooldown > 0 ? ` · cooldown ${Math.ceil(cooldown / 60)} мин` : source.backoff_recommended ? " · backoff рекомендован" : ""; const success = source.last_success_at ? ` · успех ${new Date(String(source.last_success_at)).toLocaleString("ru-RU")}` : " · успешных запусков нет"; return `<li><span><i class="status-dot ${safeState}"></i>${esc(source.name)}<small>${esc(detail + success)}</small></span><strong>${sourceLabels[safeState]}</strong></li>`; }).join("");
|
const sourceRows = (sources as Record<string, unknown>[]).map(source => { const state = String(source.status); const safeState = Object.hasOwn(sourceLabels, state) ? state : "waiting"; const cooldown = Number(source.cooldown_seconds ?? 0); const detail = cooldown > 0 ? ` · cooldown ${Math.ceil(cooldown / 60)} мин` : source.backoff_recommended ? " · backoff рекомендован" : ""; const success = source.last_success_at ? ` · успех ${new Date(String(source.last_success_at)).toLocaleString("ru-RU")}` : " · успешных запусков нет"; return `<li><span><i class="status-dot ${safeState}"></i>${esc(source.name)}<small>${esc(detail + success)}</small></span><strong>${sourceLabels[safeState]}</strong></li>`; }).join("");
|
||||||
|
const savedCooldown = Math.max(0, ...(sources as Record<string, unknown>[]).map(source => Number(source.cooldown_seconds ?? 0)).filter(Number.isFinite));
|
||||||
|
if (savedCooldown > 0) startImportCooldown(savedCooldown);
|
||||||
const importLabels: Record<string, string> = {running:"Выполняется",success:"Успешно",partial:"Частично",failed:"Ошибка"};
|
const importLabels: Record<string, string> = {running:"Выполняется",success:"Успешно",partial:"Частично",failed:"Ошибка"};
|
||||||
const importRows = (imports as Record<string, unknown>[]).map(run => { const status = String(run.status); const rows = Number(run.rows_seen ?? 0); const created = Number(run.rows_created ?? 0); const updated = Number(run.rows_updated ?? 0); const result = rows ? ` · ${rows} строк · +${created}/↻${updated}` : ""; const finished = run.finished_at ? ` · завершён ${new Date(String(run.finished_at)).toLocaleString("ru-RU")}` : ""; return `<li><span>${esc(importLabels[status] ?? status)}<small>${esc(result)}</small></span><time>${esc(new Date(String(run.started_at)).toLocaleString("ru-RU"))}<small>${esc(finished)}</small></time></li>`; }).join("");
|
const importRows = (imports as Record<string, unknown>[]).map(run => { const status = String(run.status); const rows = Number(run.rows_seen ?? 0); const created = Number(run.rows_created ?? 0); const updated = Number(run.rows_updated ?? 0); const result = rows ? ` · ${rows} строк · +${created}/↻${updated}` : ""; const finished = run.finished_at ? ` · завершён ${new Date(String(run.finished_at)).toLocaleString("ru-RU")}` : ""; return `<li><span>${esc(importLabels[status] ?? status)}<small>${esc(result)}</small></span><time>${esc(new Date(String(run.started_at)).toLocaleString("ru-RU"))}<small>${esc(finished)}</small></time></li>`; }).join("");
|
||||||
const actionLabels: Record<string, string> = {approved:"Одобрено",rejected:"Отклонено",pending:"Возвращено на проверку",published:"Опубликовано",mapped:"Сопоставлено",ready:"Готово"};
|
const actionLabels: Record<string, string> = {approved:"Одобрено",rejected:"Отклонено",pending:"Возвращено на проверку",published:"Опубликовано",mapped:"Сопоставлено",ready:"Готово"};
|
||||||
@@ -46,8 +58,39 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
const historyRows = (history as Record<string, unknown>[]).map(event => { const action = String(event.action); const type = String(event.entity_type); return `<li><span><b>${esc(typeLabels[type] ?? "Запись")}</b> · ${esc(actionLabels[action] ?? action)}${event.reason ? `<small>${esc(event.reason)}</small>` : ""}</span><time>${esc(new Date(String(event.decided_at)).toLocaleString("ru-RU"))}</time></li>`; }).join("");
|
const historyRows = (history as Record<string, unknown>[]).map(event => { const action = String(event.action); const type = String(event.entity_type); return `<li><span><b>${esc(typeLabels[type] ?? "Запись")}</b> · ${esc(actionLabels[action] ?? action)}${event.reason ? `<small>${esc(event.reason)}</small>` : ""}</span><time>${esc(new Date(String(event.decided_at)).toLocaleString("ru-RU"))}</time></li>`; }).join("");
|
||||||
content.removeAttribute("aria-busy"); content.innerHTML = `<div class="admin-kpis"><a href="/admin/moderation"><span>Уловы на проверке</span><strong>${esc(reports.pending ?? 0)}</strong><small>Открыть очередь →</small></a><a href="/admin/external-sources"><span>Наблюдения в staging</span><strong>${esc((observations.staged ?? 0) + (observations.mapped ?? 0) + (observations.ready ?? 0))}</strong><small>Проверить источники →</small></a><article><span>Одобрено уловов</span><strong>${esc(reports.approved ?? 0)}</strong><small>Участвуют в статистике</small></article><article><span>Источников включено</span><strong>${esc(diagnostics.counts?.enabled_data_sources ?? 0)}</strong><small>из ${esc(diagnostics.counts?.data_sources ?? 0)}</small></article></div><div class="admin-dashboard-grid"><section><div class="admin-section-head"><h2>Состояние источников</h2><button type="button" data-action="secondary" data-refresh>Обновить</button></div><ul>${sourceRows || "<li>Нет данных</li>"}</ul><a href="/status">Публичная страница состояния →</a><br /><a href="/admin/media">Проверить медиа →</a></section><section><div class="admin-section-head"><h2>Последние импорты</h2><button type="button" data-action="secondary" data-official-import>Запустить импорт</button></div><ul>${importRows || "<li>Запусков пока нет</li>"}</ul><p class="privacy">Импорт обращается к официальному источнику и соблюдает cooldown.</p></section><section class="admin-history"><div class="admin-section-head"><h2>Последние решения</h2><button type="button" data-action="secondary" data-history-export>Экспорт JSON</button></div><ul>${historyRows || "<li>Решений пока нет</li>"}</ul><p class="privacy">Экспорт обезличен: без UUID, модератора, причин и исходных данных.</p></section></div>`;
|
content.removeAttribute("aria-busy"); content.innerHTML = `<div class="admin-kpis"><a href="/admin/moderation"><span>Уловы на проверке</span><strong>${esc(reports.pending ?? 0)}</strong><small>Открыть очередь →</small></a><a href="/admin/external-sources"><span>Наблюдения в staging</span><strong>${esc((observations.staged ?? 0) + (observations.mapped ?? 0) + (observations.ready ?? 0))}</strong><small>Проверить источники →</small></a><article><span>Одобрено уловов</span><strong>${esc(reports.approved ?? 0)}</strong><small>Участвуют в статистике</small></article><article><span>Источников включено</span><strong>${esc(diagnostics.counts?.enabled_data_sources ?? 0)}</strong><small>из ${esc(diagnostics.counts?.data_sources ?? 0)}</small></article></div><div class="admin-dashboard-grid"><section><div class="admin-section-head"><h2>Состояние источников</h2><button type="button" data-action="secondary" data-refresh>Обновить</button></div><ul>${sourceRows || "<li>Нет данных</li>"}</ul><a href="/status">Публичная страница состояния →</a><br /><a href="/admin/media">Проверить медиа →</a></section><section><div class="admin-section-head"><h2>Последние импорты</h2><button type="button" data-action="secondary" data-official-import>Запустить импорт</button></div><ul>${importRows || "<li>Запусков пока нет</li>"}</ul><p class="privacy">Импорт обращается к официальному источнику и соблюдает cooldown.</p></section><section class="admin-history"><div class="admin-section-head"><h2>Последние решения</h2><button type="button" data-action="secondary" data-history-export>Экспорт JSON</button></div><ul>${historyRows || "<li>Решений пока нет</li>"}</ul><p class="privacy">Экспорт обезличен: без UUID, модератора, причин и исходных данных.</p></section></div>`;
|
||||||
}
|
}
|
||||||
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadDashboard(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; } catch (cause) { if (content) { content.removeAttribute("aria-busy"); content.innerHTML = ""; } fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
|
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadDashboard(); ensureImportMoreButton(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; } catch (cause) { if (content) { content.removeAttribute("aria-busy"); content.innerHTML = ""; } fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
|
||||||
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
|
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
|
||||||
|
content?.addEventListener("click", async event => {
|
||||||
|
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-official-import]");
|
||||||
|
if (!button) return;
|
||||||
|
event.preventDefault(); event.stopImmediatePropagation();
|
||||||
|
if (Date.now() < importCooldownUntil) { syncImportButton(); return; }
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/imports/official-records`, {method:"POST",headers:{Authorization:`Bearer ${token}`} });
|
||||||
|
if (!response.ok) {
|
||||||
|
const retryAfter = Number(response.headers.get("Retry-After") ?? 0);
|
||||||
|
if (response.status === 429 && retryAfter > 0) startImportCooldown(retryAfter);
|
||||||
|
if (adminEndsSession(response.status)) endSession();
|
||||||
|
throw new Error(response.status === 429 ? "Источник на cooldown. Повторный запуск временно заблокирован." : response.status === 502 ? "Официальный источник временно недоступен. Старые данные сохранены." : adminErrorMessage(response.status, "Не удалось запустить импорт."));
|
||||||
|
}
|
||||||
|
keepSession(); await loadDashboard(); succeed("Импорт запущен. Список запусков обновлён.");
|
||||||
|
} catch (cause) { fail(cause instanceof Error ? cause.message : "Операция не выполнена."); }
|
||||||
|
finally { syncImportButton(); }
|
||||||
|
}, true);
|
||||||
|
content?.addEventListener("click", async event => {
|
||||||
|
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-import-more]");
|
||||||
|
if (!button) return;
|
||||||
|
event.preventDefault(); event.stopImmediatePropagation(); button.disabled = true;
|
||||||
|
try {
|
||||||
|
const rows = await authorizedJson(`/api/v1/admin/imports?limit=5&offset=${importHistoryOffset}`) as Record<string, unknown>[];
|
||||||
|
const list = content?.querySelector<HTMLUListElement>(".admin-dashboard-grid section:nth-child(2) ul");
|
||||||
|
if (list) list.insertAdjacentHTML("beforeend", rows.map(importRow).join(""));
|
||||||
|
importHistoryOffset += rows.length;
|
||||||
|
if (rows.length < 5) button.remove(); else button.disabled = false;
|
||||||
|
keepSession();
|
||||||
|
} catch (cause) { button.disabled = false; fail(cause instanceof Error ? cause.message : "Не удалось загрузить историю импортов."); }
|
||||||
|
}, true);
|
||||||
content?.addEventListener("click", async event => {
|
content?.addEventListener("click", async event => {
|
||||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-history-export],[data-official-import],[data-refresh]"); if (!button) return;
|
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("[data-history-export],[data-official-import],[data-refresh]"); if (!button) return;
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
|
|||||||
@@ -8,14 +8,15 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
<section class="moderation-app" data-api-url={apiUrl}>
|
<section class="moderation-app" data-api-url={apiUrl}>
|
||||||
<AdminNav />
|
<AdminNav />
|
||||||
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть медиатеку</button></form>
|
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть медиатеку</button></form>
|
||||||
<p class="privacy">Публичные файлы не переключаются из этого экрана. Токен хранится только в памяти страницы.</p>
|
<p class="privacy">Публикация требует явной причины и атомарно сохраняет fallback для rollback. Токен хранится только в памяти страницы.</p>
|
||||||
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
|
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
|
||||||
<form class="admin-queue-filters" hidden><label>Тип<select name="entity_type"><option value="">Все типы</option><option value="fish">Рыбы</option><option value="waterbody">Водоёмы</option><option value="tackle">Снасти</option><option value="reference">Справка</option></select></label><label>Состояние<select name="status"><option value="">Все состояния</option><option value="approved">Approved</option><option value="upgrade_queued">Upgrade queued</option><option value="upgrade_stored">Upgrade stored</option></select></label><button data-action="primary" type="submit">Применить</button></form>
|
<form class="admin-queue-filters" hidden><label>Поиск по подписи<input name="q" maxlength="100" placeholder="Например, щука или Вьюнок" /></label><label>Тип<select name="entity_type"><option value="">Все типы</option><option value="fish">Рыбы</option><option value="waterbody">Водоёмы</option><option value="tackle">Снасти</option><option value="reference">Справка</option></select></label><label>Роль<select name="media_role"><option value="">Все роли</option><option value="waterbody_cover">Заставка водоёма</option><option value="waterbody_map">Карта водоёма</option><option value="waterbody_depth_map">Карта глубин</option><option value="waterbody_screenshot">Скриншот водоёма</option><option value="tackle_card">Карточка снасти</option><option value="tackle_detail">Деталь снасти</option><option value="rig_diagram">Схема сборки</option><option value="tackle_screenshot">Скриншот снасти</option></select></label><label>Состояние<select name="status"><option value="">Все состояния</option><option value="approved">Approved</option><option value="upgrade_queued">Upgrade queued</option><option value="upgrade_stored">Upgrade stored</option></select></label><button data-action="primary" type="submit">Применить</button></form>
|
||||||
<div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><section class="media-library__grid" data-media-list aria-live="polite"></section>
|
<div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><section class="media-library__grid" data-media-list aria-live="polite"></section>
|
||||||
<nav data-pages aria-label="Страницы медиатеки" hidden><button data-action="secondary" type="button" data-previous>Предыдущая</button><span data-page-number aria-live="polite"></span><button data-action="secondary" type="button" data-next>Следующая</button><button data-action="secondary" type="button" data-refresh>Обновить</button></nav>
|
<nav data-pages aria-label="Страницы медиатеки" hidden><button data-action="secondary" type="button" data-previous>Предыдущая</button><span data-page-number aria-live="polite"></span><button data-action="secondary" type="button" data-next>Следующая</button><button data-action="secondary" type="button" data-refresh>Обновить</button></nav>
|
||||||
</section>
|
</section>
|
||||||
<script>
|
<script>
|
||||||
import { adminEndsSession, adminErrorMessage } from "../../lib/admin-errors";
|
import { adminEndsSession, adminErrorMessage } from "../../lib/admin-errors";
|
||||||
|
import { mediaRoleLabel } from "../../lib/media";
|
||||||
const root = document.querySelector<HTMLElement>("[data-api-url]");
|
const root = document.querySelector<HTMLElement>("[data-api-url]");
|
||||||
const login = document.querySelector<HTMLFormElement>(".admin-login");
|
const login = document.querySelector<HTMLFormElement>(".admin-login");
|
||||||
const filters = document.querySelector<HTMLFormElement>(".admin-queue-filters");
|
const filters = document.querySelector<HTMLFormElement>(".admin-queue-filters");
|
||||||
@@ -29,9 +30,13 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
const next = document.querySelector<HTMLButtonElement>("[data-next]");
|
const next = document.querySelector<HTMLButtonElement>("[data-next]");
|
||||||
const refresh = document.querySelector<HTMLButtonElement>("[data-refresh]");
|
const refresh = document.querySelector<HTMLButtonElement>("[data-refresh]");
|
||||||
const pageNumber = document.querySelector<HTMLElement>("[data-page-number]");
|
const pageNumber = document.querySelector<HTMLElement>("[data-page-number]");
|
||||||
|
const entityFilter = filters?.querySelector<HTMLSelectElement>('[name="entity_type"]');
|
||||||
|
const roleFilter = filters?.querySelector<HTMLSelectElement>('[name="media_role"]');
|
||||||
|
const rolesByEntity: Record<string, Set<string>> = { waterbody: new Set(["waterbody_cover", "waterbody_map", "waterbody_depth_map", "waterbody_screenshot"]), tackle: new Set(["tackle_card", "tackle_detail", "rig_diagram", "tackle_screenshot"]) };
|
||||||
|
const syncRoleFilter = () => { const allowed = rolesByEntity[entityFilter?.value ?? ""] ?? new Set<string>(); if (roleFilter) { [...roleFilter.options].forEach(option => { if (!option.value) return; option.hidden = !allowed.has(option.value); option.disabled = !allowed.has(option.value); }); if (roleFilter.value && !allowed.has(roleFilter.value)) roleFilter.value = ""; roleFilter.disabled = allowed.size === 0; } };
|
||||||
let token = ""; let offset = 0; let timer: ReturnType<typeof setTimeout> | undefined;
|
let token = ""; let offset = 0; let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
||||||
const url = (value: unknown) => { try { const parsed = new URL(String(value), root?.dataset.apiUrl); return parsed.protocol === "http:" || parsed.protocol === "https:" ? esc(parsed.href) : ""; } catch { return ""; } };
|
const url = (value: unknown) => { try { const parsed = new URL(String(value), root?.dataset.apiUrl); return (parsed.protocol === "http:" || parsed.protocol === "https:") && !parsed.username && !parsed.password && parsed.hostname ? esc(parsed.href) : ""; } catch { return ""; } };
|
||||||
const fail = (message: string) => { if (status) status.hidden = true; if (error) { error.textContent = message; error.hidden = false; } };
|
const fail = (message: string) => { if (status) status.hidden = true; if (error) { error.textContent = message; error.hidden = false; } };
|
||||||
const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } };
|
const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } };
|
||||||
const endSession = (message?: string) => { token = ""; if (timer) clearTimeout(timer); timer = undefined; if (login) { login.hidden = false; login.reset(); } if (filters) filters.hidden = true; if (sessionBar) sessionBar.hidden = true; if (list) list.innerHTML = ""; pages?.setAttribute("hidden", ""); if (message) fail(message); };
|
const endSession = (message?: string) => { token = ""; if (timer) clearTimeout(timer); timer = undefined; if (login) { login.hidden = false; login.reset(); } if (filters) filters.hidden = true; if (sessionBar) sessionBar.hidden = true; if (list) list.innerHTML = ""; pages?.setAttribute("hidden", ""); if (message) fail(message); };
|
||||||
@@ -40,19 +45,23 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
async function load() {
|
async function load() {
|
||||||
if (!root || !list || !filters) return;
|
if (!root || !list || !filters) return;
|
||||||
error?.setAttribute("hidden", ""); list.innerHTML = loading(); list.setAttribute("aria-busy", "true");
|
error?.setAttribute("hidden", ""); list.innerHTML = loading(); list.setAttribute("aria-busy", "true");
|
||||||
const values = new FormData(filters); const params = new URLSearchParams({limit:"51", offset:String(offset)}); for (const key of ["entity_type", "status"]) { const value = String(values.get(key) || ""); if (value) params.set(key, value); }
|
const values = new FormData(filters); const params = new URLSearchParams({limit:"51", offset:String(offset)}); for (const key of ["q", "entity_type", "media_role", "status"]) { const value = String(values.get(key) || "").trim(); if (value) params.set(key, value); }
|
||||||
const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/media/catalog?${params}`, {headers:{Authorization:`Bearer ${token}`} });
|
const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/media/catalog?${params}`, {headers:{Authorization:`Bearer ${token}`} });
|
||||||
if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось загрузить медиатеку.")); }
|
if (!response.ok) { if (adminEndsSession(response.status)) endSession(); throw new Error(adminErrorMessage(response.status, "Не удалось загрузить медиатеку.")); }
|
||||||
const rows: Record<string, unknown>[] = await response.json(); keepSession(); list.removeAttribute("aria-busy");
|
const rows: Record<string, unknown>[] = await response.json(); keepSession(); list.removeAttribute("aria-busy");
|
||||||
if (!rows.length && offset > 0) { offset = 0; return load(); }
|
if (!rows.length && offset > 0) { offset = 0; return load(); }
|
||||||
const assets = rows.slice(0, 50); if (pages) pages.hidden = !assets.length; if (previous) previous.disabled = offset === 0; if (next) next.disabled = rows.length <= 50; if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`;
|
const assets = rows.slice(0, 50); if (pages) pages.hidden = !assets.length; if (previous) previous.disabled = offset === 0; if (next) next.disabled = rows.length <= 50; if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`;
|
||||||
if (!assets.length) { list.innerHTML = '<div class="state"><h2>Кандидатов нет</h2><p>Для выбранных фильтров нет approved или upgrade_queued файлов.</p></div>'; return; }
|
if (!assets.length) { list.innerHTML = '<div class="state"><h2>Кандидатов нет</h2><p>Для выбранных фильтров нет approved или upgrade_queued файлов.</p></div>'; return; }
|
||||||
list.innerHTML = assets.map(asset => { const image = url(asset.image_url); const source = url(asset.source_url); const variants = (asset.derivatives as Record<string, unknown>[] ?? []).map(item => `${esc(item.format)} ${esc(item.width)}×${esc(item.height)}`).join(", "); return `<article class="media-library__card"><a href="${image}" target="_blank" rel="noreferrer">${image ? `<img src="${image}" alt="${esc(asset.label)}" loading="lazy" />` : ""}</a><h2>${esc(asset.label)}</h2><span>${esc(asset.status)} · ${esc(asset.entity_type)} · ${esc(asset.width)}×${esc(asset.height)}</span><p>${esc(asset.source_system)}${asset.duplicate_of ? ` · duplicate_of ${esc(asset.duplicate_of)}` : ""}</p>${variants ? `<small>Производные: ${variants}</small>` : "<small>Производных нет</small>"}${source ? `<a href="${source}" target="_blank" rel="noreferrer">Первоисточник →</a>` : ""}</article>`; }).join("");
|
list.innerHTML = assets.map(asset => { const image = url(asset.image_url); const source = url(asset.source_url); const role = String(asset.media_role || ""); const variants = (asset.derivatives as Record<string, unknown>[] ?? []).map(item => `${esc(item.format)} ${esc(item.width)}×${esc(item.height)}`).join(", "); const rollback = asset.status === "approved" && asset.supersedes ? `<button data-media-rollback="${esc(asset.asset_url)}" type="button" data-action="secondary">Откатить замену</button>` : ""; return `<article class="media-library__card"${role ? ` data-media-role="${esc(role)}"` : ""}><a href="${image}" target="_blank" rel="noreferrer">${image ? `<img src="${image}" alt="${esc(asset.label)}" loading="lazy" />` : ""}</a><h2>${esc(asset.label)}</h2><span>${esc(asset.status)} · ${esc(asset.entity_type)} · ${esc(asset.width)}×${esc(asset.height)}</span>${role ? `<small>${esc(mediaRoleLabel(role))}</small>` : ""}<p>${esc(asset.source_system)}${asset.duplicate_of ? ` · duplicate_of ${esc(asset.duplicate_of)}` : ""}</p>${variants ? `<small>Производные: ${variants}</small>` : "<small>Производных нет</small>"}${source ? `<a href="${source}" target="_blank" rel="noreferrer">Первоисточник →</a>` : ""}${rollback}</article>`; }).join("");
|
||||||
|
list.querySelectorAll<HTMLButtonElement>("[data-media-rollback]").forEach(button => button.addEventListener("click", async () => { const note = window.prompt("Причина отката:"); if (!note?.trim()) return; button.disabled = true; try { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/media/upgrades/rollback`, {method:"POST", headers:{Authorization:`Bearer ${token}`, "Content-Type":"application/json"}, body:JSON.stringify({asset_url:button.dataset.mediaRollback, note:note.trim()})}); if (!response.ok) throw new Error(adminErrorMessage(response.status, "Не удалось выполнить откат.")); succeed("Fallback восстановлен."); await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось выполнить откат."); } finally { button.disabled = false; } }));
|
||||||
|
if (assets.some(asset => asset.status === "upgrade_stored")) { const publish = document.createElement("button"); publish.type = "button"; publish.dataset.action = "primary"; publish.textContent = "Опубликовать сохранённые замены"; publish.addEventListener("click", async () => { const note = window.prompt("Причина публикации замен:"); if (!note?.trim()) return; publish.disabled = true; try { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/media/upgrades/publish`, {method:"POST", headers:{Authorization:`Bearer ${token}`, "Content-Type":"application/json"}, body:JSON.stringify({note:note.trim()})}); if (!response.ok) throw new Error(adminErrorMessage(response.status, "Не удалось опубликовать замены.")); succeed("Замены опубликованы атомарно, fallback сохранены."); await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось опубликовать замены."); } finally { publish.disabled = false; } }); list.prepend(publish); }
|
||||||
}
|
}
|
||||||
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); offset = 0; try { await load(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; if (filters) filters.hidden = false; } catch (cause) { list && (list.innerHTML = ""); fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
|
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); offset = 0; try { await load(); login.hidden = true; if (sessionBar) sessionBar.hidden = false; if (filters) filters.hidden = false; } catch (cause) { list && (list.innerHTML = ""); fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
|
||||||
filters?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; try { await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка фильтрации."); } });
|
filters?.addEventListener("submit", async event => { event.preventDefault(); offset = 0; try { await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка фильтрации."); } });
|
||||||
const move = async (delta: number) => { offset = Math.max(0, offset + delta); try { await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка загрузки страницы."); } };
|
const move = async (delta: number) => { offset = Math.max(0, offset + delta); try { await load(); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка загрузки страницы."); } };
|
||||||
previous?.addEventListener("click", () => move(-50)); next?.addEventListener("click", () => move(50)); refresh?.addEventListener("click", async () => { refresh.disabled = true; try { await load(); succeed("Медиатека обновлена."); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось обновить медиатеку."); } finally { refresh.disabled = false; } });
|
previous?.addEventListener("click", () => move(-50)); next?.addEventListener("click", () => move(50)); refresh?.addEventListener("click", async () => { refresh.disabled = true; try { await load(); succeed("Медиатека обновлена."); } catch (cause) { fail(cause instanceof Error ? cause.message : "Не удалось обновить медиатеку."); } finally { refresh.disabled = false; } });
|
||||||
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
|
logout?.addEventListener("click", () => endSession("Вы вышли из административной панели."));
|
||||||
|
entityFilter?.addEventListener("change", syncRoleFilter);
|
||||||
|
syncRoleFilter();
|
||||||
</script>
|
</script>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть очередь</button></form>
|
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button data-action="primary" type="submit">Открыть очередь</button></form>
|
||||||
<p class="privacy">Токен хранится только в памяти страницы и не записывается в URL или localStorage.</p>
|
<p class="privacy">Токен хранится только в памяти страницы и не записывается в URL или localStorage.</p>
|
||||||
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
|
<div class="admin-session-bar" hidden><span>Административная сессия активна</span><button data-action="secondary" type="button" data-admin-logout>Выйти</button></div>
|
||||||
<div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><div class="moderation-list" data-moderation-list aria-live="polite"></div>
|
<div class="notice error" data-admin-error role="alert" hidden></div><div class="notice success" data-admin-status role="status" hidden></div><label class="queue-select-all"><input type="checkbox" data-bulk-select-all> Выбрать все записи на странице</label><div class="moderation-bulk" data-bulk hidden><strong data-bulk-count>0 выбрано</strong><label>Причина отклонения<textarea data-bulk-reason rows="1" maxlength="1000"></textarea></label><button data-action="primary" type="button" data-bulk-approve>Одобрить выбранные</button><button data-action="danger" type="button" data-bulk-reject>Отклонить выбранные</button></div><div class="moderation-list" data-moderation-list aria-live="polite"></div>
|
||||||
<nav data-pages aria-label="Страницы очереди" hidden><button data-action="secondary" type="button" data-previous>Предыдущая</button><span data-page-number aria-live="polite"></span><button data-action="secondary" type="button" data-next>Следующая</button><button data-action="secondary" type="button" data-refresh>Обновить</button></nav>
|
<nav data-pages aria-label="Страницы очереди" hidden><button data-action="secondary" type="button" data-previous>Предыдущая</button><span data-page-number aria-live="polite"></span><button data-action="secondary" type="button" data-next>Следующая</button><button data-action="secondary" type="button" data-refresh>Обновить</button></nav>
|
||||||
<p class="admin-shortcuts"><kbd>A</kbd> одобрить карточку с фокусом · отклонение и удаление — только кнопками</p>
|
<p class="admin-shortcuts"><kbd>A</kbd> одобрить карточку с фокусом · отклонение и удаление — только кнопками</p>
|
||||||
</section>
|
</section>
|
||||||
@@ -28,17 +28,27 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
|||||||
const pageNumber = document.querySelector<HTMLElement>("[data-page-number]");
|
const pageNumber = document.querySelector<HTMLElement>("[data-page-number]");
|
||||||
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
|
const sessionBar = document.querySelector<HTMLElement>(".admin-session-bar");
|
||||||
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
|
const logout = document.querySelector<HTMLButtonElement>("[data-admin-logout]");
|
||||||
|
const bulk = document.querySelector<HTMLElement>("[data-bulk]");
|
||||||
|
const selectAll = document.querySelector<HTMLInputElement>("[data-bulk-select-all]");
|
||||||
let token = "";
|
let token = "";
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
let sessionTimer: ReturnType<typeof setTimeout> | undefined;
|
let sessionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
const loadingCards = () => `<div class="loading-grid" aria-hidden="true">${Array.from({length:2}, () => '<div class="loading-card"><span class="loading-line loading-line--label"></span><span class="loading-line loading-line--title"></span><span class="loading-line"></span><span class="loading-line loading-line--short"></span></div>').join("")}</div><span class="sr-only">Загружаем очередь модерации</span>`;
|
const loadingCards = () => `<div class="loading-grid" aria-hidden="true">${Array.from({length:2}, () => '<div class="loading-card"><span class="loading-line loading-line--label"></span><span class="loading-line loading-line--title"></span><span class="loading-line"></span><span class="loading-line loading-line--short"></span></div>').join("")}</div><span class="sr-only">Загружаем очередь модерации</span>`;
|
||||||
const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading));
|
const setLoading = (loading: boolean) => list?.setAttribute("aria-busy", String(loading));
|
||||||
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
const esc = (value: unknown) => String(value ?? "—").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char] ?? char));
|
||||||
const safeHttpUrl = (value: unknown) => { try { const url = new URL(String(value)); return url.protocol === "http:" || url.protocol === "https:" ? esc(url.href) : ""; } catch { return ""; } };
|
const safeHttpUrl = (value: unknown) => { try { const url = new URL(String(value)); return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password && url.hostname ? esc(url.href) : ""; } catch { return ""; } };
|
||||||
const fail = (message: string) => { if (status) status.hidden = true; if (error) { error.textContent = message; error.hidden = false; } };
|
const fail = (message: string) => { if (status) status.hidden = true; if (error) { error.textContent = message; error.hidden = false; } };
|
||||||
const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } };
|
const succeed = (message: string) => { if (error) error.hidden = true; if (status) { status.textContent = message; status.hidden = false; } };
|
||||||
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (list) list.innerHTML = ""; if (message) fail(message); };
|
const endSession = (message?: string) => { token = ""; if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = undefined; if (login) { login.hidden = false; login.reset(); } if (sessionBar) sessionBar.hidden = true; if (list) list.innerHTML = ""; if (selectAll) { selectAll.checked = false; selectAll.indeterminate = false; } syncBulk(); if (message) fail(message); };
|
||||||
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
|
const keepSession = () => { if (sessionTimer) clearTimeout(sessionTimer); sessionTimer = setTimeout(() => endSession("Сессия завершена после 15 минут бездействия. Введите токен снова."), 15 * 60 * 1000); };
|
||||||
|
const syncBulk = () => { const inputs = [...(list?.querySelectorAll<HTMLInputElement>("[data-bulk-select]") ?? [])]; const cards = inputs.filter(input => input.checked); if (bulk) bulk.hidden = cards.length === 0; if (selectAll) { selectAll.checked = inputs.length > 0 && cards.length === inputs.length; selectAll.indeterminate = cards.length > 0 && cards.length < inputs.length; } const count = bulk?.querySelector<HTMLElement>("[data-bulk-count]"); if (count) count.textContent = `${cards.length} выбрано`; };
|
||||||
|
const hydrateBulk = () => { list?.querySelectorAll<HTMLElement>("[data-report-id]").forEach(card => { if (card.querySelector("[data-bulk-select]")) return; const label = document.createElement("label"); label.className = "moderation-select"; label.innerHTML = `<input type="checkbox" data-bulk-select aria-label="Выбрать улов ${card.querySelector("h2")?.textContent ?? ""}">`; card.prepend(label); }); syncBulk(); };
|
||||||
|
window.setInterval(hydrateBulk, 1000);
|
||||||
|
async function runBulk(statusValue: "approved" | "rejected") { const selected = [...(list?.querySelectorAll<HTMLInputElement>("[data-bulk-select]:checked") ?? [])].map(input => input.closest<HTMLElement>("[data-report-id]")); const reason = bulk?.querySelector<HTMLTextAreaElement>("[data-bulk-reason]")?.value.trim() || null; if (!selected.length) return; if (statusValue === "rejected" && !reason) { fail("Укажите причину отклонения."); return; } const buttons = bulk?.querySelectorAll<HTMLButtonElement>("button") ?? []; buttons.forEach(button => button.disabled = true); try { const response = await fetch(`${root?.dataset.apiUrl}/api/v1/admin/catch-reports/bulk`, {method:"POST",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:JSON.stringify({status:statusValue,reason,items:selected.map(card => ({id:card?.dataset.reportId,expected_version:Number(card?.dataset.version)}))})}); if (response.status === 409) { await loadQueue(); throw new Error("Часть выбранных записей уже изменена. Очередь обновлена."); } if (!response.ok) throw new Error(adminErrorMessage(response.status, "Не удалось применить пакетное решение.")); keepSession(); await loadQueue(); succeed(`Обработано записей: ${selected.length}.`); } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка пакетного решения."); } finally { buttons.forEach(button => button.disabled = false); syncBulk(); } }
|
||||||
|
bulk?.querySelector<HTMLButtonElement>("[data-bulk-approve]")?.addEventListener("click", () => runBulk("approved"));
|
||||||
|
bulk?.querySelector<HTMLButtonElement>("[data-bulk-reject]")?.addEventListener("click", () => runBulk("rejected"));
|
||||||
|
list?.addEventListener("change", event => { if ((event.target as HTMLElement).matches("[data-bulk-select]")) syncBulk(); });
|
||||||
|
selectAll?.addEventListener("change", () => { list?.querySelectorAll<HTMLInputElement>("[data-bulk-select]").forEach(input => input.checked = selectAll.checked); syncBulk(); });
|
||||||
async function loadQueue() {
|
async function loadQueue() {
|
||||||
if (!root || !list) return;
|
if (!root || !list) return;
|
||||||
error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();
|
error?.setAttribute("hidden", ""); setLoading(true); list.innerHTML = loadingCards();
|
||||||
|
|||||||
@@ -1,20 +1,36 @@
|
|||||||
---
|
---
|
||||||
import ActivityCard from "../../components/ActivityCard.astro";
|
import ActivityCard from "../../components/ActivityCard.astro";
|
||||||
|
import ActivityEvidenceSummary from "../../components/ActivityEvidenceSummary.astro";
|
||||||
|
import ActivityPeriodFilter from "../../components/ActivityPeriodFilter.astro";
|
||||||
import EntityMedia from "../../components/EntityMedia.astro";
|
import EntityMedia from "../../components/EntityMedia.astro";
|
||||||
import AtlasBreadcrumbs from "../../components/AtlasBreadcrumbs.astro";
|
import AtlasBreadcrumbs from "../../components/AtlasBreadcrumbs.astro";
|
||||||
import AtlasEntityLink from "../../components/AtlasEntityLink.astro";
|
import AtlasEntityLink from "../../components/AtlasEntityLink.astro";
|
||||||
import PageHero from "../../components/PageHero.astro";
|
import PageHero from "../../components/PageHero.astro";
|
||||||
import StatePanel from "../../components/StatePanel.astro";
|
import StatePanel from "../../components/StatePanel.astro";
|
||||||
import Layout from "../../layouts/Layout.astro";
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
import Pagination from "../../components/Pagination.astro";
|
||||||
import { api, plural, type Activity, type DictionaryItem, type MediaAsset, type PaginatedActivity } from "../../lib/api";
|
import { api, plural, type Activity, type DictionaryItem, type MediaAsset, type PaginatedActivity } from "../../lib/api";
|
||||||
import { findMediaByLabel } from "../../lib/media";
|
import { findMediaByLabel } from "../../lib/media";
|
||||||
const { slug } = Astro.params;
|
const { slug } = Astro.params;
|
||||||
let fish: DictionaryItem | undefined, items: Activity[] = [], media: MediaAsset[] = [], unavailable = false;
|
const requestedHours = Astro.url.searchParams.get("hours") ?? "72";
|
||||||
|
const hours = ["24", "72", "168"].includes(requestedHours) ? requestedHours : "72";
|
||||||
|
const periodLabel = { "24": "24 часа", "72": "72 часа", "168": "7 дней" }[hours];
|
||||||
|
const requestedOffset = Number(Astro.url.searchParams.get("offset") ?? 0);
|
||||||
|
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
|
||||||
|
const activityLimit = 24;
|
||||||
|
let fish: DictionaryItem | undefined, items: Activity[] = [], media: MediaAsset[] = [], totalItems = 0, unavailable = false;
|
||||||
try {
|
try {
|
||||||
const [fishes, fishMedia] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes?limit=500"), api<MediaAsset[]>("/api/v1/media/catalog?entity_type=fish")]);
|
const [fishes, fishMedia] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes?limit=500"), api<MediaAsset[]>("/api/v1/media/catalog?entity_type=fish")]);
|
||||||
media = fishMedia;
|
media = fishMedia;
|
||||||
fish = fishes.find(item => item.slug === slug);
|
fish = fishes.find(item => item.slug === slug);
|
||||||
if (fish) { const paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=72&fish=${encodeURIComponent(fish.slug)}&limit=100`); items = paginated.items; }
|
if (fish) {
|
||||||
|
let paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=${hours}&fish=${encodeURIComponent(fish.slug)}&limit=${activityLimit}&offset=${offset}`);
|
||||||
|
if (paginated.total > 0 && offset >= paginated.total) {
|
||||||
|
offset = Math.floor((paginated.total - 1) / activityLimit) * activityLimit;
|
||||||
|
paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=${hours}&fish=${encodeURIComponent(fish.slug)}&limit=${activityLimit}&offset=${offset}`);
|
||||||
|
}
|
||||||
|
items = paginated.items; totalItems = paginated.total;
|
||||||
|
}
|
||||||
} catch { unavailable = true; }
|
} catch { unavailable = true; }
|
||||||
if (unavailable) {
|
if (unavailable) {
|
||||||
Astro.response.status = 503;
|
Astro.response.status = 503;
|
||||||
@@ -28,7 +44,10 @@ const schema = fish ? { "@context":"https://schema.org", "@type":"CollectionPage
|
|||||||
---
|
---
|
||||||
<Layout title={fish ? `Где ловить ${fish.name_ru} в RF4 — свежие точки` : "Рыба не найдена — RF4 Spotter"} description={fish ? `Свежие точки ловли ${fish.name_ru} в Russian Fishing 4: водоёмы, координаты, приманки, активность и источники наблюдений.` : "Такого вида рыбы нет в каталоге RF4 Spotter."} noindex={!fish || unavailable} structuredData={schema} errorPage={!fish || unavailable}>
|
<Layout title={fish ? `Где ловить ${fish.name_ru} в RF4 — свежие точки` : "Рыба не найдена — RF4 Spotter"} description={fish ? `Свежие точки ловли ${fish.name_ru} в Russian Fishing 4: водоёмы, координаты, приманки, активность и источники наблюдений.` : "Такого вида рыбы нет в каталоге RF4 Spotter."} noindex={!fish || unavailable} structuredData={schema} errorPage={!fish || unavailable}>
|
||||||
<AtlasBreadcrumbs items={[{ label: "Рыбы", href: "/fish" }, { label: fish?.name_ru ?? "Не найдено" }]} />
|
<AtlasBreadcrumbs items={[{ label: "Рыбы", href: "/fish" }, { label: fish?.name_ru ?? "Не найдено" }]} />
|
||||||
<PageHero eyebrow="Свежие данные за 72 часа" title={fish?.name_ru ?? "Рыба не найдена"} description={fish ? `${items.length} ${plural(items.length,["активная точка","активные точки","активных точек"])} на ${waters.length} ${plural(waters.length,["водоёме","водоёмах","водоёмах"])}.` : undefined} variant={fish ? "fish" : undefined} identity={fish?.name_ru} />
|
<PageHero eyebrow={`Свежие данные за ${periodLabel}`} title={fish?.name_ru ?? "Рыба не найдена"} description={fish ? `${totalItems} ${plural(totalItems,["активная точка","активные точки","активных точек"])} на ${waters.length} ${plural(waters.length,["водоёме","водоёмах","водоёмах"])}.` : undefined} variant={fish ? "fish" : undefined} identity={fish?.name_ru} />
|
||||||
|
{fish && <ActivityPeriodFilter hours={hours} />}
|
||||||
{image && <section class="entity-feature content-grid" aria-label={`Изображение: ${fish!.name_ru}`}><EntityMedia asset={image} sourceLink /><div><span class="overline">Визуальный справочник</span><h2>{fish!.name_ru}</h2><p>Изображение опубликовано с прямой ссылкой на источник. Оно помогает отличить вид, а актуальные точки ниже остаются отдельными наблюдениями игроков.</p></div></section>}
|
{image && <section class="entity-feature content-grid" aria-label={`Изображение: ${fish!.name_ru}`}><EntityMedia asset={image} sourceLink /><div><span class="overline">Визуальный справочник</span><h2>{fish!.name_ru}</h2><p>Изображение опубликовано с прямой ссылкой на источник. Оно помогает отличить вид, а актуальные точки ниже остаются отдельными наблюдениями игроков.</p></div></section>}
|
||||||
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !fish ? <StatePanel tone="error" title="Такой рыбы нет в справочнике" actionHref="/fish" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Водоёмы</span>{waters.length ? <nav>{waters.map(([waterSlug,name]) => <AtlasEntityLink href={`/waterbodies/${waterSlug}/${fish!.slug}`} label={name} kind="water" identity={waterSlug} />)}</nav> : <p>Свежих подтверждённых водоёмов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Проверьте позже или посмотрите полевые сигналы на главной." />}</div></section>}
|
{fish && <ActivityEvidenceSummary items={items} totalPoints={totalItems} periodLabel={periodLabel} />}
|
||||||
|
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !fish ? <StatePanel tone="error" title="Такой рыбы нет в справочнике" actionHref="/fish" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Водоёмы</span>{waters.length ? <nav>{waters.map(([waterSlug,name]) => <AtlasEntityLink href={`/waterbodies/${waterSlug}/${fish!.slug}?hours=${hours}`} label={name} kind="water" identity={waterSlug} />)}</nav> : <p>Свежих подтверждённых водоёмов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item} periodLabel={periodLabel}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Проверьте позже или посмотрите полевые сигналы на главной." />}</div></section>}
|
||||||
|
{fish && <Pagination path={`/fish/${fish.slug}`} params={new URLSearchParams({ hours })} total={totalItems} limit={activityLimit} offset={offset} itemLabel="точек" />}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -4,17 +4,31 @@ import FishSilhouette from "../../components/FishSilhouette.astro";
|
|||||||
import EntityMedia from "../../components/EntityMedia.astro";
|
import EntityMedia from "../../components/EntityMedia.astro";
|
||||||
import PageHero from "../../components/PageHero.astro";
|
import PageHero from "../../components/PageHero.astro";
|
||||||
import StatePanel from "../../components/StatePanel.astro";
|
import StatePanel from "../../components/StatePanel.astro";
|
||||||
|
import Pagination from "../../components/Pagination.astro";
|
||||||
import { api, type DictionaryItem, type MediaAsset } from "../../lib/api";
|
import { api, type DictionaryItem, type MediaAsset } from "../../lib/api";
|
||||||
import { findMediaByLabel } from "../../lib/media";
|
import { findMediaByLabel } from "../../lib/media";
|
||||||
let fishes: DictionaryItem[] = [], media: MediaAsset[] = [], unavailable = false;
|
const params = Astro.url.searchParams;
|
||||||
try { [fishes, media] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes?limit=500"), api<MediaAsset[]>("/api/v1/media/catalog?entity_type=fish")]); } catch { unavailable = true; }
|
const search = params.get("q")?.trim() ?? "";
|
||||||
|
const pageLimit = 48;
|
||||||
|
const requestedOffset = Number(params.get("offset") ?? 0);
|
||||||
|
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
|
||||||
|
let fishes: DictionaryItem[] = [], allFishes: DictionaryItem[] = [], media: MediaAsset[] = [], unavailable = false;
|
||||||
|
try {
|
||||||
|
[allFishes, media] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes?limit=500"), api<MediaAsset[]>("/api/v1/media/catalog?entity_type=fish")]);
|
||||||
|
const matchingFishes = search ? allFishes.filter(item => item.name_ru.toLocaleLowerCase("ru-RU").includes(search.toLocaleLowerCase("ru-RU"))) : allFishes;
|
||||||
|
if (matchingFishes.length > 0 && offset >= matchingFishes.length) offset = Math.floor((matchingFishes.length - 1) / pageLimit) * pageLimit;
|
||||||
|
fishes = matchingFishes.slice(offset, offset + pageLimit);
|
||||||
|
allFishes = matchingFishes;
|
||||||
|
} catch { unavailable = true; }
|
||||||
if (unavailable) {
|
if (unavailable) {
|
||||||
Astro.response.status = 503;
|
Astro.response.status = 503;
|
||||||
Astro.response.headers.set("Retry-After", "60");
|
Astro.response.headers.set("Retry-After", "60");
|
||||||
Astro.response.headers.set("Cache-Control", "no-store");
|
Astro.response.headers.set("Cache-Control", "no-store");
|
||||||
}
|
}
|
||||||
---
|
---
|
||||||
<Layout title="Все виды рыб Russian Fishing 4 — RF4 Spotter" description="Каталог рыб RF4 со свежими точками, уловами, приманками и прозрачными источниками данных.">
|
<Layout title="Все виды рыб Russian Fishing 4 — RF4 Spotter" description="Каталог рыб RF4 со свежими точками, уловами, приманками и прозрачными источниками данных." noindex={unavailable} errorPage={unavailable}>
|
||||||
<PageHero eyebrow="Справочник RF4" title="Рыбы" description="Выберите вид, чтобы увидеть свежие подтверждённые точки и полевые сигналы." variant="fish" count={fishes.length} />
|
<PageHero eyebrow="Справочник RF4" title="Рыбы" description="Выберите вид, чтобы увидеть свежие подтверждённые точки и полевые сигналы." variant="fish" count={allFishes.length} />
|
||||||
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенный список. Попробуйте обновить страницу позже." /> : <nav class="catalog-grid content-grid" aria-label="Виды рыб">{fishes.map((fish,index) => { const asset = findMediaByLabel(media, fish.name_ru); return <a href={`/fish/${fish.slug}`}><span>Вид рыбы · {String(index + 1).padStart(2,"0")}</span><strong>{fish.name_ru}</strong><i>Открыть <b>→</b></i>{asset ? <EntityMedia asset={asset} compact /> : <FishSilhouette name={fish.name_ru} size={92}/>}</a>; })}</nav>}
|
<form class="record-filters content-grid" method="get" aria-label="Поиск рыбы"><label>Поиск по названию<input name="q" value={search} maxlength="100" placeholder="Например, щука" /></label><button data-action="primary">Найти</button>{search && <a data-action="quiet" href="/fish">Сбросить</a>}</form>
|
||||||
|
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенный список. Попробуйте обновить страницу позже." /> : fishes.length ? <nav class="catalog-grid content-grid" aria-label="Виды рыб">{fishes.map((fish,index) => { const asset = findMediaByLabel(media, fish.name_ru); return <a href={`/fish/${fish.slug}`}><span>Вид рыбы · {String(offset + index + 1).padStart(2,"0")}</span><strong>{fish.name_ru}</strong><i>Открыть <b>→</b></i>{asset ? <EntityMedia asset={asset} compact /> : <FishSilhouette name={fish.name_ru} size={92}/>}</a>; })}</nav> : <StatePanel title="Ничего не найдено" description="Попробуйте изменить запрос или открыть полный справочник рыбы." />}
|
||||||
|
{!unavailable && <Pagination path="/fish" params={params} total={allFishes.length} limit={pageLimit} offset={offset} itemLabel="видов" />}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
---
|
---
|
||||||
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 ActivityMap from "../components/ActivityMap.astro";
|
||||||
import FishingIcon from "../components/FishingIcon.astro";
|
import FishingIcon from "../components/FishingIcon.astro";
|
||||||
import SourceBadge from "../components/SourceBadge.astro";
|
import SourceBadge from "../components/SourceBadge.astro";
|
||||||
import TackleGlyph from "../components/TackleGlyph.astro";
|
import TackleGlyph from "../components/TackleGlyph.astro";
|
||||||
import SignalFeed from "../components/SignalFeed.astro";
|
import SignalFeed from "../components/SignalFeed.astro";
|
||||||
import StatePanel from "../components/StatePanel.astro";
|
import StatePanel from "../components/StatePanel.astro";
|
||||||
|
import Pagination from "../components/Pagination.astro";
|
||||||
import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem, type PaginatedActivity, type PublicObservation } from "../lib/api";
|
import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem, type PaginatedActivity, type PublicObservation } from "../lib/api";
|
||||||
|
|
||||||
const params = Astro.url.searchParams;
|
const params = Astro.url.searchParams;
|
||||||
@@ -13,8 +15,10 @@ const hours = params.get("hours") ?? "24";
|
|||||||
const waterbody = params.get("waterbody") ?? "";
|
const waterbody = params.get("waterbody") ?? "";
|
||||||
const fish = params.get("fish") ?? "";
|
const fish = params.get("fish") ?? "";
|
||||||
const sort = params.get("sort") ?? "activity";
|
const sort = params.get("sort") ?? "activity";
|
||||||
|
const view = params.get("view") === "map" ? "map" : "list";
|
||||||
|
const activityLimit = 5;
|
||||||
const requestedOffset = Number(params.get("offset") ?? 0);
|
const requestedOffset = Number(params.get("offset") ?? 0);
|
||||||
const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
|
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
|
||||||
const requestedSignalLimit = Number(params.get("signals") ?? 12);
|
const requestedSignalLimit = Number(params.get("signals") ?? 12);
|
||||||
const signalLimit = Number.isInteger(requestedSignalLimit) ? Math.min(48, Math.max(12, requestedSignalLimit)) : 12;
|
const signalLimit = Number.isInteger(requestedSignalLimit) ? Math.min(48, Math.max(12, requestedSignalLimit)) : 12;
|
||||||
let items: Activity[] = [], signals: PublicObservation[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
|
let items: Activity[] = [], signals: PublicObservation[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
|
||||||
@@ -38,11 +42,16 @@ if (!signalsUnavailable) {
|
|||||||
signals = signalRows.slice(0, signalLimit);
|
signals = signalRows.slice(0, signalLimit);
|
||||||
}
|
}
|
||||||
if (!filterError) {
|
if (!filterError) {
|
||||||
const query = new URLSearchParams({ hours, waterbody, fish, sort, limit: "20", offset: String(offset) });
|
const query = new URLSearchParams({ hours, waterbody, fish, sort, limit: String(activityLimit), offset: String(offset) });
|
||||||
try {
|
try {
|
||||||
const paginated = await api<PaginatedActivity>(`/api/v1/activity?${query}`);
|
const paginated = await api<PaginatedActivity>(`/api/v1/activity?${query}`);
|
||||||
items = offset > 0 ? [...items, ...paginated.items] : paginated.items;
|
items = paginated.items;
|
||||||
totalItems = paginated.total;
|
totalItems = paginated.total;
|
||||||
|
if (totalItems > 0 && offset >= totalItems) {
|
||||||
|
offset = Math.floor((totalItems - 1) / activityLimit) * activityLimit;
|
||||||
|
query.set("offset", String(offset));
|
||||||
|
items = (await api<PaginatedActivity>(`/api/v1/activity?${query}`)).items;
|
||||||
|
}
|
||||||
} catch { activityUnavailable = true; }
|
} catch { activityUnavailable = true; }
|
||||||
}
|
}
|
||||||
const catalogUnavailable = fishCatalogUnavailable || waterCatalogUnavailable;
|
const catalogUnavailable = fishCatalogUnavailable || waterCatalogUnavailable;
|
||||||
@@ -62,9 +71,11 @@ const selectedFish = fishes.find(item => item.slug === fish)?.name_ru ?? "Люб
|
|||||||
const periodLabel = { "6": "6 часов", "12": "12 часов", "24": "24 часа", "72": "72 часа" }[hours] ?? hours;
|
const periodLabel = { "6": "6 часов", "12": "12 часов", "24": "24 часа", "72": "72 часа" }[hours] ?? hours;
|
||||||
const sortLabel = { activity: "Сначала активные", confidence: "Сначала надёжные", freshness: "Сначала свежие" }[sort] ?? sort;
|
const sortLabel = { activity: "Сначала активные", confidence: "Сначала надёжные", freshness: "Сначала свежие" }[sort] ?? sort;
|
||||||
const filtersChanged = Boolean(waterbody || fish || hours !== "24" || sort !== "activity");
|
const filtersChanged = Boolean(waterbody || fish || hours !== "24" || sort !== "activity");
|
||||||
|
const queryTitle = `${selectedFish} · ${selectedWaterbody}`;
|
||||||
const moreSignalParams = new URLSearchParams(params);
|
const moreSignalParams = new URLSearchParams(params);
|
||||||
moreSignalParams.set("signals", String(Math.min(48, signalLimit + 12)));
|
moreSignalParams.set("signals", String(Math.min(48, signalLimit + 12)));
|
||||||
const moreSignalsHref = `/?${moreSignalParams.toString()}#signals-title`;
|
const moreSignalsHref = `/?${moreSignalParams.toString()}#signals-title`;
|
||||||
|
const viewHref = (next: "list" | "map") => { const query = new URLSearchParams(params); query.set("view", next); query.delete("offset"); return `/?${query.toString()}#results`; };
|
||||||
const datasetJsonLd = {
|
const datasetJsonLd = {
|
||||||
"@context": "https://schema.org", "@type": "Dataset",
|
"@context": "https://schema.org", "@type": "Dataset",
|
||||||
name: "RF4 Spotter — наблюдения об активности рыбы",
|
name: "RF4 Spotter — наблюдения об активности рыбы",
|
||||||
@@ -85,10 +96,11 @@ const datasetJsonLd = {
|
|||||||
</div></details>
|
</div></details>
|
||||||
<button data-action="primary">⌕ Найти клёв</button>
|
<button data-action="primary">⌕ Найти клёв</button>
|
||||||
</form></section>
|
</form></section>
|
||||||
<div class="active-filters content-grid" aria-label="Применённые фильтры"><span>{selectedWaterbody}</span><span>{selectedFish}</span><span>{periodLabel}</span><span>{sortLabel}</span>{filtersChanged && <a href="/#results">Сбросить</a>}</div>
|
<section class="query-summary content-grid" aria-label="Контекст запроса" aria-live="polite"><div><span class="overline">Ваш запрос</span><h2 id="query-summary-title">{queryTitle}</h2><p>{periodLabel} · {sortLabel}</p></div><div class="query-summary__filters" aria-label="Применённые фильтры"><span>{selectedWaterbody}</span><span>{selectedFish}</span><span>{periodLabel}</span><span>{sortLabel}</span>{filtersChanged && <a href="/#results">Сбросить запрос</a>}</div></section>
|
||||||
<section class="dashboard content-grid" id="results"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} из {totalItems} {plural(totalItems, ["точка", "точки", "точек"])}</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a data-action="secondary" href="/">Сбросить фильтры</a></div> : activityUnavailable ? <StatePanel contained={false} tone="unavailable" title="Горячие точки временно недоступны" description="Полевые сигналы и справочники продолжают работать независимо." /> : items.length ? <><div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div>{offset + items.length < totalItems && <a class="load-more" data-action="secondary" href={`/?${(() => { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}#results`}>Показать ещё <span>{offset + items.length} из {totalItems}</span> ↓</a>}</> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p></div>}</div>
|
<section class="dashboard content-grid" id="results"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><div class="result-tools"><span class="result-count">{items.length} на странице · всего {totalItems}</span><nav class="view-toggle" aria-label="Режим отображения"><a class:list={{ "view-toggle__active": view === "list" }} href={viewHref("list")} aria-current={view === "list" ? "page" : undefined}>Список</a><a class:list={{ "view-toggle__active": view === "map" }} href={viewHref("map")} aria-current={view === "map" ? "page" : undefined}>Схема</a></nav></div></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a data-action="secondary" href="/">Сбросить фильтры</a></div> : activityUnavailable ? <StatePanel contained={false} tone="unavailable" title="Горячие точки временно недоступны" description="Полевые сигналы и справочники продолжают работать независимо." /> : items.length ? (view === "map" ? <ActivityMap items={items} /> : <div class="spot-list">{items.map(item => <ActivityCard item={item} periodLabel={periodLabel} />)}</div>) : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p>{filtersChanged && <a data-action="secondary" href="/#results">Показать все данные</a>}</div>}</div>
|
||||||
{items[0] && leaderLevel && <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="Открыть точку"><FishingIcon name="arrow"/></a></div><div class="source-strip">{items[0].sources.map(source => <SourceBadge source={source}/>)}</div><div class="detail-score"><div class:list={["float-gauge", `meter-level-${leaderMeter}`]} aria-label={`Индекс активности: ${items[0].activity_score} из 100`}><span class="float-gauge__line"></span><span class="float-gauge__water"></span><span class="float-gauge__bob"><i></i></span><strong>{items[0].activity_score}</strong><small>из 100</small></div><div><span>Индекс активности</span><strong data-activity-level={leaderLevel.short}>{leaderLevel.description}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span><FishingIcon name="ripple"/></span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span><FishingIcon name="angler"/></span><small>{plural(items[0].unique_players, ["Игрок", "Игрока", "Игроков"])}</small><strong>{items[0].unique_players}</strong></div><div><span><FishingIcon name="clock"/></span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span><FishingIcon name="scale"/></span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><TackleGlyph name={items[0].best_bait}/><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{items[0].catches} {plural(items[0].catches, ["улов", "улова", "уловов"])}</span></div></div><p class="confidence-note"><span>✓</span><span><strong>Оценка объяснима.</strong> Один игрок не может искусственно поднять уверенность.</span></p></aside>}
|
{items[0] && leaderLevel && <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="Открыть точку"><FishingIcon name="arrow"/></a></div><div class="source-strip">{items[0].sources.map(source => <SourceBadge source={source}/>)}</div><div class="detail-score"><div class:list={["float-gauge", `meter-level-${leaderMeter}`]} aria-label={`Индекс активности: ${items[0].activity_score} из 100`}><span class="float-gauge__line"></span><span class="float-gauge__water"></span><span class="float-gauge__bob"><i></i></span><strong>{items[0].activity_score}</strong><small>из 100</small></div><div><span>Индекс активности</span><strong data-activity-level={leaderLevel.short}>{leaderLevel.description}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span><FishingIcon name="ripple"/></span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span><FishingIcon name="angler"/></span><small>{plural(items[0].unique_players, ["Игрок", "Игрока", "Игроков"])}</small><strong>{items[0].unique_players}</strong></div><div><span><FishingIcon name="clock"/></span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span><FishingIcon name="scale"/></span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><TackleGlyph name={items[0].best_bait}/><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{items[0].catches} {plural(items[0].catches, ["улов", "улова", "уловов"])}</span></div></div><p class="confidence-note"><span>✓</span><span><strong>Оценка объяснима.</strong> Один игрок не может искусственно поднять уверенность.</span></p></aside>}
|
||||||
</section>
|
</section>
|
||||||
|
{!filterError && !activityUnavailable && <Pagination path="/" params={params} total={totalItems} limit={activityLimit} offset={offset} anchor="#results" itemLabel="точек" />}
|
||||||
{signals.length > 0 && <SignalFeed signals={signals}/>}
|
{signals.length > 0 && <SignalFeed signals={signals}/>}
|
||||||
{signalsUnavailable && <div class="content-grid"><StatePanel tone="unavailable" title="Полевые сигналы временно недоступны" description="Горячие точки и справочники продолжают работать независимо." /></div>}
|
{signalsUnavailable && <div class="content-grid"><StatePanel tone="unavailable" title="Полевые сигналы временно недоступны" description="Горячие точки и справочники продолжают работать независимо." /></div>}
|
||||||
{hasMoreSignals && signalLimit < 48 && <a class="signal-more" data-action="secondary" href={moreSignalsHref}>Показать ещё <span>{signalLimit} из доступных</span> ↓</a>}
|
{hasMoreSignals && signalLimit < 48 && <a class="signal-more" data-action="secondary" href={moreSignalsHref}>Показать ещё <span>{signalLimit} из доступных</span> ↓</a>}
|
||||||
|
|||||||
@@ -1,20 +1,36 @@
|
|||||||
---
|
---
|
||||||
import EntityMedia from "../components/EntityMedia.astro";
|
import EntityMedia from "../components/EntityMedia.astro";
|
||||||
|
import Pagination from "../components/Pagination.astro";
|
||||||
import PageHero from "../components/PageHero.astro";
|
import PageHero from "../components/PageHero.astro";
|
||||||
import StatePanel from "../components/StatePanel.astro";
|
import StatePanel from "../components/StatePanel.astro";
|
||||||
import Layout from "../layouts/Layout.astro";
|
import Layout from "../layouts/Layout.astro";
|
||||||
import { api, type MediaAsset } from "../lib/api";
|
import { api, type MediaAsset } from "../lib/api";
|
||||||
|
import { mediaRoleLabel } from "../lib/media";
|
||||||
const allowed = new Set(["fish", "waterbody", "tackle", "reference"]);
|
const allowed = new Set(["fish", "waterbody", "tackle", "reference"]);
|
||||||
const requested = Astro.url.searchParams.get("type") ?? "fish";
|
const requested = Astro.url.searchParams.get("type") ?? "fish";
|
||||||
const type = allowed.has(requested) ? requested : "fish";
|
const type = allowed.has(requested) ? requested : "fish";
|
||||||
let assets: MediaAsset[] = [], unavailable = false;
|
const requestedRole = Astro.url.searchParams.get("role") ?? "";
|
||||||
try { assets = await api<MediaAsset[]>(`/api/v1/media/catalog?entity_type=${type}`); } catch { unavailable = true; }
|
const search = Astro.url.searchParams.get("q")?.trim() ?? "";
|
||||||
|
const requestedOffset = Number(Astro.url.searchParams.get("offset") ?? 0);
|
||||||
|
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
|
||||||
|
const pageLimit = 48;
|
||||||
|
const roleOptions: Record<string, Array<[string, string]>> = { waterbody: [["waterbody_cover", "Заставки"], ["waterbody_map", "Карты"], ["waterbody_depth_map", "Карты глубин"], ["waterbody_screenshot", "Скриншоты"]], tackle: [["tackle_card", "Карточки снастей"], ["tackle_detail", "Детали снастей"], ["rig_diagram", "Схемы сборок"], ["tackle_screenshot", "Скриншоты снастей"]] };
|
||||||
|
const selectedRole = (roleOptions[type] ?? []).some(([value]) => value === requestedRole) ? requestedRole : "";
|
||||||
|
let assets: MediaAsset[] = [], allAssets: MediaAsset[] = [], unavailable = false;
|
||||||
|
try { const query = new URLSearchParams({ entity_type: type }); if (selectedRole) query.set("media_role", selectedRole); allAssets = await api<MediaAsset[]>(`/api/v1/media/catalog?${query}`); if (search) { const needle = search.toLocaleLowerCase("ru-RU"); allAssets = allAssets.filter(asset => (asset.label ?? "").toLocaleLowerCase("ru-RU").includes(needle)); } if (allAssets.length > 0 && offset >= allAssets.length) offset = Math.floor((allAssets.length - 1) / pageLimit) * pageLimit; assets = allAssets.slice(offset, offset + pageLimit); } catch { unavailable = true; }
|
||||||
|
if (unavailable) {
|
||||||
|
Astro.response.status = 503;
|
||||||
|
Astro.response.headers.set("Retry-After", "60");
|
||||||
|
Astro.response.headers.set("Cache-Control", "no-store");
|
||||||
|
}
|
||||||
const labels: Record<string,string> = {fish:"Рыбы",waterbody:"Водоёмы",tackle:"Снасти и приманки",reference:"Справочные материалы"};
|
const labels: Record<string,string> = {fish:"Рыбы",waterbody:"Водоёмы",tackle:"Снасти и приманки",reference:"Справочные материалы"};
|
||||||
---
|
---
|
||||||
<Layout title={`${labels[type]} RF4 — медиатека RF4 Spotter`} description="Изображения рыб, водоёмов и снастей Russian Fishing 4 с обязательной атрибуцией каждого источника.">
|
<Layout title={`${labels[type]} RF4 — медиатека RF4 Spotter`} description="Изображения рыб, водоёмов и снастей Russian Fishing 4 с обязательной атрибуцией каждого источника." noindex={unavailable} errorPage={unavailable}>
|
||||||
<PageHero eyebrow="Визуальный справочник" title="Медиатека" description="Собранные материалы RF4 с источником у каждого изображения. Каталог пополняется по мере импорта и проверки." variant="fish" count={assets.length} />
|
<PageHero eyebrow="Визуальный справочник" title="Медиатека" description="Собранные материалы RF4 с источником у каждого изображения. Каталог пополняется по мере импорта и проверки." variant="fish" count={allAssets.length} />
|
||||||
<section class="media-library content-grid">
|
<section class="media-library content-grid">
|
||||||
<nav class="media-library__intro" aria-label="Разделы медиатеки"><strong>{labels[type]} · {assets.length}</strong><span><a href="/media?type=fish">Рыбы</a> · <a href="/media?type=waterbody">Водоёмы</a> · <a href="/media?type=tackle">Снасти</a> · <a href="/media?type=reference">Справка</a></span></nav>
|
<nav class="media-library__intro" aria-label="Разделы медиатеки"><strong>{labels[type]} · {allAssets.length}</strong><span><a href="/media?type=fish">Рыбы</a> · <a href="/media?type=waterbody">Водоёмы</a> · <a href="/media?type=tackle">Снасти</a> · <a href="/media?type=reference">Справка</a></span></nav>
|
||||||
{unavailable ? <StatePanel tone="unavailable" title="Медиатека временно недоступна" /> : assets.length ? <div class="media-library__grid">{assets.map(asset => <article class="media-library__card"><EntityMedia asset={asset} sourceLink /><h2>{asset.label ?? "Без подписи"}</h2><span>{labels[asset.entity_type]}</span></article>)}</div> : <StatePanel title="В этом разделе пока нет изображений" description="Материалы появятся после следующего импорта." />}
|
<form class="media-role-filter" method="get"><input type="hidden" name="type" value={type} /><label>Поиск по подписи<input name="q" value={search} maxlength="100" placeholder="Например, щука" /></label>{roleOptions[type]?.length > 0 && <label>Тип материала<select name="role"><option value="">Все роли</option>{roleOptions[type].map(([value, label]) => <option value={value} selected={selectedRole === value}>{label}</option>)}</select></label>}<button data-action="secondary">Показать</button>{(selectedRole || search) && <a data-action="quiet" href={`/media?type=${type}`}>Сбросить</a>}</form>
|
||||||
|
{unavailable ? <StatePanel tone="unavailable" title="Медиатека временно недоступна" /> : assets.length ? <div class="media-library__grid">{assets.map(asset => <article class="media-library__card"><EntityMedia asset={asset} sourceLink /><h2>{asset.label ?? "Без подписи"}</h2><span>{labels[asset.entity_type]} · {mediaRoleLabel(asset.media_role ?? "")}</span><small>Источник: {asset.source_system}</small></article>)}</div> : <StatePanel title={search ? "Ничего не найдено" : "В этом разделе пока нет изображений"} description="Материалы появятся после следующего импорта." />}
|
||||||
|
{!unavailable && <Pagination path="/media" params={new URLSearchParams({ type, ...(selectedRole ? { role: selectedRole } : {}), ...(search ? { q: search } : {}) })} total={allAssets.length} limit={pageLimit} offset={offset} itemLabel="материалов" />}
|
||||||
</section>
|
</section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
---
|
||||||
|
import Layout from "../layouts/Layout.astro";
|
||||||
|
---
|
||||||
|
<Layout title="Мой план рыбалки — RF4 Spotter" description="Сохранённые точки рыбалки RF4 без аккаунта: координаты, наживки, свежесть и доверие." noindex>
|
||||||
|
<section class="plan-hero content-grid"><div><span class="eyebrow">Локально на этом устройстве</span><h1>Мой план<br/><em>рыбалки.</em></h1><p>Соберите до пяти точек, сравните их перед выездом и распечатайте список. Данные не отправляются на сервер.</p></div><div class="plan-hero__mark" aria-hidden="true">♢</div></section>
|
||||||
|
<section class="plan-toolbar content-grid" aria-label="Действия с планом"><span data-plan-count>0 из 5 точек</span><div><button data-action="secondary" type="button" data-plan-share>Поделиться планом</button><button data-action="secondary" type="button" data-plan-copy>Скопировать ссылку</button><button data-action="secondary" type="button" data-plan-print>Печать / PDF</button><button data-action="quiet" type="button" data-plan-clear>Очистить план</button><small data-plan-share-status aria-live="polite"></small></div></section>
|
||||||
|
<section class="plan-content content-grid" data-plan-page aria-live="polite"><div class="plan-grid" data-plan-list></div><div class="state" data-plan-empty><h2>План пока пуст</h2><p>Сохраните точку на её detail-странице — она появится здесь.</p><a data-action="secondary" href="/">Посмотреть горячие точки</a></div></section>
|
||||||
|
</Layout>
|
||||||
|
<script>
|
||||||
|
const planStorageKey = "rf4spotter:fishing-plan";
|
||||||
|
const list = document.querySelector<HTMLElement>("[data-plan-list]");
|
||||||
|
const empty = document.querySelector<HTMLElement>("[data-plan-empty]");
|
||||||
|
const count = document.querySelector<HTMLElement>("[data-plan-count]");
|
||||||
|
const shareStatus = document.querySelector<HTMLElement>("[data-plan-share-status]");
|
||||||
|
const shareButton = document.querySelector<HTMLButtonElement>("[data-plan-share]");
|
||||||
|
const copyButton = document.querySelector<HTMLButtonElement>("[data-plan-copy]");
|
||||||
|
const printButton = document.querySelector<HTMLButtonElement>("[data-plan-print]");
|
||||||
|
const clearButton = document.querySelector<HTMLButtonElement>("[data-plan-clear]");
|
||||||
|
const planFieldLimits: Record<string, number> = { key: 512, waterbody: 160, coordinates: 80, baits: 1000, method: 120, retrieve: 160, risk: 160, freshness: 40, confidence: 8 };
|
||||||
|
const planText = (key: string, value: unknown) => typeof value === "string" ? value.slice(0, planFieldLimits[key] ?? 240) : "";
|
||||||
|
const normalisePlan = (value: unknown): Array<Record<string, string>> => {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return value.filter(item => item && typeof item === "object" && typeof item.key === "string" && item.key.startsWith("/spots/")).map(item => {
|
||||||
|
const source = item as Record<string, unknown>;
|
||||||
|
const entry = Object.fromEntries(["key", "waterbody", "coordinates", "baits", "method", "retrieve", "risk", "freshness", "confidence"].map(key => [key, planText(key, source[key])]));
|
||||||
|
if (!/^\d{1,3}$/.test(entry.confidence) || Number(entry.confidence) > 100) entry.confidence = "";
|
||||||
|
return entry;
|
||||||
|
}).filter(item => {
|
||||||
|
if (seen.has(item.key)) return false;
|
||||||
|
seen.add(item.key);
|
||||||
|
return true;
|
||||||
|
}).slice(0, 5);
|
||||||
|
};
|
||||||
|
const readPlan = (): Array<Record<string, string>> => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(planStorageKey) || "[]";
|
||||||
|
const normalised = normalisePlan(JSON.parse(raw));
|
||||||
|
const serialised = JSON.stringify(normalised);
|
||||||
|
if (serialised !== raw) localStorage.setItem(planStorageKey, serialised);
|
||||||
|
return normalised;
|
||||||
|
}
|
||||||
|
catch { return []; }
|
||||||
|
};
|
||||||
|
const shared = new URLSearchParams(location.search).get("plan");
|
||||||
|
if (shared) {
|
||||||
|
try {
|
||||||
|
const imported = normalisePlan(JSON.parse(shared));
|
||||||
|
if (imported.length) localStorage.setItem(planStorageKey, JSON.stringify(imported));
|
||||||
|
else if (shareStatus) shareStatus.textContent = "В ссылке нет сохранённых точек";
|
||||||
|
} catch { if (shareStatus) shareStatus.textContent = "Ссылка плана не распознана"; }
|
||||||
|
}
|
||||||
|
const render = () => {
|
||||||
|
if (!list || !empty) return;
|
||||||
|
const plan = readPlan();
|
||||||
|
list.replaceChildren();
|
||||||
|
empty.hidden = plan.length > 0;
|
||||||
|
if (count) count.textContent = `${plan.length} из 5 точек`;
|
||||||
|
[shareButton, copyButton, printButton, clearButton].forEach(button => { if (button) button.disabled = plan.length === 0; });
|
||||||
|
plan.forEach(item => {
|
||||||
|
const card = document.createElement("article"); card.className = "plan-card";
|
||||||
|
const heading = document.createElement("h2"); heading.textContent = item.waterbody || "Водоём не указан";
|
||||||
|
const coordinate = document.createElement("strong"); coordinate.textContent = item.coordinates || "Координаты не указаны";
|
||||||
|
const meta = document.createElement("p"); meta.textContent = item.baits ? `Наживка: ${item.baits.split("|").join(", ")}` : "Наживка не указана";
|
||||||
|
const approach = document.createElement("p"); approach.textContent = [item.method, item.retrieve].filter(Boolean).join(" · ") || "Метод не указан";
|
||||||
|
const facts = document.createElement("small"); facts.textContent = [item.risk ? `Риск: ${item.risk}` : "Риск не рассчитан", item.freshness ? "Свежесть сохранена" : "Свежесть не указана", item.confidence !== "" ? `Доверие: ${item.confidence}%` : "Доверие не рассчитано"].join(" · ");
|
||||||
|
const link = document.createElement("a"); link.href = item.key || "/"; link.textContent = "Открыть точку";
|
||||||
|
const remove = document.createElement("button"); remove.type = "button"; remove.dataset.action = "quiet"; remove.textContent = "Убрать"; remove.addEventListener("click", () => { localStorage.setItem(planStorageKey, JSON.stringify(readPlan().filter(entry => entry.key !== item.key))); render(); });
|
||||||
|
const actions = document.createElement("div"); actions.className = "plan-card__actions"; actions.append(link, remove);
|
||||||
|
card.append(heading, coordinate, meta, approach, facts, actions); list.append(card);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const planUrl = () => {
|
||||||
|
const plan = readPlan();
|
||||||
|
const url = new URL(location.href); url.search = ""; url.searchParams.set("plan", JSON.stringify(plan));
|
||||||
|
return { plan, value: url.toString() };
|
||||||
|
};
|
||||||
|
const copyText = async (value: string) => {
|
||||||
|
if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(value); return; }
|
||||||
|
const field = document.createElement("textarea");
|
||||||
|
field.value = value; field.setAttribute("readonly", ""); field.style.position = "fixed"; field.style.opacity = "0";
|
||||||
|
document.body.append(field); field.select();
|
||||||
|
const copied = document.execCommand("copy");
|
||||||
|
field.remove();
|
||||||
|
if (!copied) throw new Error("Clipboard is unavailable");
|
||||||
|
};
|
||||||
|
const copyPlanLink = async () => {
|
||||||
|
const { plan, value } = planUrl();
|
||||||
|
if (!plan.length) { if (shareStatus) shareStatus.textContent = "Сначала добавьте точку в план"; return; }
|
||||||
|
await copyText(value);
|
||||||
|
if (shareStatus) shareStatus.textContent = "Ссылка скопирована";
|
||||||
|
};
|
||||||
|
shareButton?.addEventListener("click", async () => {
|
||||||
|
const { plan, value } = planUrl();
|
||||||
|
if (!plan.length) { if (shareStatus) shareStatus.textContent = "Сначала добавьте точку в план"; return; }
|
||||||
|
try {
|
||||||
|
if (navigator.share) { await navigator.share({ title: "Мой план рыбалки RF4", url: value }); if (shareStatus) shareStatus.textContent = "План отправлен"; }
|
||||||
|
else await copyPlanLink();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === "AbortError") { if (shareStatus) shareStatus.textContent = "Поделиться отменено"; return; }
|
||||||
|
if (shareStatus) shareStatus.textContent = "Не удалось скопировать ссылку";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
copyButton?.addEventListener("click", async () => { try { await copyPlanLink(); } catch { if (shareStatus) shareStatus.textContent = "Не удалось скопировать ссылку"; } });
|
||||||
|
printButton?.addEventListener("click", () => window.print());
|
||||||
|
clearButton?.addEventListener("click", () => { localStorage.removeItem(planStorageKey); if (shareStatus) shareStatus.textContent = "План очищен"; render(); });
|
||||||
|
render();
|
||||||
|
</script>
|
||||||
@@ -4,30 +4,44 @@ import SourceBadge from "../components/SourceBadge.astro";
|
|||||||
import SectionHeading from "../components/SectionHeading.astro";
|
import SectionHeading from "../components/SectionHeading.astro";
|
||||||
import StatePanel from "../components/StatePanel.astro";
|
import StatePanel from "../components/StatePanel.astro";
|
||||||
import TackleGlyph from "../components/TackleGlyph.astro";
|
import TackleGlyph from "../components/TackleGlyph.astro";
|
||||||
|
import Pagination from "../components/Pagination.astro";
|
||||||
import { api, kg, type DictionaryItem, type ImportRun, type OfficialRecord, type PaginatedOfficialRecord } from "../lib/api";
|
import { api, kg, type DictionaryItem, type ImportRun, type OfficialRecord, type PaginatedOfficialRecord } from "../lib/api";
|
||||||
const params = Astro.url.searchParams;
|
const params = Astro.url.searchParams;
|
||||||
const fish = params.get("fish") ?? "";
|
const fish = params.get("fish") ?? "";
|
||||||
const waterbody = params.get("waterbody") ?? "";
|
const waterbody = params.get("waterbody") ?? "";
|
||||||
|
const search = params.get("q") ?? "";
|
||||||
|
const recordsLimit = 50;
|
||||||
const requestedOffset = Number(params.get("offset") ?? 0);
|
const requestedOffset = Number(params.get("offset") ?? 0);
|
||||||
const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
|
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
|
||||||
let items: OfficialRecord[] = [], runs: ImportRun[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
|
let items: OfficialRecord[] = [], runs: ImportRun[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [];
|
||||||
let unavailable = false, showNoIndex = false, totalRecords = 0;
|
let unavailable = false, showNoIndex = false, totalRecords = 0;
|
||||||
try {
|
try {
|
||||||
const query = new URLSearchParams({ fish, waterbody, limit: "50", offset: String(offset) });
|
const query = new URLSearchParams({ fish, waterbody, q: search, limit: String(recordsLimit), offset: String(offset) });
|
||||||
const paginated = await api<PaginatedOfficialRecord>(`/api/v1/records?${query}`);
|
const paginated = await api<PaginatedOfficialRecord>(`/api/v1/records?${query}`);
|
||||||
items = offset > 0 ? [...items, ...paginated.items] : paginated.items;
|
items = paginated.items;
|
||||||
totalRecords = paginated.total;
|
totalRecords = paginated.total;
|
||||||
|
if (totalRecords > 0 && offset >= totalRecords) {
|
||||||
|
offset = Math.floor((totalRecords - 1) / recordsLimit) * recordsLimit;
|
||||||
|
query.set("offset", String(offset));
|
||||||
|
items = (await api<PaginatedOfficialRecord>(`/api/v1/records?${query}`)).items;
|
||||||
|
}
|
||||||
[runs, fishes, waterbodies] = await Promise.all([api<ImportRun[]>("/api/v1/imports?limit=1"), api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]);
|
[runs, fishes, waterbodies] = await Promise.all([api<ImportRun[]>("/api/v1/imports?limit=1"), api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]);
|
||||||
} catch { unavailable = true; showNoIndex = true; Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); }
|
} catch {
|
||||||
|
unavailable = true;
|
||||||
|
showNoIndex = true;
|
||||||
|
Astro.response.status = 503;
|
||||||
|
Astro.response.headers.set("Retry-After", "60");
|
||||||
|
Astro.response.headers.set("Cache-Control", "no-store");
|
||||||
|
}
|
||||||
const last = runs[0];
|
const last = runs[0];
|
||||||
---
|
---
|
||||||
<Layout title="Официальные рекорды Russian Fishing 4 — RF4 Spotter" description="Последние официальные рекорды RF4 по рыбам и водоёмам: вес, приманка, игрок, дата и прямая ссылка на источник." noindex={showNoIndex} errorPage={unavailable}>
|
<Layout title="Официальные рекорды Russian Fishing 4 — RF4 Spotter" description="Последние официальные рекорды RF4 по рыбам и водоёмам: вес, приманка, игрок, дата и прямая ссылка на источник." noindex={showNoIndex} errorPage={unavailable}>
|
||||||
<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>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(item => <option value={item.slug} selected={fish === item.slug}>{item.name_ru}</option>)}</select></label><label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(item => <option value={item.slug} selected={waterbody === item.slug}>{item.name_ru}</option>)}</select></label><button data-action="primary">Фильтровать</button>{(fish || waterbody) && <a data-action="quiet" href="/records">Сбросить</a>}</form>
|
<form class="record-filters" method="get"><label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(item => <option value={item.slug} selected={fish === item.slug}>{item.name_ru}</option>)}</select></label><label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(item => <option value={item.slug} selected={waterbody === item.slug}>{item.name_ru}</option>)}</select></label><label>Игрок или приманка<input name="q" value={search} maxlength="100" placeholder="Например, игрок или блесна" /></label><button data-action="primary">Фильтровать</button>{(fish || waterbody || search) && <a data-action="quiet" href="/records">Сбросить</a>}</form>
|
||||||
<div class="content-grid"><SectionHeading eyebrow="Официальный источник" title="Последние записи" count={`${items.length} из ${totalRecords} записей`} /></div>
|
<div class="content-grid"><SectionHeading eyebrow="Официальный источник" title="Последние записи" count={`${items.length} на странице · всего ${totalRecords}`} /></div>
|
||||||
{unavailable && <StatePanel tone="unavailable" title="Источник временно недоступен" description="Сохраняем ранее импортированные данные, но не показываем их как свежее обновление." />}
|
{unavailable && <StatePanel tone="unavailable" title="Источник временно недоступен" description="Сохраняем ранее импортированные данные, но не показываем их как свежее обновление." />}
|
||||||
{!unavailable && items.length && <div class="record-table"><div class="record-row record-head"><span>Рыба</span><span>Вес</span><span>Водоём</span><span>Приманка</span><span>Игрок</span><span>Дата и источник</span></div>{items.map(record => <article class="record-row"><strong data-label="Рыба">{record.fish}</strong><strong data-label="Вес">{kg(record.weight_g)}</strong><span data-label="Водоём">{record.waterbody}</span><span class="tackle-label" data-label="Приманка"><TackleGlyph name={record.bait} size={24}/><span>{record.bait ?? "—"}</span></span><span data-label="Игрок">{record.player_name ?? "—"}</span><span data-label="Дата и источник" class="record-provenance"><time>{record.record_date ? new Date(record.record_date).toLocaleDateString("ru-RU") : "—"}</time><SourceBadge source={record.source_system} href={record.source_url}/></span></article>)}</div>}
|
{!unavailable && items.length && <div class="record-table"><div class="record-row record-head"><span>Рыба</span><span>Вес</span><span>Водоём</span><span>Приманка</span><span>Игрок</span><span>Дата и источник</span></div>{items.map(record => <article class="record-row"><strong data-label="Рыба">{record.fish}</strong><strong data-label="Вес">{kg(record.weight_g)}</strong><span data-label="Водоём">{record.waterbody}</span><span class="tackle-label" data-label="Приманка"><TackleGlyph name={record.bait} size={24}/><span>{record.bait ?? "—"}</span></span><span data-label="Игрок">{record.player_name ?? "—"}</span><span data-label="Дата и источник" class="record-provenance"><time>{record.record_date ? new Date(record.record_date).toLocaleDateString("ru-RU") : "—"}</time><SourceBadge source={record.source_system} href={record.source_url}/></span></article>)}</div>}
|
||||||
{!unavailable && items.length && offset + items.length < totalRecords && <a class="load-more" data-action="secondary" href={`/records?${(() => { const p = new URLSearchParams(params); p.delete("offset"); p.set("offset", String(offset + items.length)); return p.toString(); })()}`}>Показать ещё <span>{offset + items.length} из {totalRecords}</span> ↓</a>}
|
{!unavailable && <Pagination path="/records" params={params} total={totalRecords} limit={recordsLimit} offset={offset} itemLabel="записей" />}
|
||||||
{!unavailable && !items.length && <StatePanel title="Рекорды ещё не импортированы" description="Для выбранных условий записей пока нет." actionHref="/records" actionLabel="Сбросить фильтры" />}
|
{!unavailable && !items.length && <StatePanel title="Рекорды ещё не импортированы" description="Для выбранных условий записей пока нет." actionHref="/records" actionLabel="Сбросить фильтры" />}
|
||||||
<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>
|
||||||
|
|||||||
@@ -2,11 +2,17 @@
|
|||||||
import Layout from "../layouts/Layout.astro";
|
import Layout from "../layouts/Layout.astro";
|
||||||
import { api, type DictionaryItem } from "../lib/api";
|
import { api, type DictionaryItem } from "../lib/api";
|
||||||
let fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [], unavailable = false;
|
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; }
|
try { [fishes, waterbodies] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]); }
|
||||||
|
catch {
|
||||||
|
unavailable = true;
|
||||||
|
Astro.response.status = 503;
|
||||||
|
Astro.response.headers.set("Retry-After", "60");
|
||||||
|
Astro.response.headers.set("Cache-Control", "no-store");
|
||||||
|
}
|
||||||
const state = Astro.url.searchParams.get("state");
|
const state = Astro.url.searchParams.get("state");
|
||||||
const reportId = Astro.url.searchParams.get("report_id");
|
const reportId = Astro.url.searchParams.get("report_id");
|
||||||
---
|
---
|
||||||
<Layout title="Добавить улов Russian Fishing 4 — RF4 Spotter" description="Отправьте собственное наблюдение об улове RF4 на проверку и помогите игрокам находить актуальные точки.">
|
<Layout title="Добавить улов Russian Fishing 4 — RF4 Spotter" description="Отправьте собственное наблюдение об улове RF4 на проверку и помогите игрокам находить актуальные точки." noindex={unavailable} errorPage={unavailable}>
|
||||||
<span id="report-state" data-state={state ?? ""} hidden></span>
|
<span id="report-state" data-state={state ?? ""} hidden></span>
|
||||||
<section class="form-hero"><div><span class="eyebrow"><b>+1</b> Помочь сообществу</span><h1>Добавить<br/><em>свой улов</em></h1></div><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>}
|
||||||
@@ -17,7 +23,7 @@ const reportId = Astro.url.searchParams.get("report_id");
|
|||||||
{state === "server_error" && <div class="notice error" id="form-error" tabindex="-1"><strong>Сервер временно недоступен.</strong> Попробуйте позже.</div>}
|
{state === "server_error" && <div class="notice error" id="form-error" tabindex="-1"><strong>Сервер временно недоступен.</strong> Попробуйте позже.</div>}
|
||||||
{state === "screenshot_error" && <div class="notice warning"><strong>Заявка сохранена без скриншота.</strong> Изображение не загрузилось; можно повторить отдельно, не отправляя улов заново.</div>}
|
{state === "screenshot_error" && <div class="notice warning"><strong>Заявка сохранена без скриншота.</strong> Изображение не загрузилось; можно повторить отдельно, не отправляя улов заново.</div>}
|
||||||
{state === "screenshot_error" && reportId && <form class="screenshot-retry" method="post" action="/api/report-screenshot" enctype="multipart/form-data"><input type="hidden" name="report_id" value={reportId} /><label>Повторная загрузка скриншота<input name="screenshot" type="file" accept="image/jpeg,image/png,image/webp" required /></label><button data-action="inverse" type="submit">Загрузить скриншот</button></form>}
|
{state === "screenshot_error" && reportId && <form class="screenshot-retry" method="post" action="/api/report-screenshot" enctype="multipart/form-data"><input type="hidden" name="report_id" value={reportId} /><label>Повторная загрузка скриншота<input name="screenshot" type="file" accept="image/jpeg,image/png,image/webp" required /></label><button data-action="inverse" type="submit">Загрузить скриншот</button></form>}
|
||||||
{unavailable ? <div class="state"><h2>Форма временно недоступна</h2></div> : <form class="report-form" method="post" action="/api/report" enctype="multipart/form-data">
|
{unavailable ? <div class="state" role="alert"><h2>Форма временно недоступна</h2></div> : <form class="report-form" method="post" action="/api/report" enctype="multipart/form-data">
|
||||||
<fieldset><legend>Главное <span>обязательно</span></legend><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" placeholder="Например, 72" required /></label><label>Координата Y *<input name="y" type="number" min="-10000" max="10000" placeholder="Например, 84" required /></label><label>Вес, граммы *<input name="weight_g" type="number" min="1" max="3000000" placeholder="1250" required /></label><label>Приманка<input name="bait_name" maxlength="200" placeholder="Название в игре" /></label></div><p class="field-help">Координаты — два целых числа с карты. Вес: 1,25 кг = 1250 г.</p></fieldset>
|
<fieldset><legend>Главное <span>обязательно</span></legend><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" placeholder="Например, 72" required /></label><label>Координата Y *<input name="y" type="number" min="-10000" max="10000" placeholder="Например, 84" required /></label><label>Вес, граммы *<input name="weight_g" type="number" min="1" max="3000000" placeholder="1250" required /></label><label>Приманка<input name="bait_name" maxlength="200" placeholder="Название в игре" /></label></div><p class="field-help">Координаты — два целых числа с карты. Вес: 1,25 кг = 1250 г.</p></fieldset>
|
||||||
<details class="optional-fields"><summary>Дополнительные сведения <span>необязательно</span></summary><div class="form-grid"><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="wide">Скриншот, JPEG/PNG/WebP до 8 МБ<input name="screenshot" type="file" accept="image/jpeg,image/png,image/webp" /></label></details><label class="honeypot" aria-hidden="true">Сайт<input name="website" tabindex="-1" autocomplete="off" /></label><p class="privacy">Ник и скриншот необязательны. Из изображения удаляются EXIF и прочие метаданные.</p><label class="consent"><input name="consent" type="checkbox" required /> <span>Я отправляю собственное наблюдение и принимаю <a href="/rules" target="_blank">правила</a> и <a href="/privacy" target="_blank">политику конфиденциальности</a>.</span></label><button data-action="primary" type="submit">Отправить на проверку</button>
|
<details class="optional-fields"><summary>Дополнительные сведения <span>необязательно</span></summary><div class="form-grid"><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="wide">Скриншот, JPEG/PNG/WebP до 8 МБ<input name="screenshot" type="file" accept="image/jpeg,image/png,image/webp" /></label></details><label class="honeypot" aria-hidden="true">Сайт<input name="website" tabindex="-1" autocomplete="off" /></label><p class="privacy">Ник и скриншот необязательны. Из изображения удаляются EXIF и прочие метаданные.</p><label class="consent"><input name="consent" type="checkbox" required /> <span>Я отправляю собственное наблюдение и принимаю <a href="/rules" target="_blank">правила</a> и <a href="/privacy" target="_blank">политику конфиденциальности</a>.</span></label><button data-action="primary" type="submit">Отправить на проверку</button>
|
||||||
</form>}
|
</form>}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
---
|
---
|
||||||
import Layout from "../../layouts/Layout.astro";
|
import Layout from "../../layouts/Layout.astro";
|
||||||
import AtlasBreadcrumbs from "../../components/AtlasBreadcrumbs.astro";
|
import AtlasBreadcrumbs from "../../components/AtlasBreadcrumbs.astro";
|
||||||
import SourceBadge from "../../components/SourceBadge.astro";
|
|
||||||
import CoordinateRadar from "../../components/CoordinateRadar.astro";
|
import CoordinateRadar from "../../components/CoordinateRadar.astro";
|
||||||
import TackleGlyph from "../../components/TackleGlyph.astro";
|
import TackleGlyph from "../../components/TackleGlyph.astro";
|
||||||
import ActivityTimeline from "../../components/ActivityTimeline.astro";
|
import ActivityTimeline from "../../components/ActivityTimeline.astro";
|
||||||
import CatchList from "../../components/CatchList.astro";
|
import CatchList from "../../components/CatchList.astro";
|
||||||
import { activityLevel, api, ApiError, plural, type Activity, type Catch, type PaginatedActivity, type Spot } from "../../lib/api";
|
import DataPassport from "../../components/DataPassport.astro";
|
||||||
|
import { activityLevel, api, ApiError, coordinatePrecisionLabel, type Activity, type Catch, type PaginatedActivity, type Spot } from "../../lib/api";
|
||||||
const { id } = Astro.params;
|
const { id } = Astro.params;
|
||||||
let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false;
|
let spot: Spot | null = null, catches: Catch[] = [], activity: Activity | null = null, unavailable = false;
|
||||||
let timeline: { start: string; end: string; count: number }[] = [];
|
let timeline: { start: string; end: string; count: number }[] = [];
|
||||||
@@ -22,19 +22,28 @@ try {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
unavailable = true;
|
unavailable = true;
|
||||||
Astro.response.status = error instanceof ApiError && [404, 422].includes(error.status) ? 404 : 503;
|
Astro.response.status = error instanceof ApiError && [404, 422].includes(error.status) ? 404 : 503;
|
||||||
if (Astro.response.status === 503) Astro.response.headers.set("Retry-After", "60");
|
if (Astro.response.status === 503) {
|
||||||
|
Astro.response.headers.set("Retry-After", "60");
|
||||||
|
Astro.response.headers.set("Cache-Control", "no-store");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const level = activity ? activityLevel(activity.activity_score) : null;
|
const level = activity ? activityLevel(activity.activity_score) : null;
|
||||||
|
const methodLabels: Record<string, string> = { spinning: "Спиннинг", bottom: "Донная", float: "Поплавочная" };
|
||||||
|
const method = catches.map(item => item.fishing_method).find(Boolean) ?? "";
|
||||||
|
const retrieve = catches.map(item => item.retrieve_method).find(Boolean) ?? "";
|
||||||
|
const confirmedMethods = [...new Set(catches.map(item => item.fishing_method).filter((value): value is string => Boolean(value)))].map(value => methodLabels[value] ?? value);
|
||||||
|
const confirmedRetrieves = [...new Set(catches.map(item => item.retrieve_method).filter((value): value is string => Boolean(value)))];
|
||||||
|
const risk = activity ? activity.catches < 3 ? "Малая выборка" : activity.confidence_score < 50 ? "Низкая уверенность" : "Подтверждено" : "Нет оценки";
|
||||||
const spotDescription = spot ? `Свежие уловы и активность на точке ${spot.x}:${spot.y}, ${spot.waterbody}: рыба, вес, приманки и источники данных.` : "Данные точки ловли Russian Fishing 4.";
|
const spotDescription = spot ? `Свежие уловы и активность на точке ${spot.x}:${spot.y}, ${spot.waterbody}: рыба, вес, приманки и источники данных.` : "Данные точки ловли Russian Fishing 4.";
|
||||||
const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [
|
const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [
|
||||||
{ "@type": "ListItem", position: 1, name: "Сейчас клюёт", item: "https://rf4spotter.ru/" },
|
{ "@type": "ListItem", position: 1, name: "Сейчас клюёт", item: "https://rf4spotter.ru/" },
|
||||||
{ "@type": "ListItem", position: 2, name: `${spot.waterbody} ${spot.x}:${spot.y}`, item: `https://rf4spotter.ru/spots/${spot.waterbody_slug}-${spot.x}x${spot.y}` },
|
{ "@type": "ListItem", position: 2, name: `${spot.waterbody} ${spot.x}:${spot.y}`, item: `https://rf4spotter.ru/spots/${spot.waterbody_slug}-${spot.x}x${spot.y}` },
|
||||||
] } : null;
|
] } : null;
|
||||||
---
|
---
|
||||||
<Layout title={spot ? `Точка ${spot.x}:${spot.y}, ${spot.waterbody} — RF4 Spotter` : "Точка не найдена — RF4 Spotter"} description={spotDescription} noindex={!spot} structuredData={breadcrumbs} errorPage={!spot || unavailable}>
|
<Layout title={spot ? `Точка ${spot.x}:${spot.y}, ${spot.waterbody} — RF4 Spotter` : "Точка не найдена — RF4 Spotter"} description={spotDescription} noindex={!spot || unavailable} structuredData={breadcrumbs} errorPage={!spot || unavailable}>
|
||||||
<AtlasBreadcrumbs items={[{ label: "Сейчас клюёт", href: "/" }, ...(spot ? [{ label: spot.waterbody, href: `/waterbodies/${spot.waterbody_slug}` }, { label: `Точка ${spot.x}:${spot.y}` }] : [{ label: "Точка недоступна" }])]} />
|
<AtlasBreadcrumbs items={[{ label: "Сейчас клюёт", href: "/" }, ...(spot ? [{ label: spot.waterbody, href: `/waterbodies/${spot.waterbody_slug}` }, { label: `Точка ${spot.x}:${spot.y}` }] : [{ label: "Точка недоступна" }])]} />
|
||||||
{unavailable || !spot ? <div class="state"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
|
{unavailable || !spot ? <div class="state" role="alert"><h1>Точка недоступна</h1><p>API не ответил или такой точки нет.</p></div> : <>
|
||||||
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p><button class="coordinate-copy" data-action="inverse" type="button" data-copy-coordinates={`${spot.x}:${spot.y}`}>Скопировать координаты</button><small class="copy-status" aria-live="polite"></small></div><CoordinateRadar x={spot.x} y={spot.y}/></section>
|
<section class="spot-hero"><div><span class="eyebrow">{spot.waterbody}</span><h1>Точка {spot.x}:{spot.y}</h1><p>{spot.description}</p><p class="coordinate-precision">Точность координат: <strong>{coordinatePrecisionLabel(spot.coordinate_precision)}</strong></p><div class="spot-hero__actions"><button class="coordinate-copy" data-action="inverse" type="button" data-copy-coordinates={`${spot.x}:${spot.y}`}>Скопировать координаты</button><button class="plan-save" data-action="inverse" type="button" data-plan-save data-plan-key={Astro.url.pathname} data-plan-waterbody={spot.waterbody} data-plan-coordinates={`${spot.x}:${spot.y}`} data-plan-baits={spot.top_baits.join("|")} data-plan-method={methodLabels[method] ?? method} data-plan-retrieve={retrieve} data-plan-risk={risk} data-plan-freshness={activity?.last_confirmed_at ?? ""} data-plan-confidence={activity?.confidence_score ?? ""} aria-pressed="false">Сохранить в план</button><small class="copy-status" aria-live="polite"></small><small class="plan-status" aria-live="polite"></small></div></div><CoordinateRadar x={spot.x} y={spot.y}/></section>
|
||||||
<div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div>
|
<div class="periods"><div><strong>{spot.catches_24h}</strong><span>за 24 часа</span></div><div><strong>{spot.catches_3d}</strong><span>за 3 дня</span></div><div><strong>{spot.catches_7d}</strong><span>за 7 дней</span></div></div>
|
||||||
<ActivityTimeline buckets={timeline}/>
|
<ActivityTimeline buckets={timeline}/>
|
||||||
<div class="activity-legend" aria-label="Уровни активности"><span>Тихо</span><span>Есть сигналы</span><span>Горячо</span></div>
|
<div class="activity-legend" aria-label="Уровни активности"><span>Тихо</span><span>Есть сигналы</span><span>Горячо</span></div>
|
||||||
@@ -43,17 +52,59 @@ const breadcrumbs = spot ? { "@context": "https://schema.org", "@type": "Breadcr
|
|||||||
<div class="section-heading"><h2>Последние уловы</h2></div>
|
<div class="section-heading"><h2>Последние уловы</h2></div>
|
||||||
{catches.length ? <CatchList catches={catches}/> : <div class="state compact-state"><h2>Уловов пока нет</h2><p>Для этой точки нет одобренных наблюдений.</p></div>}
|
{catches.length ? <CatchList catches={catches}/> : <div class="state compact-state"><h2>Уловов пока нет</h2><p>Для этой точки нет одобренных наблюдений.</p></div>}
|
||||||
</div>
|
</div>
|
||||||
<aside><span class="eyebrow">Оценка за 24 часа</span>{activity && level ? <><h2>{activity.activity_score} / 100</h2><strong data-activity-level={level.short}>{level.description}</strong><div class="source-strip">{activity.sources.map(source => <SourceBadge source={source}/>)}</div><p>{activity.explanation}</p><p class="note">Основано на {activity.catches} {plural(activity.catches, ["наблюдении", "наблюдениях", "наблюдениях"])} от {activity.unique_players} {plural(activity.unique_players, ["игрока", "игроков", "игроков"])}. Уверенность: {activity.confidence_score}%.</p></> : <p>За последние 24 часа данных для расчёта нет.</p>}<span class="eyebrow">Лучшие приманки</span>{spot.top_baits.length ? <ol>{spot.top_baits.map(name => <li><span class="tackle-label"><TackleGlyph name={name} size={24}/><span>{name}</span></span></li>)}</ol> : <p>Недостаточно данных.</p>}<p class="note">Учитываются только одобренные наблюдения.</p></aside>
|
<aside><span class="eyebrow">Оценка за 24 часа</span>{activity && level ? <><h2>{activity.activity_score} / 100</h2><strong data-activity-level={level.short}>{level.description}</strong><p>{activity.explanation}</p><DataPassport sources={activity.sources} observedAt={activity.last_confirmed_at} confidence={activity.confidence_score} sampleSize={activity.catches} independentPlayers={activity.unique_players} coordinatePrecision={activity.coordinate_precision} coordinateSources={activity.coordinate_sources} sourceConflicts={activity.source_conflicts} status={activity.catches < 3 ? "insufficient" : "verified"}/></> : <p>За последние 24 часа данных для расчёта нет.</p>}<h3 class="detail-action-heading">Что взять</h3>{spot.top_baits.length ? <ol>{spot.top_baits.map(name => <li><span class="tackle-label"><TackleGlyph name={name} size={24}/><span>{name}</span></span></li>)}</ol> : <p>Недостаточно данных.</p>}<section class="confirmed-approach" aria-label="Подтверждённый метод и проводка"><h3>Подтверждённый подход</h3>{confirmedMethods.length || confirmedRetrieves.length ? <dl>{confirmedMethods.length > 0 && <div><dt>Метод</dt><dd>{confirmedMethods.join(", ")}</dd></div>}{confirmedRetrieves.length > 0 && <div><dt>Проводка</dt><dd>{confirmedRetrieves.join(", ")}</dd></div>}</dl> : <p>Метод и проводка в уловах не указаны.</p>}<small>По одобренным уловам этой точки.</small></section><p class="note">Учитываются только одобренные наблюдения.</p></aside>
|
||||||
</section>
|
</section>
|
||||||
</>}
|
</>}
|
||||||
</Layout>
|
</Layout>
|
||||||
<script>
|
<script>
|
||||||
|
const copyText = async (value: string) => {
|
||||||
|
if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(value); return; }
|
||||||
|
const field = document.createElement("textarea");
|
||||||
|
field.value = value; field.setAttribute("readonly", ""); field.style.position = "fixed"; field.style.opacity = "0";
|
||||||
|
document.body.append(field); field.select();
|
||||||
|
const copied = document.execCommand("copy");
|
||||||
|
field.remove();
|
||||||
|
if (!copied) throw new Error("Clipboard is unavailable");
|
||||||
|
};
|
||||||
document.querySelectorAll<HTMLButtonElement>("[data-copy-coordinates]").forEach((button) => {
|
document.querySelectorAll<HTMLButtonElement>("[data-copy-coordinates]").forEach((button) => {
|
||||||
button.addEventListener("click", async () => {
|
button.addEventListener("click", async () => {
|
||||||
const value = button.dataset.copyCoordinates || "";
|
const value = button.dataset.copyCoordinates || "";
|
||||||
const status = button.parentElement?.querySelector<HTMLElement>(".copy-status");
|
const status = button.parentElement?.querySelector<HTMLElement>(".copy-status");
|
||||||
try { await navigator.clipboard.writeText(value); if (status) status.textContent = "Скопировано"; }
|
try { await copyText(value); if (status) status.textContent = "Скопировано"; }
|
||||||
catch { if (status) status.textContent = value; }
|
catch { if (status) status.textContent = value; }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
const planStorageKey = "rf4spotter:fishing-plan";
|
||||||
|
const readPlan = (): Array<Record<string, string>> => {
|
||||||
|
try { const value = JSON.parse(localStorage.getItem(planStorageKey) || "[]"); return Array.isArray(value) ? value : []; }
|
||||||
|
catch { return []; }
|
||||||
|
};
|
||||||
|
document.querySelectorAll<HTMLButtonElement>("[data-plan-save]").forEach((button) => {
|
||||||
|
const key = button.dataset.planKey || "";
|
||||||
|
const status = button.parentElement?.querySelector<HTMLElement>(".plan-status");
|
||||||
|
const sync = () => {
|
||||||
|
const saved = readPlan().some(item => item.key === key);
|
||||||
|
button.textContent = saved ? "В плане" : "Сохранить в план";
|
||||||
|
button.setAttribute("aria-pressed", String(saved));
|
||||||
|
button.dataset.saved = String(saved);
|
||||||
|
};
|
||||||
|
sync();
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const plan = readPlan();
|
||||||
|
const index = plan.findIndex(item => item.key === key);
|
||||||
|
if (index >= 0) { plan.splice(index, 1); if (status) status.textContent = "Удалено из плана"; }
|
||||||
|
else { plan.unshift({ key, waterbody: button.dataset.planWaterbody || "", coordinates: button.dataset.planCoordinates || "", baits: button.dataset.planBaits || "", method: button.dataset.planMethod || "", retrieve: button.dataset.planRetrieve || "", risk: button.dataset.planRisk || "", freshness: button.dataset.planFreshness || "", confidence: button.dataset.planConfidence || "" }); if (status) status.textContent = "Добавлено в план"; }
|
||||||
|
localStorage.setItem(planStorageKey, JSON.stringify(plan.slice(0, 5)));
|
||||||
|
sync();
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<style>
|
||||||
|
.confirmed-approach { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border); }
|
||||||
|
.confirmed-approach h3 { margin: 0 0 12px; }
|
||||||
|
.confirmed-approach dl { display: grid; gap: 8px; margin: 0 0 8px; }
|
||||||
|
.confirmed-approach dl div { display: grid; grid-template-columns: 92px 1fr; gap: 8px; }
|
||||||
|
.confirmed-approach dt { color: var(--text-subtle); font-size: 11px; font-weight: 750; text-transform: uppercase; }
|
||||||
|
.confirmed-approach dd { margin: 0; color: var(--text-secondary); }
|
||||||
|
.confirmed-approach p, .confirmed-approach small { color: var(--text-muted); }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -4,13 +4,18 @@ import SourceBadge from "../components/SourceBadge.astro";
|
|||||||
import DataLegend from "../components/DataLegend.astro";
|
import DataLegend from "../components/DataLegend.astro";
|
||||||
import PageHero from "../components/PageHero.astro";
|
import PageHero from "../components/PageHero.astro";
|
||||||
import StatePanel from "../components/StatePanel.astro";
|
import StatePanel from "../components/StatePanel.astro";
|
||||||
import { ago, api, plural, type SourceStatus } from "../lib/api";
|
import { ago, api, plural, sourceStatusLabel, type SourceStatus } from "../lib/api";
|
||||||
let sources: SourceStatus[] = [], unavailable = false;
|
let sources: SourceStatus[] = [], unavailable = false;
|
||||||
try { sources = await api<SourceStatus[]>("/api/v1/source-status"); } catch { unavailable = true; }
|
try { sources = await api<SourceStatus[]>("/api/v1/source-status"); }
|
||||||
const labels = { healthy:"Актуален", stale:"Данные устарели", temporarily_limited:"Временная пауза", source_changed:"Источник изменился", waiting:"Ожидает запуска", disabled:"Выключен" };
|
catch {
|
||||||
|
unavailable = true;
|
||||||
|
Astro.response.status = 503;
|
||||||
|
Astro.response.headers.set("Retry-After", "60");
|
||||||
|
Astro.response.headers.set("Cache-Control", "no-store");
|
||||||
|
}
|
||||||
---
|
---
|
||||||
<Layout title="Состояние источников — RF4 Spotter" description="Текущее безопасное состояние источников данных RF4 Spotter.">
|
<Layout title="Состояние источников — RF4 Spotter" description="Текущее безопасное состояние источников данных RF4 Spotter." noindex={unavailable} errorPage={unavailable}>
|
||||||
<PageHero eyebrow="Прозрачность данных" title="Статус источников" description="Пауза или изменение страницы не удаляют уже опубликованные данные." />
|
<PageHero eyebrow="Прозрачность данных" title="Статус источников" description="Пауза или изменение страницы не удаляют уже опубликованные данные." />
|
||||||
{unavailable ? <StatePanel tone="unavailable" title="Статус временно недоступен" description="Данные о состоянии источников не получены. Публичные наблюдения при этом не изменяются." /> : <section class="source-health-grid content-grid">{sources.map(source => <article data-status={source.status}><div><SourceBadge source={source.source_system}/><span class="health-state"><i></i>{labels[source.status]}</span></div><h2>{source.name}</h2><strong>{source.observations} {plural(source.observations,["наблюдение","наблюдения","наблюдений"])}</strong><p>{source.last_success_at ? `Последнее успешное обновление ${ago(source.last_success_at)}` : "Успешных запусков пока нет"}</p></article>)}</section>}
|
{unavailable ? <StatePanel tone="unavailable" title="Статус временно недоступен" description="Данные о состоянии источников не получены. Публичные наблюдения при этом не изменяются." /> : <section class="source-health-grid content-grid">{sources.map(source => <article data-status={source.status}><div><SourceBadge source={source.source_system}/><span class="health-state"><i></i>{sourceStatusLabel(source.status)}</span></div><h2>{source.name}</h2><strong>{source.observations} {plural(source.observations,["наблюдение","наблюдения","наблюдений"])}</strong><p>{source.status === "temporarily_limited" ? "Источник ограничен; опубликованные данные сохранены." : source.last_success_at ? `Последнее успешное обновление ${ago(source.last_success_at)}` : "Успешных запусков пока нет"}</p></article>)}</section>}
|
||||||
<DataLegend />
|
<DataLegend />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
import PageHero from "../../components/PageHero.astro";
|
||||||
|
import StatePanel from "../../components/StatePanel.astro";
|
||||||
|
import { ago, api, type DictionaryItem, type TackleCombination } from "../../lib/api";
|
||||||
|
|
||||||
|
const params = Astro.url.searchParams;
|
||||||
|
const waterbody = params.get("waterbody") ?? "";
|
||||||
|
const fish = params.get("fish") ?? "";
|
||||||
|
const method = params.get("method") ?? "";
|
||||||
|
const role = params.get("role") ?? "";
|
||||||
|
const hours = [24, 72, 168].includes(Number(params.get("hours"))) ? Number(params.get("hours")) : 72;
|
||||||
|
const query = new URLSearchParams({ hours: String(hours), min_samples: "3", min_players: "2" });
|
||||||
|
if (waterbody) query.set("waterbody", waterbody);
|
||||||
|
if (fish) query.set("fish", fish);
|
||||||
|
if (method) query.set("method", method);
|
||||||
|
if (role) query.set("role", role);
|
||||||
|
let combinations: TackleCombination[] = [], waters: DictionaryItem[] = [], fishes: DictionaryItem[] = [], unavailable = false;
|
||||||
|
try {
|
||||||
|
[combinations, waters, fishes] = await Promise.all([
|
||||||
|
api<TackleCombination[]>(`/api/v1/analytics/tackle?${query}`),
|
||||||
|
api<DictionaryItem[]>("/api/v1/waterbodies?limit=500"),
|
||||||
|
api<DictionaryItem[]>("/api/v1/fishes?limit=500"),
|
||||||
|
]);
|
||||||
|
} catch { unavailable = true; }
|
||||||
|
if (unavailable) { Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); Astro.response.headers.set("Cache-Control", "no-store"); }
|
||||||
|
const methodLabels: Record<string, string> = { spinning: "Спиннинг", bottom: "Донная", float: "Поплавочная" };
|
||||||
|
const roleLabels: Record<string, string> = { lure: "Приманка", bait: "Наживка", rig: "Сборка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", float: "Поплавок", sinker: "Груз" };
|
||||||
|
---
|
||||||
|
<Layout title="Сочетания снастей RF4 — RF4 Spotter" description="Подтверждённые сочетания снастей RF4 по одобренным наблюдениям, с выборкой и независимыми игроками." noindex={unavailable} errorPage={unavailable}>
|
||||||
|
<PageHero eyebrow="Только одобренные наблюдения" title="Сочетания снастей" description="Факт использования отделён от рекомендации: малой выборке нельзя приписывать эффективность." variant="fish" />
|
||||||
|
<form class="record-filters" method="get" aria-label="Фильтры сочетаний снастей">
|
||||||
|
<label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waters.map(item => <option value={item.slug} selected={waterbody === item.slug}>{item.name_ru}</option>)}</select></label>
|
||||||
|
<label>Рыба<select name="fish"><option value="">Все виды</option>{fishes.map(item => <option value={item.slug} selected={fish === item.slug}>{item.name_ru}</option>)}</select></label>
|
||||||
|
<label>Метод<select name="method"><option value="">Все методы</option>{Object.entries(methodLabels).map(([value, label]) => <option value={value} selected={method === value}>{label}</option>)}</select></label>
|
||||||
|
<label>Роль<select name="role"><option value="">Все роли</option>{Object.entries(roleLabels).map(([value, label]) => <option value={value} selected={role === value}>{label}</option>)}</select></label>
|
||||||
|
<label>Период<select name="hours">{[24, 72, 168].map(value => <option value={value} selected={hours === value}>{value} ч</option>)}</select></label>
|
||||||
|
<button data-action="primary">Показать</button>
|
||||||
|
{(waterbody || fish || method || role || hours !== 72) && <a data-action="quiet" href="/tackle/analytics">Сбросить</a>}
|
||||||
|
</form>
|
||||||
|
<p class="privacy content-grid">Порог рекомендации: минимум 3 наблюдения от 2 независимых игроков. Это не рейтинг снасти и не гарантия улова.</p>
|
||||||
|
{unavailable ? <StatePanel tone="unavailable" title="Аналитика временно недоступна" description="Не показываем непроверенные сочетания. Попробуйте позже." /> : combinations.length ? <section class="signal-grid content-grid" aria-label="Сочетания снастей" data-analytics-results>{combinations.map(item => { const canonicalHref = item.rig_id ? `/tackle/rigs/${item.rig_id}` : item.tackle_item_id ? `/tackle/items/${item.tackle_item_id}` : null; return <article class="signal-card" data-analytics-status={item.status}><div class="signal-card__top"><span class="source-chip"><span>{roleLabels[item.role] ?? item.role}</span></span><span class="quality-chip">{item.status === "recommendation" ? "Рекомендация" : "Недостаточно данных"}</span></div><h2>{canonicalHref ? <a href={canonicalHref}>{item.value}</a> : item.value}</h2><dl><div><dt>Наблюдения</dt><dd>{item.catches}</dd></div><div><dt>Игроки</dt><dd>{item.unique_players}</dd></div><div><dt>Свежесть выборки</dt><dd>{item.freshness_score}%</dd></div><div><dt>Последнее</dt><dd>{ago(item.last_seen_at)}</dd></div></dl><p>{item.explanation}</p></article>})}</section> : <div data-analytics-results><StatePanel title="Подтверждённых сочетаний пока нет" description="Сочетания появятся после новых одобренных наблюдений с указанием компонентов." /></div>}
|
||||||
|
</Layout>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
import PageHero from "../../components/PageHero.astro";
|
||||||
|
import StatePanel from "../../components/StatePanel.astro";
|
||||||
|
import Pagination from "../../components/Pagination.astro";
|
||||||
|
import TackleGlyph from "../../components/TackleGlyph.astro";
|
||||||
|
import DataPassport from "../../components/DataPassport.astro";
|
||||||
|
import { api, type PaginatedRigs, type PaginatedTackleItems } from "../../lib/api";
|
||||||
|
|
||||||
|
const params = Astro.url.searchParams;
|
||||||
|
const category = params.get("category") ?? "";
|
||||||
|
const search = params.get("q") ?? "";
|
||||||
|
const brand = params.get("brand") ?? "";
|
||||||
|
const family = params.get("family") ?? "";
|
||||||
|
const unlockLevelParam = params.get("unlock_level") ?? "";
|
||||||
|
const unlockLevel = /^\d+$/.test(unlockLevelParam) ? unlockLevelParam : "";
|
||||||
|
const requestedOffset = Number(params.get("offset") ?? 0);
|
||||||
|
const limit = 48;
|
||||||
|
const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
|
||||||
|
const query = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
||||||
|
if (category) query.set("category", category);
|
||||||
|
if (search) query.set("q", search);
|
||||||
|
if (brand) query.set("brand", brand);
|
||||||
|
if (family) query.set("family", family);
|
||||||
|
if (unlockLevel) query.set("unlock_level", unlockLevel);
|
||||||
|
let result: PaginatedTackleItems = { items: [], total: 0, limit, offset }, rigs: PaginatedRigs = { items: [], total: 0, limit, offset }, unavailable = false, rigsUnavailable = false;
|
||||||
|
const [itemsResult, rigsResult] = await Promise.allSettled([
|
||||||
|
api<PaginatedTackleItems>(`/api/v1/tackle/items?${query}`),
|
||||||
|
api<PaginatedRigs>(`/api/v1/tackle/rigs?limit=${limit}&offset=${offset}${search ? `&q=${encodeURIComponent(search)}` : ""}`),
|
||||||
|
]);
|
||||||
|
if (itemsResult.status === "fulfilled") result = itemsResult.value; else unavailable = true;
|
||||||
|
if (rigsResult.status === "fulfilled") rigs = rigsResult.value; else rigsUnavailable = true;
|
||||||
|
if (unavailable) { Astro.response.status = 503; Astro.response.headers.set("Retry-After", "60"); Astro.response.headers.set("Cache-Control", "no-store"); }
|
||||||
|
const categoryLabels: Record<string, string> = { bait: "Наживка", lure: "Приманка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", rig: "Сборка", float: "Поплавок", sinker: "Груз", other: "Другое" };
|
||||||
|
const filterParams = new URLSearchParams();
|
||||||
|
if (category) filterParams.set("category", category);
|
||||||
|
if (search) filterParams.set("q", search);
|
||||||
|
if (brand) filterParams.set("brand", brand);
|
||||||
|
if (family) filterParams.set("family", family);
|
||||||
|
if (unlockLevel) filterParams.set("unlock_level", unlockLevel);
|
||||||
|
---
|
||||||
|
<Layout title="Снасти и приманки RF4 — RF4 Spotter" description="Канонический каталог снастей, приманок и сборок Russian Fishing 4 с источниками и отметками неполноты." noindex={unavailable} errorPage={unavailable}>
|
||||||
|
<PageHero eyebrow="Канонический справочник" title="Снасти и приманки" description="Показываем только подтверждённые карточки. Пустые поля явно отмечены и не заменяются догадками." variant="fish" count={result.total} />
|
||||||
|
<form class="record-filters" method="get" aria-label="Фильтры каталога снастей">
|
||||||
|
<label>Категория<select name="category"><option value="">Все категории</option>{Object.entries(categoryLabels).map(([value, label]) => <option value={value} selected={category === value}>{label}</option>)}</select></label>
|
||||||
|
<label>Поиск<input name="q" value={search} maxlength="100" placeholder="Название снасти или приманки" /></label>
|
||||||
|
<label>Бренд<input name="brand" value={brand} maxlength="100" placeholder="Например, RF4" /></label>
|
||||||
|
<label>Семейство<input name="family" value={family} maxlength="100" placeholder="Название семейства" /></label>
|
||||||
|
<label>Уровень открытия<input name="unlock_level" value={unlockLevel} inputmode="numeric" pattern="[0-9]+" min="0" maxlength="4" placeholder="Например, 20" /></label>
|
||||||
|
<button data-action="primary">Фильтровать</button>
|
||||||
|
{(category || search || brand || family || unlockLevel) && <a data-action="quiet" href="/tackle">Сбросить</a>}
|
||||||
|
<a data-action="quiet" href="/tackle/analytics">Сочетания снастей</a>
|
||||||
|
</form>
|
||||||
|
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенные карточки. Попробуйте обновить страницу позже." /> : result.items.length ? <section class="catalog-grid content-grid" aria-label="Карточки снастей">{result.items.map(item => <a href={`/tackle/items/${item.id}`}><TackleGlyph name={item.name} size={32} /><span>{categoryLabels[item.category] ?? item.category}</span><strong>{item.name}</strong><small>{[item.brand, item.family].filter(Boolean).join(" · ") || "Характеристики уточняются"}</small>{item.unlock_level !== null && <small>Открывается с уровня {item.unlock_level}</small>}<div class="tackle-card-passport"><DataPassport sources={item.source_system ? [item.source_system] : []} sourceUrl={item.source_url} observedAt={item.source_checked_at} completeness={item.missing_fields.length ? null : 100} status={item.missing_fields.length ? "incomplete" : item.source_checked_at ? "verified" : "unverified"}/></div></a>)}</section> : <StatePanel title="Подтверждённых карточек пока нет" description="Каталог заполнится после разрешённой загрузки и ручной проверки источников." />}
|
||||||
|
{!unavailable && <section class="rig-catalog content-grid" aria-labelledby="rig-catalog-title"><div class="section-heading"><div><span class="overline">Сборки снастей</span><h2 id="rig-catalog-title">Сборки</h2></div><span class="result-count">{rigs.total} всего</span></div>{rigsUnavailable ? <StatePanel contained={false} tone="unavailable" title="Сборки временно недоступны" description="Карточки отдельных снастей продолжают работать независимо." /> : rigs.items.length ? <div class="catalog-grid">{rigs.items.map(rig => <a href={`/tackle/rigs/${rig.id}`}><TackleGlyph name={rig.name} size={32} /><span>Сборка · {rig.component_count} компонентов</span><strong>{rig.name}</strong><small>{rig.source_system ?? "Источник не указан"}</small><div class="tackle-card-passport"><DataPassport sources={rig.source_system ? [rig.source_system] : []} sourceUrl={rig.source_url} observedAt={rig.source_checked_at} completeness={rig.missing_fields.length ? null : 100} status={rig.missing_fields.length ? "incomplete" : rig.source_checked_at ? "verified" : "unverified"}/></div></a>)}</div> : <StatePanel contained={false} title="Подтверждённых сборок пока нет" description="Сборки появятся после разрешённой загрузки и проверки источников." />}</section>}
|
||||||
|
{!unavailable && <Pagination path="/tackle" params={filterParams} total={result.total} limit={limit} offset={offset} itemLabel="карточек" />}
|
||||||
|
</Layout>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
import AtlasBreadcrumbs from "../../../components/AtlasBreadcrumbs.astro";
|
||||||
|
import DataPassport from "../../../components/DataPassport.astro";
|
||||||
|
import EntityMedia from "../../../components/EntityMedia.astro";
|
||||||
|
import Layout from "../../../layouts/Layout.astro";
|
||||||
|
import PageHero from "../../../components/PageHero.astro";
|
||||||
|
import StatePanel from "../../../components/StatePanel.astro";
|
||||||
|
import TackleGlyph from "../../../components/TackleGlyph.astro";
|
||||||
|
import { ApiError, api, type MediaAsset, type TackleItem } from "../../../lib/api";
|
||||||
|
import { findMediaByLabel, mediaRoleLabel } from "../../../lib/media";
|
||||||
|
import { safeHttpUrl } from "../../../lib/urls";
|
||||||
|
|
||||||
|
const { id } = Astro.params;
|
||||||
|
let item: TackleItem | undefined, media: MediaAsset[] = [];
|
||||||
|
let unavailable = false;
|
||||||
|
const [itemResult, mediaResult] = await Promise.allSettled([
|
||||||
|
api<TackleItem>(`/api/v1/tackle/items/${encodeURIComponent(id ?? "")}`),
|
||||||
|
api<MediaAsset[]>("/api/v1/media/catalog?entity_type=tackle"),
|
||||||
|
]);
|
||||||
|
if (itemResult.status === "fulfilled") item = itemResult.value;
|
||||||
|
else { const error = itemResult.reason; unavailable = !(error instanceof ApiError) || error.status >= 500; }
|
||||||
|
if (mediaResult.status === "fulfilled") media = mediaResult.value;
|
||||||
|
if (!item && !unavailable) Astro.response.status = 404;
|
||||||
|
if (unavailable) {
|
||||||
|
Astro.response.status = 503;
|
||||||
|
Astro.response.headers.set("Retry-After", "60");
|
||||||
|
Astro.response.headers.set("Cache-Control", "no-store");
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryLabels: Record<string, string> = { bait: "Наживка", lure: "Приманка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", rig: "Сборка", float: "Поплавок", sinker: "Груз", other: "Другое" };
|
||||||
|
const missingLabels: Record<string, string> = { subcategory: "подкатегория", brand: "бренд", family: "семейство", unlock_level: "уровень открытия", source_url: "ссылка на источник", source_checked_at: "дата проверки" };
|
||||||
|
const missing = item?.missing_fields.map((field) => missingLabels[field] ?? field) ?? [];
|
||||||
|
const sourceHref = safeHttpUrl(item?.source_url);
|
||||||
|
const mediaCandidates = item ? (["tackle_card", "tackle_detail"] as const).map(role => findMediaByLabel(media, item!.name, [role])).filter((asset): asset is MediaAsset => Boolean(asset)) : [];
|
||||||
|
---
|
||||||
|
<Layout title={item ? `${item.name} — снасти RF4` : "Снасть не найдена — RF4 Spotter"} description={item ? `Подтверждённая карточка ${item.name} в каталоге снастей RF4 с источником и отметками полноты.` : "Такой карточки нет в каталоге снастей RF4."} noindex={!item || unavailable} errorPage={!item || unavailable}>
|
||||||
|
<AtlasBreadcrumbs items={[{ label: "Снасти", href: "/tackle" }, { label: item?.name ?? "Не найдено" }]} />
|
||||||
|
<PageHero eyebrow={item ? categoryLabels[item.category] ?? "Каталог RF4" : "Каталог RF4"} title={item?.name ?? (unavailable ? "Каталог недоступен" : "Снасть не найдена")} description={item ? "Канонические характеристики и происхождение без догадок." : undefined} variant={item ? "fish" : undefined} identity={item?.name} />
|
||||||
|
{unavailable ? <StatePanel tone="unavailable" title="Карточка временно недоступна" description="Каталог не ответил. Непроверенные характеристики не показываем." /> : !item ? <StatePanel tone="error" title="Такой карточки нет в каталоге" actionHref="/tackle" actionLabel="Открыть каталог" /> : <>
|
||||||
|
<section class="tackle-detail content-grid" aria-label="Характеристики снасти">
|
||||||
|
<div class="tackle-detail__identity"><TackleGlyph name={item.name} size={72} /><span class="overline">{categoryLabels[item.category] ?? item.category}</span><h2>{item.name}</h2><p>{[item.brand, item.family].filter(Boolean).join(" · ") || "Бренд и семейство не указаны."}</p></div>
|
||||||
|
<div><dl><div><dt>Подкатегория</dt><dd>{item.subcategory ?? "Не указана"}</dd></div><div><dt>Уровень открытия</dt><dd>{item.unlock_level ?? "Не указан"}</dd></div><div><dt>Источник</dt><dd>{item.source_system ?? "Не указан"}</dd></div></dl>{missing.length > 0 && <p class="tackle-detail__missing"><strong>Не хватает:</strong> {missing.join(", ")}.</p>}{sourceHref && <a data-action="secondary" href={sourceHref} rel="noreferrer">Открыть первоисточник</a>}</div>
|
||||||
|
</section>
|
||||||
|
<DataPassport sources={item.source_system ? [item.source_system] : []} sourceUrl={item.source_url} observedAt={item.source_checked_at} completeness={missing.length ? null : 100} status={missing.length ? "incomplete" : item.source_checked_at ? "verified" : "unverified"} />
|
||||||
|
<section class="related-rigs content-grid" aria-labelledby="related-rigs-title"><div><span class="overline">Связанные сборки</span><h2 id="related-rigs-title">Где используется снасть</h2><p>Показываем только сборки, где компонент привязан к канонической карточке.</p></div>{item.related_rigs.length ? <nav class="related-rigs__list">{item.related_rigs.map(rig => <a href={`/tackle/rigs/${rig.id}`}><strong>{rig.name}</strong><span>{rig.component_count} компонентов · {rig.source_system ?? "Источник не указан"}</span></a>)}</nav> : <p>Подтверждённых связанных сборок пока нет.</p>}</section>
|
||||||
|
{mediaCandidates.length > 0 && <section class="catalog-media-grid content-grid" aria-label={`Изображения снасти: ${item.name}`}>{mediaCandidates.map(asset => <article><span class="overline">{mediaRoleLabel(asset.media_role ?? "")}</span><EntityMedia asset={asset} sourceLink /></article>)}</section>}
|
||||||
|
</>}
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.related-rigs { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 1fr); gap: 24px; margin-block: 26px; }
|
||||||
|
.related-rigs h2 { margin: 6px 0 8px; }
|
||||||
|
.related-rigs p { margin: 0; color: var(--text-muted); line-height: 1.5; }
|
||||||
|
.related-rigs__list { display: grid; gap: 10px; }
|
||||||
|
.related-rigs__list a { display: grid; gap: 4px; padding: 14px 16px; border: 1px solid var(--border); border-radius: 14px; color: inherit; text-decoration: none; }
|
||||||
|
.related-rigs__list a:hover, .related-rigs__list a:focus-visible { border-color: var(--focus); }
|
||||||
|
.related-rigs__list span { color: var(--text-muted); font-size: 12px; }
|
||||||
|
@media (max-width: 720px) { .related-rigs { grid-template-columns: 1fr; } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
import AtlasBreadcrumbs from "../../../components/AtlasBreadcrumbs.astro";
|
||||||
|
import DataPassport from "../../../components/DataPassport.astro";
|
||||||
|
import EntityMedia from "../../../components/EntityMedia.astro";
|
||||||
|
import Layout from "../../../layouts/Layout.astro";
|
||||||
|
import PageHero from "../../../components/PageHero.astro";
|
||||||
|
import StatePanel from "../../../components/StatePanel.astro";
|
||||||
|
import TackleGlyph from "../../../components/TackleGlyph.astro";
|
||||||
|
import { ApiError, api, type MediaAsset, type Rig } from "../../../lib/api";
|
||||||
|
import { findMediaByLabel, mediaRoleLabel } from "../../../lib/media";
|
||||||
|
import { safeHttpUrl } from "../../../lib/urls";
|
||||||
|
|
||||||
|
const { id } = Astro.params;
|
||||||
|
let rig: Rig | undefined, media: MediaAsset[] = [];
|
||||||
|
let unavailable = false;
|
||||||
|
const [rigResult, mediaResult] = await Promise.allSettled([
|
||||||
|
api<Rig>(`/api/v1/tackle/rigs/${encodeURIComponent(id ?? "")}`),
|
||||||
|
api<MediaAsset[]>("/api/v1/media/catalog?entity_type=tackle"),
|
||||||
|
]);
|
||||||
|
if (rigResult.status === "fulfilled") rig = rigResult.value;
|
||||||
|
else { const error = rigResult.reason; unavailable = !(error instanceof ApiError) || error.status >= 500; }
|
||||||
|
if (mediaResult.status === "fulfilled") media = mediaResult.value;
|
||||||
|
if (!rig && !unavailable) Astro.response.status = 404;
|
||||||
|
if (unavailable) {
|
||||||
|
Astro.response.status = 503;
|
||||||
|
Astro.response.headers.set("Retry-After", "60");
|
||||||
|
Astro.response.headers.set("Cache-Control", "no-store");
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleLabels: Record<string, string> = { lure: "Приманка", bait: "Наживка", rig: "Сборка", rod: "Удилище", reel: "Катушка", line: "Леска", hook: "Крючок", float: "Поплавок", sinker: "Груз" };
|
||||||
|
const missingLabels: Record<string, string> = { source_url: "ссылка на источник", source_checked_at: "дата проверки" };
|
||||||
|
const missing = rig?.missing_fields.map((field) => missingLabels[field] ?? field) ?? [];
|
||||||
|
const sourceHref = safeHttpUrl(rig?.source_url);
|
||||||
|
const mediaCandidates = rig ? [findMediaByLabel(media, rig.name, ["rig_diagram"])].filter((asset): asset is MediaAsset => Boolean(asset)) : [];
|
||||||
|
---
|
||||||
|
<Layout title={rig ? `${rig.name} — сборки RF4` : "Сборка не найдена — RF4 Spotter"} description={rig ? `Подтверждённая карточка сборки ${rig.name} в каталоге RF4 с составом и источником.` : "Такой карточки сборки нет в каталоге RF4."} noindex={!rig || unavailable} errorPage={!rig || unavailable}>
|
||||||
|
<AtlasBreadcrumbs items={[{ label: "Снасти", href: "/tackle" }, { label: "Сборки" }, { label: rig?.name ?? "Не найдено" }]} />
|
||||||
|
<PageHero eyebrow="Каноническая сборка" title={rig?.name ?? (unavailable ? "Каталог недоступен" : "Сборка не найдена")} description={rig ? "Состав сборки показан в исходном порядке; неподтверждённые значения не дополняются догадками." : undefined} variant={rig ? "fish" : undefined} identity={rig?.name} />
|
||||||
|
{unavailable ? <StatePanel tone="unavailable" title="Карточка временно недоступна" description="Каталог не ответил. Непроверенный состав не показываем." /> : !rig ? <StatePanel tone="error" title="Такой сборки нет в каталоге" actionHref="/tackle" actionLabel="Открыть каталог снастей" /> : <>
|
||||||
|
<section class="tackle-detail content-grid" aria-label="Состав сборки">
|
||||||
|
<div class="tackle-detail__identity"><TackleGlyph name={rig.name} size={72} /><span class="overline">Сборка</span><h2>{rig.name}</h2><p>{rig.source_external_id ? `Идентификатор источника: ${rig.source_external_id}` : "Внешний идентификатор не указан."}</p></div>
|
||||||
|
<div><span class="overline">Компоненты</span><ol class="rig-components">{rig.components.length ? rig.components.map(component => <li><span>{roleLabels[component.role] ?? component.role}</span>{component.tackle_item_id ? <a href={`/tackle/items/${component.tackle_item_id}`}>{component.raw_value ?? "Карточка снасти"}</a> : <span>{component.raw_value ?? "Значение не указано"}</span>}</li>) : <li>Состав не указан.</li>}</ol>{missing.length > 0 && <p class="tackle-detail__missing"><strong>Не хватает:</strong> {missing.join(", ")}.</p>}{sourceHref && <a data-action="secondary" href={sourceHref} rel="noreferrer">Открыть первоисточник</a>}</div>
|
||||||
|
</section>
|
||||||
|
<DataPassport sources={rig.source_system ? [rig.source_system] : []} sourceUrl={rig.source_url} observedAt={rig.source_checked_at} completeness={missing.length ? null : 100} status={missing.length ? "incomplete" : rig.source_checked_at ? "verified" : "unverified"} />
|
||||||
|
{mediaCandidates.length > 0 && <section class="catalog-media-grid content-grid" aria-label={`Изображения сборки: ${rig.name}`}>{mediaCandidates.map(asset => <article><span class="overline">{mediaRoleLabel(asset.media_role ?? "")}</span><EntityMedia asset={asset} sourceLink /></article>)}</section>}
|
||||||
|
</>}
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.rig-components { display: grid; gap: 10px; margin: 14px 0 20px; padding-left: 22px; }
|
||||||
|
.rig-components li { padding-left: 4px; color: var(--text-secondary); }
|
||||||
|
.rig-components li > span:first-child { display: inline-block; min-width: 110px; margin-right: 8px; color: var(--text-subtle); font-size: 11px; font-weight: 750; text-transform: uppercase; letter-spacing: .06em; }
|
||||||
|
.rig-components a { color: inherit; text-underline-offset: 3px; }
|
||||||
|
</style>
|
||||||
@@ -1,20 +1,38 @@
|
|||||||
---
|
---
|
||||||
import ActivityCard from "../../components/ActivityCard.astro";
|
import ActivityCard from "../../components/ActivityCard.astro";
|
||||||
|
import ActivityEvidenceSummary from "../../components/ActivityEvidenceSummary.astro";
|
||||||
|
import ActivityPeriodFilter from "../../components/ActivityPeriodFilter.astro";
|
||||||
import EntityMedia from "../../components/EntityMedia.astro";
|
import EntityMedia from "../../components/EntityMedia.astro";
|
||||||
import AtlasBreadcrumbs from "../../components/AtlasBreadcrumbs.astro";
|
import AtlasBreadcrumbs from "../../components/AtlasBreadcrumbs.astro";
|
||||||
import AtlasEntityLink from "../../components/AtlasEntityLink.astro";
|
import AtlasEntityLink from "../../components/AtlasEntityLink.astro";
|
||||||
import PageHero from "../../components/PageHero.astro";
|
import PageHero from "../../components/PageHero.astro";
|
||||||
import StatePanel from "../../components/StatePanel.astro";
|
import StatePanel from "../../components/StatePanel.astro";
|
||||||
|
import DataPassport from "../../components/DataPassport.astro";
|
||||||
import Layout from "../../layouts/Layout.astro";
|
import Layout from "../../layouts/Layout.astro";
|
||||||
|
import Pagination from "../../components/Pagination.astro";
|
||||||
import { api, plural, type Activity, type DictionaryItem, type MediaAsset, type PaginatedActivity } from "../../lib/api";
|
import { api, plural, type Activity, type DictionaryItem, type MediaAsset, type PaginatedActivity } from "../../lib/api";
|
||||||
import { findMediaByLabel } from "../../lib/media";
|
import { findMediaByLabel, mediaRoleLabel } from "../../lib/media";
|
||||||
|
import { safeHttpUrl } from "../../lib/urls";
|
||||||
const { slug } = Astro.params;
|
const { slug } = Astro.params;
|
||||||
let water: DictionaryItem | undefined, items: Activity[] = [], media: MediaAsset[] = [], unavailable = false;
|
const requestedHours = Astro.url.searchParams.get("hours") ?? "72";
|
||||||
|
const hours = ["24", "72", "168"].includes(requestedHours) ? requestedHours : "72";
|
||||||
|
const periodLabel = { "24": "24 часа", "72": "72 часа", "168": "7 дней" }[hours];
|
||||||
|
const requestedOffset = Number(Astro.url.searchParams.get("offset") ?? 0);
|
||||||
|
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
|
||||||
|
const activityLimit = 24;
|
||||||
|
let water: DictionaryItem | undefined, items: Activity[] = [], media: MediaAsset[] = [], totalItems = 0, unavailable = false;
|
||||||
try {
|
try {
|
||||||
const [waters, waterMedia] = await Promise.all([api<DictionaryItem[]>("/api/v1/waterbodies?limit=500"), api<MediaAsset[]>("/api/v1/media/catalog?entity_type=waterbody")]);
|
const [waters, waterMedia] = await Promise.all([api<DictionaryItem[]>("/api/v1/waterbodies?limit=500"), api<MediaAsset[]>("/api/v1/media/catalog?entity_type=waterbody")]);
|
||||||
media = waterMedia;
|
media = waterMedia;
|
||||||
water = waters.find(item => item.slug === slug);
|
water = waters.find(item => item.slug === slug);
|
||||||
if (water) { const paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=72&waterbody=${encodeURIComponent(water.slug)}&limit=100`); items = paginated.items; }
|
if (water) {
|
||||||
|
let paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=${hours}&waterbody=${encodeURIComponent(water.slug)}&limit=${activityLimit}&offset=${offset}`);
|
||||||
|
if (paginated.total > 0 && offset >= paginated.total) {
|
||||||
|
offset = Math.floor((paginated.total - 1) / activityLimit) * activityLimit;
|
||||||
|
paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=${hours}&waterbody=${encodeURIComponent(water.slug)}&limit=${activityLimit}&offset=${offset}`);
|
||||||
|
}
|
||||||
|
items = paginated.items; totalItems = paginated.total;
|
||||||
|
}
|
||||||
} catch { unavailable = true; }
|
} catch { unavailable = true; }
|
||||||
if (unavailable) {
|
if (unavailable) {
|
||||||
Astro.response.status = 503;
|
Astro.response.status = 503;
|
||||||
@@ -23,12 +41,20 @@ if (unavailable) {
|
|||||||
}
|
}
|
||||||
if (!water && !unavailable) Astro.response.status = 404;
|
if (!water && !unavailable) Astro.response.status = 404;
|
||||||
const fishes = [...new Map(items.map(item => [item.fish_slug, item.fish])).entries()];
|
const fishes = [...new Map(items.map(item => [item.fish_slug, item.fish])).entries()];
|
||||||
const image = water ? findMediaByLabel(media, water.name_ru) : undefined;
|
const coverImage = water ? findMediaByLabel(media, water.name_ru, ["waterbody_cover"]) : undefined;
|
||||||
|
const supportingMedia = water ? (["waterbody_map", "waterbody_depth_map"] as const).map(role => findMediaByLabel(media, water!.name_ru, [role])).filter((asset): asset is MediaAsset => Boolean(asset)) : [];
|
||||||
|
const sourceHref = safeHttpUrl(water?.source_url);
|
||||||
const schema = water ? { "@context":"https://schema.org", "@type":"CollectionPage", name:`Что ловить на ${water.name_ru} в RF4`, url:`https://rf4spotter.ru/waterbodies/${water.slug}` } : null;
|
const schema = water ? { "@context":"https://schema.org", "@type":"CollectionPage", name:`Что ловить на ${water.name_ru} в RF4`, url:`https://rf4spotter.ru/waterbodies/${water.slug}` } : null;
|
||||||
---
|
---
|
||||||
<Layout title={water ? `${water.name_ru} в RF4 — рыба и свежие точки` : "Водоём не найден — RF4 Spotter"} description={water ? `${water.name_ru} в Russian Fishing 4: свежие координаты, активные виды рыб, приманки и источники наблюдений.` : "Такого водоёма нет в каталоге RF4 Spotter."} noindex={!water || unavailable} structuredData={schema} errorPage={!water || unavailable}>
|
<Layout title={water ? `${water.name_ru} в RF4 — рыба и свежие точки` : "Водоём не найден — RF4 Spotter"} description={water ? `${water.name_ru} в Russian Fishing 4: свежие координаты, активные виды рыб, приманки и источники наблюдений.` : "Такого водоёма нет в каталоге RF4 Spotter."} noindex={!water || unavailable} structuredData={schema} errorPage={!water || unavailable}>
|
||||||
<AtlasBreadcrumbs items={[{ label: "Водоёмы", href: "/waterbodies" }, { label: water?.name_ru ?? "Не найдено" }]} />
|
<AtlasBreadcrumbs items={[{ label: "Водоёмы", href: "/waterbodies" }, { label: water?.name_ru ?? "Не найдено" }]} />
|
||||||
<PageHero eyebrow="Свежие данные за 72 часа" title={water?.name_ru ?? "Водоём не найден"} description={water ? `${items.length} ${plural(items.length,["активная точка","активные точки","активных точек"])} для ${fishes.length} ${plural(fishes.length,["вида рыбы","видов рыб","видов рыб"])}.` : undefined} variant={water ? "water" : undefined} identity={water?.slug} />
|
<PageHero eyebrow={`Свежие данные за ${periodLabel}`} title={water?.name_ru ?? "Водоём не найден"} description={water ? `${totalItems} ${plural(totalItems,["активная точка","активные точки","активных точек"])} для ${fishes.length} ${plural(fishes.length,["вида рыбы","видов рыб","видов рыб"])}.` : undefined} variant={water ? "water" : undefined} identity={water?.slug} />
|
||||||
{image && <section class="entity-feature content-grid" aria-label={`Изображение: ${water!.name_ru}`}><EntityMedia asset={image} sourceLink /><div><span class="overline">Карта и образ водоёма</span><h2>{water!.name_ru}</h2><p>Материал показан с прямой атрибуцией. Координаты активных точек ниже относятся к данным наблюдений, а не к геометрии изображения.</p></div></section>}
|
{water && <ActivityPeriodFilter hours={hours} />}
|
||||||
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !water ? <StatePanel tone="error" title="Такого водоёма нет в справочнике" actionHref="/waterbodies" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Рыбы</span>{fishes.length ? <nav>{fishes.map(([fishSlug,name]) => <AtlasEntityLink href={`/waterbodies/${water!.slug}/${fishSlug}`} label={name} kind="fish" identity={name} />)}</nav> : <p>Свежих подтверждённых видов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Проверьте позже или посмотрите полевые сигналы на главной." />}</div></section>}
|
{coverImage && <section class="entity-feature content-grid" aria-label={`Заставка: ${water!.name_ru}`}><EntityMedia asset={coverImage} sourceLink /><div><span class="overline">{mediaRoleLabel("waterbody_cover")}</span><h2>{water!.name_ru}</h2><p>Материал показан с прямой атрибуцией. Координаты активных точек ниже относятся к данным наблюдений, а не к геометрии изображения.</p></div></section>}
|
||||||
|
{supportingMedia.length > 0 && <section class="waterbody-media-grid content-grid" aria-label={`Карты: ${water!.name_ru}`}>{supportingMedia.map(asset => <article><span class="overline">{mediaRoleLabel(asset.media_role ?? "")}</span><EntityMedia asset={asset} sourceLink /></article>)}</section>}
|
||||||
|
{water && (water.description || water.unlock_level !== undefined || water.source_url || water.source_fish_species) && <section class="data-passport content-grid" aria-label="Паспорт водоёма"><div><span class="overline">Канонические сведения</span><h2>{water.name_ru}</h2>{water.description && <p>{water.description}</p>}{water.unlock_level !== undefined && water.unlock_level !== null && <p>Открывается с уровня: <strong>{water.unlock_level}</strong></p>}{water.source_fish_species?.length && <p>В карточке источника указано видов рыб: <strong>{water.source_fish_species.length}</strong>.</p>}{water.source_aliases?.length && <p>Алиасы источника: {water.source_aliases.join(", ")}.</p>}</div><div><span class="overline">Источник</span>{sourceHref ? <p><a href={sourceHref} rel="noreferrer">{water.source_system ?? "Внешний источник"} · исходная карточка</a></p> : <p>Источник для описания ещё не подтверждён.</p>}{water.source_point_urls?.length && <p>Сохранено ссылок на точки: <strong>{water.source_point_urls.length}</strong>.</p>}{water.source_image_urls?.length && <p>Изображений-кандидатов: <strong>{water.source_image_urls.length}</strong>; публикация требует отдельной проверки.</p>}{water.source_checked_at && <p>Проверено: {new Date(water.source_checked_at).toLocaleDateString("ru-RU")}</p>}</div></section>}
|
||||||
|
{water && <DataPassport sources={water.source_system ? [water.source_system] : []} sourceUrl={water.source_url} observedAt={water.source_checked_at} status={water.source_checked_at ? "verified" : "unverified"}/>}
|
||||||
|
{water && <ActivityEvidenceSummary items={items} totalPoints={totalItems} periodLabel={periodLabel} />}
|
||||||
|
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Каталог сохранён, но свежие наблюдения сейчас не получены." /> : !water ? <StatePanel tone="error" title="Такого водоёма нет в справочнике" actionHref="/waterbodies" actionLabel="Открыть каталог" /> : <section class="catalog-results content-grid"><aside><span class="overline">Рыбы</span>{fishes.length ? <nav>{fishes.map(([fishSlug,name]) => <AtlasEntityLink href={`/waterbodies/${water!.slug}/${fishSlug}?hours=${hours}`} label={name} kind="fish" identity={name} />)}</nav> : <p>Свежих подтверждённых видов пока нет.</p>}</aside><div>{items.length ? items.map(item => <ActivityCard item={item} periodLabel={periodLabel}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Свежих подтверждённых наблюдений пока нет." />}</div></section>}
|
||||||
|
{water && <Pagination path={`/waterbodies/${water.slug}`} params={new URLSearchParams({ hours })} total={totalItems} limit={activityLimit} offset={offset} itemLabel="точек" />}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -1,16 +1,32 @@
|
|||||||
---
|
---
|
||||||
import ActivityCard from "../../../components/ActivityCard.astro";
|
import ActivityCard from "../../../components/ActivityCard.astro";
|
||||||
|
import ActivityEvidenceSummary from "../../../components/ActivityEvidenceSummary.astro";
|
||||||
|
import ActivityPeriodFilter from "../../../components/ActivityPeriodFilter.astro";
|
||||||
import AtlasBreadcrumbs from "../../../components/AtlasBreadcrumbs.astro";
|
import AtlasBreadcrumbs from "../../../components/AtlasBreadcrumbs.astro";
|
||||||
import PageHero from "../../../components/PageHero.astro";
|
import PageHero from "../../../components/PageHero.astro";
|
||||||
import StatePanel from "../../../components/StatePanel.astro";
|
import StatePanel from "../../../components/StatePanel.astro";
|
||||||
import Layout from "../../../layouts/Layout.astro";
|
import Layout from "../../../layouts/Layout.astro";
|
||||||
|
import Pagination from "../../../components/Pagination.astro";
|
||||||
import { api, plural, type Activity, type DictionaryItem, type PaginatedActivity } from "../../../lib/api";
|
import { api, plural, type Activity, type DictionaryItem, type PaginatedActivity } from "../../../lib/api";
|
||||||
const { slug, fish: fishSlug } = Astro.params;
|
const { slug, fish: fishSlug } = Astro.params;
|
||||||
let water: DictionaryItem | undefined, fish: DictionaryItem | undefined, items: Activity[] = [], unavailable = false;
|
const requestedHours = Astro.url.searchParams.get("hours") ?? "72";
|
||||||
|
const hours = ["24", "72", "168"].includes(requestedHours) ? requestedHours : "72";
|
||||||
|
const periodLabel = { "24": "24 часа", "72": "72 часа", "168": "7 дней" }[hours];
|
||||||
|
const requestedOffset = Number(Astro.url.searchParams.get("offset") ?? 0);
|
||||||
|
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
|
||||||
|
const activityLimit = 24;
|
||||||
|
let water: DictionaryItem | undefined, fish: DictionaryItem | undefined, items: Activity[] = [], totalItems = 0, unavailable = false;
|
||||||
try {
|
try {
|
||||||
const [waters, fishes] = await Promise.all([api<DictionaryItem[]>("/api/v1/waterbodies?limit=500"), api<DictionaryItem[]>("/api/v1/fishes?limit=500")]);
|
const [waters, fishes] = await Promise.all([api<DictionaryItem[]>("/api/v1/waterbodies?limit=500"), api<DictionaryItem[]>("/api/v1/fishes?limit=500")]);
|
||||||
water = waters.find(item => item.slug === slug); fish = fishes.find(item => item.slug === fishSlug);
|
water = waters.find(item => item.slug === slug); fish = fishes.find(item => item.slug === fishSlug);
|
||||||
if (water && fish) { const paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=72&waterbody=${encodeURIComponent(water.slug)}&fish=${encodeURIComponent(fish.slug)}&limit=100`); items = paginated.items; }
|
if (water && fish) {
|
||||||
|
let paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=${hours}&waterbody=${encodeURIComponent(water.slug)}&fish=${encodeURIComponent(fish.slug)}&limit=${activityLimit}&offset=${offset}`);
|
||||||
|
if (paginated.total > 0 && offset >= paginated.total) {
|
||||||
|
offset = Math.floor((paginated.total - 1) / activityLimit) * activityLimit;
|
||||||
|
paginated = await api<PaginatedActivity>(`/api/v1/activity?hours=${hours}&waterbody=${encodeURIComponent(water.slug)}&fish=${encodeURIComponent(fish.slug)}&limit=${activityLimit}&offset=${offset}`);
|
||||||
|
}
|
||||||
|
items = paginated.items; totalItems = paginated.total;
|
||||||
|
}
|
||||||
} catch { unavailable = true; }
|
} catch { unavailable = true; }
|
||||||
if (unavailable) {
|
if (unavailable) {
|
||||||
Astro.response.status = 503;
|
Astro.response.status = 503;
|
||||||
@@ -23,6 +39,9 @@ const schema = valid ? { "@type":"CollectionPage", name:`${fish!.name_ru} на $
|
|||||||
---
|
---
|
||||||
<Layout title={valid ? `${fish!.name_ru} на ${water!.name_ru} в RF4 — точки и приманки` : "Страница не найдена — RF4 Spotter"} description={valid ? `Где ловить ${fish!.name_ru} на ${water!.name_ru} в Russian Fishing 4: свежие координаты, приманки, активность и источники.` : "Такого сочетания рыбы и водоёма нет в каталоге."} noindex={!valid || unavailable} structuredData={schema} errorPage={!valid || unavailable}>
|
<Layout title={valid ? `${fish!.name_ru} на ${water!.name_ru} в RF4 — точки и приманки` : "Страница не найдена — RF4 Spotter"} description={valid ? `Где ловить ${fish!.name_ru} на ${water!.name_ru} в Russian Fishing 4: свежие координаты, приманки, активность и источники.` : "Такого сочетания рыбы и водоёма нет в каталоге."} noindex={!valid || unavailable} structuredData={schema} errorPage={!valid || unavailable}>
|
||||||
<AtlasBreadcrumbs items={[{ label: "Водоёмы", href: "/waterbodies" }, { label: water?.name_ru ?? "Не найдено", href: water ? `/waterbodies/${water.slug}` : undefined }, { label: fish?.name_ru ?? "Не найдено" }]} />
|
<AtlasBreadcrumbs items={[{ label: "Водоёмы", href: "/waterbodies" }, { label: water?.name_ru ?? "Не найдено", href: water ? `/waterbodies/${water.slug}` : undefined }, { label: fish?.name_ru ?? "Не найдено" }]} />
|
||||||
<PageHero eyebrow={water?.name_ru ?? "Каталог RF4"} title={fish?.name_ru ?? "Данные не найдены"} description={valid ? `${items.length} ${plural(items.length,["свежая точка","свежие точки","свежих точек"])} за последние 72 часа.` : undefined} variant={valid ? "pair" : undefined} identity={fish?.name_ru} secondaryIdentity={water?.slug} />
|
<PageHero eyebrow={water?.name_ru ?? "Каталог RF4"} title={fish?.name_ru ?? "Данные не найдены"} description={valid ? `${totalItems} ${plural(totalItems,["свежая точка","свежие точки","свежих точек"])} за ${periodLabel}.` : undefined} variant={valid ? "pair" : undefined} identity={fish?.name_ru} secondaryIdentity={water?.slug} />
|
||||||
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Свежие наблюдения сейчас не получены. Попробуйте позднее." /> : !valid ? <StatePanel tone="error" title="Такого сочетания нет в справочнике" actionHref="/" actionLabel="Вернуться на главную" /> : <section class="catalog-results catalog-results--single content-grid"><div>{items.length ? items.map(item => <ActivityCard item={item}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Данные появятся после новых подтверждённых наблюдений." />}</div></section>}
|
{valid && <ActivityPeriodFilter hours={hours} />}
|
||||||
|
{valid && <ActivityEvidenceSummary items={items} totalPoints={totalItems} periodLabel={periodLabel} />}
|
||||||
|
{unavailable ? <StatePanel tone="unavailable" title="Данные временно недоступны" description="Свежие наблюдения сейчас не получены. Попробуйте позднее." /> : !valid ? <StatePanel tone="error" title="Такого сочетания нет в справочнике" actionHref="/" actionLabel="Вернуться на главную" /> : <section class="catalog-results catalog-results--single content-grid"><div>{items.length ? items.map(item => <ActivityCard item={item} periodLabel={periodLabel}/>) : <StatePanel contained={false} title="Свежих точек пока нет" description="Данные появятся после новых подтверждённых наблюдений." />}</div></section>}
|
||||||
|
{valid && <Pagination path={`/waterbodies/${water!.slug}/${fish!.slug}`} params={new URLSearchParams({ hours })} total={totalItems} limit={activityLimit} offset={offset} itemLabel="точек" />}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -3,16 +3,30 @@ import Layout from "../../layouts/Layout.astro";
|
|||||||
import PageHero from "../../components/PageHero.astro";
|
import PageHero from "../../components/PageHero.astro";
|
||||||
import StatePanel from "../../components/StatePanel.astro";
|
import StatePanel from "../../components/StatePanel.astro";
|
||||||
import WaterbodyMark from "../../components/WaterbodyMark.astro";
|
import WaterbodyMark from "../../components/WaterbodyMark.astro";
|
||||||
|
import Pagination from "../../components/Pagination.astro";
|
||||||
import { api, type DictionaryItem } from "../../lib/api";
|
import { api, type DictionaryItem } from "../../lib/api";
|
||||||
let waters: DictionaryItem[] = [], unavailable = false;
|
const params = Astro.url.searchParams;
|
||||||
try { waters = await api<DictionaryItem[]>("/api/v1/waterbodies?limit=500"); } catch { unavailable = true; }
|
const search = params.get("q")?.trim() ?? "";
|
||||||
|
const pageLimit = 48;
|
||||||
|
const requestedOffset = Number(params.get("offset") ?? 0);
|
||||||
|
let offset = Number.isInteger(requestedOffset) && requestedOffset >= 0 ? requestedOffset : 0;
|
||||||
|
let waters: DictionaryItem[] = [], allWaters: DictionaryItem[] = [], unavailable = false;
|
||||||
|
try {
|
||||||
|
allWaters = await api<DictionaryItem[]>("/api/v1/waterbodies?limit=500");
|
||||||
|
const matchingWaters = search ? allWaters.filter(item => item.name_ru.toLocaleLowerCase("ru-RU").includes(search.toLocaleLowerCase("ru-RU"))) : allWaters;
|
||||||
|
if (matchingWaters.length > 0 && offset >= matchingWaters.length) offset = Math.floor((matchingWaters.length - 1) / pageLimit) * pageLimit;
|
||||||
|
waters = matchingWaters.slice(offset, offset + pageLimit);
|
||||||
|
allWaters = matchingWaters;
|
||||||
|
} catch { unavailable = true; }
|
||||||
if (unavailable) {
|
if (unavailable) {
|
||||||
Astro.response.status = 503;
|
Astro.response.status = 503;
|
||||||
Astro.response.headers.set("Retry-After", "60");
|
Astro.response.headers.set("Retry-After", "60");
|
||||||
Astro.response.headers.set("Cache-Control", "no-store");
|
Astro.response.headers.set("Cache-Control", "no-store");
|
||||||
}
|
}
|
||||||
---
|
---
|
||||||
<Layout title="Все водоёмы Russian Fishing 4 — RF4 Spotter" description="Каталог водоёмов RF4 со свежими точками, рыбами, приманками и прозрачными источниками данных.">
|
<Layout title="Все водоёмы Russian Fishing 4 — RF4 Spotter" description="Каталог водоёмов RF4 со свежими точками, рыбами, приманками и прозрачными источниками данных." noindex={unavailable} errorPage={unavailable}>
|
||||||
<PageHero eyebrow="Карта водоёмов RF4" title="Водоёмы" description="Откройте водоём, чтобы увидеть активные виды рыб и последние подтверждённые точки." variant="water" count={waters.length} />
|
<PageHero eyebrow="Карта водоёмов RF4" title="Водоёмы" description="Откройте водоём, чтобы увидеть активные виды рыб и последние подтверждённые точки." variant="water" count={allWaters.length} />
|
||||||
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенный список. Попробуйте обновить страницу позже." /> : <nav class="catalog-grid catalog-grid--waters content-grid" aria-label="Водоёмы">{waters.map((water,index) => <a href={`/waterbodies/${water.slug}`}><span>Водоём · {String(index + 1).padStart(2,"0")}</span><strong>{water.name_ru}</strong><i>Открыть <b>→</b></i><WaterbodyMark identity={water.slug}/></a>)}</nav>}
|
<form class="record-filters content-grid" method="get" aria-label="Поиск водоёма"><label>Поиск по названию<input name="q" value={search} maxlength="100" placeholder="Например, Вьюнок" /></label><button data-action="primary">Найти</button>{search && <a data-action="quiet" href="/waterbodies">Сбросить</a>}</form>
|
||||||
|
{unavailable ? <StatePanel tone="unavailable" title="Каталог временно недоступен" description="Не показываем непроверенный список. Попробуйте обновить страницу позже." /> : waters.length ? <nav class="catalog-grid catalog-grid--waters content-grid" aria-label="Водоёмы">{waters.map((water,index) => <a href={`/waterbodies/${water.slug}`}><span>Водоём · {String(offset + index + 1).padStart(2,"0")}</span><strong>{water.name_ru}</strong><i>Открыть <b>→</b></i><WaterbodyMark identity={water.slug}/></a>)}</nav> : <StatePanel title="Ничего не найдено" description="Попробуйте изменить запрос или открыть полный каталог водоёмов." />}
|
||||||
|
{!unavailable && <Pagination path="/waterbodies" params={params} total={allWaters.length} limit={pageLimit} offset={offset} itemLabel="водоёмов" />}
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
.dashboard:has(>.results-column:only-child){grid-template-columns:1fr}
|
.dashboard:has(>.results-column:only-child){grid-template-columns:1fr}
|
||||||
|
.result-tools{display:flex;align-items:center;gap:10px}.view-toggle{display:flex;padding:3px;border:1px solid var(--border);border-radius:999px;background:var(--surface-soft)}.view-toggle a{padding:7px 10px;border-radius:999px;color:var(--text-muted);font-size:11px;font-weight:750;text-decoration:none}.view-toggle a:hover,.view-toggle__active{background:var(--surface);color:var(--text)!important;box-shadow:0 2px 8px color-mix(in srgb,var(--deep) 10%,transparent)}
|
||||||
|
@media(max-width:720px){.section-heading{align-items:start}.result-tools{display:grid;justify-items:end;gap:7px}.view-toggle a{padding:6px 9px}}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
.data-passport{margin-top:18px;padding:14px;border:1px solid var(--border);border-radius:13px;background:var(--surface-soft);color:var(--deep)}
|
.data-passport{margin-top:18px;padding:14px;border:1px solid var(--border);border-radius:13px;background:var(--surface-soft);color:var(--deep)}
|
||||||
.data-passport header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:11px}.data-passport header>span{font:italic 15px Georgia,serif}.data-passport header strong{padding:4px 8px;border-radius:999px;background:var(--success-soft);color:var(--success);font-size:9px;text-transform:uppercase;letter-spacing:.06em}.data-passport header strong[data-passport-status="unverified"]{background:var(--info-soft);color:var(--info)}.data-passport header strong[data-passport-status="incomplete"]{background:var(--warning-soft);color:var(--warning)}
|
.data-passport header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:11px}.data-passport header>span{font:italic 15px Georgia,serif}.data-passport header strong{padding:4px 8px;border-radius:999px;background:var(--success-soft);color:var(--success);font-size:9px;text-transform:uppercase;letter-spacing:.06em}.data-passport header strong[data-passport-status="unverified"]{background:var(--info-soft);color:var(--info)}.data-passport header strong[data-passport-status="incomplete"],.data-passport header strong[data-passport-status="insufficient"],.data-passport header strong[data-passport-status="blocked"],.data-passport header strong[data-passport-status="stale"]{background:var(--warning-soft);color:var(--warning)}
|
||||||
.data-passport__sources{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:12px}.data-passport dl{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:0}.data-passport dl div{padding-top:8px;border-top:1px solid var(--border-soft)}.data-passport dt{font-size:8px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-subtle)}.data-passport dd{margin:3px 0 0;font:400 12px Georgia,serif;color:var(--deep)}
|
.data-passport__sources{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:12px}.data-passport dl{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:0}.data-passport dl div{padding-top:8px;border-top:1px solid var(--border-soft)}.data-passport dt{font-size:8px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-subtle)}.data-passport dd{margin:3px 0 0;font:400 12px Georgia,serif;color:var(--deep)}
|
||||||
|
.data-passport__minimum{margin:12px 0 0;color:var(--warning);font-size:11px;line-height:1.4}
|
||||||
|
.data-passport__conflict{margin:12px 0 0;padding:9px 10px;border-left:3px solid var(--warning);background:var(--warning-soft);color:var(--warning);font-size:11px;line-height:1.4}.data-passport__conflict strong{font-weight:750}.data-passport__coordinate-sources{margin-top:12px;border-top:1px solid var(--border-soft);padding-top:10px}.data-passport__coordinate-sources summary{cursor:pointer;color:var(--text-secondary);font-size:10px;font-weight:750;text-transform:uppercase;letter-spacing:.06em}.data-passport__coordinate-sources>div{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}
|
||||||
.detail-card .data-passport,.detail-grid aside .data-passport{border-color:#ffffff20;background:#ffffff08;color:#fff}.detail-card .data-passport dd,.detail-grid aside .data-passport dd{color:#fff}.detail-card .data-passport dl div,.detail-grid aside .data-passport dl div{border-color:#ffffff1c}.detail-card .data-passport dt,.detail-grid aside .data-passport dt{color:#a9b8b5}.signal-card .data-passport{position:relative;z-index:1;margin-top:15px;background:#fffdf7;border-color:#ddd3af}
|
.detail-card .data-passport,.detail-grid aside .data-passport{border-color:#ffffff20;background:#ffffff08;color:#fff}.detail-card .data-passport dd,.detail-grid aside .data-passport dd{color:#fff}.detail-card .data-passport dl div,.detail-grid aside .data-passport dl div{border-color:#ffffff1c}.detail-card .data-passport dt,.detail-grid aside .data-passport dt{color:#a9b8b5}.signal-card .data-passport{position:relative;z-index:1;margin-top:15px;background:#fffdf7;border-color:#ddd3af}
|
||||||
@media(max-width:420px){.data-passport dl{grid-template-columns:1fr}.data-passport dl div{display:flex;justify-content:space-between;gap:10px}}
|
@media(max-width:720px){.data-passport dl{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:420px){.data-passport dl{grid-template-columns:1fr}.data-passport dl div{display:flex;justify-content:space-between;gap:10px}}
|
||||||
|
|||||||
@@ -17,16 +17,26 @@
|
|||||||
.admin-queue-filters{display:grid;grid-template-columns:1.3fr repeat(3,1fr) auto;gap:10px;align-items:end;margin:0 0 18px;padding:16px;border-radius:12px;background:var(--deep)}.admin-queue-filters[hidden]{display:none}.admin-queue-filters label{color:#b8c5c2;font-size:11px;text-transform:uppercase;letter-spacing:.08em}.admin-queue-filters input,.admin-queue-filters select{display:block;width:100%;height:42px;margin-top:6px;padding:0 10px;border:1px solid #ffffff29;border-radius:8px;background:#ffffff0c;color:#fff}.admin-queue-filters option{color:var(--ink)}.admin-queue-filters button{height:42px}
|
.admin-queue-filters{display:grid;grid-template-columns:1.3fr repeat(3,1fr) auto;gap:10px;align-items:end;margin:0 0 18px;padding:16px;border-radius:12px;background:var(--deep)}.admin-queue-filters[hidden]{display:none}.admin-queue-filters label{color:#b8c5c2;font-size:11px;text-transform:uppercase;letter-spacing:.08em}.admin-queue-filters input,.admin-queue-filters select{display:block;width:100%;height:42px;margin-top:6px;padding:0 10px;border:1px solid #ffffff29;border-radius:8px;background:#ffffff0c;color:#fff}.admin-queue-filters option{color:var(--ink)}.admin-queue-filters button{height:42px}
|
||||||
.admin-shortcuts{margin:14px 0;color:var(--text-muted);font-size:12px}.admin-shortcuts kbd{padding:2px 6px;border:1px solid var(--line);border-bottom-width:2px;border-radius:5px;background:var(--surface);font:700 11px Inter,sans-serif}.moderation-card:focus-within{outline:2px solid var(--focus);outline-offset:3px}
|
.admin-shortcuts{margin:14px 0;color:var(--text-muted);font-size:12px}.admin-shortcuts kbd{padding:2px 6px;border:1px solid var(--line);border-bottom-width:2px;border-radius:5px;background:var(--surface);font:700 11px Inter,sans-serif}.moderation-card:focus-within{outline:2px solid var(--focus);outline-offset:3px}
|
||||||
.moderation-provenance{margin-top:18px;border-top:1px solid var(--border-soft);padding-top:14px}.moderation-provenance summary{cursor:pointer;color:var(--deep);font-weight:700}.moderation-provenance dl{margin-top:14px}.moderation-provenance p{margin:12px 0 0;color:var(--text-muted);font-size:12px;line-height:1.5}
|
.moderation-provenance{margin-top:18px;border-top:1px solid var(--border-soft);padding-top:14px}.moderation-provenance summary{cursor:pointer;color:var(--deep);font-weight:700}.moderation-provenance dl{margin-top:14px}.moderation-provenance p{margin:12px 0 0;color:var(--text-muted);font-size:12px;line-height:1.5}
|
||||||
|
.queue-select-all{display:inline-flex;align-items:center;gap:8px;margin:0 0 12px;color:var(--text-muted);font-size:13px}.moderation-select{display:block;grid-column:1/-1}.moderation-select input{width:18px;height:18px}.moderation-bulk,[data-external-bulk]{display:flex;align-items:end;flex-wrap:wrap;gap:12px;margin:0 0 18px;padding:14px;border:1px solid var(--border);border-radius:12px;background:var(--surface);color:var(--text)}.moderation-bulk strong,[data-external-bulk] strong{margin-right:auto}.moderation-bulk label,[data-external-bulk] label{min-width:180px;color:var(--text-muted);font-size:11px;text-transform:uppercase;letter-spacing:.08em}.moderation-bulk textarea,[data-external-bulk] textarea,.moderation-bulk select,[data-external-bulk] select{display:block;width:100%;margin-top:5px;padding:9px;border:1px solid var(--border);border-radius:7px;background:var(--surface-field);color:var(--text);resize:vertical}.moderation-bulk button,[data-external-bulk] button{min-height:42px;padding:0 15px;border:0;border-radius:8px;background:var(--lime);color:var(--deep);font-weight:750}.moderation-bulk button[data-action="danger"],[data-external-bulk] button[data-action="danger"]{background:var(--danger-soft);color:var(--danger)}
|
||||||
@media(max-width:900px){.admin-queue-filters{grid-template-columns:1fr 1fr}}@media(max-width:520px){.admin-queue-filters{grid-template-columns:1fr}}
|
@media(max-width:900px){.admin-queue-filters{grid-template-columns:1fr 1fr}}@media(max-width:520px){.admin-queue-filters{grid-template-columns:1fr}}
|
||||||
.admin-dashboard{width:min(1120px,calc(100% - 64px));margin:0 auto 100px}.admin-dashboard>.privacy{margin:10px 4px 25px}.admin-kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.admin-kpis>a,.admin-kpis>article,.admin-dashboard-grid>section{padding:22px;border:1px solid var(--line);border-radius:14px;background:var(--surface)}.admin-kpis>a{color:inherit;text-decoration:none}.admin-kpis span,.admin-kpis small{display:block;color:var(--text-muted)}.admin-kpis strong{display:block;margin:10px 0;font:400 42px Georgia,serif;color:var(--deep)}.admin-kpis>a:hover{border-color:var(--focus);transform:translateY(-2px)}.admin-dashboard-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:18px}.admin-dashboard-grid h2{margin:0 0 16px;font:400 25px Georgia,serif}.admin-dashboard-grid ul{list-style:none;margin:0 0 16px;padding:0}.admin-dashboard-grid li{display:flex;justify-content:space-between;gap:14px;padding:10px 0;border-bottom:1px solid var(--border-soft);font-size:13px}.admin-dashboard-grid li span{display:flex;align-items:center;gap:8px}.admin-dashboard-grid time{color:var(--text-muted)}
|
.admin-dashboard{width:min(1120px,calc(100% - 64px));margin:0 auto 100px}.admin-dashboard>.privacy{margin:10px 4px 25px}.admin-kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.admin-kpis>a,.admin-kpis>article,.admin-dashboard-grid>section{padding:22px;border:1px solid var(--line);border-radius:14px;background:var(--surface)}.admin-kpis>a{color:inherit;text-decoration:none}.admin-kpis span,.admin-kpis small{display:block;color:var(--text-muted)}.admin-kpis strong{display:block;margin:10px 0;font:400 42px Georgia,serif;color:var(--deep)}.admin-kpis>a:hover{border-color:var(--focus);transform:translateY(-2px)}.admin-dashboard-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:18px}.admin-dashboard-grid h2{margin:0 0 16px;font:400 25px Georgia,serif}.admin-dashboard-grid ul{list-style:none;margin:0 0 16px;padding:0}.admin-dashboard-grid li{display:flex;justify-content:space-between;gap:14px;padding:10px 0;border-bottom:1px solid var(--border-soft);font-size:13px}.admin-dashboard-grid li span{display:flex;align-items:center;gap:8px}.admin-dashboard-grid time{color:var(--text-muted)}
|
||||||
@media(max-width:900px){.admin-kpis{grid-template-columns:1fr 1fr}}@media(max-width:720px){.admin-dashboard{width:calc(100% - 28px)}.admin-kpis,.admin-dashboard-grid{grid-template-columns:1fr}}
|
@media(max-width:900px){.admin-kpis{grid-template-columns:1fr 1fr}}@media(max-width:720px){.admin-dashboard{width:calc(100% - 28px)}.admin-kpis,.admin-dashboard-grid{grid-template-columns:1fr}}
|
||||||
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 .brand-name span{color:#9dafaa}footer p{font-size:12px;color:#92a4a0}footer>span{font:italic 16px Georgia;color:var(--lime)}
|
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 .brand-name span{color:#9dafaa}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: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:30px 0 25px}.intro h1{font-size:47px;margin:13px 0 12px}.intro-copy>p{font-size:15px;margin:0}.lake-card{display:none}.filters-wrap{position:relative;padding:17px 0}.filters{grid-template-columns:1fr 1fr}.filter-advanced-field{display:none}.filter-compact-hidden{display:none!important}.filter-advanced-fallback{display:block;grid-column:1/-1;border-top:1px solid #ffffff1d;padding-top:10px}.filter-advanced-fallback summary{display:flex;justify-content:space-between;color:#d5dfdc;font-size:12px;cursor:pointer;list-style:none}.filter-advanced-fallback summary::-webkit-details-marker{display:none}.filter-advanced-fallback summary:before{content:"+";margin-right:7px;color:var(--lime)}.filter-advanced-fallback[open] summary:before{content:"−"}.filter-advanced-fallback summary span{margin-left:auto;color:#9fb0ad}.filter-advanced-fallback .advanced-fields{display:grid!important;grid-template-columns:1fr 1fr;gap:12px;padding-top:12px}.filter-advanced-fallback:not([open]) .advanced-fields{display:none!important}.filters button{grid-column:1/-1}.active-filters{min-height:0;overflow-x:auto;padding:10px 14px 0;width:100%;scrollbar-width:none}.active-filters span{flex:0 0 auto}.active-filters a{position:sticky;right:0;padding:6px 10px;background:var(--paper)}.dashboard{padding:28px 0 74px;scroll-margin-top:10px}.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: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:30px 0 25px}.intro h1{font-size:47px;margin:13px 0 12px}.intro-copy>p{font-size:15px;margin:0}.lake-card{display:none}.filters-wrap{position:relative;padding:17px 0}.filters{grid-template-columns:1fr 1fr}.filter-advanced-field{display:none}.filter-compact-hidden{display:none!important}.filter-advanced-fallback{display:block;grid-column:1/-1;border-top:1px solid #ffffff1d;padding-top:10px}.filter-advanced-fallback summary{display:flex;justify-content:space-between;color:#d5dfdc;font-size:12px;cursor:pointer;list-style:none}.filter-advanced-fallback summary::-webkit-details-marker{display:none}.filter-advanced-fallback summary:before{content:"+";margin-right:7px;color:var(--lime)}.filter-advanced-fallback[open] summary:before{content:"−"}.filter-advanced-fallback summary span{margin-left:auto;color:#9fb0ad}.filter-advanced-fallback .advanced-fields{display:grid!important;grid-template-columns:1fr 1fr;gap:12px;padding-top:12px}.filter-advanced-fallback:not([open]) .advanced-fields{display:none!important}.filters button{grid-column:1/-1}.active-filters{min-height:0;overflow-x:auto;padding:10px 14px 0;width:100%;scrollbar-width:none}.active-filters span{flex:0 0 auto}.active-filters a{position:sticky;right:0;padding:6px 10px;background:var(--paper)}.dashboard{padding:28px 0 74px;scroll-margin-top:10px}.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}}
|
||||||
|
|
||||||
|
/* The tablet header needs the same two-row layout as mobile: the full theme
|
||||||
|
switcher and horizontal navigation cannot share one 720px row. */
|
||||||
|
@media (min-width:721px) and (max-width:900px){
|
||||||
|
.topbar{width:100%;padding:13px 24px 0;display:flex;flex-wrap:wrap;height:auto}
|
||||||
|
.topbar .brand{flex:1;min-width:0}
|
||||||
|
.topbar nav{order:2;width:100%;height:46px;overflow-x:auto}
|
||||||
|
.topbar nav a{flex:0 0 auto;font-size:13px}
|
||||||
|
}
|
||||||
@media(max-width:720px){.moderation-app{width:calc(100% - 28px)}.moderation-card{grid-template-columns:1fr}.moderation-proof{grid-row:2}.moderation-actions{grid-column:1;display:block}.moderation-actions>div{margin-top:12px}.admin-login{display:block}.admin-login button{width:100%;margin-top:12px}}
|
@media(max-width:720px){.moderation-app{width:calc(100% - 28px)}.moderation-card{grid-template-columns:1fr}.moderation-proof{grid-row:2}.moderation-actions{grid-column:1;display:block}.moderation-actions>div{margin-top:12px}.admin-login{display:block}.admin-login button{width:100%;margin-top:12px}}
|
||||||
@media(max-width:480px){.filters{grid-template-columns:1fr 1fr}.spot-stats strong{font-size:16px}.topbar nav{gap:4px}.topbar nav a{padding:0 5px}.brand-name strong{font-size:14px}.brand-name span{font-size:11px}.moderation-actions>div{display:grid}.moderation-actions button{width:100%}}
|
@media(max-width:480px){.filters{grid-template-columns:1fr 1fr}.spot-stats strong{font-size:16px}.topbar nav{gap:4px}.topbar nav a{padding:0 5px}.brand-name strong{font-size:14px}.brand-name span{font-size:11px}.moderation-actions>div{display:grid}.moderation-actions button{width:100%}}
|
||||||
@media(max-width:720px){.filter-advanced-fallback .filter-advanced-field{display:flex!important;align-items:center;gap:6px}.filter-advanced-fallback .filter-compact-hidden{display:flex!important}}
|
@media(max-width:720px){.filter-advanced-fallback .filter-advanced-field{display:flex!important;align-items:center;gap:6px}.filter-advanced-fallback .filter-compact-hidden{display:flex!important}}
|
||||||
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}}
|
@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01s!important;animation-iteration-count:1!important}}
|
||||||
.activity-legend{display:flex;gap:10px;flex-wrap:wrap;margin:8px 0;color:#7d918b;font-size:11px}.activity-legend span:before{content:"";display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:4px;background:var(--lime)}.activity-legend span:nth-child(2):before{background:#e5b95c}.activity-legend span:nth-child(3):before{background:#ef795e}
|
.activity-legend{display:flex;gap:10px;flex-wrap:wrap;margin:8px 0;color:#7d918b;font-size:11px}.activity-legend span:before{content:"";display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:4px;background:var(--lime)}.activity-legend span:nth-child(2):before{background:#e5b95c}.activity-legend span:nth-child(3):before{background:#ef795e}
|
||||||
|
|
||||||
/* RF4 field-guide iconography and float activity gauge */
|
/* RF4 field-guide iconography and float activity gauge */
|
||||||
|
|||||||
@@ -1,2 +1,8 @@
|
|||||||
.entity-media{margin:0;min-width:0}.entity-media__frame{position:relative;display:grid;place-items:center;overflow:hidden;min-height:190px;padding:18px;border:1px solid var(--border-soft);border-radius:16px;background:radial-gradient(circle at 50% 46%,color-mix(in srgb,var(--lime) 15%,var(--surface)) 0 18%,var(--surface-soft) 62%)}.entity-media__frame:after{content:"";position:absolute;inset:12px;border:1px solid color-mix(in srgb,var(--border) 55%,transparent);border-radius:11px;pointer-events:none}.entity-media img{position:relative;z-index:1;display:block;width:100%;height:180px;object-fit:contain;filter:drop-shadow(0 12px 18px #08222624);image-rendering:auto}.entity-media figcaption{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:9px;color:var(--text-muted);font-size:11px}.entity-media figcaption>span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.entity-media--compact{grid-column:2;grid-row:1/4;width:118px}.entity-media--compact .entity-media__frame{min-height:88px;height:88px;padding:10px;border:0;background:color-mix(in srgb,var(--lime) 8%,var(--surface-soft))}.entity-media--compact img{height:72px}.entity-media--compact figcaption{justify-content:flex-end}.entity-media--compact figcaption>span:last-child{display:none}.entity-media--compact .source-chip{transform:scale(.9);transform-origin:right center}.media-library{padding:34px 0 100px}.media-library__intro{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:24px;color:var(--text-muted)}.media-library__grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}.media-library__card{padding:12px;border:1px solid var(--border-soft);border-radius:18px;background:var(--surface)}.media-library__card .entity-media__frame{min-height:170px}.media-library__card h2{margin:12px 3px 3px;font:400 19px Georgia,serif}.media-library__card>span{margin-left:3px;color:var(--text-subtle);font-size:10px;text-transform:uppercase;letter-spacing:.09em}@media(max-width:1050px){.media-library__grid{grid-template-columns:repeat(3,1fr)}}@media(max-width:720px){.media-library__grid{grid-template-columns:repeat(2,1fr)}.entity-media--compact{width:90px}.entity-media--compact .entity-media__frame{height:76px}.media-library__intro{display:block}}@media(max-width:440px){.media-library__grid{grid-template-columns:1fr}}
|
.entity-media{margin:0;min-width:0}.entity-media__frame{position:relative;display:grid;place-items:center;overflow:hidden;min-height:190px;padding:18px;border:1px solid var(--border-soft);border-radius:16px;background:radial-gradient(circle at 50% 46%,color-mix(in srgb,var(--lime) 15%,var(--surface)) 0 18%,var(--surface-soft) 62%)}.entity-media__frame:after{content:"";position:absolute;inset:12px;border:1px solid color-mix(in srgb,var(--border) 55%,transparent);border-radius:11px;pointer-events:none}.entity-media img{position:relative;z-index:1;display:block;width:100%;height:180px;object-fit:contain;filter:drop-shadow(0 12px 18px #08222624);image-rendering:auto}.entity-media figcaption{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:9px;color:var(--text-muted);font-size:11px}.entity-media figcaption>span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.entity-media--compact{grid-column:2;grid-row:1/4;width:118px}.entity-media--compact .entity-media__frame{min-height:88px;height:88px;padding:10px;border:0;background:color-mix(in srgb,var(--lime) 8%,var(--surface-soft))}.entity-media--compact img{height:72px}.entity-media--compact figcaption{justify-content:flex-end}.entity-media--compact figcaption>span:last-child{display:none}.entity-media--compact .source-chip{transform:scale(.9);transform-origin:right center}.media-library{padding:34px 0 100px}.media-library__intro{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:24px;color:var(--text-muted)}.media-library__grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:16px}.media-library__card{padding:12px;border:1px solid var(--border-soft);border-radius:18px;background:var(--surface)}.media-library__card .entity-media__frame{min-height:170px}.media-library__card h2{margin:12px 3px 3px;font:400 19px Georgia,serif}.media-library__card>span{margin-left:3px;color:var(--text-subtle);font-size:10px;text-transform:uppercase;letter-spacing:.09em}@media(max-width:1050px){.media-library__grid{grid-template-columns:repeat(3,1fr)}}@media(max-width:720px){.media-library__grid{grid-template-columns:repeat(2,1fr)}.entity-media--compact{width:90px}.entity-media--compact .entity-media__frame{height:76px}.media-library__intro{display:block}}@media(max-width:440px){.media-library__grid{grid-template-columns:1fr}}
|
||||||
.entity-feature{display:grid;grid-template-columns:minmax(280px,420px) 1fr;align-items:center;gap:48px;padding-block:36px;border-bottom:1px solid var(--border)}.entity-feature .entity-media__frame{min-height:260px}.entity-feature .entity-media img{height:230px}.entity-feature h2{margin:10px 0 12px;font:400 clamp(34px,4vw,58px)/.95 Georgia,serif}.entity-feature p{max-width:620px;color:var(--text-muted);line-height:1.6}@media(max-width:720px){.entity-feature{grid-template-columns:1fr;gap:24px;padding-block:26px}}
|
.entity-feature{display:grid;grid-template-columns:minmax(280px,420px) 1fr;align-items:center;gap:48px;padding-block:36px;border-bottom:1px solid var(--border)}.entity-feature .entity-media__frame{min-height:260px}.entity-feature .entity-media img{height:230px}.entity-feature h2{margin:10px 0 12px;font:400 clamp(34px,4vw,58px)/.95 Georgia,serif}.entity-feature p{max-width:620px;color:var(--text-muted);line-height:1.6}@media(max-width:720px){.entity-feature{grid-template-columns:1fr;gap:24px;padding-block:26px}}
|
||||||
|
.waterbody-media-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px;padding-block:28px}.waterbody-media-grid article{min-width:0;padding:18px;border:1px solid var(--border-soft);border-radius:16px;background:var(--surface-soft)}.waterbody-media-grid article>.overline{display:block;margin-bottom:10px}.waterbody-media-grid .entity-media__frame{min-height:220px}@media(max-width:720px){.waterbody-media-grid{grid-template-columns:1fr;padding-block:20px}.waterbody-media-grid .entity-media__frame{min-height:190px}}
|
||||||
|
.catalog-media-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr));gap:18px;padding-block:28px 70px}.catalog-media-grid article{min-width:0;padding:18px;border:1px solid var(--border-soft);border-radius:16px;background:var(--surface-soft)}.catalog-media-grid article>.overline{display:block;margin-bottom:10px}.catalog-media-grid .entity-media__frame{min-height:220px}
|
||||||
|
|
||||||
|
.media-role-filter{display:flex;align-items:end;gap:10px;margin:0 0 22px;padding:14px 16px;border:1px solid var(--border-soft);border-radius:12px;background:var(--surface-soft)}.media-role-filter label{display:grid;gap:6px;color:var(--text-muted);font-size:11px;font-weight:750;text-transform:uppercase;letter-spacing:.08em}.media-role-filter select{min-width:220px;height:40px;padding:0 10px;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--text)}.media-role-filter a{padding:11px 4px;color:var(--text-muted);font-size:12px}@media(max-width:720px){.media-role-filter{align-items:stretch;flex-wrap:wrap}.media-role-filter label{flex:1 1 100%}.media-role-filter select{width:100%}}
|
||||||
|
|
||||||
|
@media (min-width:721px) and (max-width:1050px){.media-library__grid{grid-template-columns:repeat(3,minmax(0,1fr))}}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
.pagination{width:min(1360px,calc(100% - 64px));margin:22px auto 48px;display:flex;align-items:center;justify-content:space-between;gap:16px;color:var(--text-secondary);font-size:13px}.pagination>div{display:flex;gap:10px}.pagination a,.pagination__disabled{display:inline-flex;align-items:center;min-height:42px;padding:10px 15px;border:1px solid var(--border-strong);border-radius:10px;background:var(--surface-elevated);color:var(--text-secondary);text-decoration:none}.pagination a:hover{background:var(--surface-raised);color:var(--text-primary)}.pagination__disabled{opacity:.5}@media(max-width:720px){.pagination{width:calc(100% - 28px);align-items:stretch;flex-direction:column}.pagination>div{display:grid;grid-template-columns:1fr 1fr}.pagination a,.pagination__disabled{justify-content:center;text-align:center}}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.plan-hero{padding:70px 0 45px;display:flex;justify-content:space-between;align-items:center;gap:40px}.plan-hero h1{margin:18px 0 20px;font:400 clamp(58px,7vw,100px)/.88 Georgia,serif;letter-spacing:-.06em}.plan-hero h1 em{color:#497074;font-style:normal}.plan-hero p{max-width:620px;color:var(--text-muted);font-size:17px;line-height:1.6}.plan-hero__mark{width:150px;height:150px;display:grid;place-items:center;border:1px solid #345155;border-radius:50%;background:var(--deep);color:var(--lime);font-size:76px;transform:rotate(45deg)}.plan-toolbar{display:flex;justify-content:space-between;align-items:center;padding:18px 0;border-top:1px solid var(--border);border-bottom:1px solid var(--border);color:var(--text-muted);font-size:13px}.plan-toolbar>div{display:flex;gap:8px}.plan-content{padding:30px 0 100px}.plan-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,300px),1fr));gap:14px}.plan-card{display:flex;flex-direction:column;gap:8px;min-height:220px;padding:22px;border:1px solid var(--border-soft);border-radius:15px;background:var(--surface);box-shadow:0 12px 30px var(--shadow-color)}.plan-card h2{margin:0;font:400 27px Georgia,serif}.plan-card>strong{font:400 22px Georgia,serif;color:var(--success)}.plan-card p{margin:5px 0 0;color:var(--text-muted);font-size:13px}.plan-card small{color:var(--text-subtle);font-size:11px}.plan-card__actions{display:flex;align-items:center;gap:15px;margin-top:auto}.plan-card__actions a{color:var(--text);font-size:13px;text-underline-offset:3px}.plan-card__actions button{border:0;background:none;color:var(--text-muted);font-size:12px;text-decoration:underline;text-underline-offset:3px}.plan-content>.state[hidden]{display:none!important}@media(max-width:720px){.plan-hero{padding:36px 0 25px}.plan-hero__mark{width:90px;height:90px;font-size:46px}.plan-toolbar{display:block}.plan-toolbar>div{margin-top:12px;flex-wrap:wrap}.plan-content{padding-top:20px}}@media print{ @page{size:auto;margin:12mm} .topbar,.alpha-banner,footer,.plan-hero__mark,.plan-toolbar button,.plan-card__actions button{display:none!important}.plan-hero{padding:0 0 20px}.plan-content{padding-top:0}.plan-card{break-inside:avoid;box-shadow:none}}
|
||||||
|
.plan-toolbar>div{align-items:center;flex-wrap:wrap}.plan-toolbar [data-plan-share-status]{color:var(--text-muted);font-size:11px}
|
||||||
|
@media(max-width:900px){.plan-hero__mark{display:none}}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
.query-summary{display:flex;justify-content:space-between;align-items:end;gap:24px;padding-top:20px}
|
||||||
|
.query-summary h2{margin:5px 0 4px;font:400 27px Georgia,serif;letter-spacing:-.035em}
|
||||||
|
.query-summary p{margin:0;color:var(--text-muted);font-size:13px}
|
||||||
|
.query-summary__filters{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:8px}
|
||||||
|
.query-summary__filters span{padding:6px 10px;border:1px solid #c8d3cb;border-radius:20px;background:#f8faf5;color:#526662;font-size:12px}
|
||||||
|
.query-summary__filters a{margin-left:4px;color:#4d625e;font-size:13px;text-underline-offset:3px}
|
||||||
|
.detail-action-heading{margin:27px 0 8px;font:400 24px Georgia,serif;color:var(--success)}
|
||||||
|
.tackle-card-passport{grid-column:1/-1;min-width:0}.tackle-card-passport .data-passport{margin-top:14px}
|
||||||
|
.spot-hero__actions{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.spot-hero__actions small{color:#a9b8b5;font-size:11px}.plan-save[data-saved="true"]{background:var(--lime);color:var(--deep)}
|
||||||
|
.card-cta{display:inline-flex;align-items:center;gap:8px;margin-top:16px;color:var(--text);font-size:12px;font-weight:750;text-underline-offset:3px}.card-cta span{color:var(--success);font-size:17px;line-height:1}
|
||||||
|
@media(max-width:720px){.spot-hero__actions{align-items:flex-start;flex-direction:column}.spot-hero__actions small{min-height:15px}}
|
||||||
|
@media(max-width:720px){.query-summary{display:block;padding:16px 14px 0;width:100%}.query-summary h2{font-size:23px}.query-summary__filters{justify-content:flex-start;flex-wrap:nowrap;overflow-x:auto;padding-top:11px;scroll-snap-type:x proximity;scrollbar-width:none}.query-summary__filters::-webkit-scrollbar{display:none}.query-summary__filters span{flex:0 0 auto;scroll-snap-align:start}.query-summary__filters a{position:sticky;right:0;flex:0 0 auto;padding:6px 10px;background:var(--paper)}}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
--surface-control: light-dark(#f3f6f1, #0a1d1f);
|
--surface-control: light-dark(#f3f6f1, #0a1d1f);
|
||||||
--text-primary: light-dark(#092226, #edf5ef);
|
--text-primary: light-dark(#092226, #edf5ef);
|
||||||
--text-secondary: light-dark(#526662, #b8c7c3);
|
--text-secondary: light-dark(#526662, #b8c7c3);
|
||||||
--text-tertiary: light-dark(#647572, #93a7a2);
|
--text-tertiary: light-dark(#5b6b68, #93a7a2);
|
||||||
--text-on-dark: #f5f8f3;
|
--text-on-dark: #f5f8f3;
|
||||||
--border-strong: light-dark(#cbd6ce, #3a5658);
|
--border-strong: light-dark(#cbd6ce, #3a5658);
|
||||||
--border-subtle: light-dark(#d6dfd7, #294244);
|
--border-subtle: light-dark(#d6dfd7, #294244);
|
||||||
@@ -39,11 +39,11 @@
|
|||||||
--status-warning: light-dark(#9a741d, #e8c768);
|
--status-warning: light-dark(#9a741d, #e8c768);
|
||||||
--status-changed: light-dark(#a44236, #ff9587);
|
--status-changed: light-dark(#a44236, #ff9587);
|
||||||
--source-official: light-dark(#815713, #e3b65b);
|
--source-official: light-dark(#815713, #e3b65b);
|
||||||
--source-rf4db: light-dark(#286581, #74bee3);
|
--source-rf4db: light-dark(#245d78, #74bee3);
|
||||||
--source-rf4stat: light-dark(#60499a, #b7a2ed);
|
--source-rf4stat: light-dark(#60499a, #b7a2ed);
|
||||||
--source-rf4map: light-dark(#2f704f, #79c79e);
|
--source-rf4map: light-dark(#276044, #79c79e);
|
||||||
--source-rf4posts: light-dark(#984a33, #ee9a7e);
|
--source-rf4posts: light-dark(#984a33, #ee9a7e);
|
||||||
--source-players: light-dark(#526b1c, #add066);
|
--source-players: light-dark(#4b6419, #add066);
|
||||||
--graphic-line: light-dark(#3f7779, #79b5b8);
|
--graphic-line: light-dark(#3f7779, #79b5b8);
|
||||||
--graphic-grid: light-dark(#dce5de, #294244);
|
--graphic-grid: light-dark(#dce5de, #294244);
|
||||||
--tackle-orange: light-dark(#b96b27, #f0a55f);
|
--tackle-orange: light-dark(#b96b27, #f0a55f);
|
||||||
@@ -76,6 +76,7 @@
|
|||||||
|
|
||||||
.active-filters span, .spot-card, .record-table, .report-form,
|
.active-filters span, .spot-card, .record-table, .report-form,
|
||||||
.periods, .detail-grid aside, .screenshot-retry, .moderation-card,
|
.periods, .detail-grid aside, .screenshot-retry, .moderation-card,
|
||||||
|
.moderation-bulk, [data-external-bulk],
|
||||||
.admin-kpis > a, .admin-kpis > article, .admin-dashboard-grid > section,
|
.admin-kpis > a, .admin-kpis > article, .admin-dashboard-grid > section,
|
||||||
.source-health-grid article, .state, .load-more, .signal-more {
|
.source-health-grid article, .state, .load-more, .signal-more {
|
||||||
background: var(--surface-elevated);
|
background: var(--surface-elevated);
|
||||||
@@ -88,7 +89,8 @@
|
|||||||
|
|
||||||
.spot-card:hover { background: var(--surface-raised); border-color: var(--border-strong); box-shadow: 0 18px 40px var(--shadow-color); }
|
.spot-card:hover { background: var(--surface-raised); border-color: var(--border-strong); box-shadow: 0 18px 40px var(--shadow-color); }
|
||||||
.report-form input, .report-form textarea, .report-form select,
|
.report-form input, .report-form textarea, .report-form select,
|
||||||
.moderation-actions textarea { background: var(--surface-control); color: var(--text-primary); border-color: var(--border-strong); }
|
.moderation-actions textarea, .moderation-bulk textarea, .moderation-bulk select,
|
||||||
|
[data-external-bulk] textarea, [data-external-bulk] select { background: var(--surface-control); color: var(--text-primary); border-color: var(--border-strong); }
|
||||||
.record-head, .no-proof { background: var(--surface-raised); }
|
.record-head, .no-proof { background: var(--surface-raised); }
|
||||||
.record-row, .periods > div, .catch-list article, .detail-grid aside li,
|
.record-row, .periods > div, .catch-list article, .detail-grid aside li,
|
||||||
.bait-line, .spot-stats, .moderation-summary dl div,
|
.bait-line, .spot-stats, .moderation-summary dl div,
|
||||||
@@ -127,6 +129,11 @@
|
|||||||
.timeline-chart { border-color: var(--border-strong); background: repeating-linear-gradient(to top, transparent 0, transparent 35px, var(--border-subtle) 36px); }
|
.timeline-chart { border-color: var(--border-strong); background: repeating-linear-gradient(to top, transparent 0, transparent 35px, var(--border-subtle) 36px); }
|
||||||
.timeline-slot strong { color: var(--text-primary); }
|
.timeline-slot strong { color: var(--text-primary); }
|
||||||
.timeline-slot small, .activity-legend { color: var(--text-tertiary); }
|
.timeline-slot small, .activity-legend { color: var(--text-tertiary); }
|
||||||
|
.spot-hero .eyebrow { color: var(--text-on-dark); }
|
||||||
|
.activity-legend { color: var(--text-secondary); }
|
||||||
|
.catch-list .tackle-label > span { color: var(--text-secondary); }
|
||||||
|
.catch-list span { color: var(--text-secondary); }
|
||||||
|
.catch-list .source-chip > span { color: var(--chip) !important; }
|
||||||
|
|
||||||
.catalog-hero { border-color: var(--border-strong); }
|
.catalog-hero { border-color: var(--border-strong); }
|
||||||
.catalog-hero p, .catalog-results aside a, .catalog-grid span { color: var(--text-secondary); }
|
.catalog-hero p, .catalog-results aside a, .catalog-grid span { color: var(--text-secondary); }
|
||||||
@@ -185,7 +192,7 @@
|
|||||||
|
|
||||||
@media (forced-colors: active) {
|
@media (forced-colors: active) {
|
||||||
.theme-switcher, .theme-switcher button, .spot-card, .record-table,
|
.theme-switcher, .theme-switcher button, .spot-card, .record-table,
|
||||||
.report-form, .moderation-card, .catalog-grid > a, .data-passport,
|
.report-form, .moderation-card, .moderation-bulk, [data-external-bulk], .catalog-grid > a, .data-passport,
|
||||||
.source-chip, .notice, .state { border: 1px solid CanvasText; }
|
.source-chip, .notice, .state { border: 1px solid CanvasText; }
|
||||||
.theme-switcher button[aria-pressed="true"] { background: Highlight; color: HighlightText; }
|
.theme-switcher button[aria-pressed="true"] { background: Highlight; color: HighlightText; }
|
||||||
.source-chip i, .health-state i, .quality-chip i, .activity-pill i { forced-color-adjust: none; }
|
.source-chip i, .health-state i, .quality-chip i, .activity-pill i { forced-color-adjust: none; }
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import AxeBuilder from "@axe-core/playwright";
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
for (const path of [
|
for (const path of [
|
||||||
"/", "/records", "/report", "/rules", "/privacy",
|
"/", "/records", "/report", "/tackle", "/tackle/analytics", "/tackle/items/00000000-0000-0000-0000-000000000000", "/rules", "/privacy",
|
||||||
"/admin", "/admin/moderation", "/admin/external-sources", "/admin/media",
|
"/admin", "/admin/moderation", "/admin/external-sources", "/admin/media",
|
||||||
]) {
|
]) {
|
||||||
test(`${path} has no serious accessibility violations`, async ({ page }) => {
|
test(`${path} has no serious accessibility violations`, async ({ page }) => {
|
||||||
@@ -11,3 +11,53 @@ for (const path of [
|
|||||||
expect(results.violations.filter(item => ["critical", "serious"].includes(item.impact ?? ""))).toEqual([]);
|
expect(results.violations.filter(item => ["critical", "serious"].includes(item.impact ?? ""))).toEqual([]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("admin keyboard navigation keeps focus out of the document body", async ({ page }) => {
|
||||||
|
await page.goto("/admin");
|
||||||
|
await page.keyboard.press("Tab");
|
||||||
|
await expect(page.locator(".skip-link")).toBeFocused();
|
||||||
|
|
||||||
|
const focusedLabels: string[] = [];
|
||||||
|
for (let index = 0; index < 20; index += 1) {
|
||||||
|
await page.keyboard.press("Tab");
|
||||||
|
focusedLabels.push(await page.evaluate(() => {
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (active?.tagName === "BODY") return "BODY";
|
||||||
|
if (active?.matches(".admin-login input")) return "ADMIN_TOKEN_INPUT";
|
||||||
|
if (active?.matches(".admin-login button")) return "ADMIN_PANEL_BUTTON";
|
||||||
|
return (active?.getAttribute("aria-label") || active?.textContent || active?.tagName || "").trim().slice(0, 80);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(focusedLabels).not.toContain("BODY");
|
||||||
|
expect(focusedLabels).toContain("ADMIN_TOKEN_INPUT");
|
||||||
|
expect(focusedLabels).toContain("ADMIN_PANEL_BUTTON");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("print media keeps a light color scheme and removes hero image filters", async ({ page }) => {
|
||||||
|
await page.emulateMedia({ media: "print" });
|
||||||
|
await page.goto("/");
|
||||||
|
const printState = await page.evaluate(() => ({
|
||||||
|
colorScheme: getComputedStyle(document.documentElement).colorScheme,
|
||||||
|
heroFilter: document.querySelector(".lake-card img") ? getComputedStyle(document.querySelector(".lake-card img")!).filter : "none",
|
||||||
|
}));
|
||||||
|
expect(printState.colorScheme).toBe("light");
|
||||||
|
expect(printState.heroFilter).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("forced colors keeps theme controls visibly bordered", async ({ page }) => {
|
||||||
|
await page.emulateMedia({ forcedColors: "active" });
|
||||||
|
await page.goto("/admin");
|
||||||
|
const forcedColorsState = await page.evaluate(() => {
|
||||||
|
const control = document.querySelector(".theme-switcher button");
|
||||||
|
const pressed = document.querySelector(".theme-switcher button[aria-pressed='true']");
|
||||||
|
return {
|
||||||
|
borderStyle: control ? getComputedStyle(control).borderStyle : "",
|
||||||
|
borderWidth: control ? getComputedStyle(control).borderWidth : "",
|
||||||
|
activeBackground: pressed ? getComputedStyle(pressed).backgroundColor : "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(forcedColorsState.borderStyle).toBe("solid");
|
||||||
|
expect(forcedColorsState.borderWidth).toBe("1px");
|
||||||
|
expect(forcedColorsState.activeBackground).not.toBe("transparent");
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("waterbody passport and honest empty tackle catalog are explicit", async ({ page }) => {
|
||||||
|
await page.goto("/waterbodies/р-вьюнок");
|
||||||
|
const waterPassport = page.locator('.data-passport[aria-label="Паспорт данных"]');
|
||||||
|
await expect(waterPassport).toBeVisible();
|
||||||
|
await expect(waterPassport.getByText("Паспорт данных")).toBeVisible();
|
||||||
|
await expect(waterPassport.getByText("Свежесть")).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto("/tackle");
|
||||||
|
await expect(page.getByText("Подтверждённых карточек пока нет")).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
const unavailableRun = process.env.EXPECT_UNAVAILABLE === "1";
|
||||||
|
|
||||||
|
for (const path of [
|
||||||
|
"/waterbodies",
|
||||||
|
"/waterbodies/vyunok",
|
||||||
|
"/waterbodies/vyunok/pike",
|
||||||
|
"/fish",
|
||||||
|
"/media?type=waterbody",
|
||||||
|
"/tackle",
|
||||||
|
"/tackle/analytics",
|
||||||
|
"/tackle/items/00000000-0000-0000-0000-000000000000",
|
||||||
|
"/records",
|
||||||
|
"/spots/unknown-0x0",
|
||||||
|
"/report",
|
||||||
|
"/status",
|
||||||
|
]) {
|
||||||
|
test(`${path} exposes a cache-safe unavailable state`, async ({ page }) => {
|
||||||
|
test.skip(!unavailableRun, "Run with EXPECT_UNAVAILABLE=1 and an API-unavailable web process.");
|
||||||
|
const response = await page.goto(path);
|
||||||
|
expect(response?.status(), `${path} status`).toBe(503);
|
||||||
|
expect(response?.headers()["retry-after"], `${path} retry header`).toBe("60");
|
||||||
|
expect(response?.headers()["cache-control"], `${path} cache header`).toBe("no-store");
|
||||||
|
await expect(page.getByRole("alert")).toContainText(/временно недоступ|недоступ/i);
|
||||||
|
await expect(page.locator('meta[name="robots"]')).toHaveAttribute("content", "noindex, nofollow");
|
||||||
|
await expect(page.locator("body")).not.toContainText(/Traceback|localhost:8000|API \d+/i);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import AxeBuilder from "@axe-core/playwright";
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
for (const path of [
|
||||||
|
"/waterbodies",
|
||||||
|
"/waterbodies/р-вьюнок",
|
||||||
|
"/spots/vyunok-321x654",
|
||||||
|
"/media",
|
||||||
|
"/tackle/analytics",
|
||||||
|
"/tackle/items/00000000-0000-0000-0000-000000000000",
|
||||||
|
]) {
|
||||||
|
test(`${path} has no serious accessibility violations`, async ({ page }) => {
|
||||||
|
await page.goto(path);
|
||||||
|
const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21aa"]).analyze();
|
||||||
|
expect(results.violations.filter(item => ["critical", "serious"].includes(item.impact ?? ""))).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("activity cards explain freshness, sample size and coordinate precision", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
const passport = page.locator(".spot-card .data-passport");
|
||||||
|
const count = await passport.count();
|
||||||
|
expect(count).toBeGreaterThan(0);
|
||||||
|
const first = passport.first();
|
||||||
|
const firstCard = page.locator(".spot-card").first();
|
||||||
|
await expect(first).toContainText("Свежесть");
|
||||||
|
await expect(first).toContainText("Наблюдения");
|
||||||
|
await expect(first).toContainText("Игроки");
|
||||||
|
await expect(first).toContainText("Координаты");
|
||||||
|
await expect(first).toContainText("Период");
|
||||||
|
await expect(firstCard).toContainText("Открыть точку");
|
||||||
|
if (await first.locator('[data-passport-status="insufficient"]').count()) {
|
||||||
|
await expect(first).toContainText("Недостаточно данных");
|
||||||
|
await expect(first).toContainText("Минимум для рекомендации: 3 наблюдения");
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("home focuses the first screen on five results and keeps pagination", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await expect(page.locator(".spot-card")).toHaveCount(5);
|
||||||
|
await expect(page.getByRole("navigation", { name: "Пагинация" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: "Следующая →" })).toHaveAttribute("href", /offset=5/);
|
||||||
|
|
||||||
|
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||||
|
expect(overflow).toBeLessThanOrEqual(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("media catalog keeps image provenance and loaded assets healthy", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 1280, height: 900 });
|
||||||
|
await page.goto("/media");
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
const state = await page.evaluate(() => ({
|
||||||
|
images: Array.from(document.images).map(image => ({
|
||||||
|
alt: image.alt,
|
||||||
|
complete: image.complete,
|
||||||
|
naturalWidth: image.naturalWidth,
|
||||||
|
})),
|
||||||
|
width: document.documentElement.clientWidth,
|
||||||
|
scrollWidth: document.documentElement.scrollWidth,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(state.images.length).toBeGreaterThan(0);
|
||||||
|
expect(state.images.every(image => image.alt.trim().length > 0)).toBe(true);
|
||||||
|
expect(state.images.filter(image => image.complete && image.naturalWidth === 0)).toEqual([]);
|
||||||
|
expect(state.scrollWidth).toBeLessThanOrEqual(state.width);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const theme of ["light", "dark"] as const) {
|
||||||
|
test(`media catalog respects ${theme} theme on a narrow viewport`, async ({ page, context }) => {
|
||||||
|
await context.addCookies([{ name: "rf4-theme", value: theme, url: "http://127.0.0.1:4321" }]);
|
||||||
|
await page.setViewportSize({ width: 320, height: 800 });
|
||||||
|
await page.goto("/media");
|
||||||
|
|
||||||
|
const state = await page.evaluate(() => ({
|
||||||
|
theme: document.documentElement.dataset.theme,
|
||||||
|
width: document.documentElement.clientWidth,
|
||||||
|
scrollWidth: document.documentElement.scrollWidth,
|
||||||
|
}));
|
||||||
|
expect(state.theme).toBe(theme);
|
||||||
|
expect(state.scrollWidth).toBeLessThanOrEqual(state.width);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const type of ["waterbody", "tackle", "reference"]) {
|
||||||
|
test(`media ${type} section has a stable page state`, async ({ page }) => {
|
||||||
|
await page.goto(`/media?type=${type}`);
|
||||||
|
await expect(page.getByRole("heading", { name: "Медиатека" })).toBeVisible();
|
||||||
|
await expect(page.locator("main")).toBeVisible();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("saved spot appears on the plan page and can be removed or cleared", async ({ page }) => {
|
||||||
|
await page.goto("/spots/vyunok-6331x6332");
|
||||||
|
await page.evaluate(() => localStorage.setItem("rf4spotter:fishing-plan", JSON.stringify([{ key: "/spots/vyunok-6331x6332", waterbody: "Вьюнок", coordinates: "6331:6332", baits: "Тестовая приманка", method: "Спиннинг", retrieve: "равномерная", risk: "Подтверждено", freshness: "2026-09-20T12:55:11.236309Z", confidence: "24" }])));
|
||||||
|
await page.goto("/plan");
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "Вьюнок" })).toBeVisible();
|
||||||
|
await expect(page.getByText("6331:6332")).toBeVisible();
|
||||||
|
await expect(page.getByText("Спиннинг · равномерная")).toBeVisible();
|
||||||
|
await expect(page.getByText(/Риск: Подтверждено/)).toBeVisible();
|
||||||
|
await expect(page.getByText("1 из 5 точек")).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Убрать" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "План пока пуст" })).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto("/spots/vyunok-6331x6332");
|
||||||
|
await page.evaluate(() => localStorage.setItem("rf4spotter:fishing-plan", JSON.stringify([{ key: "/spots/vyunok-6331x6332", waterbody: "Вьюнок", coordinates: "6331:6332", baits: "Тестовая приманка", freshness: "", confidence: "" }])));
|
||||||
|
await page.goto("/plan");
|
||||||
|
await page.getByRole("button", { name: "Очистить план" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "План пока пуст" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plan can be restored from a shareable URL", async ({ page }) => {
|
||||||
|
const shared = encodeURIComponent(JSON.stringify([{ key: "/spots/vyunok-6331x6332", waterbody: "Вьюнок", coordinates: "6331:6332", baits: "Тестовая приманка", freshness: "", confidence: "" }]));
|
||||||
|
await page.goto(`/plan?plan=${shared}`);
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "Вьюнок" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Поделиться планом" })).toBeVisible();
|
||||||
|
await expect(page.getByText("1 из 5 точек")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plan caps imported entries, stays printable and fits narrow viewports", async ({ page }) => {
|
||||||
|
const plan = Array.from({ length: 6 }, (_, index) => ({
|
||||||
|
key: `/spots/vyunok-6331x633${index + 2}`,
|
||||||
|
waterbody: "Вьюнок",
|
||||||
|
coordinates: `6331:633${index + 2}`,
|
||||||
|
baits: "Тестовая приманка",
|
||||||
|
freshness: "",
|
||||||
|
confidence: "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
await page.goto("/plan");
|
||||||
|
await page.evaluate((value) => localStorage.setItem("rf4spotter:fishing-plan", JSON.stringify(value)), plan);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await expect(page.getByText("5 из 5 точек")).toBeVisible();
|
||||||
|
await expect(page.locator(".plan-card")).toHaveCount(5);
|
||||||
|
for (const width of [320, 390]) {
|
||||||
|
await page.setViewportSize({ width, height: 800 });
|
||||||
|
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||||
|
expect(overflow, `${width}px plan overflows`).toBeLessThanOrEqual(0);
|
||||||
|
}
|
||||||
|
await page.emulateMedia({ media: "print" });
|
||||||
|
await expect(page.getByRole("button", { name: "Печать / PDF" })).toBeHidden();
|
||||||
|
await expect(page.locator(".plan-card")).toHaveCount(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plan preserves a real zero confidence and bounds imported text", async ({ page }) => {
|
||||||
|
await page.goto("/plan");
|
||||||
|
await page.evaluate(() => localStorage.setItem("rf4spotter:fishing-plan", JSON.stringify([{
|
||||||
|
key: "/spots/" + "x".repeat(600),
|
||||||
|
waterbody: "Вьюнок",
|
||||||
|
coordinates: "6331:6332",
|
||||||
|
baits: "Приманка",
|
||||||
|
confidence: "0",
|
||||||
|
risk: "Малая выборка",
|
||||||
|
method: "Спиннинг",
|
||||||
|
retrieve: "равномерная",
|
||||||
|
oversized: "x".repeat(5000),
|
||||||
|
}, {
|
||||||
|
key: "/spots/vyunok-7450x7451",
|
||||||
|
waterbody: "Вьюнок",
|
||||||
|
coordinates: "7450:7451",
|
||||||
|
confidence: "999",
|
||||||
|
}])));
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await expect(page.getByText(/Доверие: 0%/)).toBeVisible();
|
||||||
|
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem("rf4spotter:fishing-plan") || "[]"));
|
||||||
|
expect(stored[0].key.length).toBeLessThanOrEqual(512);
|
||||||
|
expect(stored[0].confidence).toBe("0");
|
||||||
|
expect(stored[1].confidence).toBe("");
|
||||||
|
expect(stored[0].oversized).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plan import deduplicates the same point before applying the five-item cap", async ({ page }) => {
|
||||||
|
await page.goto("/plan");
|
||||||
|
await page.evaluate(() => localStorage.setItem("rf4spotter:fishing-plan", JSON.stringify([
|
||||||
|
{ key: "/spots/vyunok-6331x6332", waterbody: "Вьюнок", coordinates: "6331:6332", confidence: "0" },
|
||||||
|
{ key: "/spots/vyunok-6331x6332", waterbody: "Вьюнок", coordinates: "6331:6332", confidence: "80" },
|
||||||
|
{ key: "/spots/vyunok-7450x7451", waterbody: "Вьюнок", coordinates: "7450:7451", confidence: "40" },
|
||||||
|
])));
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await expect(page.getByText("2 из 5 точек")).toBeVisible();
|
||||||
|
await expect(page.locator(".plan-card")).toHaveCount(2);
|
||||||
|
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem("rf4spotter:fishing-plan") || "[]"));
|
||||||
|
expect(stored.map((item: { key: string }) => item.key)).toEqual([
|
||||||
|
"/spots/vyunok-6331x6332",
|
||||||
|
"/spots/vyunok-7450x7451",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plan explains empty and malformed share states", async ({ page }) => {
|
||||||
|
await page.goto("/plan");
|
||||||
|
await page.getByRole("button", { name: "Поделиться планом" }).click();
|
||||||
|
await expect(page.locator("[data-plan-share-status]")).toHaveText("Сначала добавьте точку в план");
|
||||||
|
|
||||||
|
await page.goto("/plan?plan=not-json");
|
||||||
|
await expect(page.locator("[data-plan-share-status]")).toHaveText("Ссылка плана не распознана");
|
||||||
|
|
||||||
|
const emptyPlan = encodeURIComponent(JSON.stringify([]));
|
||||||
|
await page.goto(`/plan?plan=${emptyPlan}`);
|
||||||
|
await expect(page.locator("[data-plan-share-status]")).toHaveText("В ссылке нет сохранённых точек");
|
||||||
|
});
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
test("production bootstrap supports submission and moderation", async ({ page, request }) => {
|
test("production bootstrap supports submission and moderation", async ({ page, request }) => {
|
||||||
|
test.skip(
|
||||||
|
!process.env.BOOTSTRAP_API_URL || !process.env.BOOTSTRAP_ADMIN_TOKEN,
|
||||||
|
"requires BOOTSTRAP_API_URL and BOOTSTRAP_ADMIN_TOKEN; run deploy/test-production-bootstrap.sh",
|
||||||
|
);
|
||||||
const player = `Bootstrap Player ${Date.now()}`;
|
const player = `Bootstrap Player ${Date.now()}`;
|
||||||
const x = 7000 + Date.now() % 1000;
|
const x = 7000 + Date.now() % 1000;
|
||||||
const y = x + 1;
|
const y = x + 1;
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("home makes the selected query context explicit and shareable", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto("/?waterbody=kuori&fish=lake-trout&hours=72&sort=freshness");
|
||||||
|
|
||||||
|
await expect(page.getByRole("heading", { name: "Озёрная форель · Куори" })).toBeVisible();
|
||||||
|
await expect(page.getByText("72 часа · Сначала свежие")).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: "Сбросить запрос" })).toHaveAttribute("href", "/#results");
|
||||||
|
await expect(page.getByLabel("Водоём")).toHaveValue("kuori");
|
||||||
|
await expect(page.getByLabel("Рыба")).toHaveValue("lake-trout");
|
||||||
|
await expect(page.getByLabel("Период")).toHaveValue("72");
|
||||||
|
await expect(page.getByLabel("Сначала")).toHaveValue("freshness");
|
||||||
|
|
||||||
|
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||||
|
expect(overflow).toBeLessThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty filtered result offers a clear way back to the full dataset", async ({ page }) => {
|
||||||
|
await page.goto("/?waterbody=kuori&fish=lake-trout&hours=6&sort=freshness");
|
||||||
|
|
||||||
|
const emptyState = page.getByRole("heading", { name: "Пока нет свежих данных" });
|
||||||
|
if (await emptyState.isVisible()) {
|
||||||
|
await expect(page.getByRole("link", { name: "Показать все данные" })).toHaveAttribute("href", "/#results");
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("spot can be saved to a five-item local fishing plan and survives reload", async ({ page }) => {
|
||||||
|
await page.goto("/spots/vyunok-6331x6332");
|
||||||
|
await page.evaluate(() => localStorage.removeItem("rf4spotter:fishing-plan"));
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
const save = page.getByRole("button", { name: "Сохранить в план" });
|
||||||
|
await expect(save).toHaveAttribute("aria-pressed", "false");
|
||||||
|
await save.click();
|
||||||
|
await expect(page.getByRole("button", { name: "В плане" })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await expect(page.getByText("Добавлено в план")).toBeVisible();
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole("button", { name: "В плане" })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await page.getByRole("button", { name: "В плане" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Сохранить в план" })).toHaveAttribute("aria-pressed", "false");
|
||||||
|
});
|
||||||
@@ -1,19 +1,10 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
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("/spots/vyunok-321x654");
|
||||||
await expect(page.getByRole("heading", { name: "Выбирай место, пока клюёт." })).toBeVisible();
|
|
||||||
const activeSpots = page.locator(".spot-card[data-testid]");
|
|
||||||
await expect(activeSpots).not.toHaveCount(0);
|
|
||||||
const activeSpot = activeSpots.first();
|
|
||||||
await expect(activeSpot.locator(".source-chip")).not.toHaveCount(0);
|
|
||||||
const level = await activeSpot.locator("[data-activity-level]").getAttribute("data-activity-level");
|
|
||||||
await expect(page.locator(".detail-score [data-activity-level]")).toHaveAttribute("data-activity-level", level ?? "");
|
|
||||||
await activeSpot.click();
|
|
||||||
await expect(page.getByRole("heading", { name: /^Точка / })).toBeVisible();
|
await expect(page.getByRole("heading", { name: /^Точка / })).toBeVisible();
|
||||||
|
await expect(page.getByText("Точность координат:", { exact: false })).toBeVisible();
|
||||||
await expect(page.getByRole("heading", { name: "Последние уловы" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Последние уловы" })).toBeVisible();
|
||||||
await expect(page.locator(".catch-list .source-chip")).not.toHaveCount(0);
|
|
||||||
await expect(page.locator("[data-activity-level]")).toHaveAttribute("data-activity-level", level ?? "");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("submitted catch appears publicly only after moderation", async ({ page }) => {
|
test("submitted catch appears publicly only after moderation", async ({ page }) => {
|
||||||
@@ -117,7 +108,7 @@ for (const viewport of [{ name: "desktop", width: 1280, height: 900 }, { name: "
|
|||||||
|
|
||||||
test("public pages fit the narrow viewport and expose the skip link", async ({ page }) => {
|
test("public pages fit the narrow viewport and expose the skip link", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 320, height: 800 });
|
await page.setViewportSize({ width: 320, height: 800 });
|
||||||
for (const path of ["/", "/records", "/report", "/waterbodies", "/status"]) {
|
for (const path of ["/", "/records", "/report", "/waterbodies", "/tackle", "/status"]) {
|
||||||
await page.goto(path);
|
await page.goto(path);
|
||||||
const dimensions = await page.evaluate(() => ({
|
const dimensions = await page.evaluate(() => ({
|
||||||
clientWidth: document.documentElement.clientWidth,
|
clientWidth: document.documentElement.clientWidth,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user