diff --git a/README.md b/README.md index 9f15b33..84c3d30 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,16 @@ # RF4 Spotter -RF4 Spotter — неофициальный сервис свежих точек и статистики клёва для Russian Fishing 4. Этап 1 содержит контейнерный каркас, демоданные, read-only API, главную страницу и страницу точки. Реальный импорт официальных рекордов пока остаётся отдельным исследовательским адаптером этапа 0. +RF4 Spotter — неофициальный сервис свежих точек и статистики клёва для Russian Fishing 4. Он объединяет публичные рекорды и подтверждённые пользовательские уловы, показывает свежесть источников и рассчитывает объяснимые индексы активности и уверенности. Проект не взаимодействует с игровым клиентом и не является официальным продуктом RF4. + +## Статус разработки + +- этапы 0 и 1 завершены; +- этап 2, официальный импорт, завершён технически; автоматический профиль остаётся выключенным до явного разрешения владельца источника; +- этап 3 выполнен частично: форма, MinIO, модерация, удаление с аудитом и постоянный rate limit готовы; +- ближайшие задачи — сквозной тест полного пользовательского сценария и раздельные состояния ошибок формы; +- затем начинается этап 4: формализация и расширенное тестирование индекса клёва. + +Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). ## Запуск через Docker @@ -37,7 +47,7 @@ docker compose up --build ## Что реализовано - FastAPI и SQLAlchemy 2; -- PostgreSQL 17 и начальная миграция Alembic; +- PostgreSQL 17 и миграции Alembic до `0007`; - идемпотентный seed с двумя точками и свежими демо-уловами; - `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки; - `GET /api/v1/spots/{id}` и `/catches`; @@ -48,8 +58,9 @@ docker compose up --build - идемпотентный импорт официальных записей с журналом запусков; - публичная страница `/records` с источником и временем последнего импорта; - форма `/report`, защищённые admin API и журнал модерации; -- honeypot и базовый лимит отправок; -- скриншоты уловов в MinIO/S3 с декодированием изображения, ограничением размера и очисткой метаданных. +- honeypot и постоянный rate limit в PostgreSQL с HMAC-отпечатками вместо исходных IP; +- скриншоты уловов в MinIO/S3 с проверкой MIME, расширения, размера и фактического содержимого, повторным кодированием и очисткой метаданных; +- административная очередь `/admin/moderation` с одобрением, отклонением и обезличенным удалением записи с аудитом. Все пользовательские ники и уловы в seed демонстрационные. @@ -61,9 +72,11 @@ Backend и исследовательский парсер: python3 -m venv .venv .venv/bin/pip install -r apps/api/requirements.txt .venv/bin/pip install -e . -.venv/bin/pytest apps/api/tests tests -q +.venv/bin/pytest -q ``` +На текущем этапе набор содержит 21 backend/parser-тест. + Frontend: ```bash @@ -89,7 +102,7 @@ npm run test:e2e docker compose --profile tools run --rm importer ``` -Импорт делает до трёх ограниченных попыток, проверяет DOM-контракт и не удаляет ранее сохранённые данные при сбое. Повторный запуск обновляет совпавшие записи по SHA-256 ключу и не создаёт дубликаты. Автоматическое расписание намеренно ещё не включено: сначала требуется согласовать допустимость регулярного опроса официального сайта. +Импорт делает до трёх ограниченных попыток, проверяет DOM-контракт и не удаляет ранее сохранённые данные при сбое. Повторный запуск обновляет совпавшие записи по SHA-256 ключу и не создаёт дубликаты. Расписание реализовано, но намеренно не включается обычным запуском: сначала требуется согласовать допустимость регулярного опроса официального сайта. Ручной административный запуск также доступен через `POST /api/v1/admin/imports/official-records`, журнал — через `GET /api/v1/admin/imports`. Импорт сохраняет HTTP-метаданные и использует `ETag`/`Last-Modified`, когда источник их предоставляет. @@ -110,10 +123,12 @@ curl -H "Authorization: Bearer change-me-in-production" \ "http://localhost:8000/api/v1/admin/catch-reports?status=pending" ``` -Перед внешним развёртыванием обязательно замените демонстрационный `ADMIN_TOKEN`, а также значения `S3_ACCESS_KEY` и `S3_SECRET_KEY`. Форма принимает JPEG, PNG и WebP до 8 МБ; API повторно кодирует изображение и удаляет EXIF перед сохранением в MinIO. Модератор получает временную подписанную ссылку через admin API. +Перед внешним развёртыванием обязательно замените демонстрационные `ADMIN_TOKEN`, `RATE_LIMIT_SECRET`, `S3_ACCESS_KEY` и `S3_SECRET_KEY`. Форма принимает JPEG, PNG и WebP до 8 МБ; API сверяет MIME и расширение с фактическим форматом, повторно кодирует изображение и удаляет EXIF перед сохранением в MinIO. Модератор получает временную подписанную ссылку через admin API. Очередь модерации доступна по адресу . Администратор вводит `ADMIN_TOKEN`; интерфейс держит его только в памяти открытой страницы и не сохраняет в URL или браузерном хранилище. +Администратор может одобрить, отклонить или удалить сообщение. Удаление очищает ник, комментарий, исходную ссылку и объект скриншота, исключает запись из статистики, но сохраняет обезличенный факт действия в журнале аудита. + ## Исследовательский парсер официальных рекордов ```bash @@ -123,4 +138,4 @@ python -m rf4_research.records \ --category records ``` -Команда делает один HTTP-запрос и печатает типизированные записи в JSON. Это ещё не продуктивный импортёр: в нём нет повторов, кэша, транзакций и дедупликации. Подтверждённая структура источника и риски описаны в [docs/data-sources.md](docs/data-sources.md). +Команда делает один HTTP-запрос и печатает типизированные записи в JSON. Это исследовательский инструмент этапа 0; продуктивный адаптер находится в `apps/api/app/importer.py` и добавляет ограниченные повторы, условные HTTP-запросы, транзакции, дедупликацию и журнал запусков. Подтверждённая структура источника и риски описаны в [docs/data-sources.md](docs/data-sources.md). diff --git a/design-reference/.gitignore b/design-reference/.gitignore new file mode 100644 index 0000000..c413892 --- /dev/null +++ b/design-reference/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +next-env.d.ts +/dist/ +/.wrangler/ +/.sites-runtime/ +/outputs/ +/work/ diff --git a/design-reference/.npmrc b/design-reference/.npmrc new file mode 100644 index 0000000..0edab15 --- /dev/null +++ b/design-reference/.npmrc @@ -0,0 +1,4 @@ +audit=false +fund=false +update-notifier=false +cache=.sites-runtime/npm-cache diff --git a/design-reference/.openai/hosting.json b/design-reference/.openai/hosting.json new file mode 100644 index 0000000..3de0f6e --- /dev/null +++ b/design-reference/.openai/hosting.json @@ -0,0 +1,5 @@ +{ + "project_id": "appgprj_6a98209fe77c8191a3f64f1f55215055", + "d1": null, + "r2": null +} diff --git a/design-reference/CODEX_HANDOFF.md b/design-reference/CODEX_HANDOFF.md new file mode 100644 index 0000000..95e9405 --- /dev/null +++ b/design-reference/CODEX_HANDOFF.md @@ -0,0 +1,44 @@ +# Передача проекта «Ни хвоста, ни чешуи» в Codex + +## Что находится в архиве + +- исходники интерактивного визуального прототипа; +- `app/page.tsx` — структура экранов и демонстрационные данные; +- `app/globals.css` — вся визуальная система, адаптивность и состояния; +- `public/lake-dawn.png` — основное изображение; +- `public/favicon.svg` — знак проекта; +- `RF4_MVP_SPEC.md` — продуктовая идея, данные, архитектура и этапы MVP. + +Живая версия макета: + +## Важное замечание + +Визуальный прототип собран на Vinext/React. Это референс интерфейса, а не обязательный технологический фундамент. Если основной проект создаётся на Astro, нужно перенести визуальный язык, компоненты и поведение в Astro, а не добавлять второй frontend-фреймворк. + +Все уловы, рекорды, показатели активности и имена игроков в прототипе демонстрационные. + +## Первый запрос Codex + +```text +Прочитай RF4_MVP_SPEC.md и CODEX_HANDOFF.md целиком. Затем изучи визуальный прототип: app/page.tsx, app/globals.css и public/lake-dawn.png. Живая версия для визуальной сверки: https://ni-hvosta-ni-cheshui.ilia-klimentiev.chatgpt.site + +Это дизайн-референс проекта «Ни хвоста, ни чешуи». Сохрани его визуальный язык: тёмная вода, светлые информационные поверхности, лаймовый сигнальный акцент, антиквенная типографика заголовков, плотные карточки точек и честное отображение свежести/уверенности данных. + +Пока не переписывай весь продукт. Сначала: +1. изучи переданные материалы и текущий репозиторий; +2. перечисли, какие элементы можно перенести напрямую, а какие нужно адаптировать под выбранный стек; +3. предложи структуру frontend-компонентов и страниц; +4. составь небольшой поэтапный план реализации; +5. дождись подтверждения плана перед крупными архитектурными изменениями. + +Не воспринимай демонстрационные данные как реальные. Не исследуй игровой клиент и не перехватывай его трафик. +``` + +## Если новый проект должен быть на Astro + +Добавьте к запросу: + +```text +Целевой frontend — Astro + TypeScript. Используй islands только для фильтров, переключения карточек и формы. Не переноси Vinext/Next.js в новый проект. Воспроизведи дизайн по переданным исходникам и живой версии. +``` + diff --git a/design-reference/README.md b/design-reference/README.md new file mode 100644 index 0000000..d233db2 --- /dev/null +++ b/design-reference/README.md @@ -0,0 +1,93 @@ +# vinext-starter + +A clean full-stack starter running on [vinext](https://github.com/cloudflare/vinext), with optional Cloudflare D1 and Drizzle support. + +## Prerequisites + +- Node.js `>=22.13.0` +- Linux with `flock`, `curl`, and GNU `timeout` + +## Sites Lifecycle + +The Sites lifecycle CLI runs the locked dependency install before returning this checkout. Edit the source under `app/`, then checkpoint when a coherent milestone is ready to inspect or share. The remote Sites builder runs `npm run build` against the pushed commit. Do not repeat install or build as a normal pre-checkpoint step. + +This starter does not use `wrangler.jsonc`. + +`install:ci` is intentionally a single, non-retrying `npm ci`. It refuses a concurrent install for the same project, consumes a matching image-seeded npm cache with `--prefer-offline` while retaining registry fallback for a missing cache object, otherwise downloads and verifies the complete vinext tarball recorded in `package-lock.json`, limits npm to one socket, and terminates a stalled install. `build` applies a short timeout. These helpers target Linux and use GNU `timeout`; they are not native macOS scripts. + +Scripts that need writable project-scoped home, npm, XDG, and temporary paths use `scripts/sites-env.sh`. The `dev` and `start` scripts honor the caller's runtime environment and keep Wrangler logs inside the checkout. The generated `.sites-runtime/` directory is disposable and ignored by Git. + +## Included Shape + +- edit site code under `app/` +- `app/chatgpt-auth.ts` provides optional dispatch-owned ChatGPT sign-in helpers +- `.openai/hosting.json` declares optional Sites D1 and R2 bindings +- `vite.config.ts` simulates declared bindings for local development +- `db/index.ts` reads the D1 binding from the Cloudflare Worker environment +- `db/schema.ts` starts intentionally empty +- `examples/d1/` contains an optional D1 example surface +- `drizzle.config.ts` supports local migration generation when needed + +## Workspace Auth Headers + +OpenAI workspace sites can read the current user's email from `oai-authenticated-user-email`. + +SIWC-authenticated workspace sites may also receive `oai-authenticated-user-full-name` when the user's SIWC profile has a non-empty `name` claim. The full-name value is percent-encoded UTF-8 and is accompanied by `oai-authenticated-user-full-name-encoding: percent-encoded-utf-8`. + +Treat the full name as optional and fall back to email when it is absent: + +```tsx +import { headers } from "next/headers"; + +export default async function Home() { + const requestHeaders = await headers(); + const email = requestHeaders.get("oai-authenticated-user-email"); + const encodedFullName = requestHeaders.get("oai-authenticated-user-full-name"); + const fullName = + encodedFullName && + requestHeaders.get("oai-authenticated-user-full-name-encoding") === + "percent-encoded-utf-8" + ? decodeURIComponent(encodedFullName) + : null; + + const displayName = fullName ?? email; + // ... +} +``` + +## Optional Dispatch-Owned ChatGPT Sign-In + +Import the ready-to-use helpers from `app/chatgpt-auth.ts` when the site needs optional or required ChatGPT sign-in: + +- Use `getChatGPTUser()` for optional signed-in UI. +- Use `requireChatGPTUser(returnTo)` for server-rendered pages that should send anonymous visitors through Sign in with ChatGPT. +- In a Server Component, start sign-in with ``. The auth helper module is server-only; do not import it into a Client Component. +- Do not use `fetch`, XHR, a client-side router, or a framework link that can prefetch the sign-in route. SIWC must start as a top-level navigation. +- Never request the AuthAPI authorization endpoint directly. The dispatch-owned `/signin-with-chatgpt` route must start the SIWC flow. +- Use `chatGPTSignOutPath(returnTo)` for browser sign-out links or actions. +- Pass a same-origin relative `returnTo` path for the destination after sign-in or sign-out. The helper validates and safely encodes it. +- Mark protected pages with `export const dynamic = "force-dynamic"` because they depend on per-request identity headers. + +Dispatch owns `/signin-with-chatgpt`, `/signout-with-chatgpt`, `/callback`, the OAuth cookies, and identity header injection. Do not implement app routes for those reserved paths. Routes that do not import and call the helper remain anonymous-compatible. + +SIWC establishes identity only; it does not prove workspace membership. Use the Sites hosting platform's access policy controls for workspace-wide restrictions, or enforce explicit server-side membership or allowlist checks. + +Use SIWC for account pages, user-specific dashboards, saved records, and write actions tied to the current ChatGPT user. Leave public content anonymous. + +## Diagnostic Commands + +- `npm run install:ci`: perform the one bounded lockfile install +- `npm run dev`: start the Vite/Vinext development server +- `npm run build`: build the deployable Sites artifact +- `npm run start`: start the built Vinext application +- `npm test`: build and verify the rendered development-preview metadata +- `npm run db:generate`: generate Drizzle migrations after schema changes + +Use build commands for targeted diagnosis after a remote failure, not as part of the normal checkpoint path. + +The timeout defaults can be overridden for a controlled canary with `SITES_INSTALL_TIMEOUT`, `SITES_INSTALL_KILL_AFTER`, `SITES_BUILD_TIMEOUT`, and `SITES_BUILD_KILL_AFTER`. A timeout fails the command; the helpers never retry an unchanged install or build. + +## Learn More + +- [vinext Documentation](https://github.com/cloudflare/vinext) +- [Drizzle D1 Guide](https://orm.drizzle.team/docs/get-started/d1-new) diff --git a/design-reference/RF4_MVP_SPEC.md b/design-reference/RF4_MVP_SPEC.md new file mode 100644 index 0000000..a2ad496 --- /dev/null +++ b/design-reference/RF4_MVP_SPEC.md @@ -0,0 +1,703 @@ +# RF4 Spotter — идеи и техническое задание для MVP + +> Рабочий документ для передачи в Codex. Его задача — дать Codex достаточно контекста, чтобы начать проектирование и разработку без пересказа всей переписки. + +## 1. Идея продукта + +**RF4 Spotter** — неофициальный информационный сайт для игроков «Русской Рыбалки 4» (Russian Fishing 4), который отвечает на практический вопрос: + +> **Куда мне пойти ловить прямо сейчас, какую снасть или приманку взять и насколько свежа эта информация?** + +Сайт должен объединять: + +- официальные рекорды RF4; +- пользовательские сообщения об уловах; +- координаты точек; +- приманки, наживки, оснастки и способы проводки; +- время поимки; +- историю активности; +- простой и понятный индекс клёва. + +Главная ценность — не вечный справочник старых точек, а **оценка текущей активности с указанием свежести и надёжности данных**. + +## 2. Что известно об источниках данных + +### Официальные данные + +У RF4, по предварительным данным, нет документированного публичного API со всеми уловами и координатами. Однако официальный сайт публикует таблицы рекордов и рейтингов, которые можно разбирать автоматически. + +Предположительно из таблиц рекордов можно получать: + +- регион; +- категорию рекорда; +- игрока; +- вид рыбы; +- вес; +- водоём; +- приманку или наживку; +- дату. + +Исходные точки для исследования: + +- официальный раздел рекордов: ; +- пример открытого парсера: ; +- существующий статистический сервис: ; +- пример пользовательской базы: . + +Перед реализацией парсера нужно проверить актуальную HTML-структуру, сетевые запросы страницы и условия использования сайта. Нельзя считать приведённые URL и структуру полей неизменными. + +### Пользовательские данные + +Официальные таблицы, вероятно, не содержат точных координат, проводки и всех обычных уловов. Их нужно собирать отдельно: + +- через собственную форму на сайте; +- позднее — из разрешённых Telegram-, Discord- или VK-источников; +- позднее — из скриншотов с подтверждением распознанных полей пользователем. + +### Важное ограничение + +В MVP нельзя перехватывать трафик игрового клиента, внедряться в процесс игры, обходить античит или заниматься reverse engineering закрытого протокола. Проект должен работать только с публичными веб-данными и добровольно переданной пользователями информацией. + +## 3. Для кого делаем + +Основной пользователь — игрок RF4, который не хочет просматривать десятки сообщений в сообществах перед каждой игровой сессией. + +Типичный запрос: + +```text +Уровень: 19 +Способ ловли: спиннинг +Доступные водоёмы: до Куори включительно +Цель: серебро +``` + +Ожидаемый ответ: + +```text +Сейчас активна щука на Вьюнке +Точка: 110:103 +Приманка: Spiker #2 01-015 +Последнее подтверждение: 34 минуты назад +Уловов за 12 часов: 27 от 11 игроков +Активность: высокая +Уверенность: 87 из 100 +``` + +## 4. Главная гипотеза MVP + +Игроку полезнее несколько свежих и объяснимых рекомендаций, чем большая база точек без даты и источника. + +MVP считается полезным, если пользователь может: + +1. открыть главную страницу; +2. выбрать водоём, рыбу или способ ловли; +3. увидеть активные комбинации «водоём + рыба + точка»; +4. понять, на чём основана оценка; +5. открыть карточку точки с приманками и динамикой; +6. отправить свой улов через простую форму. + +## 5. Состав MVP + +### 5.1. Главная страница «Что клюёт сейчас» + +На странице должны быть: + +- фильтр по водоёму; +- фильтр по рыбе; +- фильтр по способу ловли; +- фильтр по периоду: 6, 12, 24, 72 часа; +- сортировка по активности, уверенности и свежести; +- карточки активных точек; +- явное время последнего обновления данных. + +Карточка содержит: + +- водоём; +- рыбу; +- координаты; +- лучшую приманку или наживку; +- количество уловов за выбранный период; +- количество разных авторов; +- средний и максимальный вес; +- время последнего подтверждения; +- `activity_score` от 0 до 100; +- `confidence_score` от 0 до 100; +- короткое текстовое объяснение оценки. + +### 5.2. Страница точки + +URL вида: + +```text +/spots/vyunok/110-103/pike +``` + +Содержимое: + +- координаты и описание места; +- статистика уловов за 24 часа, 3 дня и 7 дней; +- список наиболее результативных приманок или наживок; +- способы проводки и скорость, если указаны; +- распределение уловов по игровому времени; +- последние подтверждённые уловы; +- источник и свежесть каждой записи; +- предупреждение, если данных мало или они устарели. + +### 5.3. Страница официальных рекордов + +- таблица импортированных рекордов; +- фильтры по рыбе, водоёму, категории и периоду; +- дата последнего успешного импорта; +- отметка, что это данные официального сайта RF4; +- ссылка на исходную страницу. + +### 5.4. Форма добавления улова + +Обязательные поля: + +- рыба; +- вес; +- водоём; +- координаты `x:y`; +- дата и время отправки. + +Необязательные поля: + +- приманка или наживка; +- тип оснастки; +- проводка; +- скорость проводки; +- игровое время; +- комментарий; +- имя или ник игрока; +- ссылка на исходную публикацию; +- скриншот. + +Для первой версии скриншот хранится как подтверждение, но **не распознаётся автоматически**. + +### 5.5. Простая модерация + +Минимальная закрытая страница администратора: + +- список новых записей; +- просмотр всех полей и скриншота; +- действия «одобрить», «отклонить», «исправить»; +- причина отклонения; +- журнал изменения статуса. + +До одобрения пользовательский улов не влияет на публичный индекс клёва. + +## 6. Что не входит в MVP + +- перехват данных из запущенной игры; +- reverse engineering протокола RF4; +- OCR и компьютерное зрение для скриншотов; +- автоматический сбор всех публикаций Telegram, Discord и VK; +- мобильное приложение; +- сложная персонализация по снастям и бюджету; +- прогнозирование клёва с помощью ML; +- комментарии и социальная сеть; +- полноценная интерактивная карта каждого водоёма; +- автоматическое определение игровых обновлений и миграций рыбы. + +Эти возможности допустимы после проверки основного сценария. + +## 7. Предлагаемый стек + +### Вариант для первого релиза + +- **Frontend:** Astro + TypeScript; +- **интерактивные компоненты:** React или Svelte islands только там, где они нужны; +- **стили:** Tailwind CSS либо обычный CSS с дизайн-токенами; +- **Backend API:** Python 3.12 + FastAPI; +- **ORM и миграции:** SQLAlchemy 2 + Alembic; +- **БД:** PostgreSQL; +- **фоновые задания:** APScheduler или отдельный CLI, запускаемый cron; +- **парсер:** сначала `httpx` + BeautifulSoup; Playwright только если обычного HTTP недостаточно; +- **хранение скриншотов:** S3-совместимое объектное хранилище; +- **локальный запуск:** Docker Compose; +- **тесты:** pytest для backend, Vitest для frontend, Playwright для ключевого end-to-end сценария. + +На старте Celery и Redis не нужны: периодический импорт можно сделать отдельной идемпотентной CLI-командой. + +## 8. Архитектура + +```text +Официальный сайт RF4 ──> импортёр рекордов ──┐ + │ +Форма пользователя ──> модерация ───────────┼──> PostgreSQL + │ │ +Ручной импорт CSV/JSON ──────────────────────┘ │ + v + расчёт активности + │ + v + FastAPI JSON API + │ + v + Astro-сайт +``` + +Предлагаемая структура репозитория: + +```text +rf4-spotter/ +├── README.md +├── .env.example +├── compose.yaml +├── apps/ +│ ├── web/ # Astro +│ └── api/ # FastAPI +├── packages/ +│ └── contracts/ # OpenAPI/types, если понадобится +├── data/ +│ └── fixtures/ # обезличенные тестовые HTML/JSON +├── docs/ +│ ├── RF4_MVP_SPEC.md +│ └── data-sources.md +└── scripts/ + └── seed_demo_data.* +``` + +## 9. Модель данных + +Названия таблиц и полей можно скорректировать после прототипа, но модель должна отделять справочники, исходные наблюдения и рассчитанные агрегаты. + +### `fish` + +| Поле | Тип | Назначение | +|---|---|---| +| `id` | UUID | идентификатор | +| `slug` | text unique | значение для URL | +| `name_ru` | text unique | название рыбы | +| `trophy_weight_g` | integer nullable | порог трофея, если известен | + +### `waterbody` + +| Поле | Тип | Назначение | +|---|---|---| +| `id` | UUID | идентификатор | +| `slug` | text unique | значение для URL | +| `name_ru` | text unique | название водоёма | +| `unlock_level` | integer nullable | уровень открытия | + +### `bait` + +Объединённый справочник наживок и искусственных приманок для MVP. + +| Поле | Тип | Назначение | +|---|---|---| +| `id` | UUID | идентификатор | +| `name` | text | отображаемое название | +| `normalized_name` | text unique | ключ для сопоставления | +| `kind` | enum | `bait`, `lure`, `unknown` | + +### `spot` + +| Поле | Тип | Назначение | +|---|---|---| +| `id` | UUID | идентификатор | +| `waterbody_id` | UUID FK | водоём | +| `x` | integer | координата X | +| `y` | integer | координата Y | +| `description` | text nullable | заметка о месте | + +Уникальный ключ MVP: `(waterbody_id, x, y)`. + +### `catch_report` + +| Поле | Тип | Назначение | +|---|---|---| +| `id` | UUID | идентификатор | +| `fish_id` | UUID FK | рыба | +| `spot_id` | UUID FK nullable | точка; у официального рекорда её может не быть | +| `waterbody_id` | UUID FK | водоём хранится явно | +| `bait_id` | UUID FK nullable | приманка или наживка | +| `weight_g` | integer | вес в граммах | +| `rig_type` | text nullable | тип оснастки | +| `retrieve_method` | text nullable | способ проводки | +| `retrieve_speed` | integer nullable | скорость проводки | +| `game_time` | time nullable | игровое время | +| `caught_at` | timestamptz nullable | время поимки, если известно | +| `reported_at` | timestamptz | время попадания в систему | +| `player_name` | text nullable | ник игрока | +| `source_type` | enum | `official_record`, `user`, `manual_import` | +| `source_url` | text nullable | ссылка на источник | +| `source_external_id` | text nullable | ключ для дедупликации | +| `source_confidence` | smallint | 0–100 | +| `moderation_status` | enum | `pending`, `approved`, `rejected` | +| `screenshot_key` | text nullable | ключ объекта в хранилище | +| `raw_payload` | jsonb nullable | исходные данные импортёра | + +### `official_record_import` + +| Поле | Тип | Назначение | +|---|---|---| +| `id` | UUID | запуск импортёра | +| `started_at` | timestamptz | начало | +| `finished_at` | timestamptz nullable | завершение | +| `status` | enum | `running`, `success`, `partial`, `failed` | +| `source_url` | text | источник | +| `rows_seen` | integer | найдено строк | +| `rows_created` | integer | добавлено записей | +| `rows_updated` | integer | обновлено записей | +| `error_summary` | text nullable | краткая ошибка | + +### `moderation_event` + +Хранит историю решений по пользовательскому сообщению: кто, когда, какой статус установил и почему. + +## 10. Нормализация и дедупликация + +Это критическая часть проекта. + +Нужно: + +- хранить исходное значение из источника в `raw_payload`; +- нормализовать пробелы, регистр, дефисы и десятичные разделители; +- вести таблицу алиасов для рыб, водоёмов и приманок; +- вес всегда приводить к граммам; +- время хранить с явным указанием, реальное оно или игровое; +- официальный импорт делать идемпотентным; +- не считать две одинаковые строки двумя независимыми подтверждениями. + +Для официальных рекордов ключ дедупликации можно сначала строить из нормализованной комбинации: + +```text +region + category + player + fish + weight_g + waterbody + bait + record_date +``` + +Этот ключ следует хешировать и хранить в `source_external_id`. + +## 11. Индекс активности + +### Требование + +Алгоритм MVP должен быть простым, детерминированным и объяснимым. Нельзя показывать псевдоточную оценку без расшифровки. + +Расчёт производится для группы: + +```text +водоём + точка + рыба + выбранный временной интервал +``` + +Предлагаемый первый вариант: + +```text +freshness_i = exp(-age_hours_i / 18) + +weighted_reports = sum(freshness_i * source_confidence_i / 100) +unique_players = количество уникальных непустых player_name +trophy_bonus = min(1, trophy_count / 3) + +activity_raw = + 55 * min(1, weighted_reports / 12) + + 25 * min(1, unique_players / 6) + + 20 * trophy_bonus + +activity_score = round(activity_raw) +``` + +Начальная оценка уверенности: + +```text +confidence_score = round( + 45 * min(1, approved_reports / 10) + + 35 * min(1, unique_players / 5) + + 20 * average_source_confidence / 100 +) +``` + +Правила отображения: + +- меньше трёх одобренных пользовательских наблюдений — пометка «данных мало»; +- нет уловов за 72 часа — пометка «данные устарели»; +- один игрок не может создать высокую уверенность большим количеством сообщений; +- официальный рекорд без координат нельзя автоматически приписывать конкретной точке; +- значения 0–100 — сравнительные индексы сервиса, а не вероятность поймать рыбу. + +Карточка должна объяснять результат, например: + +```text +Высокая активность: 18 свежих уловов от 7 игроков. +Последнее подтверждение 42 минуты назад. +Уверенность средняя: часть сообщений без скриншотов. +``` + +Формулу после накопления реальных данных нужно откалибровать. + +## 12. API MVP + +Публичные методы: + +```http +GET /api/v1/activity +GET /api/v1/spots/{spot_id} +GET /api/v1/spots/{spot_id}/catches +GET /api/v1/records +GET /api/v1/fishes +GET /api/v1/waterbodies +GET /api/v1/baits +POST /api/v1/catch-reports +``` + +Пример фильтров активности: + +```http +GET /api/v1/activity?waterbody=vyunok&fish=pike&hours=24&method=spinning +``` + +Административные методы: + +```http +GET /api/v1/admin/catch-reports?status=pending +PATCH /api/v1/admin/catch-reports/{id} +POST /api/v1/admin/imports/official-records +GET /api/v1/admin/imports +``` + +Все списочные методы должны иметь пагинацию, предсказуемую сортировку и валидацию фильтров. + +## 13. Импорт официальных рекордов + +Импортёр должен быть отдельным адаптером, чтобы изменение вёрстки RF4 не затронуло остальное приложение. + +Интерфейс адаптера: + +```python +class OfficialRecordsSource(Protocol): + async def fetch(self, region: str, category: str) -> list[RawRecord]: ... +``` + +Этапы: + +1. загрузить страницу или JSON/XHR, если он существует; +2. сохранить диагностические метаданные ответа; +3. разобрать строки в `RawRecord`; +4. нормализовать значения; +5. вычислить ключ дедупликации; +6. добавить или обновить записи транзакционно; +7. записать результат запуска в `official_record_import`. + +Требования: + +- таймауты и повторы с ограничением; +- понятный User-Agent с названием проекта и контактным URL, когда он появится; +- умеренная частота запросов; +- кэширование; +- фикстуры реальных HTML-ответов для unit-тестов; +- отказ с понятной ошибкой, если ожидаемые колонки исчезли; +- никакого молчаливого импорта перепутанных полей; +- возможность запустить командой вроде `python -m app.cli import-records`; +- расписание не чаще необходимого; начать с одного раза в 30–60 минут. + +Если официальный сайт отдаёт данные через внутренний JSON/XHR endpoint, нужно предпочесть его HTML-парсингу, но только если он доступен без обхода защиты. + +## 14. Интерфейс и визуальный принцип + +Сайт должен выглядеть как современный полезный инструмент, а не как форум или перегруженная игровая база. + +Принципы: + +- тёмная и светлая темы допустимы, но первая версия может иметь одну хорошо сделанную тему; +- важнее всего: рыба, водоём, точка, свежесть и рабочая приманка; +- цветовая шкала активности не должна быть единственным носителем смысла; +- на мобильном карточки должны читаться без горизонтальной прокрутки; +- таблица рекордов на мобильном превращается в карточки; +- возле любой оценки показывать, на каких данных она основана; +- не смешивать реальное время и игровое время; +- не выдавать старую точку за текущий клёв. + +Минимальные состояния каждой страницы: + +- загрузка; +- данные есть; +- данных нет; +- данных мало; +- источник временно недоступен; +- ошибка валидации. + +## 15. Безопасность и приватность + +- не принимать исполняемые файлы; +- проверять MIME-тип, расширение и размер скриншотов; +- удалять EXIF из загруженных изображений; +- генерировать серверные имена объектов; +- ограничить частоту отправки формы; +- добавить honeypot или CAPTCHA после появления спама; +- экранировать пользовательский текст; +- не публиковать IP и технические идентификаторы; +- ник игрока сделать необязательным; +- предусмотреть удаление пользовательского сообщения; +- административную часть защитить аутентификацией; +- секреты хранить только в переменных окружения; +- не коммитить `.env` и реальные скриншоты пользователей. + +## 16. Критерии готовности MVP + +MVP готов, когда: + +- проект поднимается одной документированной командой через Docker Compose; +- миграции создают чистую БД; +- seed-команда добавляет демонстрационные водоёмы, рыб, приманки и уловы; +- импортёр получает или разбирает официальные рекорды из актуального источника; +- повторный импорт не создаёт дубликаты; +- сбой источника не удаляет ранее загруженные данные; +- главная страница показывает активные точки и фильтруется; +- карточка точки объясняет активность и уверенность; +- пользователь может отправить улов; +- администратор может его одобрить или отклонить; +- одобренный улов появляется в публичной статистике; +- есть автоматические тесты главного сценария; +- README описывает запуск, настройку, импорт, тесты и ограничения проекта. + +## 17. Этапы разработки + +### Этап 0. Исследование источника + +- проверить официальный сайт RF4 и его сетевые запросы; +- зафиксировать доступные регионы и категории; +- проверить актуальность проекта `hurfy/rf4-api`; +- сохранить небольшие HTML/JSON-фикстуры; +- описать риски и ограничения в `docs/data-sources.md`; +- не писать весь продукт, пока не доказано, что хотя бы один источник стабильно разбирается. + +Результат: маленький исследовательский скрипт и документ с подтверждённой структурой данных. + +### Этап 1. Каркас и демонстрационные данные + +- создать монорепозиторий; +- настроить Astro, FastAPI, PostgreSQL и миграции; +- создать модель данных; +- добавить seed; +- реализовать read-only API; +- сверстать главную и страницу точки на демоданных. + +### Этап 2. Официальные рекорды + +- реализовать адаптер источника; +- добавить нормализацию и дедупликацию; +- добавить журнал запусков; +- сделать страницу рекордов; +- добавить периодический запуск. + +### Этап 3. Пользовательские уловы + +- форма; +- загрузка скриншота; +- модерация; +- rate limit; +- включение одобренных записей в статистику. + +### Этап 4. Индекс клёва + +- агрегаты; +- активность и уверенность; +- человекочитаемое объяснение; +- тесты на свежесть, дубликаты и вклад разных игроков. + +### Этап 5. Пилот + +- наполнить базу небольшим набором реальных данных с разрешёнными источниками; +- дать нескольким игрокам протестировать сценарий; +- собрать обратную связь; +- только после этого решать, нужен ли OCR и импорт сообществ. + +## 18. Идеи после MVP + +Приоритет определять по реальному использованию: + +- распознавание скриншота с обязательным подтверждением пользователем; +- Telegram-бот для отправки улова; +- импорт из разрешённых каналов и сообществ; +- персональный профиль: уровень, открытые водоёмы, снасти, бюджет; +- режим «куда пойти прямо сейчас»; +- сравнение приманок; +- динамика клёва по игровому времени; +- обнаружение смены рабочих точек после обновлений; +- уведомление, когда активизировалась выбранная рыба; +- карта водоёма; +- публичный API для сообщества; +- репутация источников и авторов; +- мультиязычность и поддержка разных регионов RF4. + +## 19. Открытые вопросы + +Codex не должен молча принимать решения по этим пунктам, если они становятся блокирующими: + +1. Как будет называться проект и какой домен использовать? +2. Нужна ли авторизация обычных пользователей в первом релизе? +3. Где размещать приложение и объектное хранилище? +4. Можно ли использовать реальные ники игроков в публичной выдаче? +5. Какие именно официальные категории рекордов импортировать первыми? +6. Какие водоёмы и рыбы нужны в пилотной базе? +7. Какая лицензия будет у кода и данных? +8. Разрешают ли правила и `robots.txt` выбранную частоту автоматического сбора? + +Для начала разработки допустимы безопасные значения по умолчанию: + +- рабочее название `RF4 Spotter`; +- без регистрации обычных пользователей; +- один регион RU; +- одна или две основные категории рекордов; +- локальное S3-совместимое хранилище MinIO; +- демонстрационные ники и данные в seed; +- импорт раз в 60 минут. + +## 20. Как работать над проектом в Codex + +1. Создать пустой репозиторий и положить этот файл в `docs/RF4_MVP_SPEC.md`. +2. Открыть корень репозитория в Codex. +3. Дать Codex сначала исследовательскую задачу из блока ниже. +4. Попросить фиксировать решения в `docs/` и обновлять README. +5. Делить работу на небольшие проверяемые этапы, а не просить сразу «сделать весь сайт». +6. После каждого этапа просить запускать тесты и показывать, что именно готово. +7. Не передавать Codex пароли, токены и игровые учётные данные; использовать `.env.example`. + +### Первый промпт для Codex + +```text +Прочитай docs/RF4_MVP_SPEC.md целиком. Пока не создавай весь сайт. + +Выполни только этап 0 — исследование источников официальных рекордов RF4. + +Задачи: +1. Изучи текущую структуру официальной страницы рекордов и её сетевые запросы. +2. Проверь, существует ли доступный JSON/XHR endpoint, который можно использовать без обхода защиты. +3. Изучи архитектурные идеи проекта https://github.com/hurfy/rf4-api, но не копируй код вслепую и проверь его актуальность. +4. Создай минимальный исследовательский скрипт, который получает одну страницу/категорию и преобразует записи в типизированную внутреннюю структуру. +5. Добавь обезличенную небольшую фикстуру и unit-тест парсера. +6. Создай docs/data-sources.md: подтверждённые поля, URL, параметры, ограничения, рекомендуемую частоту опроса и риски поломки. +7. Не исследуй сетевой протокол игры, не запускай игровой клиент и не обходи защиту сайтов. + +Перед изменениями изучи репозиторий и предложи краткий план. После работы запусти тесты и сообщи, что подтверждено фактически, а что осталось предположением. +``` + +### Второй промпт после успешного исследования + +```text +Прочитай docs/RF4_MVP_SPEC.md и docs/data-sources.md. Реализуй этап 1: каркас приложения на Astro + TypeScript, FastAPI, PostgreSQL, SQLAlchemy 2 и Alembic. + +Требования: +- локальный запуск через Docker Compose; +- модели и миграции для основных сущностей MVP; +- seed с демонстрационными данными; +- read-only API для активности, точек и справочников; +- главная страница и страница точки; +- адаптивная вёрстка; +- тесты backend и одного главного пользовательского сценария; +- README с точными командами запуска и проверки. + +Используй данные только из seed: реальный импортёр будет отдельным этапом. Сначала предложи план и список файлов, затем реализуй и проверь результат. +``` + +## 21. Короткое описание проекта для README + +```text +RF4 Spotter — неофициальный сервис свежих точек и статистики клёва для Russian Fishing 4. Он объединяет публичные рекорды и подтверждённые пользовательские уловы, показывает свежесть источников и рассчитывает объяснимые индексы активности и уверенности. Проект не взаимодействует с игровым клиентом и не является официальным продуктом RF4. +``` + +## 22. Главное продуктовое правило + +> Если данных недостаточно или они устарели, сайт должен честно сказать об этом. «Не знаем» полезнее, чем красивая, но выдуманная рекомендация. + diff --git a/design-reference/app/chatgpt-auth.ts b/design-reference/app/chatgpt-auth.ts new file mode 100644 index 0000000..8d1fb35 --- /dev/null +++ b/design-reference/app/chatgpt-auth.ts @@ -0,0 +1,86 @@ +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; + +export type ChatGPTUser = { + displayName: string; + email: string; + fullName: string | null; +}; + +const USER_EMAIL_HEADER = "oai-authenticated-user-email"; +const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name"; +const USER_FULL_NAME_ENCODING_HEADER = + "oai-authenticated-user-full-name-encoding"; +const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8"; +const SIGN_IN_PATH = "/signin-with-chatgpt"; +const SIGN_OUT_PATH = "/signout-with-chatgpt"; +const CALLBACK_PATH = "/callback"; + +export async function getChatGPTUser(): Promise { + const requestHeaders = await headers(); + const email = requestHeaders.get(USER_EMAIL_HEADER); + if (!email) return null; + + const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER); + const fullName = + encodedFullName && + requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8 + ? safeDecodeURIComponent(encodedFullName) + : null; + + return { + displayName: fullName ?? email, + email, + fullName, + }; +} + +export async function requireChatGPTUser( + returnTo: string, +): Promise { + const user = await getChatGPTUser(); + if (user) return user; + + redirect(chatGPTSignInPath(returnTo)); +} + +export function chatGPTSignInPath(returnTo: string): string { + const safeReturnTo = safeRelativeReturnPath(returnTo); + return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`; +} + +export function chatGPTSignOutPath(returnTo = "/"): string { + const safeReturnTo = safeRelativeReturnPath(returnTo); + return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`; +} + +function safeRelativeReturnPath(value: string): string { + if (!value.startsWith("/") || value.startsWith("//")) return "/"; + + let url: URL; + try { + url = new URL(value, "https://app.local"); + } catch { + return "/"; + } + if (url.origin !== "https://app.local") return "/"; + if (isReservedAuthPath(url.pathname)) return "/"; + + return `${url.pathname}${url.search}${url.hash}`; +} + +function isReservedAuthPath(pathname: string): boolean { + return ( + pathname === SIGN_IN_PATH || + pathname === SIGN_OUT_PATH || + pathname === CALLBACK_PATH + ); +} + +function safeDecodeURIComponent(value: string): string | null { + try { + return decodeURIComponent(value); + } catch { + return null; + } +} diff --git a/design-reference/app/globals.css b/design-reference/app/globals.css new file mode 100644 index 0000000..dbd53ef --- /dev/null +++ b/design-reference/app/globals.css @@ -0,0 +1,211 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "../vendor/shadcn-tailwind-4.13.0.css"; + +:root { + --background: #f2f5ee; --foreground: #092226; --card: #fff; --card-foreground: #092226; + --popover: #fff; --popover-foreground: #092226; --primary: #12383b; --primary-foreground: #f7f9f3; + --secondary: #dce6db; --secondary-foreground: #092226; --muted: #e7ece4; --muted-foreground: #647472; + --accent: #c9f45b; --accent-foreground: #092226; --destructive: #c94634; --border: #cbd6ce; + --input: #becbc4; --ring: #87a436; --radius: .8rem; --lime: #c9f45b; --deep: #082226; + --teal: #12383b; --paper: #f2f5ee; --orange: #ffb65c; +} +@theme inline { + --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); + --color-card-foreground: var(--card-foreground); --color-popover: var(--popover); --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); + --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); + --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 6px); + --font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { margin: 0; background: var(--paper); color: var(--foreground); font-family: Inter, ui-sans-serif, system-ui, sans-serif; min-width: 320px; } +button, input, textarea { font: inherit; } button { cursor: pointer; } +.site-shell { min-height: 100vh; display: block !important; } +.content-grid { width: min(1360px, calc(100% - 64px)); margin-inline: auto; } + +.topbar { height: 86px; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 28px; width: min(1480px, calc(100% - 48px)); margin: auto; } +.brand { display: flex; align-items: center; gap: 12px; min-width: max-content; } +.brand-mark { width: 42px; height: 42px; border: 1px solid #9eb0a7; border-radius: 50%; display: grid; place-items: center; background: var(--deep); color: var(--lime); } +.brand-mark svg { width: 23px; height: 23px; } +.brand-name { font-family: Georgia, "Times New Roman", serif; font-size: 17px; line-height: .95; display: flex; flex-direction: column; letter-spacing: -.02em; } +.brand-name strong { font-size: 20px; font-style: italic; } +.main-nav { align-self: center; height: 44px !important; gap: 22px !important; } +.main-nav button { font-size: 14px; padding-inline: 8px; height: 41px; color: #526461; } +.main-nav button[data-state="active"] { color: var(--deep); } +.main-nav button[data-state="active"]::after { background: var(--deep); height: 3px; border-radius: 3px; } +.live-badge { justify-self: end; display: flex; align-items: center; gap: 9px; color: #60716e; font-size: 13px; } +.live-badge span { width: 8px; height: 8px; border-radius: 50%; background: #83b83a; box-shadow: 0 0 0 5px #83b83a20; } + +.intro { padding: 74px 0 42px; display: grid; grid-template-columns: .92fr 1.08fr; align-items: end; gap: 70px; } +.eyebrow, .overline { text-transform: uppercase; letter-spacing: .14em; font-size: 12px; font-weight: 750; color: #657673; } +.eyebrow { display: flex; align-items: center; gap: 10px; } +.eyebrow > span { display: grid; place-items: center; min-width: 41px; height: 25px; padding: 0 8px; border: 1px solid #9aaa9f; border-radius: 20px; color: var(--deep); letter-spacing: .05em; } +.intro h1, .subpage-title h1, .submit-intro h1 { margin: 18px 0 22px; font-family: Georgia, "Times New Roman", serif; font-weight: 400; font-size: clamp(58px, 6.3vw, 102px); line-height: .87; letter-spacing: -.065em; } +h1 em, h2 em { color: #497074; font-weight: 400; } +.intro-copy > p { font-size: 18px; line-height: 1.55; color: #586a68; max-width: 560px; } +.lake-card { position: relative; border-radius: 18px; overflow: hidden; height: 326px; box-shadow: 0 24px 70px #173c3f24; } +.lake-card img { width: 100%; height: 100%; object-fit: cover; display: block; } +.lake-card::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, transparent 35%, #041c20d9); } +.lake-overlay { position: absolute; z-index: 2; inset: auto 24px 22px 26px; display: flex; justify-content: space-between; align-items: end; color: #fff; } +.lake-overlay .overline { color: #d2dedb; display: block; margin-bottom: 7px; } +.lake-overlay strong { font-family: Georgia, serif; font-size: 21px; font-weight: 400; } +.pulse-orb { width: 45px; height: 45px; border: 1px solid #ffffff7d; border-radius: 50%; display: grid; place-items: center; } +.pulse-orb span { width: 9px; height: 9px; border-radius: 50%; background: var(--lime); box-shadow: 0 0 0 8px #c9f45b25; } + +.filters-wrap { background: var(--deep); padding: 24px 0; position: sticky; top: 0; z-index: 20; box-shadow: 0 10px 35px #08222620; } +.filters { display: grid; grid-template-columns: 1fr 1fr 1fr auto; gap: 12px; align-items: end; } +.filter-field { display: flex; flex-direction: column; gap: 7px; color: #9fb0ad; font-size: 12px; text-transform: uppercase; letter-spacing: .1em; font-weight: 700; } +.filter-field [data-slot="select-trigger"] { width: 100%; height: 48px; background: #ffffff0c; color: #f5f8f3; border-color: #ffffff29; border-radius: 10px; font-size: 15px; text-transform: none; letter-spacing: normal; box-shadow: none; } +.filter-field [data-slot="select-trigger"] svg { color: #b5c1be; } +.search-button, .submit-button { height: 48px; border-radius: 10px; padding-inline: 24px; background: var(--lime); color: var(--deep); font-weight: 750; } +.search-button:hover, .submit-button:hover { background: #dcff85; } + +.dashboard { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(360px, .75fr); gap: 26px; padding: 66px 0 104px; align-items: start; } +.section-heading { display: flex; justify-content: space-between; align-items: end; margin-bottom: 23px; } +.section-heading h2, .detail-head h2, .how-it-works h2 { font-family: Georgia, serif; font-size: 38px; font-weight: 400; margin: 6px 0 0; letter-spacing: -.04em; } +.result-count { border: 1px solid #b9c6bf; color: #667875; font-size: 13px; padding: 7px 11px; border-radius: 20px; } +.spot-list { display: flex; flex-direction: column; gap: 12px; } +.spot-card { width: 100%; position: relative; display: grid; grid-template-columns: 45px 1fr 156px; gap: 18px; padding: 22px 52px 22px 20px; text-align: left; border: 1px solid #d5ded7; border-radius: 16px; background: #fbfcf9; color: var(--deep); transition: .22s ease; } +.spot-card:hover, .spot-card.selected { transform: translateY(-2px); border-color: #7d9488; box-shadow: 0 18px 40px #14333812; } +.spot-card.selected { background: #fff; } +.spot-rank { width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center; border: 1px solid #cad4cd; font-family: Georgia, serif; font-style: italic; color: #788985; } +.spot-topline { display: flex; align-items: center; gap: 12px; color: #687a77; font-size: 13px; } +.activity-pill { display: inline-flex; align-items: center; gap: 6px; background: #e8efe3; color: #4b672c; padding: 5px 9px; border-radius: 30px; font-size: 11px; font-weight: 750; } +.activity-pill i { width: 6px; height: 6px; border-radius: 50%; background: #6d9f32; } +.activity-pill.level-4 { color: #426315; background: #e6f7c3; } +.spot-main h3 { font-family: Georgia, serif; font-size: 25px; font-weight: 400; margin: 8px 0; } +.spot-meta { display: flex; gap: 16px; color: #71817f; font-size: 13px; } +.spot-meta span { display: flex; align-items: center; gap: 5px; } .spot-meta svg { width: 14px; } +.bait-line { display: flex; gap: 10px; align-items: center; margin-top: 18px; padding-top: 15px; border-top: 1px solid #e2e8e2; } +.bait-line > svg { width: 20px; color: #759630; } +.bait-line div { display: flex; flex-direction: column; gap: 2px; } +.bait-line span { color: #82908e; font-size: 11px; text-transform: uppercase; letter-spacing: .09em; } +.bait-line strong { font-size: 14px; } +.spot-stats { border-left: 1px solid #e2e8e2; padding-left: 18px; display: grid; grid-template-columns: 1fr 1fr; align-items: start; gap: 10px; } +.spot-stats > div { display: flex; flex-direction: column; } +.spot-stats strong { font-family: Georgia, serif; font-size: 24px; font-weight: 400; } +.spot-stats span { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: #7c8d89; } +.sparkline { grid-column: 1 / -1; width: 100%; height: 44px; overflow: visible; } +.spark-area { fill: #c9f45b38; stroke: none; } .spark-line { fill: none; stroke: #6c8d28; stroke-width: 2; vector-effect: non-scaling-stroke; } +.card-arrow { position: absolute; right: 18px; top: 50%; width: 20px; transform: translateY(-50%); color: #91a09c; } +.empty-state { min-height: 300px; display: grid; place-items: center; align-content: center; text-align: center; color: #71817f; border: 1px dashed #b9c7bf; border-radius: 16px; } +.empty-state svg { width: 32px; height: 32px; } .empty-state h3 { margin: 14px 0 0; color: var(--deep); } .empty-state p { margin: 6px 0; } + +.detail-card { position: sticky; top: 124px; background: var(--deep); color: #f5f8f3; border-radius: 18px; padding: 27px; overflow: hidden; } +.detail-card::before { content: ""; position: absolute; width: 420px; height: 420px; border: 1px solid #ffffff0f; border-radius: 50%; left: 48%; top: -210px; box-shadow: 0 0 0 48px #ffffff08, 0 0 0 96px #ffffff05; } +.detail-card > * { position: relative; z-index: 1; } +.detail-head { display: flex; justify-content: space-between; gap: 20px; } +.detail-head .overline, .detail-section .overline { color: #a7b6b3; } +.detail-head h2 { font-size: 30px; } .detail-head h2 em { color: var(--lime); font-size: 22px; margin-left: 5px; } +.icon-button { width: 40px; height: 40px; border-radius: 50%; border: 1px solid #ffffff34; background: transparent; color: #fff; display: grid; place-items: center; } +.icon-button svg { width: 18px; } +.detail-score { display: grid; grid-template-columns: 126px 1fr; gap: 20px; align-items: center; padding: 34px 0 29px; } +.score-ring { width: 122px; height: 122px; display: grid; place-items: center; border-radius: 50%; background: conic-gradient(var(--lime) var(--score), #ffffff16 0); position: relative; } +.score-ring::after { content: ""; position: absolute; inset: 8px; background: var(--deep); border-radius: 50%; } +.score-ring > div { z-index: 1; text-align: center; display: flex; flex-direction: column; } +.score-ring strong { font-family: Georgia, serif; font-size: 39px; font-weight: 400; line-height: 1; } +.score-ring span { color: #a9b8b5; font-size: 10px; text-transform: uppercase; letter-spacing: .1em; margin-top: 4px; } +.detail-score > div > span { color: #a9b8b5; font-size: 12px; text-transform: uppercase; letter-spacing: .1em; } +.detail-score > div > strong { display: block; margin: 5px 0 8px; font-family: Georgia, serif; font-size: 24px; font-weight: 400; color: var(--lime); } +.detail-score p { color: #b7c3c0; font-size: 13px; line-height: 1.5; margin: 0; } +.metric-grid { display: grid; grid-template-columns: 1fr 1fr; border: 1px solid #ffffff1c; border-radius: 12px; overflow: hidden; } +.metric-grid > div { min-height: 91px; padding: 16px; display: grid; grid-template-columns: 23px 1fr; border-bottom: 1px solid #ffffff1c; gap: 3px 8px; } +.metric-grid > div:nth-child(odd) { border-right: 1px solid #ffffff1c; } .metric-grid > div:nth-last-child(-n+2) { border-bottom: 0; } +.metric-grid svg { width: 18px; color: var(--lime); grid-row: 1 / 3; } +.metric-grid span { color: #9cadaa; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; } +.metric-grid strong { font-family: Georgia, serif; font-size: 19px; font-weight: 400; } +.detail-section { margin-top: 28px; } +.lure-chip { display: grid; grid-template-columns: 18px 1fr auto; gap: 10px; align-items: center; padding: 14px 0; border-bottom: 1px solid #ffffff17; } +.lure-dot { width: 12px; height: 28px; border-radius: 50% 50% 43% 43%; background: var(--orange); transform: rotate(22deg); box-shadow: inset -4px 0 #091e2250; } +.lure-dot.secondary { background: #7cbaa2; } .lure-chip > div:nth-child(2) { display: flex; flex-direction: column; } +.lure-chip strong { font-size: 13px; } .lure-chip span { color: #9fb0ad; font-size: 12px; } .lure-chip > span { color: var(--lime); } +.confidence-note { display: flex; gap: 10px; padding: 16px; background: #ffffff0a; border-radius: 10px; margin-top: 22px; font-size: 12px; line-height: 1.45; color: #aebcba; } +.confidence-note svg { width: 19px; flex: 0 0 auto; color: var(--lime); } .confidence-note strong { color: #f6f8f3; } + +.how-it-works { border-top: 1px solid #cfd8d1; padding: 85px 0 105px; display: grid; grid-template-columns: .8fr 1.2fr; gap: 70px; } +.how-it-works h2 { font-size: 47px; line-height: 1.05; } +.principles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; } +.principles article { padding: 24px 0 0; border-top: 2px solid #294b4e; } +.principles article > span { font-family: Georgia, serif; color: #82928f; font-style: italic; } +.principles h3 { margin: 28px 0 8px; font-family: Georgia, serif; font-size: 22px; font-weight: 400; } +.principles p { font-size: 14px; line-height: 1.55; color: #657572; } + +.subpage { padding-top: 86px; padding-bottom: 120px; min-height: calc(100vh - 190px); } +.subpage-title { display: grid; grid-template-columns: 1.2fr .8fr; align-items: end; gap: 80px; margin-bottom: 54px; } +.subpage-title h1 { font-size: clamp(58px, 7vw, 108px); margin-bottom: 0; } +.subpage-title p { color: #627370; line-height: 1.65; font-size: 17px; max-width: 500px; } +.record-toolbar { min-height: 100px; background: var(--deep); color: #fff; border-radius: 16px 16px 0 0; padding: 20px 25px; display: flex; align-items: center; gap: 38px; } +.record-stat { display: flex; align-items: center; gap: 12px; } .record-stat > svg { color: var(--lime); width: 22px; } +.record-stat div { display: flex; flex-direction: column; } .record-stat strong { font-family: Georgia, serif; font-size: 25px; font-weight: 400; } +.record-stat span, .source-label { color: #aab9b6; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; } +.source-label { margin-left: auto; } +.records-table-wrap { background: #fff; border-radius: 0 0 16px 16px; overflow: hidden; } +.records-table { width: 100%; border-collapse: collapse; } +.records-table th { text-align: left; padding: 17px 20px; background: #e4ebe3; color: #647572; font-size: 11px; letter-spacing: .08em; text-transform: uppercase; } +.records-table td { padding: 21px 20px; border-top: 1px solid #e3e9e3; font-size: 14px; } +.records-table td:first-child { font-family: Georgia, serif; font-size: 17px; display: flex; align-items: center; gap: 9px; } +.records-table td:first-child svg { width: 18px; color: #6a892d; } .records-table td:nth-child(2) { color: #537014; font-weight: 750; } + +.submit-page { display: grid; grid-template-columns: .75fr 1.25fr; gap: 100px; align-items: start; } +.submit-intro { position: sticky; top: 120px; } .submit-intro h1 { font-size: clamp(58px, 6.5vw, 96px); } +.submit-intro > p { max-width: 460px; color: #61726f; line-height: 1.6; font-size: 17px; } +.submit-tip { display: flex; gap: 15px; margin-top: 48px; max-width: 430px; border-top: 1px solid #b9c7bf; padding-top: 22px; } +.submit-tip > svg { color: #6e8e2b; width: 23px; flex: none; } .submit-tip div { display: flex; flex-direction: column; gap: 5px; } +.submit-tip span { color: #6a7977; font-size: 13px; line-height: 1.45; } +.catch-form { background: #fff; border: 1px solid #d4ddd6; border-radius: 18px; padding: 34px; box-shadow: 0 30px 80px #16383c12; min-height: 580px; } +.form-heading { display: flex; justify-content: space-between; align-items: center; padding-bottom: 22px; border-bottom: 1px solid #dfe6df; margin-bottom: 26px; } +.form-heading > span { font-family: Georgia, serif; font-size: 27px; } .form-heading small { color: #7b8986; } +.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px 16px; } +.form-grid label { display: flex; flex-direction: column; gap: 7px; color: #60716e; font-size: 12px; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; } +.form-grid label.wide { grid-column: 1 / -1; } +.form-grid input, .form-grid textarea { width: 100%; background: #f3f6f1; border: 1px solid #d6dfd7; border-radius: 9px; padding: 13px 14px; outline: none; font-size: 15px; color: var(--deep); text-transform: none; letter-spacing: normal; font-weight: 450; } +.form-grid input:focus, .form-grid textarea:focus { border-color: #839d4b; box-shadow: 0 0 0 3px #c9f45b45; } +.form-grid textarea { resize: vertical; } .input-suffix { position: relative; } +.input-suffix i { position: absolute; right: 14px; top: 50%; transform: translateY(-50%); color: #80908d; font-style: normal; font-size: 13px; text-transform: none; } +.form-actions { display: flex; justify-content: space-between; align-items: center; gap: 15px; margin-top: 28px; } +.upload-button { height: 48px; display: flex; align-items: center; gap: 8px; border: 1px dashed #91a29a; border-radius: 10px; padding: 0 17px; color: #526461; font-size: 13px; cursor: pointer; } +.upload-button svg { width: 17px; } .upload-button input { display: none; } +.success-state { min-height: 510px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; } +.success-state > svg { width: 58px; height: 58px; color: #789b2d; margin-bottom: 24px; } +.success-state h2 { font-family: Georgia, serif; font-size: 56px; font-weight: 400; margin: 8px 0; } +.success-state p { color: #647471; max-width: 370px; line-height: 1.5; } .success-state button { margin-top: 25px; background: var(--deep); } + +footer { min-height: 118px; background: var(--deep); color: #dbe4df; padding: 28px max(32px, calc((100vw - 1360px) / 2)); display: grid; grid-template-columns: 1fr 1fr auto; align-items: center; gap: 28px; } +footer .brand-mark { border-color: #ffffff32; } footer p { font-size: 12px; color: #92a4a0; line-height: 1.5; } footer > span { font-family: Georgia, serif; font-style: italic; color: var(--lime); } + +@media (max-width: 1040px) { + .content-grid { width: min(100% - 36px, 900px); } .topbar { width: calc(100% - 36px); grid-template-columns: 1fr auto; height: auto; min-height: 78px; } + .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; } + .submit-page { grid-template-columns: 1fr; gap: 38px; } .submit-intro { position: relative; top: auto; } +} +@media (max-width: 720px) { + .content-grid { width: calc(100% - 28px); } .topbar { width: 100%; padding: 13px 14px 0; display: flex; flex-wrap: wrap; } + .brand { flex: 1; } .brand-mark { width: 38px; height: 38px; } .brand-name { font-size: 15px; } .brand-name strong { font-size: 17px; } + .main-nav { order: 2; width: 100% !important; height: 46px !important; overflow-x: auto; justify-content: flex-start !important; gap: 13px !important; } + .main-nav button { flex: 0 0 auto; font-size: 13px; } .main-nav button svg { display: none; } + .intro { padding-top: 48px; } .intro h1, .subpage-title h1, .submit-intro h1 { font-size: 55px; } .intro-copy > p { font-size: 16px; } + .lake-card { height: 225px; } .lake-overlay strong { font-size: 17px; } + .filters-wrap { position: relative; padding: 18px 0; } .filters { grid-template-columns: 1fr 1fr; } + .search-button { padding-inline: 12px; } .dashboard { padding: 45px 0 74px; } .section-heading h2, .how-it-works h2 { font-size: 34px; } + .spot-card { grid-template-columns: 34px 1fr; padding: 18px 18px 18px 14px; gap: 10px; } .spot-rank { width: 30px; height: 30px; font-size: 12px; } + .spot-topline { align-items: flex-start; justify-content: space-between; gap: 6px; } .spot-main h3 { font-size: 22px; } + .spot-stats { grid-column: 2; border: 0; border-top: 1px solid #e2e8e2; padding: 13px 0 0; grid-template-columns: 70px 70px 1fr; align-items: center; } + .sparkline { grid-column: auto; } .card-arrow { display: none; } .detail-card { padding: 22px 18px; } + .detail-score { grid-template-columns: 105px 1fr; gap: 15px; } .score-ring { width: 100px; height: 100px; } + .principles { grid-template-columns: 1fr; } .how-it-works { padding: 64px 0; gap: 40px; } .principles h3 { margin-top: 12px; } + .subpage { padding-top: 56px; } .subpage-title { grid-template-columns: 1fr; gap: 15px; margin-bottom: 35px; } + .record-toolbar { flex-wrap: wrap; gap: 18px; } .source-label { width: 100%; margin-left: 0; } + .records-table thead { display: none; } .records-table tr { display: grid; grid-template-columns: 1fr 1fr; padding: 18px; border-top: 1px solid #dde5de; } + .records-table td, .records-table td:first-child { display: flex; flex-direction: column; align-items: flex-start; gap: 3px; padding: 7px; border: 0; font-family: inherit; font-size: 13px; } + .records-table td::before { content: attr(data-label); color: #788885; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; } .records-table td:first-child svg { display: none; } + .catch-form { padding: 22px 17px; } .form-grid { grid-template-columns: 1fr; } .form-grid label.wide { grid-column: auto; } + .form-heading { align-items: flex-start; } .form-heading small { max-width: 120px; text-align: right; } + .form-actions { flex-direction: column; align-items: stretch; } .upload-button, .submit-button { justify-content: center; } + footer { grid-template-columns: 1fr auto; padding: 30px 20px; } footer p { grid-column: 1 / -1; order: 3; } +} +@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; transition-duration: .01ms !important; } } diff --git a/design-reference/app/layout.tsx b/design-reference/app/layout.tsx new file mode 100644 index 0000000..0d022d0 --- /dev/null +++ b/design-reference/app/layout.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Ни хвоста, ни чешуи — живая карта клёва RF4", + description: "Свежие точки, рабочие приманки и понятная статистика клёва в Russian Fishing 4.", + icons: { icon: "/favicon.svg", shortcut: "/favicon.svg" }, +}; + +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return {children}; +} diff --git a/design-reference/app/page.tsx b/design-reference/app/page.tsx new file mode 100644 index 0000000..aadd712 --- /dev/null +++ b/design-reference/app/page.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { FormEvent, useMemo, useState } from "react"; +import { ArrowUpRight, CheckCircle2, ChevronRight, Clock3, FishSymbol, Gauge, MapPin, Plus, Search, Send, Sparkles, Trophy, Users, Waves } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +type Spot = { id: number; water: string; fish: string; coords: string; bait: string; method: string; score: number; confidence: number; reports: number; anglers: number; last: string; avg: string; peak: string; trend: number[]; note: string }; + +const spots: Spot[] = [ + { id: 1, water: "Вьюнок", fish: "Щука обыкновенная", coords: "110:103", bait: "Spiker #2 01-015", method: "Вращение · 25", score: 92, confidence: 87, reports: 27, anglers: 11, last: "34 мин", avg: "1,84 кг", peak: "18:00–22:00", trend: [28,42,36,57,51,72,84,92], note: "Стабильная серия вечерних уловов. Лучше работает проводка поперёк русла." }, + { id: 2, water: "Куори", fish: "Форель озёрная", coords: "117:92", bait: "Hornet 1-002", method: "Равномерная · 30", score: 78, confidence: 72, reports: 14, anglers: 7, last: "1 ч 12 мин", avg: "2,31 кг", peak: "05:00–09:00", trend: [31,47,44,62,69,61,74,78], note: "Утреннее окно держится второй день. Данных меньше, чем по Вьюнку." }, + { id: 3, water: "Белая", fish: "Голавль", coords: "72:58", bait: "Aikkila 4 г 006", method: "Против течения · 20", score: 64, confidence: 81, reports: 18, anglers: 9, last: "2 ч 08 мин", avg: "0,92 кг", peak: "11:00–15:00", trend: [72,68,57,49,55,62,61,64], note: "Клёв ровный, но без всплеска. Точка хорошо подтверждена разными игроками." }, + { id: 4, water: "Лосиное озеро", fish: "Окунь", coords: "44:51", bait: "Express Spinner 2-004", method: "Stop&Go · 18", score: 49, confidence: 58, reports: 8, anglers: 4, last: "3 ч 41 мин", avg: "0,41 кг", peak: "07:00–11:00", trend: [58,62,55,44,39,46,52,49], note: "Свежих сообщений немного. Подходит скорее для опыта, чем для заработка." }, +]; + +const records = [ + ["Щука обыкновенная", "18,642 кг", "Ладожское озеро", "Hunter 2-012", "SibirianFox", "сегодня, 08:14"], + ["Форель озёрная", "14,081 кг", "Куори", "Hornet 1-002", "Taimen", "сегодня, 06:47"], + ["Голавль", "7,923 кг", "Белая", "Aikkila 4 г 006", "Vega_Altai", "вчера, 21:18"], + ["Окунь", "4,218 кг", "Вьюнок", "Spiker #2 01-015", "Rybachok", "вчера, 16:03"], +]; + +function ScoreRing({ value }: { value: number }) { + return
{value}из 100
; +} + +function Sparkline({ values }: { values: number[] }) { + const points = values.map((value, index) => `${index * (100 / (values.length - 1))},${48 - value * 0.38}`).join(" "); + return ; +} + +function Brand() { + return
Ни хвоста,ни чешуи
; +} + +export default function Home() { + const [water, setWater] = useState("all"); + const [fish, setFish] = useState("all"); + const [period, setPeriod] = useState("24"); + const [selectedId, setSelectedId] = useState(1); + const [sent, setSent] = useState(false); + const visibleSpots = useMemo(() => spots.filter((spot) => (water === "all" || spot.water === water) && (fish === "all" || spot.fish === fish)), [water, fish]); + const selected = spots.find((spot) => spot.id === selectedId) ?? spots[0]; + function submitCatch(event: FormEvent) { event.preventDefault(); setSent(true); } + + return ( + +
+ + + Сейчас клюёт + Рекорды + Добавить улов + +
Данные обновлены 4 мин назад
+
+ +
+
+
RF4 Живая карта клёва

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

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

+
Туманное озеро на рассвете с поплавком
Пульс водоёмов3 точки набирают активность
+
+ +
+ + + + +
+ +
+
За {period === "24" ? "последние сутки" : `${period} часов`}

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

{visibleSpots.length} {visibleSpots.length === 1 ? "точка" : "точки"}
+
{visibleSpots.map((spot, index) => ( + + ))}{visibleSpots.length === 0 &&

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

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

}
+
+ +
+
Как читать данные

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

01

Свежесть

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

02

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

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

03

Уверенность

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

+
+ +
RF4 Официальные данные

Рекорды недели

Таблицы обновляются автоматически. Координаты точек в официальных рекордах не публикуются.

428рекордов за неделю
37новых сегодня
Источник: официальный сайт RF4 · демоданные
{records.map((row) => {row.map((cell, index) => )})}
РыбаВесВодоёмНаживка / приманкаИгрокДата
{index === 0 && }{cell}
+ +
+1 Помочь сообществу

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

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

Скриншот повышает довериеМожно загрузить экран улова или оставить ссылку на публикацию.
+
{sent ?
Улов отправлен

Ни хвоста!

Спасибо. Запись появится в статистике после проверки.

: <>
Новый уловПоля со звёздочкой обязательны