15 Commits
Author SHA1 Message Date
Igor VolochayGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
ccde822899 Potential fix for pull request finding 'CodeQL / Workflow does not contain permissions'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-09-09 11:32:24 +03:00
Igor VolochayGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
d94d2954b0 Potential fix for pull request finding 'CodeQL / Workflow does not contain permissions'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-09-09 11:30:31 +03:00
Igor VolochayGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
a904a2beb0 Potential fix for pull request finding 'CodeQL / Workflow does not contain permissions'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-09-09 11:29:34 +03:00
Igor VolochayGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
bb7063018b Potential fix for pull request finding 'CodeQL / Workflow does not contain permissions'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-09-09 11:28:36 +03:00
IgorVolochay 4a84b442a5 Rewrite README 2026-09-09 11:21:49 +03:00
IgorVolochay 8d8361ab8f Some prebuild fix 2026-09-08 09:43:49 +03:00
IgorVolochay 9fb4fc15b9 Merge branch 'prebuild' of https://github.com/IgorVolochay/thisORthat into prebuild 2026-09-01 17:26:58 +03:00
IgorVolochay 61a2eb8076 Merge pull request #4 from IgorVolochay/frontend 2026-09-01 17:26:42 +03:00
Igor VolochayandGitHub 26111ac632 Merge pull request #4 from IgorVolochay/frontend
Merge frontend and prebuild
2026-09-01 14:58:04 +03:00
IgorVolochay bff869fa9d Update GitHub CI piplines 2026-09-01 14:55:02 +03:00
IgorVolochay 008dd1c29e Update frontend for new backend 2026-09-01 14:31:52 +03:00
Igor VolochayandGitHub bdf3d5aec1 Merge pull request #3 from IgorVolochay/app
Merge app and frontend
2026-09-01 12:00:36 +03:00
Igor VolochayandGitHub 4b64a90839 Merge pull request #2 from IgorVolochay/frontend
Prebuild release 1.0
2026-08-13 12:04:07 +03:00
IgorVolochay 73d16e9111 Update compose file 2026-08-13 11:53:18 +03:00
IgorVolochay ec60cc1756 feat: create prebuild github actions 2025-03-08 20:09:24 +03:00
30 changed files with 1673 additions and 310 deletions
@@ -1,4 +1,7 @@
name: app-actions name: Backend CI
permissions:
contents: read
on: on:
workflow_dispatch: workflow_dispatch:
@@ -6,19 +9,24 @@ on:
paths: paths:
- 'app/**' - 'app/**'
branches: branches:
- main
- app - app
- prebuild
- main
pull_request: pull_request:
paths:
- 'app/**'
branches: branches:
- app
- prebuild
- main - main
jobs: jobs:
mypy: mypy:
name: Backend Lint (mypy)
runs-on: ubuntu-latest runs-on: ubuntu-latest
continue-on-error: true continue-on-error: true
steps: steps:
- name: Checkout - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Python - name: Setup Python
@@ -37,19 +45,20 @@ jobs:
run: mypy --ignore-missing-imports ./app run: mypy --ignore-missing-imports ./app
pytest: pytest:
name: Backend Tests (pytest)
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
MONGO_HOST: "127.0.0.1" MONGO_HOST: "127.0.0.1"
MONGO_PORT: ${{ secrets.MONGO_PORT || '27017' }} MONGO_PORT: ${{ secrets.MONGO_PORT || '27017' }}
MONGO_USER: ${{ secrets.MONGO_USER }} MONGO_USER: ${{ secrets.MONGO_USER || 'admin' }}
MONGO_PASS: ${{ secrets.MONGO_PASS }} MONGO_PASS: ${{ secrets.MONGO_PASS || 'secret' }}
RABBIT_HOST: "127.0.0.1" RABBIT_HOST: "127.0.0.1"
RABBIT_PORT: ${{ secrets.RABBIT_PORT || '5672' }} RABBIT_PORT: ${{ secrets.RABBIT_PORT || '5672' }}
RABBIT_USER: ${{ secrets.RABBIT_USER }} RABBIT_USER: ${{ secrets.RABBIT_USER || 'guest' }}
RABBIT_PASS: ${{ secrets.RABBIT_PASS }} RABBIT_PASS: ${{ secrets.RABBIT_PASS || 'guest' }}
DEV_MODE: ${{ secrets.DEV_MODE || 'true' }} DEV_MODE: ${{ secrets.DEV_MODE || 'true' }}
steps: steps:
- name: Checkout - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Python - name: Setup Python
@@ -97,17 +106,3 @@ jobs:
- name: Run pytest - name: Run pytest
run: pytest -vs run: pytest -vs
docker-build:
runs-on: ubuntu-latest
needs: [mypy, pytest]
if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build backend image
run: docker build -f app/dockerfile.app -t tort-backend:ci ./app
- name: Build bot image
run: docker build -f app/dockerfile.bot -t tort-tg-bot:ci ./app
+166
View File
@@ -0,0 +1,166 @@
name: Publish to GHCR (Main)
on:
workflow_dispatch:
push:
branches:
- main
permissions:
contents: read
packages: write
jobs:
frontend-test-build:
name: Frontend (Test & Build)
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: ./frontend
run: npm ci
- name: Run frontend tests
working-directory: ./frontend
run: npm test -- --watchAll=false
- name: Build React app
working-directory: ./frontend
run: npm run build
backend-mypy:
name: Backend Lint (mypy)
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: app/requirements.txt
- name: Install dependencies
run: |
pip install mypy
pip install -r app/requirements.txt
- name: Run mypy
run: mypy --ignore-missing-imports ./app
backend-pytest:
name: Backend Tests (pytest)
runs-on: ubuntu-latest
env:
MONGO_HOST: "127.0.0.1"
MONGO_PORT: ${{ secrets.MONGO_PORT || '27017' }}
MONGO_USER: ${{ secrets.MONGO_USER || 'admin' }}
MONGO_PASS: ${{ secrets.MONGO_PASS || 'secret' }}
RABBIT_HOST: "127.0.0.1"
RABBIT_PORT: ${{ secrets.RABBIT_PORT || '5672' }}
RABBIT_USER: ${{ secrets.RABBIT_USER || 'guest' }}
RABBIT_PASS: ${{ secrets.RABBIT_PASS || 'guest' }}
DEV_MODE: ${{ secrets.DEV_MODE || 'true' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: app/requirements.txt
- name: Start MongoDB
run: |
docker run -d --name mongodb \
-p "${MONGO_PORT}:27017" \
-e "MONGO_INITDB_ROOT_USERNAME=${MONGO_USER}" \
-e "MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASS}" \
mongodb/mongodb-community-server
for i in $(seq 1 30); do
docker exec mongodb mongosh \
--username "${MONGO_USER}" --password "${MONGO_PASS}" \
--eval "db.runCommand({ping:1})" && break
sleep 1
done
- name: Start RabbitMQ
run: |
docker run -d --name rabbitmq \
-p "${RABBIT_PORT}:5672" \
-e "RABBITMQ_DEFAULT_USER=${RABBIT_USER}" \
-e "RABBITMQ_DEFAULT_PASS=${RABBIT_PASS}" \
rabbitmq:3.13-alpine
for i in $(seq 1 30); do
docker exec rabbitmq rabbitmq-diagnostics -q ping && break
sleep 1
done
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest==8.3.4 pytest-asyncio==0.25.3 httpx==0.28.1
pip install -r app/requirements.txt
- name: Setup moderated base cards
working-directory: ./app/tools
run: python3 _add_base_cards.py -a 2 -f data/base_cards.json
- name: Run pytest
run: pytest -vs
docker-publish-ghcr:
name: Build & Push Images to GHCR
runs-on: ubuntu-latest
needs: [frontend-test-build, backend-mypy, backend-pytest]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set lowercase repository owner
id: repo_owner
run: echo "OWNER_LC=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV"
- name: Build and push Backend image
uses: docker/build-push-action@v6
with:
context: ./app
file: ./app/dockerfile.app
push: true
tags: |
ghcr.io/${{ env.OWNER_LC }}/tort-backend:latest
ghcr.io/${{ env.OWNER_LC }}/tort-backend:${{ github.sha }}
- name: Build and push Bot image
uses: docker/build-push-action@v6
with:
context: ./app
file: ./app/dockerfile.bot
push: true
tags: |
ghcr.io/${{ env.OWNER_LC }}/tort-tg-bot:latest
ghcr.io/${{ env.OWNER_LC }}/tort-tg-bot:${{ github.sha }}
+48
View File
@@ -0,0 +1,48 @@
name: Frontend CI
permissions:
contents: read
on:
workflow_dispatch:
push:
paths:
- 'frontend/**'
branches:
- frontend
- prebuild
- main
pull_request:
paths:
- 'frontend/**'
branches:
- frontend
- prebuild
- main
jobs:
frontend-test-build:
name: React Test & Build
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: ./frontend
run: npm ci
- name: Run frontend tests
working-directory: ./frontend
run: npm test -- --watchAll=false
- name: Build React app
working-directory: ./frontend
run: npm run build
+137
View File
@@ -0,0 +1,137 @@
name: Prebuild CI & Docker Test Build
on:
workflow_dispatch:
push:
branches:
- prebuild
pull_request:
branches:
- prebuild
permissions:
contents: read
jobs:
frontend-test-build:
name: Frontend (Test & Build)
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: ./frontend
run: npm ci
- name: Run frontend tests
working-directory: ./frontend
run: npm test -- --watchAll=false
- name: Build React app
working-directory: ./frontend
run: npm run build
backend-mypy:
name: Backend Lint (mypy)
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: app/requirements.txt
- name: Install dependencies
run: |
pip install mypy
pip install -r app/requirements.txt
- name: Run mypy
run: mypy --ignore-missing-imports ./app
backend-pytest:
name: Backend Tests (pytest)
runs-on: ubuntu-latest
env:
MONGO_HOST: "127.0.0.1"
MONGO_PORT: ${{ secrets.MONGO_PORT || '27017' }}
MONGO_USER: ${{ secrets.MONGO_USER || 'admin' }}
MONGO_PASS: ${{ secrets.MONGO_PASS || 'secret' }}
RABBIT_HOST: "127.0.0.1"
RABBIT_PORT: ${{ secrets.RABBIT_PORT || '5672' }}
RABBIT_USER: ${{ secrets.RABBIT_USER || 'guest' }}
RABBIT_PASS: ${{ secrets.RABBIT_PASS || 'guest' }}
DEV_MODE: ${{ secrets.DEV_MODE || 'true' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: app/requirements.txt
- name: Start MongoDB
run: |
docker run -d --name mongodb \
-p "${MONGO_PORT}:27017" \
-e "MONGO_INITDB_ROOT_USERNAME=${MONGO_USER}" \
-e "MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASS}" \
mongodb/mongodb-community-server
for i in $(seq 1 30); do
docker exec mongodb mongosh \
--username "${MONGO_USER}" --password "${MONGO_PASS}" \
--eval "db.runCommand({ping:1})" && break
sleep 1
done
- name: Start RabbitMQ
run: |
docker run -d --name rabbitmq \
-p "${RABBIT_PORT}:5672" \
-e "RABBITMQ_DEFAULT_USER=${RABBIT_USER}" \
-e "RABBITMQ_DEFAULT_PASS=${RABBIT_PASS}" \
rabbitmq:3.13-alpine
for i in $(seq 1 30); do
docker exec rabbitmq rabbitmq-diagnostics -q ping && break
sleep 1
done
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest==8.3.4 pytest-asyncio==0.25.3 httpx==0.28.1
pip install -r app/requirements.txt
- name: Setup moderated base cards
working-directory: ./app/tools
run: python3 _add_base_cards.py -a 2 -f data/base_cards.json
- name: Run pytest
run: pytest -vs
docker-test-build:
name: Docker Images Test Build (No Push)
runs-on: ubuntu-latest
needs: [frontend-test-build, backend-mypy, backend-pytest]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build Docker images (dry-run test via docker compose)
run: docker compose build
+44
View File
@@ -0,0 +1,44 @@
name: Prebuild Workflow
on:
pull_request:
branches:
- prebuild
permissions:
contents: read
jobs:
test_build:
name: Test build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Try to build project
run: docker-compose up --build
deploy_to_test_server:
name: Deploy to test server
needs: test_build
runs-on: ubuntu-latest
steps:
- name: Deploy to test server
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.TEST_SERVER_ADDRESS }}
username: ${{ secrets.TEST_SERVER_USER }}
port: ${{ secrets.TEST_SERVER_PORT }}
password: ${{ secrets.TEST_SERVER_PASSWORD }}
script: |
REPO_NAME=$(basename "${{ github.repository }}")
if [ ! -d "$REPO_NAME" ]; then
git clone https://github.com/${{ github.repository }}.git $REPO_NAME
fi
cd $REPO_NAME
git checkout prebuild
git pull
docker-compose down || true
docker-compose up -d --build
+142 -19
View File
@@ -1,31 +1,154 @@
# this OR that # this OR that | Telegram Mini App
Telegram mini-app where you have to choose one of two things.
## Installation <img width="1447" height="248" alt="thisORthat_logo" src="https://github.com/user-attachments/assets/fee632cf-f778-4a72-95d7-bee2f8602bc0" />
1. Installing the repository:
```bash ## Описание проекта
git clone https://github.com/IgorVolochay/thisORthat
[«this OR that»](https://t.me/thisorthat_rubot?startapp) — это интерактивное веб-приложение формата Telegram Mini App, предлагающее пользователю сделать выбор между двумя альтернативными вариантами («Это или То»). Сразу после голосования открывается статистика голосов других участников, открывается доступ к обсуждению в комментариях, реакциям (лайки / дизлайки), а также предоставляется возможность создать собственную карточку.
### Идея и источник вдохновения
Вдохновением для проекта послужил русскоязычный ресурс [thisorthat.ru](https://thisorthat.ru), где собраны тысячи вопросов для размышления. Основная цель данного проекта - адаптировать и переосмыслить эту механику в современный экосистемный формат **Telegram Mini App**.
> [!NOTE]
> **Это pet-проект.** Проект носит исключительно некоммерческий, учебно-исследовательский характер. Он был создан для отработки и демонстрации практических навыков проектирования асинхронных бэкендов, построения событийно-ориентированной архитектуры (Event-Driven Architecture), интеграции очередей сообщений, работы с Telegram WebApp API и контейнеризации сервисов.
<img width="1800" height="1244" alt="thisORthat_telegram" src="https://github.com/user-attachments/assets/172e8243-f6da-4dcf-a362-74fa18ce68e9" />
## Технологический стек, подходы и инструменты
В ходе разработки были применены современные технологии, обеспечивающие высокую производительность, масштабируемость и безопасность:
### Backend & Асинхронная экосистема
* **Python 3.12+**: основной язык разработки сервисов.
* **[FastAPI](https://github.com/fastapi/fastapi)**: высокопроизводительный асинхронный веб-фреймворк для реализации REST API.
* **[Motor](https://github.com/mongodb/motor)**: асинхронный драйвер для интеграции с базой данных MongoDB.
* **[Pydantic v2](https://github.com/pydantic/pydantic)**: строгая валидация входящих и исходящих данных через типизированные схемы.
* **[aio-pika](https://github.com/mosquito/aio-pika)**: асинхронный клиент для взаимодействия с брокером сообщений RabbitMQ.
* **[aiogram 3](https://github.com/aiogram/aiogram)**: асинхронный фреймворк для Telegram-бота модерации.
* **[FastAPI-guard](https://github.com/rennf93/fastapi-guard)**: модуль rate limiting, защита от попыток проникновения, а также временная блокировка подозрительных IP-адресов.
* **[Loguru](https://github.com/Delgan/loguru)**: структурированное логирование.
* **[Pytest](https://github.com/pytest-dev/pytest)**: тестовый фреймворк для покрытия эндпоинтов, логики карточек и проверки безопасности.
### Frontend
* **[React 19](https://github.com/react/react)**: библиотека для построения динамичного пользовательского интерфейса (SPA).
* **Telegram WebApp API**: интеграция с окружением мессенджера (Haptic Feedback для тактильного отклика, автоматическая адаптация к системной теме Telegram, SafeArea и управление кнопками).
* **Vanilla CSS & Flexbox/Grid**: адаптивная верстка под любые размеры мобильных экранов, плавные микро-анимации, кастомные скроллбары и модальные окна без утяжеления сторонними CSS-библиотеками.
### Базы данных и очереди
* **[MongoDB](https://www.mongodb.com/)**: NoSQL база данных для гибкого хранения карточек, голосов пользователей, профилей и древовидных комментариев.
* **[RabbitMQ](https://www.rabbitmq.com/)**: брокер сообщений, обеспечивающий отказоустойчивую буферизацию задач между бэкендом и ботом модерации.
### Архитектурные подходы и паттерны
* **Event-Driven Moderation (EDA)**: отправка предложенных пользователями карточек в очередь сообщений без задержек основного пользовательского API.
* **Zero-Trust авторизация через Telegram**: валидация подписи `initData` по алгоритму HMAC-SHA256 с использованием секретного ключа бота.
* **Защита API и Rate Limiting**: многоуровневая фильтрация запросов через FastAPI-guard с возвратом специализированных экранов блокировки на фронтенде при превышении лимитов.
* **Service-Oriented Architecture (SOA)**: разделение приложения на изолированные контейнеры: БД, брокер, API-сервис и бот.
---
## Архитектура проекта и взаимосвязь компонентов
Проект построен по сервисной архитектуре, где каждый компонент выполняет строго отведенную роль:
```mermaid
flowchart TD
subgraph Client["Клиентская часть"]
TMA["Telegram Mini App (React 19)"]
end
subgraph CoreBackend["Бэкенд инфраструктура"]
API["FastAPI REST Backend :5000"]
Mongo[("MongoDB Database :27017")]
RMQ[["RabbitMQ Broker :5672"]]
Bot["Telegram Модерация Bot (aiogram 3)"]
end
subgraph TelegramCloud["Инфраструктура Telegram"]
TGUser["Пользователь Telegram"]
TGAdmin["Администратор в Telegram"]
end
%% Взаимодействия
TGUser <--> |Запуск Mini App / initData| TMA
TMA <--> |REST API запросы / Голосование / Комментарии| API
API <--> |Асинхронные запросы через Motor| Mongo
API --> |Публикация новой карточки в очередь moderation| RMQ
RMQ --> |Потребление карточки из очереди| Bot
Bot --> |Уведомление с кнопками Одобрить / Отклонить| TGAdmin
TGAdmin --> |Инлайн-решение| Bot
Bot --> |Защищенный вызов API с MODERATION_SECRET| API
``` ```
### Manual setup: ### Сценарии взаимодействия:
1. **Пользовательский сценарий:**
- Пользователь открывает Mini App внутри Telegram. Приложение передает `initData`, которая верифицируется бэкендом.
- Пользователь получает случайные пары карточек из MongoDB, делает выбор, голосует, оставляет комментарии и реакции.
2. **Пайплайн предложения и модерации карточек:**
- Пользователь предлагает свою карточку через интерфейс приложения.
- Сервис FastAPI сохраняет карточку в MongoDB со статусом ожидания и мгновенно публикует событие в очередь `moderation` брокера **RabbitMQ**.
- Сервис **Telegram Bot** (aiogram) слушает очередь, получает карточку и пересылает ее в чат администратора (`TG_ADMIN_CHAT_ID`) с инлайн-кнопками «Одобрить ✅» / «Отклонить ❌».
- Администратор принимает решение в Telegram. Бот выполняет защищенный внутренний запрос к API (`/card_accept` или `/card_reject`) с заголовком `MODERATION_SECRET`, после чего статус карточки в базе обновляется.
---
## Краткое руководство по запуску
Для запуска проекта на локальной машине потребуются установленные **Docker**, **Docker Compose** и **Node.js** (версии 18+).
### Шаг 1. Конфигурация окружения
Создайте файл переменных окружения `.env` в корне проекта на основе образца `.env_example`:
2. The project is written in Python3.9. Make sure you have it on your system. Go to the project folder, create a virtual environment and download pip requirements:
```bash ```bash
cd ./thisORthat cp .env_example .env
python3.9 -m venv venv
source ./venv/bin/activate
pip3 install -r ./app/requirements.txt
``` ```
3. Installing MongoDB database. You can use the [official manual](https://www.mongodb.com/docs/manual/installation/) to install MongoDB manually, or use a [Docker image](https://hub.docker.com/r/mongodb/mongodb-community-server) to run the container: Заполните ключевые параметры в `.env`:
```bash * `TG_BOT_TOKEN` — токен вашего Telegram-бота от [@BotFather](https://t.me/BotFather).
docker run --name mongodb -d -p 27017:27017 -e MONGO_INITDB_ROOT_USERNAME=user -e MONGO_INITDB_ROOT_PASSWORD=pass mongodb/mongodb-community-server * `TG_ADMIN_CHAT_ID` — ваш Telegram ID или ID чата для модерации карточек.
``` * `MODERATION_SECRET` — произвольная секретная строка для взаимодействия между ботом и API.
### Docker Compose setup: * При необходимости скорректируйте учетные данные MongoDB и RabbitMQ. Для локальной разработки без валидации Telegram `DEV_MODE` можно оставить равным `true`.
### Шаг 2. Сборка фронтенда
Соберите статическую версию React-приложения:
2. Use docker-compose to automatically build the entire project. For correct build, it is better to use docker-compose version 1.29.2:
```bash ```bash
docker-compose up --build cd frontend
npm install
npm run build
cd ..
``` ```
### Шаг 3. Запуск сервисов через Docker Compose
Запустите сборку и старт всех сервисов в фоновом режиме:
```bash
docker compose up -d --build
```
После завершения запуска будут активны следующие компоненты:
* **Backend API:** `http://localhost:5000` (документация Swagger доступна по адресу `http://localhost:5000/docs` при `DEV_MODE=true`).
* **Панель RabbitMQ Management:** `http://localhost:15672` (логин и пароль задаются в `.env`).
* **MongoDB:** порт `27017`.
* **Telegram Bot:** сервис подключится к Telegram и начнет обработку очереди модерации.
---
## Контакты
* **Автор:** Волочай Игорь (Igor Volochay)
* **Telegram:** [@VIAproger](https://t.me/VIAproger)
* **Email:** [pseudo.developer.ru@gmail.com](mailto:pseudo.developer.ru@gmail.com)
* **GitHub репозиторий:** [https://github.com/IgorVolochay/thisORthat](https://github.com/IgorVolochay/thisORthat)
---
## Благодарности
### Идейный вдохновитель
* **[thisorthat.ru](https://thisorthat.ru)** — оригинальный проект, послуживший источником вдохновения для идеи, концепции дилемм и механики выбора.
### Open-Source сообщество, библиотеки и фреймворки
Выражаю глубокую благодарность разработчикам и мейнтейнерам ключевых библиотек и инструментов, на которых построен проект.
+3 -3
View File
@@ -61,14 +61,14 @@ app: FastAPI = FastAPI(
) )
config = SecurityConfig( config = SecurityConfig(
enable_rate_limiting=True, enable_rate_limiting=True,
rate_limit=10, # TODO: check rate limits in real usage rate_limit=10,
rate_limit_window=3, # TODO: check rate limits in real usage rate_limit_window=3,
enable_redis=False, enable_redis=False,
enable_ip_banning=True, enable_ip_banning=True,
enable_penetration_detection=True, enable_penetration_detection=True,
auto_ban_threshold=3, auto_ban_threshold=3,
auto_ban_duration=3600, auto_ban_duration=600,
detection_compiler_timeout=2.0, detection_compiler_timeout=2.0,
detection_max_content_length=10000, detection_max_content_length=10000,
+2 -1
View File
@@ -67,6 +67,7 @@ services:
RABBIT_USER: ${RABBIT_USER} RABBIT_USER: ${RABBIT_USER}
RABBIT_PASS: ${RABBIT_PASS} RABBIT_PASS: ${RABBIT_PASS}
MODERATION_SECRET: ${MODERATION_SECRET} MODERATION_SECRET: ${MODERATION_SECRET}
TG_BOT_TOKEN: ${TG_BOT_TOKEN}
DEV_MODE: ${DEV_MODE:-false} DEV_MODE: ${DEV_MODE:-false}
LOG_LEVEL: ${LOG_LEVEL:-INFO} LOG_LEVEL: ${LOG_LEVEL:-INFO}
logging: logging:
@@ -92,10 +93,10 @@ services:
backend: backend:
condition: service_started condition: service_started
environment: environment:
TG_BOT_TOKEN: ${TG_BOT_TOKEN}
TG_ADMIN_CHAT_ID: ${TG_ADMIN_CHAT_ID} TG_ADMIN_CHAT_ID: ${TG_ADMIN_CHAT_ID}
API_BASE_URL: http://backend:5000 API_BASE_URL: http://backend:5000
MODERATION_SECRET: ${MODERATION_SECRET} MODERATION_SECRET: ${MODERATION_SECRET}
TG_BOT_TOKEN: ${TG_BOT_TOKEN}
RABBIT_HOST: rabbitmq RABBIT_HOST: rabbitmq
RABBIT_PORT: "5672" RABBIT_PORT: "5672"
RABBIT_USER: ${RABBIT_USER} RABBIT_USER: ${RABBIT_USER}
+1
View File
@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#070711" /> <meta name="theme-color" content="#070711" />
<meta name="description" content="This OR That — выбирай один из двух вариантов и смотри, что выбрали другие!" /> <meta name="description" content="This OR That — выбирай один из двух вариантов и смотри, что выбрали другие!" />
<meta name="referrer" content="no-referrer" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
+6 -2
View File
@@ -5,8 +5,10 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: var(--space-sm) var(--space-md); padding: var(--space-sm) var(--space-md);
height: 48px; padding-top: max(var(--space-sm), env(safe-area-inset-top));
height: calc(48px + max(0px, env(safe-area-inset-top)));
flex-shrink: 0; flex-shrink: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
} }
.app-logo { .app-logo {
@@ -15,6 +17,7 @@
font-weight: 700; font-weight: 700;
color: var(--color-text); color: var(--color-text);
letter-spacing: -0.3px; letter-spacing: -0.3px;
user-select: none;
} }
.app-logo-or { .app-logo-or {
@@ -25,6 +28,7 @@
border-radius: 6px; border-radius: 6px;
font-size: 12px; font-size: 12px;
vertical-align: middle; vertical-align: middle;
box-shadow: 0 0 10px rgba(124, 58, 237, 0.3);
} }
/* Hamburger menu */ /* Hamburger menu */
@@ -41,7 +45,7 @@
} }
.menu-toggle:active { .menu-toggle:active {
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.08);
} }
.menu-toggle-line { .menu-toggle-line {
+6 -1
View File
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { AppProvider, useApp } from './context/AppContext'; import { AppProvider, useApp } from './context/AppContext';
import LoadingScreen from './components/common/LoadingScreen'; import LoadingScreen from './components/common/LoadingScreen';
import BannedScreen from './components/common/BannedScreen';
import Toast from './components/common/Toast'; import Toast from './components/common/Toast';
import CardPair from './components/CardPair/CardPair'; import CardPair from './components/CardPair/CardPair';
import BottomBar from './components/BottomBar/BottomBar'; import BottomBar from './components/BottomBar/BottomBar';
@@ -9,7 +10,11 @@ import MenuPanel from './components/Menu/MenuPanel';
import './App.css'; import './App.css';
function AppContent() { function AppContent() {
const { isLoading, error, openMenu, toast } = useApp(); const { isLoading, error, isBanned, handleRetryAfterBan, openMenu, toast } = useApp();
if (isBanned) {
return <BannedScreen onRetry={handleRetryAfterBan} />;
}
if (isLoading) { if (isLoading) {
return <LoadingScreen />; return <LoadingScreen />;
+12 -5
View File
@@ -1,8 +1,15 @@
import { render, screen } from '@testing-library/react'; import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App'; import App from './App';
test('renders learn react link', () => { test('renders app without crashing', async () => {
render(<App />); const container = document.createElement('div');
const linkElement = screen.getByText(/learn react/i); document.body.appendChild(container);
expect(linkElement).toBeInTheDocument();
await act(async () => {
const root = createRoot(container);
root.render(<App />);
});
expect(container.innerHTML).toBeTruthy();
}); });
+13 -15
View File
@@ -2,8 +2,9 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-around; justify-content: space-around;
height: var(--bar-height); min-height: var(--bar-height);
padding: 0 var(--space-lg); padding: 0 var(--space-lg);
padding-bottom: max(0px, env(safe-area-inset-bottom));
background: var(--color-bg-elevated); background: var(--color-bg-elevated);
border-top: 1px solid rgba(255, 255, 255, 0.06); border-top: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0; flex-shrink: 0;
@@ -13,7 +14,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 2px; gap: 3px;
padding: var(--space-sm) var(--space-md); padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
transition: color var(--duration-fast) var(--ease-smooth), transition: color var(--duration-fast) var(--ease-smooth),
@@ -21,18 +22,19 @@
color: var(--color-muted); color: var(--color-muted);
} }
.bar-btn:active { .bar-btn:active:not(:disabled) {
transform: scale(0.92); transform: scale(0.92);
} }
.bar-btn--disabled { .bar-btn:disabled:not(.bar-btn--active) {
opacity: 0.4; opacity: 0.35;
pointer-events: none; cursor: default;
} }
.bar-btn--disabled.bar-btn--active { .bar-btn--disabled.bar-btn--active,
.bar-btn:disabled.bar-btn--active {
opacity: 1; opacity: 1;
pointer-events: none; cursor: default;
} }
/* Active states */ /* Active states */
@@ -47,12 +49,8 @@
} }
.bar-btn--comments { .bar-btn--comments {
color: var(--color-muted); color: var(--color-text);
} opacity: 0.9;
.bar-btn--comments:not(.bar-btn--disabled) {
opacity: 1;
pointer-events: auto;
} }
.bar-icon { .bar-icon {
@@ -63,7 +61,7 @@
.bar-count { .bar-count {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 600;
line-height: 1; line-height: 1;
} }
+24 -20
View File
@@ -1,51 +1,54 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { hapticImpact } from '../../services/auth';
import './BottomBar.css'; import './BottomBar.css';
export default function BottomBar() { export default function BottomBar() {
const { currentCard, chosenCard, likeCard, dislikeCard, setIsCommentsOpen, user } = useApp(); const { currentCard, chosenCard, likeCard, dislikeCard, setIsCommentsOpen, user } = useApp();
const [reactionState, setReactionState] = useState(null); // 'liked' | 'disliked' | null const [isSubmitting, setIsSubmitting] = useState(false);
const isRevealed = chosenCard !== null; const isRevealed = chosenCard !== null;
// Check if user already reacted to this card // Check if user already reacted to this card
const alreadyLiked = user?.liked_card_ids?.includes(currentCard?.card_id); const alreadyLiked = user?.liked_card_ids?.includes(currentCard?.card_id);
const alreadyDisliked = user?.disliked_card_ids?.includes(currentCard?.card_id); const alreadyDisliked = user?.disliked_card_ids?.includes(currentCard?.card_id);
const currentReaction = reactionState || (alreadyLiked ? 'liked' : alreadyDisliked ? 'disliked' : null); const currentReaction = alreadyLiked ? 'liked' : alreadyDisliked ? 'disliked' : null;
const handleLike = async () => { const handleLike = async () => {
if (!isRevealed || currentReaction) return; if (!isRevealed || currentReaction || isSubmitting) return;
const result = await likeCard(); setIsSubmitting(true);
if (result && !result.error) { try {
setReactionState('liked'); await likeCard();
} finally {
setIsSubmitting(false);
} }
}; };
const handleDislike = async () => { const handleDislike = async () => {
if (!isRevealed || currentReaction) return; if (!isRevealed || currentReaction || isSubmitting) return;
const result = await dislikeCard(); setIsSubmitting(true);
if (result && !result.error) { try {
setReactionState('disliked'); await dislikeCard();
} finally {
setIsSubmitting(false);
} }
}; };
const handleComments = () => { const handleComments = () => {
hapticImpact('light');
setIsCommentsOpen(true); setIsCommentsOpen(true);
}; };
// Reset reaction state when card changes const likes = currentCard?.count_likes || 0;
React.useEffect(() => { const dislikes = currentCard?.count_dislikes || 0;
setReactionState(null); const commentsCount = currentCard?.comments?.length || 0;
}, [currentCard?.card_id]);
const likes = (currentCard?.count_likes || 0) + (reactionState === 'liked' ? 1 : 0);
const dislikes = (currentCard?.count_dislikes || 0) + (reactionState === 'disliked' ? 1 : 0);
return ( return (
<div className="bottom-bar"> <div className="bottom-bar">
<button <button
className={`bar-btn bar-btn--dislike ${currentReaction === 'disliked' ? 'bar-btn--active' : ''} ${!isRevealed || currentReaction ? 'bar-btn--disabled' : ''}`} className={`bar-btn bar-btn--dislike ${currentReaction === 'disliked' ? 'bar-btn--active' : ''} ${!isRevealed || currentReaction || isSubmitting ? 'bar-btn--disabled' : ''}`}
onClick={handleDislike} onClick={handleDislike}
disabled={!isRevealed || !!currentReaction || isSubmitting}
aria-label="Дизлайк" aria-label="Дизлайк"
> >
<svg className="bar-icon" viewBox="0 0 24 24" fill="currentColor" style={{ transform: 'rotate(180deg)' }}> <svg className="bar-icon" viewBox="0 0 24 24" fill="currentColor" style={{ transform: 'rotate(180deg)' }}>
@@ -62,12 +65,13 @@ export default function BottomBar() {
<svg className="bar-icon" viewBox="0 0 24 24" fill="currentColor"> <svg className="bar-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2v10z" /> <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2v10z" />
</svg> </svg>
<span className="bar-count">{formatCount(currentCard?.comments?.length || 0)}</span> <span className="bar-count">{formatCount(commentsCount)}</span>
</button> </button>
<button <button
className={`bar-btn bar-btn--like ${currentReaction === 'liked' ? 'bar-btn--active' : ''} ${!isRevealed || currentReaction ? 'bar-btn--disabled' : ''}`} className={`bar-btn bar-btn--like ${currentReaction === 'liked' ? 'bar-btn--active' : ''} ${!isRevealed || currentReaction || isSubmitting ? 'bar-btn--disabled' : ''}`}
onClick={handleLike} onClick={handleLike}
disabled={!isRevealed || !!currentReaction || isSubmitting}
aria-label="Лайк" aria-label="Лайк"
> >
<svg className="bar-icon" viewBox="0 0 24 24" fill="currentColor"> <svg className="bar-icon" viewBox="0 0 24 24" fill="currentColor">
+51 -10
View File
@@ -11,7 +11,7 @@
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 6px;
position: relative; position: relative;
min-height: 0; min-height: 0;
} }
@@ -31,52 +31,93 @@
.card-pair--empty { .card-pair--empty {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
text-align: center;
} }
.empty-content { .empty-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-xl); align-items: center;
width: 100%; width: 100%;
max-width: 320px; max-width: 340px;
padding: var(--space-lg);
background: var(--color-bg-elevated);
border: 1px solid var(--glass-border);
border-radius: var(--radius-card);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
animation: hintFadeIn 600ms var(--ease-smooth); animation: hintFadeIn 600ms var(--ease-smooth);
} }
.empty-badge-icon {
font-size: 36px;
margin-bottom: var(--space-sm);
}
.card-pair-empty-text { .card-pair-empty-text {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 20px; font-size: 19px;
font-weight: 700;
color: var(--color-text); color: var(--color-text);
text-align: center; margin-bottom: 6px;
}
.card-pair-empty-sub {
font-size: 13px;
line-height: 1.45;
color: var(--color-muted);
margin-bottom: var(--space-lg);
} }
.empty-actions { .empty-actions {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-sm); gap: var(--space-sm);
width: 100%;
} }
.empty-btn { .empty-btn {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
width: 100%; width: 100%;
padding: var(--space-md); padding: 12px var(--space-md);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--glass-bg); background: var(--glass-bg);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
font-size: 15px; font-size: 14px;
font-weight: 500; font-weight: 500;
color: var(--color-text); color: var(--color-text);
transition: all var(--duration-fast) var(--ease-smooth); transition: all var(--duration-fast) var(--ease-smooth);
} }
.empty-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.empty-btn:active { .empty-btn:active {
background: rgba(255, 255, 255, 0.12); transform: scale(0.98);
}
.empty-btn--refresh {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.2), rgba(2, 132, 199, 0.2));
border-color: rgba(124, 58, 237, 0.4);
color: #FFFFFF;
font-weight: 600;
} }
.empty-btn--primary { .empty-btn--primary {
background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to)); background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
border-color: transparent; border-color: transparent;
font-weight: 600; font-weight: 600;
color: #FFFFFF;
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.3);
} }
.empty-btn--primary:active { .refresh-icon {
transform: scale(0.97); transition: transform 0.4s ease;
}
.refresh-icon--spinning {
animation: spin 0.8s linear infinite;
} }
+68 -12
View File
@@ -2,24 +2,74 @@ import React from 'react';
import Card from './Card'; import Card from './Card';
import OrBadge from './OrBadge'; import OrBadge from './OrBadge';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { openExternalLink, hapticImpact } from '../../services/auth';
import './CardPair.css'; import './CardPair.css';
export default function CardPair() { export default function CardPair() {
const { currentCard, chosenCard, chooseCard, openMenu, setMenuScreen } = useApp(); const { currentCard, chosenCard, chooseCard, loadCards, isLoadingCards, openMenu, setMenuScreen } = useApp();
if (!currentCard) { if (!currentCard) {
return ( return (
<div className="card-pair card-pair--empty"> <div className="card-pair card-pair--empty">
<div className="empty-content"> <div className="empty-content">
<p className="card-pair-empty-text">Карточки закончились!</p> <div className="empty-badge-icon"></div>
<h2 className="card-pair-empty-text">Карточки закончились!</h2>
<p className="card-pair-empty-sub">
Вы посмотрели все доступные карточки. Новые карточки появляются после прохождения модерации.
</p>
<div className="empty-actions"> <div className="empty-actions">
<button className="empty-btn empty-btn--primary" onClick={() => { openMenu(); setMenuScreen('create'); }}> <button
Создать карточку className="empty-btn empty-btn--refresh"
onClick={() => loadCards(true)}
disabled={isLoadingCards}
>
<svg
className={`refresh-icon ${isLoadingCards ? 'refresh-icon--spinning' : ''}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
width="18"
height="18"
>
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 11-.57-8.38l5.67-5.67" />
</svg>
<span>{isLoadingCards ? 'Проверяем...' : 'Проверить новые карточки'}</span>
</button> </button>
<button className="empty-btn" onClick={() => { openMenu(); setMenuScreen('about'); }}>
<button
className="empty-btn empty-btn--primary"
onClick={() => {
openMenu();
setMenuScreen('create');
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="18" height="18">
<path d="M12 5v14M5 12h14" />
</svg>
<span>Создать карточку</span>
</button>
<button
className="empty-btn"
onClick={() => {
openMenu();
setMenuScreen('about');
}}
>
О проекте О проекте
</button> </button>
<button className="empty-btn" onClick={() => window.open('https://boosty.to/pseudodev/donate', '_blank')}>
<button
className="empty-btn empty-btn--donate"
onClick={() => {
hapticImpact('light');
openExternalLink('https://boosty.to/pseudodev/donate');
}}
>
Поддержать проект Поддержать проект
</button> </button>
</div> </div>
@@ -34,7 +84,7 @@ export default function CardPair() {
let percentB = 50; let percentB = 50;
if (chosenCard) { if (chosenCard) {
// Add the current user's vote to the count for display // Add current user's choice for visual distribution
const votesA = currentCard.count_choice_A + (chosenCard === 'A' ? 1 : 0); const votesA = currentCard.count_choice_A + (chosenCard === 'A' ? 1 : 0);
const votesB = currentCard.count_choice_B + (chosenCard === 'B' ? 1 : 0); const votesB = currentCard.count_choice_B + (chosenCard === 'B' ? 1 : 0);
const newTotal = votesA + votesB; const newTotal = votesA + votesB;
@@ -43,16 +93,22 @@ export default function CardPair() {
percentB = 100 - percentA; percentB = 100 - percentA;
} }
// Clamp to 75/25 max for readability // Clamp between 25% and 75% for readable card size balance
if (percentA > 75) { percentA = 75; percentB = 25; } if (percentA > 75) {
if (percentB > 75) { percentB = 75; percentA = 25; } percentA = 75;
percentB = 25;
}
if (percentB > 75) {
percentB = 75;
percentA = 25;
}
} }
// flex-grow values for animation // flex-grow values for smooth spring animation
const growA = chosenCard ? percentA : 50; const growA = chosenCard ? percentA : 50;
const growB = chosenCard ? percentB : 50; const growB = chosenCard ? percentB : 50;
// Actual percentages for display (unclamped) // Actual display percentages
let displayPercentA = 50; let displayPercentA = 50;
let displayPercentB = 50; let displayPercentB = 50;
if (chosenCard && total >= 0) { if (chosenCard && total >= 0) {
@@ -2,18 +2,26 @@
display: flex; display: flex;
gap: var(--space-md); gap: var(--space-md);
padding: var(--space-md) 0; padding: var(--space-md) 0;
transition: background var(--duration-fast) var(--ease-smooth);
} }
.comment-item + .comment-item { .comment-item + .comment-item {
border-top: 1px solid rgba(255, 255, 255, 0.06); border-top: 1px solid rgba(255, 255, 255, 0.06);
} }
.comment-item--me {
background: rgba(124, 58, 237, 0.04);
border-radius: var(--radius-sm);
padding: var(--space-sm) var(--space-xs);
}
.comment-avatar { .comment-avatar {
flex-shrink: 0; flex-shrink: 0;
width: 40px; width: 38px;
height: 40px; height: 38px;
border-radius: 50%; border-radius: 50%;
overflow: hidden; overflow: hidden;
border: 1px solid var(--glass-border);
} }
.comment-avatar-img { .comment-avatar-img {
@@ -28,13 +36,17 @@
justify-content: center; justify-content: center;
width: 100%; width: 100%;
height: 100%; height: 100%;
background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to)); background: linear-gradient(135deg, var(--color-card-a-from), var(--color-card-b-from));
font-family: var(--font-display); font-family: var(--font-display);
font-size: 16px; font-size: 15px;
font-weight: 700; font-weight: 700;
color: var(--color-text); color: var(--color-text);
} }
.comment-avatar-fallback--me {
background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
}
.comment-body { .comment-body {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -42,20 +54,43 @@
.comment-header { .comment-header {
display: flex; display: flex;
align-items: baseline; align-items: center;
justify-content: space-between;
gap: var(--space-sm); gap: var(--space-sm);
margin-bottom: 4px; margin-bottom: 4px;
} }
.comment-author-group {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.comment-author { .comment-author {
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 14px;
color: var(--color-stat-a); color: var(--color-stat-a);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.comment-me-badge {
font-size: 10px;
font-weight: 700;
padding: 1px 6px;
border-radius: 4px;
background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
color: #FFFFFF;
text-transform: uppercase;
letter-spacing: 0.5px;
} }
.comment-date { .comment-date {
font-size: 12px; font-size: 11px;
color: var(--color-muted); color: var(--color-muted);
flex-shrink: 0;
} }
.comment-text { .comment-text {
@@ -1,31 +1,57 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { currentUser } from '../../services/auth';
import './CommentItem.css'; import './CommentItem.css';
export default function CommentItem({ comment, author }) { export default function CommentItem({ comment, author }) {
const displayName = author?.username || author?.first_name || 'Аноним'; const [imageError, setImageError] = useState(false);
const date = comment.creation_date const isMe = comment.author_id === currentUser?.id;
const displayName = isMe
? (currentUser?.username ? `@${currentUser.username}` : currentUser?.first_name || 'Вы')
: (author?.username ? `@${author.username}` : author?.first_name || `Игрок #${comment.author_id}`);
const photoUrl = isMe ? currentUser?.photo_url : author?.photo_url;
useEffect(() => {
setImageError(false);
}, [photoUrl]);
const dateStr = comment.creation_date
? new Date(comment.creation_date).toLocaleDateString('ru-RU', { ? new Date(comment.creation_date).toLocaleDateString('ru-RU', {
day: '2-digit', day: '2-digit',
month: '2-digit', month: '2-digit',
year: 'numeric', hour: '2-digit',
minute: '2-digit',
}) })
: ''; : '';
const initial = displayName.replace(/^@/, '')[0]?.toUpperCase() || '?';
return ( return (
<div className="comment-item"> <div className={`comment-item ${isMe ? 'comment-item--me' : ''}`}>
<div className="comment-avatar"> <div className="comment-avatar">
{author?.photo_url ? ( {photoUrl && !imageError ? (
<img src={author.photo_url} alt="" className="comment-avatar-img" /> <img
src={photoUrl}
alt=""
className="comment-avatar-img"
referrerPolicy="no-referrer"
onError={() => setImageError(true)}
/>
) : ( ) : (
<span className="comment-avatar-fallback"> <span className={`comment-avatar-fallback ${isMe ? 'comment-avatar-fallback--me' : ''}`}>
{displayName[0]?.toUpperCase() || '?'} {initial}
</span> </span>
)} )}
</div> </div>
<div className="comment-body"> <div className="comment-body">
<div className="comment-header"> <div className="comment-header">
<div className="comment-author-group">
<span className="comment-author">{displayName}</span> <span className="comment-author">{displayName}</span>
<span className="comment-date">{date}</span> {isMe && <span className="comment-me-badge">Вы</span>}
</div>
<span className="comment-date">{dateStr}</span>
</div> </div>
<p className="comment-text">{comment.comment_text || comment.commet_text}</p> <p className="comment-text">{comment.comment_text || comment.commet_text}</p>
</div> </div>
@@ -7,6 +7,8 @@
background: var(--color-bg); background: var(--color-bg);
transform: translateY(100%); transform: translateY(100%);
transition: transform var(--duration-normal) var(--ease-smooth); transition: transform var(--duration-normal) var(--ease-smooth);
padding-top: max(0px, env(safe-area-inset-top));
padding-bottom: max(0px, env(safe-area-inset-bottom));
} }
.comments-panel--open { .comments-panel--open {
@@ -40,6 +42,15 @@
font-weight: 600; font-weight: 600;
} }
.comments-count {
font-size: 12px;
font-weight: 700;
color: var(--color-muted);
background: rgba(255, 255, 255, 0.08);
padding: 2px 8px;
border-radius: var(--radius-full);
}
/* List */ /* List */
.comments-list { .comments-list {
flex: 1; flex: 1;
@@ -47,8 +58,31 @@
padding: 0 var(--space-lg); padding: 0 var(--space-lg);
} }
.comments-placeholder { .comments-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-xl); padding: var(--space-xl);
gap: var(--space-md);
}
.comments-spinner {
width: 28px;
height: 28px;
border: 3px solid rgba(255, 255, 255, 0.1);
border-top-color: var(--color-card-a-to);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.comments-placeholder {
text-align: center; text-align: center;
color: var(--color-muted); color: var(--color-muted);
font-size: 14px; font-size: 14px;
@@ -60,9 +94,15 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
min-height: 200px;
gap: var(--space-sm); gap: var(--space-sm);
} }
.comments-empty-icon {
font-size: 32px;
margin-bottom: var(--space-xs);
}
.comments-empty-text { .comments-empty-text {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 16px; font-size: 16px;
@@ -89,15 +129,20 @@
.comments-input { .comments-input {
flex: 1; flex: 1;
resize: none; resize: none;
padding: var(--space-sm) var(--space-md); padding: 10px var(--space-md);
background: var(--glass-bg); background: var(--glass-bg);
border: 1px solid var(--glass-border); border: 1px solid var(--glass-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
color: var(--color-text); color: var(--color-text);
font-size: 14px; font-size: 14px;
line-height: 1.4; line-height: 1.4;
min-height: 40px; min-height: 42px;
max-height: 100px; max-height: 100px;
transition: border-color var(--duration-fast) var(--ease-smooth);
}
.comments-input:focus {
border-color: var(--color-card-a-to);
} }
.comments-input::placeholder { .comments-input::placeholder {
@@ -108,8 +153,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 40px; width: 42px;
height: 40px; height: 42px;
border-radius: 50%; border-radius: 50%;
background: var(--glass-bg); background: var(--glass-bg);
color: var(--color-muted); color: var(--color-muted);
@@ -118,10 +163,21 @@
} }
.comments-send--active { .comments-send--active {
background: var(--color-like); background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
color: var(--color-text); color: #FFFFFF;
box-shadow: 0 0 12px rgba(124, 58, 237, 0.3);
} }
.comments-send:disabled { .comments-send:disabled {
cursor: default; cursor: default;
opacity: 0.6;
}
.comments-btn-spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: #FFFFFF;
border-radius: 50%;
animation: spin 0.8s linear infinite;
} }
@@ -1,14 +1,23 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { showBackButton } from '../../services/auth'; import { showBackButton, currentUser, hapticNotification, hapticImpact } from '../../services/auth';
import { api } from '../../services/api'; import { api } from '../../services/api';
import { currentUser } from '../../services/auth';
import CommentItem from './CommentItem'; import CommentItem from './CommentItem';
import './CommentsPanel.css'; import './CommentsPanel.css';
export default function CommentsPanel() { export default function CommentsPanel() {
const { isCommentsOpen, setIsCommentsOpen, currentCard, showToast } = useApp(); const {
isCommentsOpen,
setIsCommentsOpen,
currentCard,
showToast,
getUserProfile,
handleApiResponse,
syncCardComments,
addCommentToCard,
} = useApp();
const [comments, setComments] = useState([]); const [comments, setComments] = useState([]);
const [authors, setAuthors] = useState({});
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [newComment, setNewComment] = useState(''); const [newComment, setNewComment] = useState('');
const [isSending, setIsSending] = useState(false); const [isSending, setIsSending] = useState(false);
@@ -17,59 +26,100 @@ export default function CommentsPanel() {
// Telegram BackButton // Telegram BackButton
useEffect(() => { useEffect(() => {
if (isCommentsOpen) { if (isCommentsOpen) {
const cleanup = showBackButton(() => setIsCommentsOpen(false)); const cleanup = showBackButton(() => {
hapticImpact('light');
setIsCommentsOpen(false);
});
return cleanup; return cleanup;
} }
}, [isCommentsOpen, setIsCommentsOpen]); }, [isCommentsOpen, setIsCommentsOpen]);
// Load comments when panel opens const cardId = currentCard?.card_id;
useEffect(() => {
if (isCommentsOpen && currentCard) {
loadComments();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isCommentsOpen, currentCard?.card_id]);
async function loadComments() { const loadComments = useCallback(async () => {
if (!cardId) return;
setIsLoading(true); setIsLoading(true);
try { try {
// TODO: Replace with real GET /get_comments when backend implements it const result = await api.getComments(cardId);
const result = await api.getComments(currentCard.card_id); handleApiResponse(result);
if (!result.error) {
setComments(result.result || []); if (!result.error && Array.isArray(result.result)) {
const loadedComments = result.result;
setComments(loadedComments);
syncCardComments(cardId, loadedComments.map((c) => c.comment_id));
// Fetch author profiles for all unique authors
const uniqueAuthorIds = Array.from(new Set(loadedComments.map((c) => c.author_id)));
const authorsData = {};
await Promise.all(
uniqueAuthorIds.map(async (authorId) => {
const profile = await getUserProfile(authorId);
if (profile) {
authorsData[authorId] = profile;
}
})
);
setAuthors(authorsData);
} else {
setComments([]);
} }
} catch (err) { } catch (err) {
console.error('Load comments error:', err); console.error('Load comments error:', err);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [cardId, getUserProfile, handleApiResponse, syncCardComments]);
// Load comments only when panel opens or active card ID changes
useEffect(() => {
if (isCommentsOpen && cardId) {
loadComments();
} }
}, [isCommentsOpen, cardId, loadComments]);
async function handleSend() { async function handleSend() {
if (!newComment.trim() || isSending) return; const text = newComment.trim();
if (!text || isSending || !currentCard) return;
setIsSending(true); setIsSending(true);
hapticImpact('light');
try { try {
const result = await api.addComment(currentUser.id, currentCard.card_id, newComment.trim()); const result = await api.addComment(currentUser.id, currentCard.card_id, text);
if (!result.error) { handleApiResponse(result);
if (!result.error && result.result) {
setNewComment(''); setNewComment('');
showToast('Комментарий отправлен'); showToast('Комментарий опубликован');
hapticNotification('success');
// Optimistically add the new comment to the list const createdComment = result.result;
if (result.result) { setComments((prev) => [...prev, createdComment]);
setComments(prev => [...prev, result.result]); addCommentToCard(currentCard.card_id, createdComment.comment_id);
// Scroll to bottom after adding // Add current user to authors map
setAuthors((prev) => ({
...prev,
[currentUser.id]: currentUser,
}));
// Scroll to bottom
setTimeout(() => { setTimeout(() => {
if (listRef.current) { if (listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight; listRef.current.scrollTop = listRef.current.scrollHeight;
} }
}, 100); }, 100);
}
} else { } else {
showToast('Ошибка: ' + (result.result || 'неизвестная')); showToast(typeof result.result === 'string' ? result.result : 'Ошибка отправки комментария');
hapticNotification('error');
} }
} catch (err) { } catch (err) {
console.error('Send comment error:', err); console.error('Send comment error:', err);
showToast('Ошибка отправки');
hapticNotification('error');
} finally { } finally {
setIsSending(false); setIsSending(false);
} }
@@ -82,30 +132,44 @@ export default function CommentsPanel() {
} }
} }
const handleClose = () => {
hapticImpact('light');
setIsCommentsOpen(false);
};
return ( return (
<div className={`comments-panel ${isCommentsOpen ? 'comments-panel--open' : ''}`}> <div className={`comments-panel ${isCommentsOpen ? 'comments-panel--open' : ''}`}>
<div className="comments-header"> <div className="comments-header">
<button <button
className="comments-close" className="comments-close"
onClick={() => setIsCommentsOpen(false)} onClick={handleClose}
aria-label="Закрыть" aria-label="Закрыть"
> >
</button> </button>
<h2 className="comments-title">Комментарии</h2> <h2 className="comments-title">Комментарии</h2>
<span className="comments-count">{comments.length}</span>
</div> </div>
<div className="comments-list custom-scroll" ref={listRef}> <div className="comments-list custom-scroll" ref={listRef}>
{isLoading ? ( {isLoading ? (
<p className="comments-placeholder">Загрузка...</p> <div className="comments-loading">
<div className="comments-spinner" />
<p className="comments-placeholder">Загрузка комментариев...</p>
</div>
) : comments.length === 0 ? ( ) : comments.length === 0 ? (
<div className="comments-empty"> <div className="comments-empty">
<div className="comments-empty-icon">💬</div>
<p className="comments-empty-text">Комментариев пока нет</p> <p className="comments-empty-text">Комментариев пока нет</p>
<p className="comments-empty-sub">Будь первым!</p> <p className="comments-empty-sub">Будь первым, кто поделится мнением!</p>
</div> </div>
) : ( ) : (
comments.map((comment, i) => ( comments.map((comment, i) => (
<CommentItem key={comment.comment_id || i} comment={comment} author={null} /> <CommentItem
key={comment.comment_id || i}
comment={comment}
author={authors[comment.author_id] || null}
/>
)) ))
)} )}
</div> </div>
@@ -113,22 +177,27 @@ export default function CommentsPanel() {
<div className="comments-input-area"> <div className="comments-input-area">
<textarea <textarea
className="comments-input" className="comments-input"
placeholder="Ваш комментарий..." placeholder="Напишите комментарий..."
value={newComment} value={newComment}
onChange={(e) => setNewComment(e.target.value)} onChange={(e) => setNewComment(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
maxLength={300} maxLength={300}
rows={1} rows={1}
disabled={isSending}
/> />
<button <button
className={`comments-send ${newComment.trim() ? 'comments-send--active' : ''}`} className={`comments-send ${newComment.trim() && !isSending ? 'comments-send--active' : ''}`}
onClick={handleSend} onClick={handleSend}
disabled={!newComment.trim() || isSending} disabled={!newComment.trim() || isSending}
aria-label="Отправить" aria-label="Отправить"
> >
{isSending ? (
<span className="comments-btn-spinner" />
) : (
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"> <svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" /> <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg> </svg>
)}
</button> </button>
</div> </div>
</div> </div>
+27 -12
View File
@@ -1,18 +1,38 @@
import React from 'react'; import React from 'react';
import { hapticImpact, hapticNotification, openExternalLink } from '../../services/auth';
import { useApp } from '../../context/AppContext';
import './AboutPage.css'; import './AboutPage.css';
export default function AboutPage({ onBack }) { export default function AboutPage({ onBack }) {
const { showToast } = useApp();
function handleCopyEmail() { function handleCopyEmail() {
navigator.clipboard.writeText('pseudo.developer.ru@gmail.com').then(() => { hapticImpact('light');
// Visual feedback handled by CSS :active navigator.clipboard
}).catch(() => { .writeText('pseudo.developer.ru@gmail.com')
// Fallback — select text .then(() => {
hapticNotification('success');
showToast('Email скопирован в буфер обмена');
})
.catch(() => {
showToast('pseudo.developer.ru@gmail.com');
}); });
} }
const handleBack = () => {
hapticImpact('light');
onBack();
};
const handleOpenGithub = (e) => {
e.preventDefault();
hapticImpact('light');
openExternalLink('https://github.com/IgorVolochay/thisORthat');
};
return ( return (
<div className="about-page custom-scroll"> <div className="about-page custom-scroll">
<button className="about-back" onClick={onBack} aria-label="Назад"> <button className="about-back" onClick={handleBack} aria-label="Назад">
Назад Назад
</button> </button>
@@ -35,17 +55,12 @@ export default function AboutPage({ onBack }) {
</div> </div>
<div className="about-links"> <div className="about-links">
<a <button className="about-link" onClick={handleOpenGithub}>
href="https://github.com/IgorVolochay/thisORthat"
target="_blank"
rel="noopener noreferrer"
className="about-link"
>
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20"> <svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" /> <path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg> </svg>
<span>Исходники проекта</span> <span>Исходники проекта</span>
</a> </button>
<button className="about-link" onClick={handleCopyEmail}> <button className="about-link" onClick={handleCopyEmail}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" width="20" height="20"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" width="20" height="20">
+21 -11
View File
@@ -23,17 +23,24 @@
margin-bottom: var(--space-md); margin-bottom: var(--space-md);
} }
.create-rules-box {
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
padding: var(--space-md);
margin-bottom: var(--space-lg);
}
.create-rules { .create-rules {
font-size: 13px; font-size: 13px;
line-height: 1.55; line-height: 1.5;
color: var(--color-muted); color: #CBD5E1;
margin-bottom: var(--space-lg);
} }
.create-fields { .create-fields {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 8px;
margin-bottom: var(--space-lg); margin-bottom: var(--space-lg);
} }
@@ -41,7 +48,8 @@
position: relative; position: relative;
border-radius: var(--radius-card); border-radius: var(--radius-card);
padding: var(--space-lg); padding: var(--space-lg);
min-height: 100px; min-height: 110px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
} }
.create-field--a { .create-field--a {
@@ -65,7 +73,7 @@
} }
.create-textarea::placeholder { .create-textarea::placeholder {
color: rgba(255, 255, 255, 0.4); color: rgba(255, 255, 255, 0.45);
} }
.create-counter { .create-counter {
@@ -74,15 +82,15 @@
right: var(--space-md); right: var(--space-md);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
color: rgba(255, 255, 255, 0.35); color: rgba(255, 255, 255, 0.4);
} }
.create-submit { .create-submit {
width: 100%; width: 100%;
padding: var(--space-md) var(--space-lg); padding: 14px var(--space-lg);
border-radius: var(--radius-full); border-radius: var(--radius-full);
font-family: var(--font-display); font-family: var(--font-display);
font-size: 18px; font-size: 16px;
font-weight: 700; font-weight: 700;
color: var(--color-muted); color: var(--color-muted);
background: var(--glass-bg); background: var(--glass-bg);
@@ -91,15 +99,17 @@
} }
.create-submit--active { .create-submit--active {
color: var(--color-text); color: #FFFFFF;
background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to)); background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
border-color: transparent; border-color: transparent;
box-shadow: 0 4px 20px rgba(124, 58, 237, 0.4);
} }
.create-submit:disabled { .create-submit:disabled {
cursor: default; cursor: default;
opacity: 0.6;
} }
.create-submit--active:active { .create-submit--active:active {
transform: scale(0.97); transform: scale(0.98);
} }
+25 -11
View File
@@ -1,13 +1,13 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { currentUser } from '../../services/auth'; import { currentUser, hapticImpact, hapticNotification } from '../../services/auth';
import { api } from '../../services/api'; import { api } from '../../services/api';
import './CreateCard.css'; import './CreateCard.css';
const MAX_LENGTH = 150; const MAX_LENGTH = 150;
export default function CreateCard({ onBack }) { export default function CreateCard({ onBack }) {
const { showToast, closeMenu } = useApp(); const { showToast, closeMenu, handleApiResponse } = useApp();
const [choiceA, setChoiceA] = useState(''); const [choiceA, setChoiceA] = useState('');
const [choiceB, setChoiceB] = useState(''); const [choiceB, setChoiceB] = useState('');
const [isSending, setIsSending] = useState(false); const [isSending, setIsSending] = useState(false);
@@ -17,47 +17,60 @@ export default function CreateCard({ onBack }) {
async function handleSubmit() { async function handleSubmit() {
if (!canSubmit) return; if (!canSubmit) return;
setIsSending(true); setIsSending(true);
hapticImpact('light');
try { try {
const result = await api.addCard(choiceA.trim(), choiceB.trim(), currentUser.id); const result = await api.addCard(choiceA.trim(), choiceB.trim(), currentUser.id);
handleApiResponse(result);
if (!result.error) { if (!result.error) {
hapticNotification('success');
showToast('Карточка отправлена на модерацию!'); showToast('Карточка отправлена на модерацию!');
setTimeout(() => { setTimeout(() => {
closeMenu(); closeMenu();
}, 2000); }, 1800);
} else { } else {
showToast('Ошибка: ' + (result.result || 'неизвестная')); hapticNotification('error');
showToast(typeof result.result === 'string' ? result.result : 'Ошибка модерации или отправки');
setIsSending(false); setIsSending(false);
} }
} catch (err) { } catch (err) {
console.error('Add card error:', err); console.error('Add card error:', err);
hapticNotification('error');
showToast('Ошибка отправки'); showToast('Ошибка отправки');
setIsSending(false); setIsSending(false);
} }
} }
const handleBack = () => {
hapticImpact('light');
onBack();
};
return ( return (
<div className="create-card custom-scroll"> <div className="create-card custom-scroll">
<button className="create-back" onClick={onBack} aria-label="Назад"> <button className="create-back" onClick={handleBack} aria-label="Назад">
Назад Назад
</button> </button>
<h2 className="create-title">Создать карточку</h2> <h2 className="create-title">Создать карточку</h2>
<div className="create-rules-box">
<p className="create-rules"> <p className="create-rules">
При создании карточек запрещается использование мата и ссылок. 💡 <strong>Правила публикации:</strong> Запрещены нецензурные выражения, оскорбления и спам-ссылки. Все карточки проверяются перед публикацией.
Все карточки проходят процесс модерации перед публикацией.
Лимит по длине текста: {MAX_LENGTH} символов.
</p> </p>
</div>
<div className="create-fields"> <div className="create-fields">
<div className="create-field create-field--a"> <div className="create-field create-field--a">
<textarea <textarea
className="create-textarea" className="create-textarea"
placeholder="Первый вариант" placeholder="Вариант А"
value={choiceA} value={choiceA}
onChange={(e) => setChoiceA(e.target.value.slice(0, MAX_LENGTH))} onChange={(e) => setChoiceA(e.target.value.slice(0, MAX_LENGTH))}
maxLength={MAX_LENGTH} maxLength={MAX_LENGTH}
rows={3} rows={3}
disabled={isSending}
/> />
<span className="create-counter"> <span className="create-counter">
{choiceA.length}/{MAX_LENGTH} {choiceA.length}/{MAX_LENGTH}
@@ -67,11 +80,12 @@ export default function CreateCard({ onBack }) {
<div className="create-field create-field--b"> <div className="create-field create-field--b">
<textarea <textarea
className="create-textarea" className="create-textarea"
placeholder="Второй вариант" placeholder="Вариант Б"
value={choiceB} value={choiceB}
onChange={(e) => setChoiceB(e.target.value.slice(0, MAX_LENGTH))} onChange={(e) => setChoiceB(e.target.value.slice(0, MAX_LENGTH))}
maxLength={MAX_LENGTH} maxLength={MAX_LENGTH}
rows={3} rows={3}
disabled={isSending}
/> />
<span className="create-counter"> <span className="create-counter">
{choiceB.length}/{MAX_LENGTH} {choiceB.length}/{MAX_LENGTH}
@@ -84,7 +98,7 @@ export default function CreateCard({ onBack }) {
onClick={handleSubmit} onClick={handleSubmit}
disabled={!canSubmit} disabled={!canSubmit}
> >
{isSending ? 'Отправка...' : 'Отправить!'} {isSending ? 'Отправка на модерацию...' : 'Отправить на модерацию'}
</button> </button>
</div> </div>
); );
+24 -8
View File
@@ -1,6 +1,6 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { useApp } from '../../context/AppContext'; import { useApp } from '../../context/AppContext';
import { showBackButton } from '../../services/auth'; import { showBackButton, hapticImpact, openExternalLink } from '../../services/auth';
import Overlay from '../common/Overlay'; import Overlay from '../common/Overlay';
import AboutPage from './AboutPage'; import AboutPage from './AboutPage';
import CreateCard from './CreateCard'; import CreateCard from './CreateCard';
@@ -12,24 +12,40 @@ export default function MenuPanel() {
// Telegram BackButton for sub-screens // Telegram BackButton for sub-screens
useEffect(() => { useEffect(() => {
if (isMenuOpen && menuScreen !== 'menu') { if (isMenuOpen && menuScreen !== 'menu') {
const cleanup = showBackButton(() => setMenuScreen('menu')); const cleanup = showBackButton(() => {
hapticImpact('light');
setMenuScreen('menu');
});
return cleanup; return cleanup;
} }
if (isMenuOpen && menuScreen === 'menu') { if (isMenuOpen && menuScreen === 'menu') {
const cleanup = showBackButton(() => closeMenu()); const cleanup = showBackButton(() => {
hapticImpact('light');
closeMenu();
});
return cleanup; return cleanup;
} }
}, [isMenuOpen, menuScreen, setMenuScreen, closeMenu]); }, [isMenuOpen, menuScreen, setMenuScreen, closeMenu]);
if (!isMenuOpen) return null; if (!isMenuOpen) return null;
const handleNavigate = (screen) => {
hapticImpact('light');
setMenuScreen(screen);
};
const handleExternalLink = (url) => {
hapticImpact('light');
openExternalLink(url);
};
// Sub-screens // Sub-screens
if (menuScreen === 'about') { if (menuScreen === 'about') {
return ( return (
<> <>
<Overlay visible={true} onClick={closeMenu} /> <Overlay visible={true} onClick={closeMenu} />
<div className="menu-panel menu-panel--open"> <div className="menu-panel menu-panel--open">
<AboutPage onBack={() => setMenuScreen('menu')} /> <AboutPage onBack={() => handleNavigate('menu')} />
</div> </div>
</> </>
); );
@@ -40,7 +56,7 @@ export default function MenuPanel() {
<> <>
<Overlay visible={true} onClick={closeMenu} /> <Overlay visible={true} onClick={closeMenu} />
<div className="menu-panel menu-panel--open"> <div className="menu-panel menu-panel--open">
<CreateCard onBack={() => setMenuScreen('menu')} /> <CreateCard onBack={() => handleNavigate('menu')} />
</div> </div>
</> </>
); );
@@ -51,7 +67,7 @@ export default function MenuPanel() {
<Overlay visible={true} onClick={closeMenu} /> <Overlay visible={true} onClick={closeMenu} />
<div className="menu-panel menu-panel--open"> <div className="menu-panel menu-panel--open">
<nav className="menu-list"> <nav className="menu-list">
<button className="menu-item" onClick={() => setMenuScreen('about')}> <button className="menu-item" onClick={() => handleNavigate('about')}>
<span className="menu-icon"> <span className="menu-icon">
<svg viewBox="0 0 24 24" fill="currentColor" width="22" height="22"> <svg viewBox="0 0 24 24" fill="currentColor" width="22" height="22">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="2" /> <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="2" />
@@ -61,7 +77,7 @@ export default function MenuPanel() {
<span className="menu-label">О проекте</span> <span className="menu-label">О проекте</span>
</button> </button>
<button className="menu-item" onClick={() => setMenuScreen('create')}> <button className="menu-item" onClick={() => handleNavigate('create')}>
<span className="menu-icon"> <span className="menu-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" width="22" height="22"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" width="22" height="22">
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" /> <path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
@@ -73,7 +89,7 @@ export default function MenuPanel() {
<button <button
className="menu-item" className="menu-item"
onClick={() => window.open('https://boosty.to/pseudodev/donate', '_blank')} onClick={() => handleExternalLink('https://boosty.to/pseudodev/donate')}
> >
<span className="menu-icon"> <span className="menu-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" width="22" height="22"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" width="22" height="22">
@@ -0,0 +1,150 @@
.banned-screen {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg);
padding: var(--space-lg);
z-index: 1000;
user-select: none;
}
.banned-card {
width: 100%;
max-width: 380px;
background: var(--color-bg-elevated);
border: 1px solid rgba(239, 68, 68, 0.3);
box-shadow: 0 0 40px rgba(239, 68, 68, 0.15);
border-radius: var(--radius-card);
padding: var(--space-xl) var(--space-lg);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
animation: bannedAppear 400ms var(--ease-spring);
}
@keyframes bannedAppear {
from {
opacity: 0;
transform: scale(0.92) translateY(12px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.banned-icon-wrapper {
width: 72px;
height: 72px;
border-radius: var(--radius-full);
background: rgba(239, 68, 68, 0.12);
border: 1px solid rgba(239, 68, 68, 0.35);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: var(--space-lg);
color: #EF4444;
box-shadow: 0 0 24px rgba(239, 68, 68, 0.25);
animation: pulseGlow 2.5s infinite ease-in-out;
}
@keyframes pulseGlow {
0%, 100% {
box-shadow: 0 0 16px rgba(239, 68, 68, 0.2);
}
50% {
box-shadow: 0 0 32px rgba(239, 68, 68, 0.45);
}
}
.banned-icon {
width: 36px;
height: 36px;
}
.banned-title {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--space-sm);
letter-spacing: -0.3px;
}
.banned-subtitle {
font-size: 14px;
color: var(--color-muted);
line-height: 1.5;
margin-bottom: var(--space-lg);
}
.banned-info-box {
display: flex;
align-items: center;
gap: var(--space-sm);
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--glass-border);
padding: 10px 14px;
border-radius: var(--radius-sm);
font-size: 12px;
color: #CBD5E1;
text-align: left;
margin-bottom: var(--space-xl);
width: 100%;
}
.banned-info-dot {
width: 8px;
height: 8px;
border-radius: var(--radius-full);
background: #EF4444;
flex-shrink: 0;
}
.banned-actions {
display: flex;
flex-direction: column;
gap: var(--space-sm);
width: 100%;
}
.banned-btn {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
width: 100%;
padding: 12px var(--space-md);
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all var(--duration-fast) var(--ease-smooth);
}
.banned-btn--primary {
background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
color: #FFFFFF;
}
.banned-btn--primary:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
.banned-btn--primary:active {
transform: translateY(0);
}
.banned-btn--secondary {
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--glass-border);
color: var(--color-text);
}
.banned-btn--secondary:hover {
background: rgba(255, 255, 255, 0.1);
}
@@ -0,0 +1,51 @@
import React from 'react';
import './BannedScreen.css';
export default function BannedScreen({ onRetry }) {
const handleSupportClick = () => {
window.open('https://t.me/IgorVolochay', '_blank');
};
return (
<div className="banned-screen">
<div className="banned-card">
<div className="banned-icon-wrapper">
<svg className="banned-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<h1 className="banned-title">Доступ ограничен</h1>
<p className="banned-subtitle">
Система безопасности зафиксировала подозрительную активность с вашего IP-адреса.
</p>
<div className="banned-info-box">
<div className="banned-info-dot" />
<span>Блокировка длится 1 час и снимается автоматически.</span>
</div>
<div className="banned-actions">
{onRetry && (
<button className="banned-btn banned-btn--primary" onClick={onRetry}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="18" height="18">
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 11-.57-8.38l5.67-5.67" />
</svg>
<span>Повторить попытку</span>
</button>
)}
<button className="banned-btn banned-btn--secondary" onClick={handleSupportClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="18" height="18">
<path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z" />
</svg>
<span>Написать в поддержку</span>
</button>
</div>
</div>
</div>
);
}
+235 -55
View File
@@ -1,6 +1,6 @@
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
import { api } from '../services/api'; import { api } from '../services/api';
import { currentUser, initTelegramApp } from '../services/auth'; import { currentUser, initTelegramApp, hapticImpact, hapticNotification } from '../services/auth';
const AppContext = createContext(null); const AppContext = createContext(null);
@@ -9,11 +9,13 @@ export function AppProvider({ children }) {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [isBanned, setIsBanned] = useState(false);
// Card queue // Card queue
const [cardQueue, setCardQueue] = useState([]); const [cardQueue, setCardQueue] = useState([]);
const [currentCardIndex, setCurrentCardIndex] = useState(0); const [currentCardIndex, setCurrentCardIndex] = useState(0);
const [chosenCard, setChosenCard] = useState(null); // null | "A" | "B" const [chosenCard, setChosenCard] = useState(null); // null | "A" | "B"
const [isLoadingCards, setIsLoadingCards] = useState(false);
// Panels // Panels
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -22,30 +24,102 @@ export function AppProvider({ children }) {
// Toast // Toast
const [toast, setToast] = useState(null); const [toast, setToast] = useState(null);
const toastTimeoutRef = useRef(null);
// User Profile Cache for comments
const userProfileCacheRef = useRef(new Map());
// Initialization ref for React StrictMode
const isInitializingRef = useRef(false);
// Current card helper // Current card helper
const currentCard = cardQueue[currentCardIndex] || null; const currentCard = cardQueue[currentCardIndex] || null;
// Toast helper
const showToast = useCallback((message, duration = 2500) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
setToast(message);
toastTimeoutRef.current = setTimeout(() => setToast(null), duration);
}, []);
// Response interceptor helper
const handleApiResponse = useCallback((res) => {
if (res?.isBanned) {
setIsBanned(true);
}
if (res?.status === 429) {
showToast(typeof res.result === 'string' ? res.result : 'Слишком много запросов. Подождите немного.');
}
return res;
}, [showToast]);
// Load cards batch
const loadCards = useCallback(async (isManualRefresh = false) => {
setIsLoadingCards(true);
if (isManualRefresh) {
hapticImpact('light');
}
try {
const result = await api.getRandomCards(currentUser.id);
handleApiResponse(result);
if (!result.error && Array.isArray(result.result) && result.result.length > 0) {
setCardQueue(result.result);
setCurrentCardIndex(0);
setChosenCard(null);
if (isManualRefresh) {
showToast('Карточки обновлены!');
hapticNotification('success');
}
} else {
// Pool is empty or all cards seen
setCardQueue([]);
setCurrentCardIndex(0);
setChosenCard(null);
if (isManualRefresh) {
showToast('Новых карточек пока нет');
}
}
} catch (err) {
console.error('Load cards error:', err);
if (isManualRefresh) {
showToast('Ошибка загрузки карточек');
}
} finally {
setIsLoadingCards(false);
}
}, [handleApiResponse, showToast]);
// Initialize app // Initialize app
useEffect(() => { const initApp = useCallback(async () => {
async function init() {
try { try {
initTelegramApp(); initTelegramApp();
// Check/register user // Check/register user
const checkResult = await api.checkUser(currentUser.id); const checkResult = await api.checkUser(currentUser.id);
if (!checkResult.result) { handleApiResponse(checkResult);
await api.addUser({ if (checkResult?.isBanned) return;
if (!checkResult.error && !checkResult.result) {
const addResult = await api.addUser({
user_id: currentUser.id, user_id: currentUser.id,
username: currentUser.username, username: currentUser.username,
first_name: currentUser.first_name, first_name: currentUser.first_name,
last_name: currentUser.last_name, last_name: currentUser.last_name,
photo_url: currentUser.photo_url, photo_url: currentUser.photo_url,
}); });
handleApiResponse(addResult);
if (addResult?.isBanned) return;
} }
const userResult = await api.getUser(currentUser.id); const userResult = await api.getUser(currentUser.id);
if (!userResult.error) { handleApiResponse(userResult);
if (userResult?.isBanned) return;
if (!userResult.error && userResult.result) {
setUser(userResult.result); setUser(userResult.result);
} }
@@ -57,49 +131,45 @@ export function AppProvider({ children }) {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
} }, [handleApiResponse, loadCards]);
init();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Load cards batch useEffect(() => {
const loadCards = useCallback(async () => { if (isInitializingRef.current) return;
isInitializingRef.current = true;
initApp();
}, [initApp]);
// User Profile resolver for comments
const getUserProfile = useCallback(async (userId) => {
if (!userId) return null;
if (userId === currentUser.id) {
return {
user_id: currentUser.id,
username: currentUser.username,
first_name: currentUser.first_name,
last_name: currentUser.last_name,
photo_url: currentUser.photo_url,
};
}
if (userProfileCacheRef.current.has(userId)) {
return userProfileCacheRef.current.get(userId);
}
try { try {
const result = await api.getRandomCards(currentUser.id); const res = await api.getUser(userId);
if (!result.error && Array.isArray(result.result) && result.result.length > 0) { handleApiResponse(res);
setCardQueue(result.result); if (!res.error && res.result) {
setCurrentCardIndex(0); userProfileCacheRef.current.set(userId, res.result);
setChosenCard(null); return res.result;
} else {
// No more cards or error
setCardQueue([]);
setCurrentCardIndex(0);
} }
} catch (err) { } catch (err) {
console.error('Load cards error:', err); console.error('Failed to get user profile:', userId, err);
setError('Ошибка загрузки карточек');
} }
}, []); return null;
}, [handleApiResponse]);
// Choose a card (A or B)
const chooseCard = useCallback((choice) => {
if (chosenCard) {
// Second tap on chosen card — go next
if (choice === chosenCard) {
goToNextCard();
}
return;
}
setChosenCard(choice);
// Fire select_choice to backend
if (currentCard) {
api.selectChoice(currentUser.id, currentCard.card_id, choice).catch(console.error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chosenCard, currentCard]);
// Go to next card // Go to next card
const goToNextCard = useCallback(async () => { const goToNextCard = useCallback(async () => {
hapticImpact('light');
const nextIndex = currentCardIndex + 1; const nextIndex = currentCardIndex + 1;
if (nextIndex < cardQueue.length) { if (nextIndex < cardQueue.length) {
setCurrentCardIndex(nextIndex); setCurrentCardIndex(nextIndex);
@@ -110,50 +180,155 @@ export function AppProvider({ children }) {
} }
}, [currentCardIndex, cardQueue.length, loadCards]); }, [currentCardIndex, cardQueue.length, loadCards]);
// Choose a card (A or B)
const chooseCard = useCallback((choice) => {
if (chosenCard) {
// Second tap on chosen card — go next
if (choice === chosenCard) {
goToNextCard();
}
return;
}
hapticImpact('medium');
setChosenCard(choice);
if (currentCard) {
api.selectChoice(currentUser.id, currentCard.card_id, choice)
.then(handleApiResponse)
.catch(console.error);
}
}, [chosenCard, currentCard, goToNextCard, handleApiResponse]);
const syncCardComments = useCallback((cardId, commentsArrayOrIds) => {
if (!Array.isArray(commentsArrayOrIds)) return;
setCardQueue((prev) => {
const targetCard = prev.find((c) => c.card_id === cardId);
if (!targetCard) return prev;
if (targetCard.comments && targetCard.comments.length === commentsArrayOrIds.length) {
return prev; // No change, avoid re-render
}
return prev.map((c) =>
c.card_id === cardId
? { ...c, comments: commentsArrayOrIds }
: c
);
});
}, []);
const addCommentToCard = useCallback((cardId, commentId) => {
setCardQueue((prev) =>
prev.map((c) =>
c.card_id === cardId
? {
...c,
comments: c.comments ? [...c.comments, commentId] : [commentId],
}
: c
)
);
}, []);
// Auto-sync real comments count for current card from /get_comments
useEffect(() => {
const cardId = currentCard?.card_id;
if (!cardId) return;
let isMounted = true;
api.getComments(cardId)
.then((res) => {
if (isMounted && !res.error && Array.isArray(res.result)) {
syncCardComments(cardId, res.result.map((c) => c.comment_id));
}
})
.catch(() => { });
return () => {
isMounted = false;
};
}, [currentCard?.card_id, syncCardComments]);
// Reactions // Reactions
const likeCard = useCallback(async () => { const likeCard = useCallback(async () => {
if (!currentCard || !chosenCard) return; if (!currentCard || !chosenCard) return;
hapticImpact('light');
const result = await api.likeCard(currentUser.id, currentCard.card_id); const result = await api.likeCard(currentUser.id, currentCard.card_id);
if (!result.error) { handleApiResponse(result);
// Refresh user data to get updated liked_card_ids
if (result && !result.error) {
// Update local card counts in cardQueue
setCardQueue((prev) =>
prev.map((c) =>
c.card_id === currentCard.card_id
? { ...c, count_likes: (c.count_likes || 0) + 1 }
: c
)
);
// Refresh user data for liked_card_ids
const userResult = await api.getUser(currentUser.id); const userResult = await api.getUser(currentUser.id);
if (!userResult.error) setUser(userResult.result); handleApiResponse(userResult);
if (!userResult.error && userResult.result) {
setUser(userResult.result);
}
} }
return result; return result;
}, [currentCard, chosenCard]); }, [currentCard, chosenCard, handleApiResponse]);
const dislikeCard = useCallback(async () => { const dislikeCard = useCallback(async () => {
if (!currentCard || !chosenCard) return; if (!currentCard || !chosenCard) return;
hapticImpact('light');
const result = await api.dislikeCard(currentUser.id, currentCard.card_id); const result = await api.dislikeCard(currentUser.id, currentCard.card_id);
if (!result.error) { handleApiResponse(result);
if (result && !result.error) {
// Update local card counts in cardQueue
setCardQueue((prev) =>
prev.map((c) =>
c.card_id === currentCard.card_id
? { ...c, count_dislikes: (c.count_dislikes || 0) + 1 }
: c
)
);
// Refresh user data for disliked_card_ids
const userResult = await api.getUser(currentUser.id); const userResult = await api.getUser(currentUser.id);
if (!userResult.error) setUser(userResult.result); handleApiResponse(userResult);
if (!userResult.error && userResult.result) {
setUser(userResult.result);
}
} }
return result; return result;
}, [currentCard, chosenCard]); }, [currentCard, chosenCard, handleApiResponse]);
// Toast helper
const showToast = useCallback((message, duration = 2500) => {
setToast(message);
setTimeout(() => setToast(null), duration);
}, []);
// Menu helpers // Menu helpers
const openMenu = useCallback(() => { const openMenu = useCallback(() => {
hapticImpact('light');
setIsMenuOpen(true); setIsMenuOpen(true);
setMenuScreen('menu'); setMenuScreen('menu');
}, []); }, []);
const closeMenu = useCallback(() => { const closeMenu = useCallback(() => {
hapticImpact('light');
setIsMenuOpen(false); setIsMenuOpen(false);
setMenuScreen('menu'); setMenuScreen('menu');
}, []); }, []);
const handleRetryAfterBan = useCallback(() => {
setIsBanned(false);
setIsLoading(true);
initApp();
}, [initApp]);
const value = { const value = {
// State // State
isLoading, isLoading,
isLoadingCards,
user, user,
error, error,
isBanned,
currentCard, currentCard,
chosenCard, chosenCard,
cardQueue, cardQueue,
@@ -169,12 +344,17 @@ export function AppProvider({ children }) {
loadCards, loadCards,
likeCard, likeCard,
dislikeCard, dislikeCard,
getUserProfile,
handleApiResponse,
syncCardComments,
addCommentToCard,
openMenu, openMenu,
closeMenu, closeMenu,
setMenuScreen, setMenuScreen,
setIsCommentsOpen, setIsCommentsOpen,
showToast, showToast,
setError, setError,
handleRetryAfterBan,
}; };
return <AppContext.Provider value={value}>{children}</AppContext.Provider>; return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
+64 -9
View File
@@ -1,22 +1,80 @@
/** /**
* API service — all backend requests for This OR That. * API service — all backend requests for This OR That.
* All endpoints return { result, error } (BaseResponse). * Includes X-Init-Data header injection, rate limit handling, and IP ban detection.
* All endpoints return { result, error, status, isBanned }.
*/ */
import { getTelegramInitData } from './auth';
const BASE_URL = process.env.REACT_APP_API_URL || '/api'; const BASE_URL = process.env.REACT_APP_API_URL || '/api';
async function request(method, path, body = null) { async function request(method, path, body = null) {
const headers = {
'Content-Type': 'application/json',
};
const initData = getTelegramInitData();
if (initData) {
headers['X-Init-Data'] = initData;
}
const options = { const options = {
method, method,
headers: { 'Content-Type': 'application/json' }, headers,
}; };
if (body) { if (body) {
options.body = JSON.stringify(body); options.body = JSON.stringify(body);
} }
try {
const response = await fetch(`${BASE_URL}${path}`, options); const response = await fetch(`${BASE_URL}${path}`, options);
const data = await response.json(); let data;
return data; try {
data = await response.json();
} catch {
data = { result: response.statusText, error: !response.ok };
}
// Check for IP ban by FastAPI-guard / penetration detection
const isIpBanned = response.status === 403 && (
(typeof data?.detail === 'string' && /banned|ip.*banned|suspicious/i.test(data.detail)) ||
(typeof data?.result === 'string' && /banned|ip.*banned|suspicious/i.test(data.result))
);
if (!response.ok) {
if (response.status === 429) {
return {
result: data?.detail || 'Слишком много запросов. Подождите несколько секунд.',
error: true,
status: 429,
isBanned: false,
};
}
return {
result: data?.result || data?.detail || `Ошибка сервера (${response.status})`,
error: true,
status: response.status,
isBanned: isIpBanned,
};
}
return {
result: data?.result !== undefined ? data.result : data,
error: data?.error || false,
status: response.status,
isBanned: false,
};
} catch (err) {
console.error(`API request error [${method} ${path}]:`, err);
return {
result: 'Ошибка соединения с сервером',
error: true,
status: 0,
isBanned: false,
};
}
} }
const GET = (path) => request('GET', path); const GET = (path) => request('GET', path);
@@ -58,9 +116,6 @@ export const api = {
addComment: (authorId, cardId, commentText) => addComment: (authorId, cardId, commentText) =>
POST('/comment', { author_id: authorId, card_id: cardId, comment_text: commentText }), POST('/comment', { author_id: authorId, card_id: cardId, comment_text: commentText }),
// TODO: GET /get_comments — endpoint not yet implemented on backend getComments: (cardId) =>
getComments: (cardId) => { GET(`/get_comments?card_id=${cardId}`),
console.warn('GET /get_comments not implemented on backend yet');
return Promise.resolve({ result: [], error: false });
},
}; };
+63 -3
View File
@@ -1,6 +1,6 @@
/** /**
* Auth service — detects Telegram WebApp user or falls back to mock. * Auth service — detects Telegram WebApp user or falls back to mock.
* Auto-registers user on backend if not yet registered. * Provides Telegram Mini App initialization, initData extraction, and Haptic Feedback.
*/ */
const MOCK_USER = { const MOCK_USER = {
@@ -11,7 +11,21 @@ const MOCK_USER = {
photo_url: '', photo_url: '',
}; };
function getTelegramUser() { /**
* Extracts raw Telegram initData query-string for backend HMAC validation.
*/
export function getTelegramInitData() {
try {
return window.Telegram?.WebApp?.initData || '';
} catch {
return '';
}
}
/**
* Extracts parsed user info from initDataUnsafe for UI display.
*/
export function getTelegramUser() {
try { try {
const tg = window.Telegram?.WebApp; const tg = window.Telegram?.WebApp;
const user = tg?.initDataUnsafe?.user; const user = tg?.initDataUnsafe?.user;
@@ -33,14 +47,47 @@ function getTelegramUser() {
export const currentUser = getTelegramUser() ?? MOCK_USER; export const currentUser = getTelegramUser() ?? MOCK_USER;
export const isTelegram = !!getTelegramUser(); export const isTelegram = !!getTelegramUser();
/**
* Initializes Telegram WebApp environment (theme, fullscreen expand, close confirmation).
*/
export function initTelegramApp() { export function initTelegramApp() {
const tg = window.Telegram?.WebApp; const tg = window.Telegram?.WebApp;
if (tg) { if (tg) {
tg.ready(); tg.ready();
tg.expand(); tg.expand();
try {
tg.setHeaderColor?.('#070711');
tg.setBackgroundColor?.('#070711');
tg.enableClosingConfirmation?.();
} catch {
// Ignored in unsupported client versions
}
} }
} }
/**
* Triggers Telegram Haptic Feedback impact.
* @param {'light' | 'medium' | 'heavy' | 'rigid' | 'soft'} style
*/
export function hapticImpact(style = 'light') {
try {
window.Telegram?.WebApp?.HapticFeedback?.impactOccurred(style);
} catch {}
}
/**
* Triggers Telegram Haptic Feedback notification.
* @param {'error' | 'success' | 'warning'} type
*/
export function hapticNotification(type = 'success') {
try {
window.Telegram?.WebApp?.HapticFeedback?.notificationOccurred(type);
} catch {}
}
/**
* Handles Telegram BackButton lifecycle with cleanup.
*/
export function showBackButton(onBack) { export function showBackButton(onBack) {
const tg = window.Telegram?.WebApp; const tg = window.Telegram?.WebApp;
if (tg?.BackButton) { if (tg?.BackButton) {
@@ -51,5 +98,18 @@ export function showBackButton(onBack) {
tg.BackButton.hide(); tg.BackButton.hide();
}; };
} }
return () => { }; return () => {};
}
/**
* Safely opens external link in Telegram WebApp or browser.
*/
export function openExternalLink(url) {
try {
if (window.Telegram?.WebApp?.openLink) {
window.Telegram.WebApp.openLink(url);
return;
}
} catch {}
window.open(url, '_blank', 'noopener,noreferrer');
} }
+1 -5
View File
@@ -1,5 +1 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes. // setupTests.js
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';