Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db98405990 | ||
|
|
ccde822899 | ||
|
|
d94d2954b0 | ||
|
|
a904a2beb0 | ||
|
|
bb7063018b | ||
|
|
4a84b442a5 | ||
|
|
8d8361ab8f | ||
|
|
9fb4fc15b9 | ||
|
|
61a2eb8076 | ||
|
|
26111ac632 | ||
|
|
bff869fa9d | ||
|
|
008dd1c29e | ||
|
|
bdf3d5aec1 | ||
|
|
4b64a90839 | ||
|
|
73d16e9111 | ||
|
|
ec60cc1756 |
@@ -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
|
|
||||||
@@ -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 }}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 />;
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -53,3 +100,16 @@ export function showBackButton(onBack) {
|
|||||||
}
|
}
|
||||||
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 +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';
|
|
||||||
|
|||||||
Reference in New Issue
Block a user