Prebuild release 1.0 #2
@@ -0,0 +1,6 @@
|
|||||||
|
DISABLE_DOCS=true
|
||||||
|
|
||||||
|
MONGO_HOST=127.0.0.1
|
||||||
|
MONGO_PORT=27017
|
||||||
|
MONGO_USER=user
|
||||||
|
MONGO_PASS=pass
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
name: app-actions
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- '**.py'
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- app
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
mypy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
continue-on-error: true
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: 3.9
|
||||||
|
architecture: x64
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
pip install mypy
|
||||||
|
pip install -r app/requirements.txt
|
||||||
|
|
||||||
|
- name: Run mypy
|
||||||
|
run: mypy --ignore-missing-imports ./app
|
||||||
|
|
||||||
|
pytest:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
MONGO_HOST: ${{ secrets.MONGO_HOST }}
|
||||||
|
MONGO_PORT: ${{ secrets.MONGO_PORT }}
|
||||||
|
MONGO_USER: ${{ secrets.MONGO_USER }}
|
||||||
|
MONGO_PASS: ${{ secrets.MONGO_PASS }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: 3.9
|
||||||
|
architecture: x64
|
||||||
|
|
||||||
|
- name: Setup MongoDB
|
||||||
|
run: docker run --name mongodb -d -p ${{ secrets.MONGO_PORT }}:27017 -e MONGO_INITDB_ROOT_USERNAME=${{ secrets.MONGO_USER }} -e MONGO_INITDB_ROOT_PASSWORD=${{ secrets.MONGO_PASS }} mongodb/mongodb-community-server
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
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
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
name: mypy-test
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- '**.py'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
mypy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Setup Python
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: 3.9
|
|
||||||
architecture: x64
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
- name: Install mypy
|
|
||||||
run: pip install mypy
|
|
||||||
- name: Run mypy
|
|
||||||
run: mypy --ignore-missing-imports ./app
|
|
||||||
+16
@@ -2,6 +2,7 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
|
.idea/
|
||||||
|
|
||||||
# C extensions
|
# C extensions
|
||||||
*.so
|
*.so
|
||||||
@@ -170,3 +171,18 @@ cython_debug/
|
|||||||
|
|
||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
app/.env
|
||||||
|
|
||||||
|
# Node.js and React
|
||||||
|
node_modules/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.eslintcache
|
||||||
|
.DS_Store
|
||||||
|
|||||||
@@ -9,15 +9,23 @@ Telegram mini-app where you have to choose one of two things.
|
|||||||
git clone https://github.com/IgorVolochay/thisORthat
|
git clone https://github.com/IgorVolochay/thisORthat
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Manual setup:
|
||||||
|
|
||||||
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:
|
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
|
cd ./thisORthat
|
||||||
python3.9 -m venv venv
|
python3.9 -m venv venv
|
||||||
source ./venv/bin/activate
|
source ./venv/bin/activate
|
||||||
pip3 install -r requirements.txt
|
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:
|
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:
|
||||||
```bash
|
```bash
|
||||||
docker run --name mongodb -d -p 27017:27017 mongodb/mongodb-community-server
|
docker run --name mongodb -d -p 27017:27017 -e MONGO_INITDB_ROOT_USERNAME=user -e MONGO_INITDB_ROOT_PASSWORD=pass mongodb/mongodb-community-server
|
||||||
```
|
```
|
||||||
|
### Docker Compose setup:
|
||||||
|
|
||||||
|
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
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
FROM python:3.9.21-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN pip3 install -r requirements.txt
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
CMD ["python3", "main.py"]
|
||||||
+188
-1
@@ -1,4 +1,191 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from fastapi import FastAPI, Depends, Response, status
|
||||||
|
|
||||||
|
from schemas.api_schemas import *
|
||||||
|
from schemas.base_schemas import *
|
||||||
from mongo_worker import MongoWorker
|
from mongo_worker import MongoWorker
|
||||||
|
from tools.base_moderation import moderate_text
|
||||||
|
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
disable_docs = os.getenv("DISABLE_DOCS", "true").lower() == "true"
|
||||||
|
|
||||||
|
app: FastAPI = FastAPI(title="This OR That",
|
||||||
|
summary="OpenAPI schema for \"This OR That\" project!",
|
||||||
|
version="0.1",
|
||||||
|
contact={"GitHub": "https://github.com/IgorVolochay/thisORthat"},
|
||||||
|
docs_url=None if disable_docs else "/docs",
|
||||||
|
redoc_url=None if disable_docs else "/redoc",
|
||||||
|
openapi_url=None if disable_docs else "/openapi.json")
|
||||||
|
mongo_worker = MongoWorker()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/check_user", status_code=200)
|
||||||
|
async def check_user(user_id: NonNegativeInt,
|
||||||
|
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
|
||||||
|
result = mongo.check_user(user_id)
|
||||||
|
return BaseResponse(result=result)
|
||||||
|
|
||||||
|
@app.get("/get_user", status_code=200)
|
||||||
|
async def get_user(user_id: NonNegativeInt,
|
||||||
|
response: Response,
|
||||||
|
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
|
||||||
|
if mongo.check_user(user_id):
|
||||||
|
result = mongo.get_user(user_id)
|
||||||
|
return BaseResponse(result=result)
|
||||||
|
else:
|
||||||
|
response.status_code = status.HTTP_404_NOT_FOUND
|
||||||
|
return BaseResponse(result="User doesn't exist", error=True)
|
||||||
|
|
||||||
|
@app.post("/add_user", status_code=201)
|
||||||
|
async def add_user(new_user: AddUserBody,
|
||||||
|
response: Response,
|
||||||
|
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
|
||||||
|
if not mongo.check_user(new_user.user_id):
|
||||||
|
result = mongo.add_user(new_user.user_id,
|
||||||
|
new_user.username,
|
||||||
|
new_user.first_name,
|
||||||
|
new_user.last_name,
|
||||||
|
new_user.photo_url)
|
||||||
|
return BaseResponse(result=result)
|
||||||
|
else:
|
||||||
|
response.status_code = status.HTTP_409_CONFLICT
|
||||||
|
return BaseResponse(result="User already exist", error=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/get_card", status_code=200)
|
||||||
|
async def get_card(card_id: NonNegativeInt,
|
||||||
|
response: Response,
|
||||||
|
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
|
||||||
|
card = mongo.get_card(card_id)
|
||||||
|
if card:
|
||||||
|
return BaseResponse(result=card)
|
||||||
|
else:
|
||||||
|
response.status_code = status.HTTP_404_NOT_FOUND
|
||||||
|
return BaseResponse(result="There is no card with this card_id", error=True)
|
||||||
|
|
||||||
|
@app.get("/get_random_cards", status_code=200)
|
||||||
|
async def get_random_cards(user_id: NonNegativeInt,
|
||||||
|
response: Response,
|
||||||
|
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
|
||||||
|
cards_visited = mongo.get_visited_cards(user_id)
|
||||||
|
|
||||||
|
if cards_visited.error:
|
||||||
|
response.status_code = status.HTTP_401_UNAUTHORIZED
|
||||||
|
return cards_visited
|
||||||
|
elif not cards_visited.result.cards_visited:
|
||||||
|
random_cards = mongo.get_random_cards(10, True)
|
||||||
|
if random_cards:
|
||||||
|
return BaseResponse(result=random_cards)
|
||||||
|
else:
|
||||||
|
response.status_code = status.HTTP_404_NOT_FOUND
|
||||||
|
return BaseResponse(result="No active cards", error=True)
|
||||||
|
|
||||||
|
result: list[Card] = list()
|
||||||
|
trys = 3
|
||||||
|
while len(result) < 10 and trys != 0:
|
||||||
|
random_cards = mongo.get_random_cards(10, True)
|
||||||
|
if not random_cards:
|
||||||
|
response.status_code = status.HTTP_404_NOT_FOUND
|
||||||
|
return BaseResponse(result="No active cards", error=True)
|
||||||
|
filtered_cards, filtered_cards_id = mongo.filter_cards(random_cards, cards_visited.result.cards_visited)
|
||||||
|
trys -= 1
|
||||||
|
if not filtered_cards:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
result.extend(filtered_cards)
|
||||||
|
cards_visited.result.cards_visited.update(filtered_cards_id)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
response.status_code = status.HTTP_404_NOT_FOUND
|
||||||
|
return BaseResponse(result="No active cards fo this user", error=True)
|
||||||
|
else:
|
||||||
|
return BaseResponse(result=result)
|
||||||
|
|
||||||
|
@app.post("/add_card", status_code=201)
|
||||||
|
async def add_card(new_card: AddCardBody,
|
||||||
|
response: Response,
|
||||||
|
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
|
||||||
|
if moderate_text(new_card.choice_A) and moderate_text(new_card.choice_B):
|
||||||
|
card = mongo.add_card_by_api(new_card.choice_A,
|
||||||
|
new_card.choice_B,
|
||||||
|
new_card.author_id)
|
||||||
|
return BaseResponse(result=card)
|
||||||
|
else:
|
||||||
|
response.status_code = status.HTTP_400_BAD_REQUEST
|
||||||
|
return BaseResponse(result="Card has not passed base moderation", error=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/select_choice", status_code=200)
|
||||||
|
async def select_choice(choice_data: SelectChoice,
|
||||||
|
response: Response,
|
||||||
|
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
|
||||||
|
check_visited = mongo.get_visited_cards(choice_data.user_id)
|
||||||
|
if check_visited.error:
|
||||||
|
response.status_code = status.HTTP_404_NOT_FOUND
|
||||||
|
return check_visited
|
||||||
|
elif not check_visited.error and choice_data.card_id in check_visited.result.cards_visited:
|
||||||
|
response.status_code = status.HTTP_403_FORBIDDEN
|
||||||
|
return BaseResponse(result="Card already visited!", error=True)
|
||||||
|
else:
|
||||||
|
select_choice_result = mongo.select_choice(choice_data.card_id, choice_data.choice)
|
||||||
|
if select_choice_result.error:
|
||||||
|
response.status_code = status.HTTP_404_NOT_FOUND
|
||||||
|
return select_choice_result
|
||||||
|
else:
|
||||||
|
update_visited_result = mongo.update_visited_cards(choice_data.user_id, choice_data.card_id)
|
||||||
|
return BaseResponse(result="Select choice complite!")
|
||||||
|
|
||||||
|
@app.patch("/like_card", status_code=200)
|
||||||
|
async def like_card(like_data: ReactionCard,
|
||||||
|
response: Response,
|
||||||
|
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
|
||||||
|
result = mongo.like_card(like_data.card_id, like_data.user_id)
|
||||||
|
if not result.error and result.result:
|
||||||
|
return BaseResponse(result="Added like to card")
|
||||||
|
else:
|
||||||
|
response.status_code = status.HTTP_404_NOT_FOUND
|
||||||
|
return result
|
||||||
|
|
||||||
|
@app.patch("/dislike_card", status_code=200)
|
||||||
|
async def dislike_card(dislike_data: ReactionCard,
|
||||||
|
response: Response,
|
||||||
|
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
|
||||||
|
result = mongo.dislike_card(dislike_data.card_id, dislike_data.user_id)
|
||||||
|
if not result.error and result.result:
|
||||||
|
return BaseResponse(result="Added dislike to card")
|
||||||
|
else:
|
||||||
|
response.status_code = status.HTTP_404_NOT_FOUND
|
||||||
|
return result
|
||||||
|
|
||||||
|
@app.post("/comment", status_code=201)
|
||||||
|
async def comment(comment_info: AddCommentBody,
|
||||||
|
response: Response,
|
||||||
|
mongo: MongoWorker = Depends(lambda: mongo_worker)) -> BaseResponse:
|
||||||
|
if not moderate_text(comment_info.comment_text):
|
||||||
|
response.status_code = status.HTTP_400_BAD_REQUEST
|
||||||
|
return BaseResponse(result="Comment has not passed base moderation", error=True)
|
||||||
|
|
||||||
|
result = mongo.add_comment(comment_info.author_id, comment_info.card_id, comment_info.comment_text)
|
||||||
|
if result.error and result.result in ["User doesn't exist", "Card doesn't exist"]:
|
||||||
|
response.status_code = status.HTTP_404_NOT_FOUND
|
||||||
|
return result
|
||||||
|
elif result.error:
|
||||||
|
response.status_code = status.HTTP_400_BAD_REQUEST
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
config = uvicorn.Config("main:app", port=5000, log_level="debug")
|
||||||
|
server = uvicorn.Server(config)
|
||||||
|
await server.serve()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
print("Hello World!")
|
asyncio.run(main())
|
||||||
+197
-28
@@ -1,45 +1,214 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
import pymongo
|
import pymongo
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from bson import ObjectId
|
from schemas.base_schemas import *
|
||||||
|
from schemas.api_schemas import *
|
||||||
|
|
||||||
|
|
||||||
class MongoWorker:
|
class MongoWorker:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.client = pymongo.MongoClient(host="127.0.0.1", port=27017)
|
load_dotenv()
|
||||||
|
self.client = pymongo.MongoClient(host = os.getenv('MONGO_HOST'),
|
||||||
|
port = int(os.getenv('MONGO_PORT')),
|
||||||
|
username = os.getenv('MONGO_USER'),
|
||||||
|
password = os.getenv('MONGO_PASS'))
|
||||||
self.db = self.client["data"]
|
self.db = self.client["data"]
|
||||||
self.users_data = self.db["users_data"]
|
self.users_data = self.db["users"]
|
||||||
self.game_data = self.db["game_data"]
|
self.visited_data = self.db["visited"]
|
||||||
|
self.counters = self.db["counters"]
|
||||||
|
self.game_data = self.db["cards"]
|
||||||
|
self.comments_data = self.db["comments"]
|
||||||
|
|
||||||
def get_mongodb_info(self) -> None:
|
|
||||||
print(self.client.list_database_names())
|
|
||||||
print(self.db.list_collection_names())
|
|
||||||
|
|
||||||
def find_user(self, user_id: int) -> dict:
|
def check_user(self, user_id: int) -> bool:
|
||||||
result = self.users_data.find_one({"user_id": user_id})
|
if self.users_data.find_one({"user_id": user_id}):
|
||||||
return result
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
def add_user(self, user_id: int, username: str, first_name: str, last_name: str, photo_path: str) -> ObjectId:
|
def add_user(self, user_id: int, username: str, first_name: str, last_name: str, photo_url: str) -> User:
|
||||||
|
new_user = User(user_id=user_id,
|
||||||
|
username=username,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
photo_url=photo_url,
|
||||||
|
registration_date=datetime.now().isoformat())
|
||||||
try:
|
try:
|
||||||
res = self.users_data.insert_one({"user_id": user_id,
|
self.users_data.insert_one(new_user.model_dump())
|
||||||
"username": username,
|
return new_user
|
||||||
"first_name": first_name,
|
except Exception as exception:
|
||||||
"last_name": last_name,
|
return new_user
|
||||||
"photo_path": photo_path,
|
|
||||||
"activity": 0,
|
def get_user(self, user_id: int) -> User:
|
||||||
"liked_post_ids": [],
|
return User.model_validate(self.users_data.find_one({"user_id": user_id}))
|
||||||
"disliked_post_ids": [],
|
|
||||||
"comments_ids": [],
|
|
||||||
"registration_date": datetime.now().isoformat()
|
def get_and_update_counter(self, counter_name: str) -> int:
|
||||||
})
|
counter = self.counters.find_one_and_update(
|
||||||
return res.inserted_id
|
{"counter_name": counter_name},
|
||||||
|
{"$inc": {"counter": 1}},
|
||||||
|
upsert=True,
|
||||||
|
return_document=True)
|
||||||
|
return counter["counter"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_visited_cards(self, user_id: int) -> BaseResponse:
|
||||||
|
document = self.visited_data.find_one({"user_id": user_id})
|
||||||
|
if not document:
|
||||||
|
check_user = self.check_user(user_id)
|
||||||
|
if check_user:
|
||||||
|
return BaseResponse(result=Visited(user_id=user_id,
|
||||||
|
cards_visited=set()))
|
||||||
|
else:
|
||||||
|
return BaseResponse(result="User doesn't exist", error=True)
|
||||||
|
else:
|
||||||
|
return BaseResponse(result=Visited.model_validate(document))
|
||||||
|
|
||||||
|
def filter_cards(self, random_cards: list[Card], cards_visited: set) -> tuple[list[Card], list[int]]:
|
||||||
|
filtered_cards = [card for card in random_cards if card.card_id not in cards_visited]
|
||||||
|
filtered_cards_id = [filtered_card.card_id for filtered_card in filtered_cards]
|
||||||
|
|
||||||
|
return filtered_cards, filtered_cards_id
|
||||||
|
|
||||||
|
def update_visited_cards(self, user_id: int, visited_card_id: int) -> Visited:
|
||||||
|
update_visited = self.visited_data.find_one_and_update({"user_id": user_id},
|
||||||
|
{"$addToSet": {"cards_visited": visited_card_id}},
|
||||||
|
upsert=True,
|
||||||
|
return_document=True)
|
||||||
|
return Visited.model_validate(update_visited)
|
||||||
|
|
||||||
|
|
||||||
|
def add_card_by_api(self, choice_A: str, choice_B: str, author_id: int) -> Card:
|
||||||
|
new_card = Card(card_id=self.get_and_update_counter(counter_name="card"),
|
||||||
|
choice_A=choice_A,
|
||||||
|
choice_B=choice_B,
|
||||||
|
author_id=author_id,
|
||||||
|
creation_date=datetime.now().isoformat())
|
||||||
|
try:
|
||||||
|
self.game_data.insert_one(new_card.model_dump())
|
||||||
|
return new_card
|
||||||
except Exception as exception:
|
except Exception as exception:
|
||||||
print(exception)
|
print(exception)
|
||||||
|
return new_card
|
||||||
|
|
||||||
|
def add_card_by_base_model(self, new_card: Card) -> Optional[Card]:
|
||||||
|
new_card.card_id = self.get_and_update_counter(counter_name="card")
|
||||||
|
try:
|
||||||
|
self.game_data.insert_one(new_card.model_dump())
|
||||||
|
return new_card
|
||||||
|
except Exception as exception:
|
||||||
|
print(exception)
|
||||||
|
return new_card
|
||||||
|
|
||||||
|
def get_card(self, card_id: int) -> Optional[Card]:
|
||||||
|
document = self.game_data.find_one({"card_id": card_id})
|
||||||
|
if document:
|
||||||
|
return Card.model_validate(document)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_random_cards(self, amount: int, active_status: bool) -> Optional[list[Card]]:
|
||||||
|
pipeline = [{"$match": {"active_status": active_status}},
|
||||||
|
{"$sample": {"size": amount}}]
|
||||||
|
raw_items = list(self.game_data.aggregate(pipeline))
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if raw_items:
|
||||||
mongo = MongoWorker()
|
validated_items = [Card.model_validate(item) for item in raw_items]
|
||||||
mongo.get_mongodb_info()
|
return validated_items
|
||||||
print(mongo.add_user(123, "VolochayIgor", "Igor", "Volochay", "path/to/img"))
|
else:
|
||||||
print(mongo.find_user(123))
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def select_choice(self, card_id: int, choice: str) -> BaseResponse:
|
||||||
|
if choice == "A":
|
||||||
|
count_choice = "count_choice_A"
|
||||||
|
elif choice == "B":
|
||||||
|
count_choice = "count_choice_B"
|
||||||
|
else:
|
||||||
|
return BaseResponse(result="Wrong choice", error=True)
|
||||||
|
|
||||||
|
result = self.game_data.find_one_and_update({"card_id": card_id},
|
||||||
|
{"$inc": {"count_total": 1, count_choice: 1}})
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
return BaseResponse(result="Card doesn't exist", error=True)
|
||||||
|
else:
|
||||||
|
return BaseResponse(result=result, error=False)
|
||||||
|
|
||||||
|
def check_user_reactions(self, user_id: int, card_id: int) -> BaseResponse:
|
||||||
|
user_info: User = self.get_user(user_id)
|
||||||
|
liked_card_ids: list = user_info.liked_card_ids
|
||||||
|
disliked_card_ids: list = user_info.disliked_card_ids
|
||||||
|
|
||||||
|
if card_id in liked_card_ids:
|
||||||
|
return BaseResponse(result="Card already liked", error=True)
|
||||||
|
elif card_id in disliked_card_ids:
|
||||||
|
return BaseResponse(result="Card already disliked", error=True)
|
||||||
|
else:
|
||||||
|
return BaseResponse(result="No reactions", error=False)
|
||||||
|
|
||||||
|
def like_card(self, card_id: int, user_id: int) -> BaseResponse:
|
||||||
|
if self.check_user(user_id):
|
||||||
|
user_reaction = self.check_user_reactions(user_id, card_id)
|
||||||
|
if user_reaction.error:
|
||||||
|
return user_reaction
|
||||||
|
update_card_info = self.game_data.find_one_and_update({"card_id": card_id},
|
||||||
|
{"$inc": {"count_likes": 1}})
|
||||||
|
if not update_card_info:
|
||||||
|
return BaseResponse(result="Card doesn't exist", error=True)
|
||||||
|
|
||||||
|
add_card_to_user = self.users_data.update_one({'user_id': user_id},
|
||||||
|
{'$push': {'liked_card_ids': card_id}})
|
||||||
|
if not add_card_to_user:
|
||||||
|
return BaseResponse(result="User doesn't exist", error=True)
|
||||||
|
else:
|
||||||
|
return BaseResponse(result=True, error=False)
|
||||||
|
else:
|
||||||
|
return BaseResponse(result="User doesn't exist", error=True)
|
||||||
|
|
||||||
|
def dislike_card(self, card_id: int, user_id: int) -> BaseResponse:
|
||||||
|
if self.check_user(user_id):
|
||||||
|
user_reaction = self.check_user_reactions(user_id, card_id)
|
||||||
|
if user_reaction.error:
|
||||||
|
return user_reaction
|
||||||
|
update_card_info = self.game_data.find_one_and_update({"card_id": card_id},
|
||||||
|
{"$inc": {"count_dislikes": 1}})
|
||||||
|
if not update_card_info:
|
||||||
|
return BaseResponse(result="Card doesn't exist", error=True)
|
||||||
|
|
||||||
|
add_card_to_user = self.users_data.update_one({'user_id': user_id},
|
||||||
|
{'$push': {'disliked_card_ids': card_id}})
|
||||||
|
if not add_card_to_user:
|
||||||
|
return BaseResponse(result="User doesn't exist", error=True)
|
||||||
|
else:
|
||||||
|
return BaseResponse(result=True, error=False)
|
||||||
|
else:
|
||||||
|
return BaseResponse(result="User doesn't exist", error=True)
|
||||||
|
|
||||||
|
def add_comment(self, user_id: int, card_id: int, comment_text: str) -> BaseResponse:
|
||||||
|
if self.check_user(user_id):
|
||||||
|
if self.get_card(card_id):
|
||||||
|
new_comment = Comment(comment_id=self.get_and_update_counter(counter_name="comment"),
|
||||||
|
author_id=user_id,
|
||||||
|
card_id=card_id,
|
||||||
|
comment_text=comment_text,
|
||||||
|
creation_date=datetime.now().isoformat())
|
||||||
|
result = self.comments_data.insert_one(new_comment.model_dump())
|
||||||
|
if result:
|
||||||
|
update_user_comments = self.users_data.find_one_and_update({"user_id": user_id},
|
||||||
|
{"$addToSet": {"comments_ids": new_comment.comment_id}})
|
||||||
|
if update_user_comments:
|
||||||
|
return BaseResponse(result=new_comment)
|
||||||
|
else:
|
||||||
|
return BaseResponse(result="Difficulty adding comment_id to user", error=True)
|
||||||
|
else:
|
||||||
|
return BaseResponse(result="Add comment error", error=True)
|
||||||
|
else:
|
||||||
|
return BaseResponse(result="Card doesn't exist", error=True)
|
||||||
|
else:
|
||||||
|
return BaseResponse(result="User doesn't exist", error=True)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi==0.115.7
|
||||||
|
pymongo==4.10.1
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
uvicorn==0.34.0
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import typing
|
||||||
|
|
||||||
|
from pydantic import BaseModel, NonNegativeInt
|
||||||
|
|
||||||
|
|
||||||
|
class BaseResponse(BaseModel):
|
||||||
|
result: typing.Any
|
||||||
|
error: bool = False
|
||||||
|
|
||||||
|
class AddUserBody(BaseModel):
|
||||||
|
user_id: NonNegativeInt
|
||||||
|
username: str
|
||||||
|
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
photo_url: str
|
||||||
|
|
||||||
|
class AddCardBody(BaseModel):
|
||||||
|
choice_A: str
|
||||||
|
choice_B: str
|
||||||
|
|
||||||
|
author_id: NonNegativeInt
|
||||||
|
|
||||||
|
class SelectChoice(BaseModel):
|
||||||
|
user_id: NonNegativeInt
|
||||||
|
card_id: NonNegativeInt
|
||||||
|
|
||||||
|
choice: typing.Literal["A", "B"]
|
||||||
|
|
||||||
|
class ReactionCard(BaseModel):
|
||||||
|
user_id: NonNegativeInt
|
||||||
|
card_id: NonNegativeInt
|
||||||
|
|
||||||
|
class AddCommentBody(BaseModel):
|
||||||
|
author_id: NonNegativeInt
|
||||||
|
card_id: NonNegativeInt
|
||||||
|
|
||||||
|
comment_text: str
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
|||||||
|
|
||||||
|
class User(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
username: str
|
||||||
|
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
photo_url: str
|
||||||
|
|
||||||
|
activity: int = 0
|
||||||
|
liked_card_ids: list[int] = list()
|
||||||
|
disliked_card_ids: list[int] = list()
|
||||||
|
comments_ids: list[int] = list()
|
||||||
|
|
||||||
|
registration_date: str
|
||||||
|
|
||||||
|
class Visited(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
cards_visited: set[int]
|
||||||
|
|
||||||
|
class Card(BaseModel):
|
||||||
|
card_id: int
|
||||||
|
|
||||||
|
choice_A: str
|
||||||
|
choice_B: str
|
||||||
|
|
||||||
|
count_choice_A: int = 0
|
||||||
|
count_choice_B: int = 0
|
||||||
|
count_total: int = 0
|
||||||
|
|
||||||
|
count_likes: int = 0
|
||||||
|
count_dislikes: int = 0
|
||||||
|
comments: list[int] = list()
|
||||||
|
|
||||||
|
author_id: int
|
||||||
|
creation_date: str
|
||||||
|
moderation_date: str = "Not moderated"
|
||||||
|
active_status: bool = False
|
||||||
|
|
||||||
|
class Comment(BaseModel):
|
||||||
|
comment_id: int
|
||||||
|
|
||||||
|
author_id: int
|
||||||
|
card_id: int
|
||||||
|
comment_text: str
|
||||||
|
|
||||||
|
creation_date: str
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import random
|
||||||
|
import asyncio
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient, ASGITransport
|
||||||
|
|
||||||
|
from main import app
|
||||||
|
from schemas.base_schemas import *
|
||||||
|
from schemas.api_schemas import *
|
||||||
|
|
||||||
|
|
||||||
|
EXIST_AUTHOR = random.randint(100000000, 1000000000)
|
||||||
|
NON_EXIST_CARD_ID = 1000
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- /add_card ----------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_card_valid():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
payload = {
|
||||||
|
"choice_A": "Option A",
|
||||||
|
"choice_B": "Option B",
|
||||||
|
"author_id": EXIST_AUTHOR
|
||||||
|
}
|
||||||
|
response = await client.post("/add_card", json=payload)
|
||||||
|
print(f"\nINPUT: endpoint=/add_card | payload={payload}\nOUTPUT: status={response.status_code} | json={response.json()}")
|
||||||
|
assert response.status_code in (200, 201)
|
||||||
|
base_resp = BaseResponse.model_validate(response.json())
|
||||||
|
assert base_resp.error is False
|
||||||
|
card = Card.model_validate(base_resp.result)
|
||||||
|
assert card.choice_A == payload["choice_A"]
|
||||||
|
assert card.choice_B == payload["choice_B"]
|
||||||
|
assert card.author_id == payload["author_id"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_card_missing_field():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
payload = {
|
||||||
|
#choice_A
|
||||||
|
"choice_B": "Option B",
|
||||||
|
"author_id": EXIST_AUTHOR
|
||||||
|
}
|
||||||
|
response = await client.post("/add_card", json=payload)
|
||||||
|
print(f"\nINPUT: endpoint=/add_card | payload (missing field)={payload}\nOUTPUT: status={response.status_code} | json={response.json()}")
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_card_wrong_type():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
payload = {
|
||||||
|
"choice_A": 123,
|
||||||
|
"choice_B": "Option B",
|
||||||
|
"author_id": EXIST_AUTHOR
|
||||||
|
}
|
||||||
|
response = await client.post("/add_card", json=payload)
|
||||||
|
print(f"\nINPUT: endpoint=/add_card | payload (wrong type)={payload}\nOUTPUT: status={response.status_code} | json={response.json()}")
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_card_empty_strings():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
payload = {
|
||||||
|
"choice_A": "",
|
||||||
|
"choice_B": "",
|
||||||
|
"author_id": EXIST_AUTHOR
|
||||||
|
}
|
||||||
|
response = await client.post("/add_card", json=payload)
|
||||||
|
print(f"\nINPUT: endpoint=/add_card | payload (empty strings)={payload}\nOUTPUT: status={response.status_code} | json={response.json()}")
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_card_long_strings():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
long_str = "A" * 5000 # long string
|
||||||
|
payload = {
|
||||||
|
"choice_A": long_str,
|
||||||
|
"choice_B": long_str,
|
||||||
|
"author_id": EXIST_AUTHOR
|
||||||
|
}
|
||||||
|
response = await client.post("/add_card", json=payload)
|
||||||
|
print(f"\nINPUT: endpoint=/add_card | payload with long strings (length={len(long_str)})\nOUTPUT: status={response.status_code} | json={response.json()}")
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_card_negative_author_id():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
payload = {
|
||||||
|
"choice_A": "Option A",
|
||||||
|
"choice_B": "Option B",
|
||||||
|
"author_id": -10 # Negative id
|
||||||
|
}
|
||||||
|
response = await client.post("/add_card", json=payload)
|
||||||
|
print(f"\nINPUT: endpoint=/add_card | payload (negative author_id)={payload}\nOUTPUT: status={response.status_code} | json={response.json()}")
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_card_malformed_json():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
malformed_json = '{"choice_A": "Option A", "choice_B": "Option B", "author_id": 123' # broken json
|
||||||
|
response = await client.post(
|
||||||
|
"/add_card",
|
||||||
|
data=malformed_json,
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
print(f"\nINPUT: endpoint=/add_card | payload (malformed JSON)={malformed_json}\nOUTPUT: status={response.status_code} | json={response.json() if response.content else 'No JSON'}")
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_async_card_creation():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
tasks = []
|
||||||
|
num_cards = 8
|
||||||
|
for i in range(num_cards):
|
||||||
|
payload = {
|
||||||
|
"choice_A": f"Async Option A {i}",
|
||||||
|
"choice_B": f"Async Option B {i}",
|
||||||
|
"author_id": EXIST_AUTHOR
|
||||||
|
}
|
||||||
|
tasks.append(client.post("/add_card", json=payload))
|
||||||
|
responses = await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
card_ids = []
|
||||||
|
for idx, response in enumerate(responses):
|
||||||
|
print(f"\nAsync creation {idx}: status={response.status_code}, response={response.json()}")
|
||||||
|
assert response.status_code in (200, 201)
|
||||||
|
base_resp = BaseResponse.model_validate(response.json())
|
||||||
|
assert base_resp.error is False
|
||||||
|
card = Card.model_validate(base_resp.result)
|
||||||
|
card_ids.append(card.card_id)
|
||||||
|
assert card.choice_A == f"Async Option A {idx}"
|
||||||
|
assert card.choice_B == f"Async Option B {idx}"
|
||||||
|
assert card.author_id == EXIST_AUTHOR
|
||||||
|
|
||||||
|
assert len(set(card_ids)) == num_cards
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- /get_card ----------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_card_valid():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
payload = {
|
||||||
|
"choice_A": "GetTest A",
|
||||||
|
"choice_B": "GetTest B",
|
||||||
|
"author_id": EXIST_AUTHOR
|
||||||
|
}
|
||||||
|
create_resp = await client.post("/add_card", json=payload)
|
||||||
|
base_create = BaseResponse.model_validate(create_resp.json())
|
||||||
|
card = Card.model_validate(base_create.result)
|
||||||
|
card_id = card.card_id
|
||||||
|
|
||||||
|
response = await client.get("/get_card", params={"card_id": card_id})
|
||||||
|
print(f"\nINPUT: endpoint=/get_card | params={{'card_id': {card_id}}}\nOUTPUT: status={response.status_code} | json={response.json()}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
base_resp = BaseResponse.model_validate(response.json())
|
||||||
|
assert base_resp.error is False
|
||||||
|
card_from_get = Card.model_validate(base_resp.result)
|
||||||
|
assert card_from_get.card_id == card_id
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_card_nonexistent():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
response = await client.get("/get_card", params={"card_id": NON_EXIST_CARD_ID})
|
||||||
|
print(f"\nINPUT: endpoint=/get_card | params={{'card_id': {NON_EXIST_CARD_ID}}}\nOUTPUT: status={response.status_code} | json={response.json()}")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_card_missing_param():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
response = await client.get("/get_card")
|
||||||
|
print(f"\nINPUT: endpoint=/get_card (missing card_id param)\nOUTPUT: status={response.status_code} | json={response.json() if response.content else 'No content'}")
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_card_wrong_type():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
response = await client.get("/get_card", params={"card_id": "abc"})
|
||||||
|
print(f"\nINPUT: endpoint=/get_card | params={{'card_id': 'abc'}}\nOUTPUT: status={response.status_code} | json={response.json()}")
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_card_negative():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
response = await client.get("/get_card", params={"card_id": -10})
|
||||||
|
print(f"\nINPUT: endpoint=/get_card | params={{'card_id': -10}}\nOUTPUT: status={response.status_code} | json={response.json()}")
|
||||||
|
assert response.status_code == 422
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import random
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from main import app
|
||||||
|
from schemas.api_schemas import *
|
||||||
|
from schemas.base_schemas import *
|
||||||
|
|
||||||
|
from httpx import AsyncClient, ASGITransport
|
||||||
|
|
||||||
|
|
||||||
|
EXIST_USER = random.randint(100000000, 1000000000)
|
||||||
|
NON_EXIST_USER = random.randint(100000000, 1000000000)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# TEST ADD USERS UTILS #
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_user_non_full_data():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app),
|
||||||
|
base_url='http://test') as client:
|
||||||
|
end_point = "/add_user"
|
||||||
|
data = {
|
||||||
|
"user_id": EXIST_USER,
|
||||||
|
"username": "TestUsername",
|
||||||
|
}
|
||||||
|
raw_response = await client.post(url=end_point,json=data)
|
||||||
|
print(f"\nINPUT: endpiont={end_point} | params={data}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
|
||||||
|
|
||||||
|
assert raw_response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_user_negative_int_id():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app),
|
||||||
|
base_url='http://test') as client:
|
||||||
|
end_point = "/add_user"
|
||||||
|
data = {
|
||||||
|
"user_id": -1,
|
||||||
|
"username": "TestUsername",
|
||||||
|
"first_name": "FName",
|
||||||
|
"last_name": "LName",
|
||||||
|
"photo_url": "http://test.test/photo.jpg"
|
||||||
|
}
|
||||||
|
raw_response = await client.post(url=end_point,json=data)
|
||||||
|
print(f"\nINPUT: endpiont={end_point} | params={data}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
|
||||||
|
|
||||||
|
assert raw_response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_new_user():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app),
|
||||||
|
base_url='http://test') as client:
|
||||||
|
end_point = "/add_user"
|
||||||
|
data = {
|
||||||
|
"user_id": EXIST_USER,
|
||||||
|
"username": "TestUsername",
|
||||||
|
"first_name": "FName",
|
||||||
|
"last_name": "LName",
|
||||||
|
"photo_url": "http://test.test/photo.jpg"
|
||||||
|
}
|
||||||
|
raw_response = await client.post(url=end_point,json=data)
|
||||||
|
print(f"\nINPUT: endpiont={end_point} | params={data}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
|
||||||
|
|
||||||
|
assert raw_response.status_code == 201
|
||||||
|
response = BaseResponse.model_validate(raw_response.json())
|
||||||
|
assert response.error == False
|
||||||
|
assert User.model_validate(response.result)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_already_exist_user():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app),
|
||||||
|
base_url='http://test') as client:
|
||||||
|
end_point = "/add_user"
|
||||||
|
data = {
|
||||||
|
"user_id": EXIST_USER,
|
||||||
|
"username": "TestUsername",
|
||||||
|
"first_name": "FName",
|
||||||
|
"last_name": "LName",
|
||||||
|
"photo_url": "http://test.test/photo.jpg"
|
||||||
|
}
|
||||||
|
raw_response = await client.post(url=end_point,json=data)
|
||||||
|
print(f"\nINPUT: endpiont={end_point} | params={data}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
|
||||||
|
|
||||||
|
assert raw_response.status_code == 409
|
||||||
|
response = BaseResponse.model_validate(raw_response.json())
|
||||||
|
assert response.error == True
|
||||||
|
assert response.result == "User already exist"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# TEST CHECK USERS UTILS #
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_non_exist_user():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app),
|
||||||
|
base_url='http://test') as client:
|
||||||
|
end_point = "/check_user"
|
||||||
|
params = {"user_id": NON_EXIST_USER}
|
||||||
|
raw_response = await client.get(url=end_point, params=params)
|
||||||
|
print(f"\nINPUT: endpiont={end_point} | params={params}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
|
||||||
|
|
||||||
|
assert raw_response.status_code == 200
|
||||||
|
response = BaseResponse.model_validate(raw_response.json())
|
||||||
|
assert response.error == False
|
||||||
|
assert response.result == False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_exist_user():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app),
|
||||||
|
base_url='http://test') as client:
|
||||||
|
end_point = "/check_user"
|
||||||
|
params = {"user_id": EXIST_USER}
|
||||||
|
raw_response = await client.get(url=end_point, params=params)
|
||||||
|
print(f"\nINPUT: endpiont={end_point} | params={params}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
|
||||||
|
|
||||||
|
assert raw_response.status_code == 200
|
||||||
|
response = BaseResponse.model_validate(raw_response.json())
|
||||||
|
assert response.error == False
|
||||||
|
assert response.result == True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# TEST GET USERS UTILS #
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_non_exist_user():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app),
|
||||||
|
base_url='http://test') as client:
|
||||||
|
end_point = "/get_user"
|
||||||
|
params = {"user_id": NON_EXIST_USER}
|
||||||
|
raw_response = await client.get(url=end_point, params=params)
|
||||||
|
print(f"\nINPUT: endpiont={end_point} | params={params}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
|
||||||
|
|
||||||
|
assert raw_response.status_code == 404
|
||||||
|
response = BaseResponse.model_validate(raw_response.json())
|
||||||
|
assert response.error == True
|
||||||
|
assert response.result == "User doesn't exist"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_exist_user():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app),
|
||||||
|
base_url='http://test') as client:
|
||||||
|
end_point = "/get_user"
|
||||||
|
params = {"user_id": EXIST_USER}
|
||||||
|
raw_response = await client.get(url=end_point, params=params)
|
||||||
|
print(f"\nINPUT: endpiont={end_point} | params={params}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
|
||||||
|
|
||||||
|
assert raw_response.status_code == 200
|
||||||
|
response = BaseResponse.model_validate(raw_response.json())
|
||||||
|
assert response.error == False
|
||||||
|
assert User.model_validate(response.result)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import random
|
||||||
|
import asyncio
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient, ASGITransport
|
||||||
|
|
||||||
|
from main import app
|
||||||
|
from schemas.base_schemas import *
|
||||||
|
from schemas.api_schemas import *
|
||||||
|
|
||||||
|
EXIST_USER = random.randint(100000000, 1000000000)
|
||||||
|
EXIST_AUTHOR = random.randint(100000000, 1000000000)
|
||||||
|
NO_ACTIVE_CARDS_STATUS = False
|
||||||
|
ACTIVE_CARDS_LESS_THAN_TEN = False
|
||||||
|
|
||||||
|
# ------------- /add_user ---------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_new_user():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app),
|
||||||
|
base_url='http://test') as client:
|
||||||
|
end_point = "/add_user"
|
||||||
|
data = {
|
||||||
|
"user_id": EXIST_USER,
|
||||||
|
"username": "TestUsername",
|
||||||
|
"first_name": "FName",
|
||||||
|
"last_name": "LName",
|
||||||
|
"photo_url": "http://test.test/photo.jpg"
|
||||||
|
}
|
||||||
|
raw_response = await client.post(url=end_point,json=data)
|
||||||
|
print(f"\nINPUT: endpiont={end_point} | params={data}\nOUTPUT: status={raw_response.status_code} | json={raw_response.json()}")
|
||||||
|
|
||||||
|
assert raw_response.status_code == 201
|
||||||
|
response = BaseResponse.model_validate(raw_response.json())
|
||||||
|
assert response.error == False
|
||||||
|
assert User.model_validate(response.result)
|
||||||
|
|
||||||
|
# ---------- /get_random_cards ----------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_random_cards_valid():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
params = {"user_id": EXIST_USER}
|
||||||
|
response = await client.get("/get_random_cards", params=params)
|
||||||
|
print(f"\nINPUT: endpoint=/get_random_cards\nOUTPUT: status={response.status_code} | json={response.json()}")
|
||||||
|
if response.status_code == 404 and BaseResponse.model_validate(response.json()).result == "No active cards":
|
||||||
|
global NO_ACTIVE_CARDS_STATUS
|
||||||
|
NO_ACTIVE_CARDS_STATUS = True
|
||||||
|
pytest.skip(reason="No active cards in MongoDB")
|
||||||
|
else:
|
||||||
|
assert response.status_code == 200
|
||||||
|
result = response.json().get("result")
|
||||||
|
assert isinstance(result, list)
|
||||||
|
if len(result) == 10:
|
||||||
|
for card in result:
|
||||||
|
assert "choice_A" in card
|
||||||
|
assert "choice_B" in card
|
||||||
|
assert "author_id" in card
|
||||||
|
assert "card_id" in card
|
||||||
|
else:
|
||||||
|
global ACTIVE_CARDS_LESS_THAN_TEN
|
||||||
|
ACTIVE_CARDS_LESS_THAN_TEN = True
|
||||||
|
pytest.skip(reason="The number of active cards is less than 10 in MongoDB")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_random_cards_randomness():
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
params = {"user_id": EXIST_USER}
|
||||||
|
response1 = await client.get("/get_random_cards", params=params)
|
||||||
|
response2 = await client.get("/get_random_cards", params=params)
|
||||||
|
result1 = response1.json().get("result")
|
||||||
|
result2 = response2.json().get("result")
|
||||||
|
print(f"\nINPUT: endpoint=/get_random_cards (двойной вызов)\nOUTPUT 1: {result1}\nOUTPUT 2: {result2}")
|
||||||
|
if len(result1) == 10 and len(result2) == 10:
|
||||||
|
assert result1 != result2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_random_cards_parallel_requests():
|
||||||
|
if NO_ACTIVE_CARDS_STATUS:
|
||||||
|
pytest.skip(reason="No active cards in MongoDB")
|
||||||
|
elif ACTIVE_CARDS_LESS_THAN_TEN:
|
||||||
|
pytest.skip(reason="The number of active cards is less than 10 in MongoDB")
|
||||||
|
else:
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
params = {"user_id": EXIST_USER}
|
||||||
|
tasks = [client.get("/get_random_cards", params=params) for _ in range(5)]
|
||||||
|
responses = await asyncio.gather(*tasks)
|
||||||
|
for resp in responses:
|
||||||
|
print(f"\nParallel call: status={resp.status_code} | json={resp.json()}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
result = resp.json().get("result")
|
||||||
|
assert isinstance(result, list)
|
||||||
|
assert len(result) == 10
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
sys.path.append('..')
|
||||||
|
from mongo_worker import MongoWorker
|
||||||
|
from schemas.base_schemas import Card
|
||||||
|
|
||||||
|
|
||||||
|
def create_cards(num_cards=10, author_id=1):
|
||||||
|
cards_list = []
|
||||||
|
for card_id in range(1, num_cards + 1):
|
||||||
|
print(f"Creating Card {card_id}")
|
||||||
|
card = Card(
|
||||||
|
card_id=card_id,
|
||||||
|
choice_A=input("Choice A: "),
|
||||||
|
choice_B=input("Choice B: "),
|
||||||
|
author_id=author_id,
|
||||||
|
creation_date=datetime.now().isoformat(),
|
||||||
|
moderation_date=datetime.now().isoformat(),
|
||||||
|
active_status=True
|
||||||
|
)
|
||||||
|
cards_list.append(card)
|
||||||
|
return cards_list
|
||||||
|
|
||||||
|
def read_json(json_file):
|
||||||
|
try:
|
||||||
|
with open(json_file, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return [Card(**card) for card in data]
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error reading JSON file: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def write_json(cards_list, json_file):
|
||||||
|
try:
|
||||||
|
with open(json_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump([card.model_dump() for card in cards_list], f, ensure_ascii=False, indent=4)
|
||||||
|
print(f"Successfully saved {len(cards_list)} cards to {json_file}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error writing to JSON file: {e}")
|
||||||
|
|
||||||
|
def add_cards_to_mongodb(cards_list):
|
||||||
|
mongo = MongoWorker()
|
||||||
|
for card in cards_list:
|
||||||
|
mongo.add_card_by_base_model(card)
|
||||||
|
print(f"Successfully added {len(cards_list)} cards to MongoDB")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('-a', '--action', type=int, required=True, choices=[0, 1, 2],
|
||||||
|
help="0 - Manual input & save to MongoDB, 1 - Manual input & save to JSON, 2 - Read JSON & save to MongoDB")
|
||||||
|
parser.add_argument('-f', '--file', type=str, default="cards.json", help="JSON file name for reading/writing")
|
||||||
|
parser.add_argument('-n', '--num', type=int, default=10, help="Number of cards to create")
|
||||||
|
parser.add_argument('-u', '--user', type=int, default=1, help="Author ID")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.action == 0:
|
||||||
|
cards = create_cards(args.num, args.user)
|
||||||
|
add_cards_to_mongodb(cards)
|
||||||
|
elif args.action == 1:
|
||||||
|
cards = create_cards(args.num, args.user)
|
||||||
|
write_json(cards, args.file)
|
||||||
|
elif args.action == 2:
|
||||||
|
cards = read_json(args.file)
|
||||||
|
if cards:
|
||||||
|
add_cards_to_mongodb(cards)
|
||||||
|
else:
|
||||||
|
print("No valid cards found in JSON file.")
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from tools.data.dirty_words import dirty_words_set
|
||||||
|
|
||||||
|
def is_not_empty(text: str) -> bool:
|
||||||
|
"""Checks that the text is not empty."""
|
||||||
|
return bool(text.strip())
|
||||||
|
|
||||||
|
def has_no_links(text: str) -> bool:
|
||||||
|
"""Checks that there are no links in the text."""
|
||||||
|
url_pattern = re.compile(r'https?://\S+|www\.\S+')
|
||||||
|
return not bool(url_pattern.search(text))
|
||||||
|
|
||||||
|
def has_no_dirty_words(text: str) -> bool:
|
||||||
|
"""Checks that there are no forbidden words in the text."""
|
||||||
|
words = set(re.findall(r'\w+', text.lower()))
|
||||||
|
return not bool(words & dirty_words_set)
|
||||||
|
|
||||||
|
def text_lenght(text: str) -> bool:
|
||||||
|
"""Check max text lenght"""
|
||||||
|
return len(text) <= 150 # TODO: update in future
|
||||||
|
|
||||||
|
def moderate_text(text: str) -> bool:
|
||||||
|
return is_not_empty(text) and has_no_links(text) and has_no_dirty_words(text) and text_lenght(text)
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"card_id": 1,
|
||||||
|
"choice_A": "Котики",
|
||||||
|
"choice_B": "Собачки",
|
||||||
|
"count_choice_A": 0,
|
||||||
|
"count_choice_B": 0,
|
||||||
|
"count_total": 0,
|
||||||
|
"count_likes": 0,
|
||||||
|
"count_dislikes": 0,
|
||||||
|
"comments": [],
|
||||||
|
"author_id": 1,
|
||||||
|
"creation_date": "2025-03-04T16:56:05.290110",
|
||||||
|
"moderation_date": "2025-03-04T16:56:05.290143",
|
||||||
|
"active_status": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"card_id": 2,
|
||||||
|
"choice_A": "Чай",
|
||||||
|
"choice_B": "Кофе",
|
||||||
|
"count_choice_A": 0,
|
||||||
|
"count_choice_B": 0,
|
||||||
|
"count_total": 0,
|
||||||
|
"count_likes": 0,
|
||||||
|
"count_dislikes": 0,
|
||||||
|
"comments": [],
|
||||||
|
"author_id": 1,
|
||||||
|
"creation_date": "2025-03-04T16:56:11.108762",
|
||||||
|
"moderation_date": "2025-03-04T16:56:11.108793",
|
||||||
|
"active_status": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"card_id": 3,
|
||||||
|
"choice_A": "Отпуск в горах",
|
||||||
|
"choice_B": "Отпуск на море",
|
||||||
|
"count_choice_A": 0,
|
||||||
|
"count_choice_B": 0,
|
||||||
|
"count_total": 0,
|
||||||
|
"count_likes": 0,
|
||||||
|
"count_dislikes": 0,
|
||||||
|
"comments": [],
|
||||||
|
"author_id": 1,
|
||||||
|
"creation_date": "2025-03-04T16:56:25.062542",
|
||||||
|
"moderation_date": "2025-03-04T16:56:25.062586",
|
||||||
|
"active_status": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"card_id": 4,
|
||||||
|
"choice_A": "День",
|
||||||
|
"choice_B": "Ночь",
|
||||||
|
"count_choice_A": 0,
|
||||||
|
"count_choice_B": 0,
|
||||||
|
"count_total": 0,
|
||||||
|
"count_likes": 0,
|
||||||
|
"count_dislikes": 0,
|
||||||
|
"comments": [],
|
||||||
|
"author_id": 1,
|
||||||
|
"creation_date": "2025-03-04T16:56:34.148692",
|
||||||
|
"moderation_date": "2025-03-04T16:56:34.148725",
|
||||||
|
"active_status": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"card_id": 5,
|
||||||
|
"choice_A": "Работать в офисе",
|
||||||
|
"choice_B": "Работать удалённо",
|
||||||
|
"count_choice_A": 0,
|
||||||
|
"count_choice_B": 0,
|
||||||
|
"count_total": 0,
|
||||||
|
"count_likes": 0,
|
||||||
|
"count_dislikes": 0,
|
||||||
|
"comments": [],
|
||||||
|
"author_id": 1,
|
||||||
|
"creation_date": "2025-03-04T16:57:00.723472",
|
||||||
|
"moderation_date": "2025-03-04T16:57:00.723518",
|
||||||
|
"active_status": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"card_id": 6,
|
||||||
|
"choice_A": "Заниматься спортом",
|
||||||
|
"choice_B": "Играть в видеоигры",
|
||||||
|
"count_choice_A": 0,
|
||||||
|
"count_choice_B": 0,
|
||||||
|
"count_total": 0,
|
||||||
|
"count_likes": 0,
|
||||||
|
"count_dislikes": 0,
|
||||||
|
"comments": [],
|
||||||
|
"author_id": 1,
|
||||||
|
"creation_date": "2025-03-04T16:57:31.524721",
|
||||||
|
"moderation_date": "2025-03-04T16:57:31.524754",
|
||||||
|
"active_status": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"card_id": 7,
|
||||||
|
"choice_A": "Гулять",
|
||||||
|
"choice_B": "Сидеть дома",
|
||||||
|
"count_choice_A": 0,
|
||||||
|
"count_choice_B": 0,
|
||||||
|
"count_total": 0,
|
||||||
|
"count_likes": 0,
|
||||||
|
"count_dislikes": 0,
|
||||||
|
"comments": [],
|
||||||
|
"author_id": 1,
|
||||||
|
"creation_date": "2025-03-04T16:57:52.022380",
|
||||||
|
"moderation_date": "2025-03-04T16:57:52.022413",
|
||||||
|
"active_status": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"card_id": 8,
|
||||||
|
"choice_A": "Посмотреть фильм",
|
||||||
|
"choice_B": "Почитать книжку",
|
||||||
|
"count_choice_A": 0,
|
||||||
|
"count_choice_B": 0,
|
||||||
|
"count_total": 0,
|
||||||
|
"count_likes": 0,
|
||||||
|
"count_dislikes": 0,
|
||||||
|
"comments": [],
|
||||||
|
"author_id": 1,
|
||||||
|
"creation_date": "2025-03-04T16:58:15.032749",
|
||||||
|
"moderation_date": "2025-03-04T16:58:15.032762",
|
||||||
|
"active_status": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"card_id": 9,
|
||||||
|
"choice_A": "Зима",
|
||||||
|
"choice_B": "Лето",
|
||||||
|
"count_choice_A": 0,
|
||||||
|
"count_choice_B": 0,
|
||||||
|
"count_total": 0,
|
||||||
|
"count_likes": 0,
|
||||||
|
"count_dislikes": 0,
|
||||||
|
"comments": [],
|
||||||
|
"author_id": 1,
|
||||||
|
"creation_date": "2025-03-04T16:58:24.800416",
|
||||||
|
"moderation_date": "2025-03-04T16:58:24.800450",
|
||||||
|
"active_status": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"card_id": 10,
|
||||||
|
"choice_A": "Лучший друг/подруга",
|
||||||
|
"choice_B": "Любимый человек",
|
||||||
|
"count_choice_A": 0,
|
||||||
|
"count_choice_B": 0,
|
||||||
|
"count_total": 0,
|
||||||
|
"count_likes": 0,
|
||||||
|
"count_dislikes": 0,
|
||||||
|
"comments": [],
|
||||||
|
"author_id": 1,
|
||||||
|
"creation_date": "2025-03-04T16:59:05.265795",
|
||||||
|
"moderation_date": "2025-03-04T16:59:05.265829",
|
||||||
|
"active_status": true
|
||||||
|
}
|
||||||
|
]
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,33 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
mongodb:
|
||||||
|
image: mongo:latest
|
||||||
|
container_name: tort-mongodb
|
||||||
|
restart: always
|
||||||
|
network_mode: bridge
|
||||||
|
environment:
|
||||||
|
MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER}
|
||||||
|
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASS}
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${MONGO_PORT}:27017"
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD", "mongosh", "--username", "${MONGO_USER}", "--password", "${MONGO_PASS}", "--eval", "db.runCommand({ ping: 1 })" ]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 2
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./app
|
||||||
|
image: tort-backend:latest
|
||||||
|
depends_on:
|
||||||
|
mongodb:
|
||||||
|
condition: service_healthy
|
||||||
|
container_name: tort-backend
|
||||||
|
network_mode: "host"
|
||||||
|
environment:
|
||||||
|
MONGO_HOST: ${MONGO_HOST}
|
||||||
|
MONGO_PORT: ${MONGO_PORT}
|
||||||
|
MONGO_USER: ${MONGO_USER}
|
||||||
|
MONGO_PASS: ${MONGO_PASS}
|
||||||
Generated
+17346
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"cra-template": "1.2.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-scripts": "5.0.1"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"react-app",
|
||||||
|
"react-app/jest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"proxy": "http://localhost:5000",
|
||||||
|
"devDependencies": {
|
||||||
|
"web-vitals": "^4.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<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="description" content="This OR That — выбирай один из двух вариантов и смотри, что выбрали другие!" />
|
||||||
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
|
||||||
|
<!-- Telegram WebApp SDK -->
|
||||||
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
|
|
||||||
|
<title>This OR That</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>Для работы приложения необходим JavaScript.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"short_name": "React App",
|
||||||
|
"name": "Create React App Sample",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/* ---------- App Layout ---------- */
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
height: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo-or {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 3px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hamburger menu */
|
||||||
|
.menu-toggle {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background var(--duration-fast) var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-toggle:active {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-toggle-line {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--color-text);
|
||||||
|
border-radius: 1px;
|
||||||
|
transition: transform var(--duration-normal) var(--ease-smooth);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { AppProvider, useApp } from './context/AppContext';
|
||||||
|
import LoadingScreen from './components/common/LoadingScreen';
|
||||||
|
import Toast from './components/common/Toast';
|
||||||
|
import CardPair from './components/CardPair/CardPair';
|
||||||
|
import BottomBar from './components/BottomBar/BottomBar';
|
||||||
|
import CommentsPanel from './components/Comments/CommentsPanel';
|
||||||
|
import MenuPanel from './components/Menu/MenuPanel';
|
||||||
|
import './App.css';
|
||||||
|
|
||||||
|
function AppContent() {
|
||||||
|
const { isLoading, error, openMenu, toast } = useApp();
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !isLoading) {
|
||||||
|
return <LoadingScreen error={error} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
{/* Header with menu button */}
|
||||||
|
<header className="app-header">
|
||||||
|
<div className="app-logo">
|
||||||
|
this<span className="app-logo-or">OR</span>that
|
||||||
|
</div>
|
||||||
|
<button className="menu-toggle" onClick={openMenu} aria-label="Меню">
|
||||||
|
<span className="menu-toggle-line" />
|
||||||
|
<span className="menu-toggle-line" />
|
||||||
|
<span className="menu-toggle-line" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main content — card pair */}
|
||||||
|
<CardPair />
|
||||||
|
|
||||||
|
{/* Bottom bar — reactions */}
|
||||||
|
<BottomBar />
|
||||||
|
|
||||||
|
{/* Panels */}
|
||||||
|
<CommentsPanel />
|
||||||
|
<MenuPanel />
|
||||||
|
|
||||||
|
{/* Toast notifications */}
|
||||||
|
<Toast message={toast} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AppProvider>
|
||||||
|
<AppContent />
|
||||||
|
</AppProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
test('renders learn react link', () => {
|
||||||
|
render(<App />);
|
||||||
|
const linkElement = screen.getByText(/learn react/i);
|
||||||
|
expect(linkElement).toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
.bottom-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
height: var(--bar-height);
|
||||||
|
padding: 0 var(--space-lg);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: color var(--duration-fast) var(--ease-smooth),
|
||||||
|
transform var(--duration-fast) var(--ease-smooth);
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-btn:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-btn--disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-btn--disabled.bar-btn--active {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active states */
|
||||||
|
.bar-btn--like.bar-btn--active {
|
||||||
|
color: var(--color-like);
|
||||||
|
animation: popIn 300ms var(--ease-spring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-btn--dislike.bar-btn--active {
|
||||||
|
color: var(--color-dislike);
|
||||||
|
animation: popIn 300ms var(--ease-spring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-btn--comments {
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-btn--comments:not(.bar-btn--disabled) {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-icon {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-count {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes popIn {
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
40% { transform: scale(1.25); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
import './BottomBar.css';
|
||||||
|
|
||||||
|
export default function BottomBar() {
|
||||||
|
const { currentCard, chosenCard, likeCard, dislikeCard, setIsCommentsOpen, user } = useApp();
|
||||||
|
const [reactionState, setReactionState] = useState(null); // 'liked' | 'disliked' | null
|
||||||
|
|
||||||
|
const isRevealed = chosenCard !== null;
|
||||||
|
|
||||||
|
// Check if user already reacted to this card
|
||||||
|
const alreadyLiked = user?.liked_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 handleLike = async () => {
|
||||||
|
if (!isRevealed || currentReaction) return;
|
||||||
|
const result = await likeCard();
|
||||||
|
if (result && !result.error) {
|
||||||
|
setReactionState('liked');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDislike = async () => {
|
||||||
|
if (!isRevealed || currentReaction) return;
|
||||||
|
const result = await dislikeCard();
|
||||||
|
if (result && !result.error) {
|
||||||
|
setReactionState('disliked');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComments = () => {
|
||||||
|
setIsCommentsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset reaction state when card changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
setReactionState(null);
|
||||||
|
}, [currentCard?.card_id]);
|
||||||
|
|
||||||
|
const likes = (currentCard?.count_likes || 0) + (reactionState === 'liked' ? 1 : 0);
|
||||||
|
const dislikes = (currentCard?.count_dislikes || 0) + (reactionState === 'disliked' ? 1 : 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bottom-bar">
|
||||||
|
<button
|
||||||
|
className={`bar-btn bar-btn--dislike ${currentReaction === 'disliked' ? 'bar-btn--active' : ''} ${!isRevealed || currentReaction ? 'bar-btn--disabled' : ''}`}
|
||||||
|
onClick={handleDislike}
|
||||||
|
aria-label="Дизлайк"
|
||||||
|
>
|
||||||
|
<svg className="bar-icon" viewBox="0 0 24 24" fill="currentColor" style={{ transform: 'rotate(180deg)' }}>
|
||||||
|
<path d="M17 4h2a2 2 0 012 2v7a2 2 0 01-2 2h-2.29a1 1 0 00-.71.3l-3.28 3.28a1 1 0 01-1.72-.7V15a1 1 0 00-1-1H7a2 2 0 01-2-2V6a2 2 0 012-2h10z" />
|
||||||
|
</svg>
|
||||||
|
<span className="bar-count">{formatCount(dislikes)}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="bar-btn bar-btn--comments"
|
||||||
|
onClick={handleComments}
|
||||||
|
aria-label="Комментарии"
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
</svg>
|
||||||
|
<span className="bar-count">{formatCount(currentCard?.comments?.length || 0)}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`bar-btn bar-btn--like ${currentReaction === 'liked' ? 'bar-btn--active' : ''} ${!isRevealed || currentReaction ? 'bar-btn--disabled' : ''}`}
|
||||||
|
onClick={handleLike}
|
||||||
|
aria-label="Лайк"
|
||||||
|
>
|
||||||
|
<svg className="bar-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M17 4h2a2 2 0 012 2v7a2 2 0 01-2 2h-2.29a1 1 0 00-.71.3l-3.28 3.28a1 1 0 01-1.72-.7V15a1 1 0 00-1-1H7a2 2 0 01-2-2V6a2 2 0 012-2h10z" />
|
||||||
|
</svg>
|
||||||
|
<span className="bar-count">{formatCount(likes)}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCount(n) {
|
||||||
|
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M';
|
||||||
|
if (n >= 1_000) return (n / 1_000).toFixed(1).replace(/\.0$/, '') + 'K';
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-lg);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: flex-grow var(--duration-card) var(--ease-spring),
|
||||||
|
opacity 400ms var(--ease-smooth),
|
||||||
|
filter 400ms var(--ease-smooth);
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card A — Deep Violet gradient */
|
||||||
|
.card--a {
|
||||||
|
background: linear-gradient(160deg, var(--color-card-a-from), var(--color-card-a-to));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card B — Electric Blue gradient */
|
||||||
|
.card--b {
|
||||||
|
background: linear-gradient(160deg, var(--color-card-b-from), var(--color-card-b-to));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tap feedback */
|
||||||
|
.card:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
transition: transform 80ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* After choice — faded (not chosen) */
|
||||||
|
.card--faded {
|
||||||
|
opacity: 0.65;
|
||||||
|
filter: brightness(0.8) saturate(0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* After choice — chosen */
|
||||||
|
.card--chosen {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tap hint on chosen card */
|
||||||
|
.card--tap-next {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Text ---------- */
|
||||||
|
.card-text {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.3;
|
||||||
|
word-break: break-word;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Stats ---------- */
|
||||||
|
.card-stats {
|
||||||
|
position: absolute;
|
||||||
|
bottom: var(--space-md);
|
||||||
|
right: var(--space-md);
|
||||||
|
text-align: right;
|
||||||
|
animation: statsReveal 500ms var(--ease-spring) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-percent {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card--a .card-percent { color: var(--color-stat-a); }
|
||||||
|
.card--b .card-percent { color: var(--color-stat-b); }
|
||||||
|
|
||||||
|
.card-count {
|
||||||
|
display: block;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Hint ---------- */
|
||||||
|
.card-hint {
|
||||||
|
position: absolute;
|
||||||
|
bottom: var(--space-sm);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
opacity: 0;
|
||||||
|
animation: hintFadeIn 600ms 800ms var(--ease-smooth) forwards;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Animations ---------- */
|
||||||
|
@keyframes statsReveal {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hintFadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 0.6; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import './Card.css';
|
||||||
|
|
||||||
|
export default function Card({ label, type, chosen, isChosen, percent, count, onClick }) {
|
||||||
|
const isRevealed = chosen !== null;
|
||||||
|
const isThis = chosen === type;
|
||||||
|
const isFaded = isRevealed && !isThis;
|
||||||
|
|
||||||
|
const cardClass = [
|
||||||
|
'card',
|
||||||
|
`card--${type.toLowerCase()}`,
|
||||||
|
isRevealed ? 'card--revealed' : '',
|
||||||
|
isThis ? 'card--chosen' : '',
|
||||||
|
isFaded ? 'card--faded' : '',
|
||||||
|
isChosen ? 'card--tap-next' : '',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className={cardClass} onClick={onClick} aria-label={label}>
|
||||||
|
<span className="card-text">{label}</span>
|
||||||
|
{isRevealed && (
|
||||||
|
<div className="card-stats">
|
||||||
|
<span className="card-percent">{percent}%</span>
|
||||||
|
<span className="card-count">{count}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isChosen && isRevealed && (
|
||||||
|
<div className="card-hint">
|
||||||
|
нажми чтобы продолжить
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
.card-pair {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: var(--space-md);
|
||||||
|
padding-bottom: var(--space-sm);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-pair-inner {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
position: relative;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-wrapper {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
transition: flex-grow var(--duration-card) var(--ease-spring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-wrapper .card {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.card-pair--empty {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xl);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
animation: hintFadeIn 600ms var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-pair-empty-text {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-md);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
transition: all var(--duration-fast) var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-btn:active {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-btn--primary {
|
||||||
|
background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
|
||||||
|
border-color: transparent;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-btn--primary:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Card from './Card';
|
||||||
|
import OrBadge from './OrBadge';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
import './CardPair.css';
|
||||||
|
|
||||||
|
export default function CardPair() {
|
||||||
|
const { currentCard, chosenCard, chooseCard, openMenu, setMenuScreen } = useApp();
|
||||||
|
|
||||||
|
if (!currentCard) {
|
||||||
|
return (
|
||||||
|
<div className="card-pair card-pair--empty">
|
||||||
|
<div className="empty-content">
|
||||||
|
<p className="card-pair-empty-text">Карточки закончились!</p>
|
||||||
|
<div className="empty-actions">
|
||||||
|
<button className="empty-btn empty-btn--primary" onClick={() => { openMenu(); setMenuScreen('create'); }}>
|
||||||
|
Создать карточку
|
||||||
|
</button>
|
||||||
|
<button className="empty-btn" onClick={() => { openMenu(); setMenuScreen('about'); }}>
|
||||||
|
О проекте
|
||||||
|
</button>
|
||||||
|
<button className="empty-btn" onClick={() => window.open('https://boosty.to/pseudodev/donate', '_blank')}>
|
||||||
|
Поддержать проект
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate percentages
|
||||||
|
const total = currentCard.count_choice_A + currentCard.count_choice_B;
|
||||||
|
let percentA = 50;
|
||||||
|
let percentB = 50;
|
||||||
|
|
||||||
|
if (chosenCard) {
|
||||||
|
// Add the current user's vote to the count for display
|
||||||
|
const votesA = currentCard.count_choice_A + (chosenCard === 'A' ? 1 : 0);
|
||||||
|
const votesB = currentCard.count_choice_B + (chosenCard === 'B' ? 1 : 0);
|
||||||
|
const newTotal = votesA + votesB;
|
||||||
|
if (newTotal > 0) {
|
||||||
|
percentA = Math.round((votesA / newTotal) * 100);
|
||||||
|
percentB = 100 - percentA;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp to 75/25 max for readability
|
||||||
|
if (percentA > 75) { percentA = 75; percentB = 25; }
|
||||||
|
if (percentB > 75) { percentB = 75; percentA = 25; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// flex-grow values for animation
|
||||||
|
const growA = chosenCard ? percentA : 50;
|
||||||
|
const growB = chosenCard ? percentB : 50;
|
||||||
|
|
||||||
|
// Actual percentages for display (unclamped)
|
||||||
|
let displayPercentA = 50;
|
||||||
|
let displayPercentB = 50;
|
||||||
|
if (chosenCard && total >= 0) {
|
||||||
|
const votesA = currentCard.count_choice_A + (chosenCard === 'A' ? 1 : 0);
|
||||||
|
const votesB = currentCard.count_choice_B + (chosenCard === 'B' ? 1 : 0);
|
||||||
|
const newTotal = votesA + votesB;
|
||||||
|
if (newTotal > 0) {
|
||||||
|
displayPercentA = Math.round((votesA / newTotal) * 100);
|
||||||
|
displayPercentB = 100 - displayPercentA;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card-pair">
|
||||||
|
<div className="card-pair-inner">
|
||||||
|
<div className="card-wrapper" style={{ flexGrow: growA }}>
|
||||||
|
<Card
|
||||||
|
label={currentCard.choice_A}
|
||||||
|
type="A"
|
||||||
|
chosen={chosenCard}
|
||||||
|
isChosen={chosenCard === 'A'}
|
||||||
|
percent={displayPercentA}
|
||||||
|
count={currentCard.count_choice_A + (chosenCard === 'A' ? 1 : 0)}
|
||||||
|
onClick={() => chooseCard('A')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OrBadge visible={!chosenCard} />
|
||||||
|
|
||||||
|
<div className="card-wrapper" style={{ flexGrow: growB }}>
|
||||||
|
<Card
|
||||||
|
label={currentCard.choice_B}
|
||||||
|
type="B"
|
||||||
|
chosen={chosenCard}
|
||||||
|
isChosen={chosenCard === 'B'}
|
||||||
|
percent={displayPercentB}
|
||||||
|
count={currentCard.count_choice_B + (chosenCard === 'B' ? 1 : 0)}
|
||||||
|
onClick={() => chooseCard('B')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
.or-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 10;
|
||||||
|
transition: opacity var(--duration-normal) var(--ease-smooth),
|
||||||
|
transform var(--duration-card) var(--ease-spring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.or-badge--hidden {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -50%) scale(0.7);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.or-badge-text {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import './OrBadge.css';
|
||||||
|
|
||||||
|
export default function OrBadge({ visible }) {
|
||||||
|
return (
|
||||||
|
<div className={`or-badge ${visible ? '' : 'or-badge--hidden'}`}>
|
||||||
|
<span className="or-badge-text">ИЛИ</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
.comment-item {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: var(--space-md) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-item + .comment-item {
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-avatar-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-avatar-fallback {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-author {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-stat-a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-date {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--color-text);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import './CommentItem.css';
|
||||||
|
|
||||||
|
export default function CommentItem({ comment, author }) {
|
||||||
|
const displayName = author?.username || author?.first_name || 'Аноним';
|
||||||
|
const date = comment.creation_date
|
||||||
|
? new Date(comment.creation_date).toLocaleDateString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="comment-item">
|
||||||
|
<div className="comment-avatar">
|
||||||
|
{author?.photo_url ? (
|
||||||
|
<img src={author.photo_url} alt="" className="comment-avatar-img" />
|
||||||
|
) : (
|
||||||
|
<span className="comment-avatar-fallback">
|
||||||
|
{displayName[0]?.toUpperCase() || '?'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="comment-body">
|
||||||
|
<div className="comment-header">
|
||||||
|
<span className="comment-author">{displayName}</span>
|
||||||
|
<span className="comment-date">{date}</span>
|
||||||
|
</div>
|
||||||
|
<p className="comment-text">{comment.comment_text || comment.commet_text}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
.comments-panel {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 60;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--color-bg);
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform var(--duration-normal) var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-panel--open {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.comments-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-close {
|
||||||
|
font-size: 20px;
|
||||||
|
padding: var(--space-xs);
|
||||||
|
color: var(--color-muted);
|
||||||
|
transition: color var(--duration-fast) var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-close:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List */
|
||||||
|
.comments-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-placeholder {
|
||||||
|
padding: var(--space-xl);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-empty-text {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-empty-sub {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input area */
|
||||||
|
.comments-input-area {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-input {
|
||||||
|
flex: 1;
|
||||||
|
resize: none;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
min-height: 40px;
|
||||||
|
max-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-input::placeholder {
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-send {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
color: var(--color-muted);
|
||||||
|
transition: all var(--duration-fast) var(--ease-smooth);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-send--active {
|
||||||
|
background: var(--color-like);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-send:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
import { showBackButton } from '../../services/auth';
|
||||||
|
import { api } from '../../services/api';
|
||||||
|
import { currentUser } from '../../services/auth';
|
||||||
|
import CommentItem from './CommentItem';
|
||||||
|
import './CommentsPanel.css';
|
||||||
|
|
||||||
|
export default function CommentsPanel() {
|
||||||
|
const { isCommentsOpen, setIsCommentsOpen, currentCard, showToast } = useApp();
|
||||||
|
const [comments, setComments] = useState([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [newComment, setNewComment] = useState('');
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
const listRef = useRef(null);
|
||||||
|
|
||||||
|
// Telegram BackButton
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCommentsOpen) {
|
||||||
|
const cleanup = showBackButton(() => setIsCommentsOpen(false));
|
||||||
|
return cleanup;
|
||||||
|
}
|
||||||
|
}, [isCommentsOpen, setIsCommentsOpen]);
|
||||||
|
|
||||||
|
// Load comments when panel opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCommentsOpen && currentCard) {
|
||||||
|
loadComments();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isCommentsOpen, currentCard?.card_id]);
|
||||||
|
|
||||||
|
async function loadComments() {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// TODO: Replace with real GET /get_comments when backend implements it
|
||||||
|
const result = await api.getComments(currentCard.card_id);
|
||||||
|
if (!result.error) {
|
||||||
|
setComments(result.result || []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Load comments error:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
if (!newComment.trim() || isSending) return;
|
||||||
|
setIsSending(true);
|
||||||
|
try {
|
||||||
|
const result = await api.addComment(currentUser.id, currentCard.card_id, newComment.trim());
|
||||||
|
if (!result.error) {
|
||||||
|
setNewComment('');
|
||||||
|
showToast('Комментарий отправлен');
|
||||||
|
|
||||||
|
// Optimistically add the new comment to the list
|
||||||
|
if (result.result) {
|
||||||
|
setComments(prev => [...prev, result.result]);
|
||||||
|
|
||||||
|
// Scroll to bottom after adding
|
||||||
|
setTimeout(() => {
|
||||||
|
if (listRef.current) {
|
||||||
|
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('Ошибка: ' + (result.result || 'неизвестная'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Send comment error:', err);
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`comments-panel ${isCommentsOpen ? 'comments-panel--open' : ''}`}>
|
||||||
|
<div className="comments-header">
|
||||||
|
<button
|
||||||
|
className="comments-close"
|
||||||
|
onClick={() => setIsCommentsOpen(false)}
|
||||||
|
aria-label="Закрыть"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<h2 className="comments-title">Комментарии</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="comments-list custom-scroll" ref={listRef}>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="comments-placeholder">Загрузка...</p>
|
||||||
|
) : comments.length === 0 ? (
|
||||||
|
<div className="comments-empty">
|
||||||
|
<p className="comments-empty-text">Комментариев пока нет</p>
|
||||||
|
<p className="comments-empty-sub">Будь первым!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
comments.map((comment, i) => (
|
||||||
|
<CommentItem key={comment.comment_id || i} comment={comment} author={null} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="comments-input-area">
|
||||||
|
<textarea
|
||||||
|
className="comments-input"
|
||||||
|
placeholder="Ваш комментарий..."
|
||||||
|
value={newComment}
|
||||||
|
onChange={(e) => setNewComment(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
maxLength={300}
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={`comments-send ${newComment.trim() ? 'comments-send--active' : ''}`}
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!newComment.trim() || isSending}
|
||||||
|
aria-label="Отправить"
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
.about-page {
|
||||||
|
padding: var(--space-lg);
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 80vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-back {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
padding: var(--space-xs) 0;
|
||||||
|
transition: color var(--duration-fast) var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-back:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
line-height: 1.65;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-content strong {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: var(--space-md);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-stat-a);
|
||||||
|
transition: background var(--duration-fast) var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-link:active {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import './AboutPage.css';
|
||||||
|
|
||||||
|
export default function AboutPage({ onBack }) {
|
||||||
|
function handleCopyEmail() {
|
||||||
|
navigator.clipboard.writeText('pseudo.developer.ru@gmail.com').then(() => {
|
||||||
|
// Visual feedback handled by CSS :active
|
||||||
|
}).catch(() => {
|
||||||
|
// Fallback — select text
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="about-page custom-scroll">
|
||||||
|
<button className="about-back" onClick={onBack} aria-label="Назад">
|
||||||
|
← Назад
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h2 className="about-title">О проекте</h2>
|
||||||
|
|
||||||
|
<div className="about-content">
|
||||||
|
<p>
|
||||||
|
<strong>This OR That</strong> — это open-source Telegram Mini App,
|
||||||
|
в которой ты выбираешь один из двух вариантов и смотришь,
|
||||||
|
что выбрали другие.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Проект создан для развлечения и исследования интересных
|
||||||
|
дилемм. Все карточки проходят модерацию перед публикацией.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Поддержи проект или загляни в исходный код —
|
||||||
|
мы открыты для идей и контрибьюций!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="about-links">
|
||||||
|
<a
|
||||||
|
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">
|
||||||
|
<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>
|
||||||
|
<span>Исходники проекта</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button className="about-link" onClick={handleCopyEmail}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" width="20" height="20">
|
||||||
|
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
|
||||||
|
<polyline points="22,6 12,13 2,6" />
|
||||||
|
</svg>
|
||||||
|
<span>pseudo.developer.ru@gmail.com</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
.create-card {
|
||||||
|
padding: var(--space-lg);
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 80vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-back {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-muted);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
padding: var(--space-xs) 0;
|
||||||
|
transition: color var(--duration-fast) var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-back:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-rules {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--color-muted);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-field {
|
||||||
|
position: relative;
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-field--a {
|
||||||
|
background: linear-gradient(160deg, var(--color-card-a-from), var(--color-card-a-to));
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-field--b {
|
||||||
|
background: linear-gradient(160deg, var(--color-card-b-from), var(--color-card-b-to));
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-textarea {
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
resize: none;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-textarea::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-counter {
|
||||||
|
position: absolute;
|
||||||
|
bottom: var(--space-sm);
|
||||||
|
right: var(--space-md);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-submit {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-muted);
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
transition: all var(--duration-normal) var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-submit--active {
|
||||||
|
color: var(--color-text);
|
||||||
|
background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-submit:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-submit--active:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
import { currentUser } from '../../services/auth';
|
||||||
|
import { api } from '../../services/api';
|
||||||
|
import './CreateCard.css';
|
||||||
|
|
||||||
|
const MAX_LENGTH = 150;
|
||||||
|
|
||||||
|
export default function CreateCard({ onBack }) {
|
||||||
|
const { showToast, closeMenu } = useApp();
|
||||||
|
const [choiceA, setChoiceA] = useState('');
|
||||||
|
const [choiceB, setChoiceB] = useState('');
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
|
||||||
|
const canSubmit = choiceA.trim().length > 0 && choiceB.trim().length > 0 && !isSending;
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!canSubmit) return;
|
||||||
|
setIsSending(true);
|
||||||
|
try {
|
||||||
|
const result = await api.addCard(choiceA.trim(), choiceB.trim(), currentUser.id);
|
||||||
|
if (!result.error) {
|
||||||
|
showToast('Карточка отправлена на модерацию!');
|
||||||
|
setTimeout(() => {
|
||||||
|
closeMenu();
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
showToast('Ошибка: ' + (result.result || 'неизвестная'));
|
||||||
|
setIsSending(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Add card error:', err);
|
||||||
|
showToast('Ошибка отправки');
|
||||||
|
setIsSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="create-card custom-scroll">
|
||||||
|
<button className="create-back" onClick={onBack} aria-label="Назад">
|
||||||
|
← Назад
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h2 className="create-title">Создать карточку</h2>
|
||||||
|
|
||||||
|
<p className="create-rules">
|
||||||
|
При создании карточек запрещается использование мата и ссылок.
|
||||||
|
Все карточки проходят процесс модерации перед публикацией.
|
||||||
|
Лимит по длине текста: {MAX_LENGTH} символов.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="create-fields">
|
||||||
|
<div className="create-field create-field--a">
|
||||||
|
<textarea
|
||||||
|
className="create-textarea"
|
||||||
|
placeholder="Первый вариант"
|
||||||
|
value={choiceA}
|
||||||
|
onChange={(e) => setChoiceA(e.target.value.slice(0, MAX_LENGTH))}
|
||||||
|
maxLength={MAX_LENGTH}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<span className="create-counter">
|
||||||
|
{choiceA.length}/{MAX_LENGTH}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="create-field create-field--b">
|
||||||
|
<textarea
|
||||||
|
className="create-textarea"
|
||||||
|
placeholder="Второй вариант"
|
||||||
|
value={choiceB}
|
||||||
|
onChange={(e) => setChoiceB(e.target.value.slice(0, MAX_LENGTH))}
|
||||||
|
maxLength={MAX_LENGTH}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<span className="create-counter">
|
||||||
|
{choiceB.length}/{MAX_LENGTH}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`create-submit ${canSubmit ? 'create-submit--active' : ''}`}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
>
|
||||||
|
{isSending ? 'Отправка...' : 'Отправить!'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
.menu-panel {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(100%);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
max-height: 85vh;
|
||||||
|
z-index: 60;
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
border-radius: var(--radius-card) var(--radius-card) 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: transform var(--duration-normal) var(--ease-spring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-panel--open {
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu list */
|
||||||
|
.menu-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: var(--space-xl) var(--space-lg);
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: var(--space-md) var(--space-md);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background var(--duration-fast) var(--ease-smooth);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:active {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
color: var(--color-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-label {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useApp } from '../../context/AppContext';
|
||||||
|
import { showBackButton } from '../../services/auth';
|
||||||
|
import Overlay from '../common/Overlay';
|
||||||
|
import AboutPage from './AboutPage';
|
||||||
|
import CreateCard from './CreateCard';
|
||||||
|
import './MenuPanel.css';
|
||||||
|
|
||||||
|
export default function MenuPanel() {
|
||||||
|
const { isMenuOpen, closeMenu, menuScreen, setMenuScreen } = useApp();
|
||||||
|
|
||||||
|
// Telegram BackButton for sub-screens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMenuOpen && menuScreen !== 'menu') {
|
||||||
|
const cleanup = showBackButton(() => setMenuScreen('menu'));
|
||||||
|
return cleanup;
|
||||||
|
}
|
||||||
|
if (isMenuOpen && menuScreen === 'menu') {
|
||||||
|
const cleanup = showBackButton(() => closeMenu());
|
||||||
|
return cleanup;
|
||||||
|
}
|
||||||
|
}, [isMenuOpen, menuScreen, setMenuScreen, closeMenu]);
|
||||||
|
|
||||||
|
if (!isMenuOpen) return null;
|
||||||
|
|
||||||
|
// Sub-screens
|
||||||
|
if (menuScreen === 'about') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Overlay visible={true} onClick={closeMenu} />
|
||||||
|
<div className="menu-panel menu-panel--open">
|
||||||
|
<AboutPage onBack={() => setMenuScreen('menu')} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (menuScreen === 'create') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Overlay visible={true} onClick={closeMenu} />
|
||||||
|
<div className="menu-panel menu-panel--open">
|
||||||
|
<CreateCard onBack={() => setMenuScreen('menu')} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Overlay visible={true} onClick={closeMenu} />
|
||||||
|
<div className="menu-panel menu-panel--open">
|
||||||
|
<nav className="menu-list">
|
||||||
|
<button className="menu-item" onClick={() => setMenuScreen('about')}>
|
||||||
|
<span className="menu-icon">
|
||||||
|
<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" />
|
||||||
|
<path d="M12 16v-4M12 8h.01" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className="menu-label">О проекте</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className="menu-item" onClick={() => setMenuScreen('create')}>
|
||||||
|
<span className="menu-icon">
|
||||||
|
<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="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className="menu-label">Создать карточку</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="menu-item"
|
||||||
|
onClick={() => window.open('https://boosty.to/pseudodev/donate', '_blank')}
|
||||||
|
>
|
||||||
|
<span className="menu-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" width="22" height="22">
|
||||||
|
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className="menu-label">Поддержать проект</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
.loading-screen {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-content {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-or {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 4px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 20px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-error {
|
||||||
|
margin-top: var(--space-lg);
|
||||||
|
color: var(--color-dislike);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-muted);
|
||||||
|
animation: dotPulse 1.4s infinite ease-in-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot:nth-child(2) { animation-delay: 0.16s; }
|
||||||
|
.dot:nth-child(3) { animation-delay: 0.32s; }
|
||||||
|
|
||||||
|
@keyframes dotPulse {
|
||||||
|
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||||
|
40% { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import './LoadingScreen.css';
|
||||||
|
|
||||||
|
export default function LoadingScreen({ error }) {
|
||||||
|
return (
|
||||||
|
<div className="loading-screen">
|
||||||
|
<div className="loading-content">
|
||||||
|
<h1 className="loading-title">
|
||||||
|
this<span className="loading-or">OR</span>that
|
||||||
|
</h1>
|
||||||
|
{error ? (
|
||||||
|
<p className="loading-error">{error}</p>
|
||||||
|
) : (
|
||||||
|
<div className="loading-dots">
|
||||||
|
<span className="dot" />
|
||||||
|
<span className="dot" />
|
||||||
|
<span className="dot" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--overlay-bg);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
z-index: 50;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity var(--duration-normal) var(--ease-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay--visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import './Overlay.css';
|
||||||
|
|
||||||
|
export default function Overlay({ visible, onClick }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`overlay ${visible ? 'overlay--visible' : ''}`}
|
||||||
|
onClick={onClick}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--space-lg);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 1000;
|
||||||
|
animation: toastIn 300ms var(--ease-spring) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-like);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-text {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toastIn {
|
||||||
|
from { opacity: 0; transform: translateX(-50%) translateY(-12px); }
|
||||||
|
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import './Toast.css';
|
||||||
|
|
||||||
|
export default function Toast({ message }) {
|
||||||
|
if (!message) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="toast" role="status" aria-live="polite">
|
||||||
|
<div className="toast-content">
|
||||||
|
<span className="toast-icon">✓</span>
|
||||||
|
<span className="toast-text">{message}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
|
||||||
|
import { api } from '../services/api';
|
||||||
|
import { currentUser, initTelegramApp } from '../services/auth';
|
||||||
|
|
||||||
|
const AppContext = createContext(null);
|
||||||
|
|
||||||
|
export function AppProvider({ children }) {
|
||||||
|
// App state
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// Card queue
|
||||||
|
const [cardQueue, setCardQueue] = useState([]);
|
||||||
|
const [currentCardIndex, setCurrentCardIndex] = useState(0);
|
||||||
|
const [chosenCard, setChosenCard] = useState(null); // null | "A" | "B"
|
||||||
|
|
||||||
|
// Panels
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
const [isCommentsOpen, setIsCommentsOpen] = useState(false);
|
||||||
|
const [menuScreen, setMenuScreen] = useState('menu'); // 'menu' | 'about' | 'create'
|
||||||
|
|
||||||
|
// Toast
|
||||||
|
const [toast, setToast] = useState(null);
|
||||||
|
|
||||||
|
// Current card helper
|
||||||
|
const currentCard = cardQueue[currentCardIndex] || null;
|
||||||
|
|
||||||
|
// Initialize app
|
||||||
|
useEffect(() => {
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
initTelegramApp();
|
||||||
|
|
||||||
|
// Check/register user
|
||||||
|
const checkResult = await api.checkUser(currentUser.id);
|
||||||
|
if (!checkResult.result) {
|
||||||
|
await api.addUser({
|
||||||
|
user_id: currentUser.id,
|
||||||
|
username: currentUser.username,
|
||||||
|
first_name: currentUser.first_name,
|
||||||
|
last_name: currentUser.last_name,
|
||||||
|
photo_url: currentUser.photo_url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userResult = await api.getUser(currentUser.id);
|
||||||
|
if (!userResult.error) {
|
||||||
|
setUser(userResult.result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load first batch of cards
|
||||||
|
await loadCards();
|
||||||
|
} catch (err) {
|
||||||
|
setError('Не удалось загрузить приложение');
|
||||||
|
console.error('Init error:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
init();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load cards batch
|
||||||
|
const loadCards = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const result = await api.getRandomCards(currentUser.id);
|
||||||
|
if (!result.error && Array.isArray(result.result) && result.result.length > 0) {
|
||||||
|
setCardQueue(result.result);
|
||||||
|
setCurrentCardIndex(0);
|
||||||
|
setChosenCard(null);
|
||||||
|
} else {
|
||||||
|
// No more cards or error
|
||||||
|
setCardQueue([]);
|
||||||
|
setCurrentCardIndex(0);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Load cards error:', err);
|
||||||
|
setError('Ошибка загрузки карточек');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
const goToNextCard = useCallback(async () => {
|
||||||
|
const nextIndex = currentCardIndex + 1;
|
||||||
|
if (nextIndex < cardQueue.length) {
|
||||||
|
setCurrentCardIndex(nextIndex);
|
||||||
|
setChosenCard(null);
|
||||||
|
} else {
|
||||||
|
// Load next batch
|
||||||
|
await loadCards();
|
||||||
|
}
|
||||||
|
}, [currentCardIndex, cardQueue.length, loadCards]);
|
||||||
|
|
||||||
|
// Reactions
|
||||||
|
const likeCard = useCallback(async () => {
|
||||||
|
if (!currentCard || !chosenCard) return;
|
||||||
|
const result = await api.likeCard(currentUser.id, currentCard.card_id);
|
||||||
|
if (!result.error) {
|
||||||
|
// Refresh user data to get updated liked_card_ids
|
||||||
|
const userResult = await api.getUser(currentUser.id);
|
||||||
|
if (!userResult.error) setUser(userResult.result);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [currentCard, chosenCard]);
|
||||||
|
|
||||||
|
const dislikeCard = useCallback(async () => {
|
||||||
|
if (!currentCard || !chosenCard) return;
|
||||||
|
const result = await api.dislikeCard(currentUser.id, currentCard.card_id);
|
||||||
|
if (!result.error) {
|
||||||
|
const userResult = await api.getUser(currentUser.id);
|
||||||
|
if (!userResult.error) setUser(userResult.result);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [currentCard, chosenCard]);
|
||||||
|
|
||||||
|
// Toast helper
|
||||||
|
const showToast = useCallback((message, duration = 2500) => {
|
||||||
|
setToast(message);
|
||||||
|
setTimeout(() => setToast(null), duration);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Menu helpers
|
||||||
|
const openMenu = useCallback(() => {
|
||||||
|
setIsMenuOpen(true);
|
||||||
|
setMenuScreen('menu');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeMenu = useCallback(() => {
|
||||||
|
setIsMenuOpen(false);
|
||||||
|
setMenuScreen('menu');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
// State
|
||||||
|
isLoading,
|
||||||
|
user,
|
||||||
|
error,
|
||||||
|
currentCard,
|
||||||
|
chosenCard,
|
||||||
|
cardQueue,
|
||||||
|
currentCardIndex,
|
||||||
|
isMenuOpen,
|
||||||
|
isCommentsOpen,
|
||||||
|
menuScreen,
|
||||||
|
toast,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
chooseCard,
|
||||||
|
goToNextCard,
|
||||||
|
loadCards,
|
||||||
|
likeCard,
|
||||||
|
dislikeCard,
|
||||||
|
openMenu,
|
||||||
|
closeMenu,
|
||||||
|
setMenuScreen,
|
||||||
|
setIsCommentsOpen,
|
||||||
|
showToast,
|
||||||
|
setError,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApp() {
|
||||||
|
const context = useContext(AppContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useApp must be used within AppProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/* ============================================
|
||||||
|
This OR That — Design System
|
||||||
|
Palette: "Abyss Neon"
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Unbounded:wght@400;600;700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@500;700&display=swap');
|
||||||
|
|
||||||
|
/* ---------- Design Tokens ---------- */
|
||||||
|
:root {
|
||||||
|
/* Background */
|
||||||
|
--color-bg: #070711;
|
||||||
|
--color-bg-elevated: #0D0C1E;
|
||||||
|
|
||||||
|
/* Card A — Deep Violet */
|
||||||
|
--color-card-a-from: #1D0658;
|
||||||
|
--color-card-a-to: #7C3AED;
|
||||||
|
|
||||||
|
/* Card B — Electric Blue */
|
||||||
|
--color-card-b-from: #0F2027;
|
||||||
|
--color-card-b-to: #0284C7;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--color-text: #F8FAFC;
|
||||||
|
--color-muted: #94A3B8;
|
||||||
|
|
||||||
|
/* Stats accent per card */
|
||||||
|
--color-stat-a: #A78BFA;
|
||||||
|
--color-stat-b: #38BDF8;
|
||||||
|
|
||||||
|
/* Reactions */
|
||||||
|
--color-like: #7C3AED;
|
||||||
|
--color-dislike: #EF4444;
|
||||||
|
|
||||||
|
/* Glassmorphism surfaces */
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.07);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.12);
|
||||||
|
--glass-blur: 12px;
|
||||||
|
|
||||||
|
/* Overlay */
|
||||||
|
--overlay-bg: rgba(7, 7, 17, 0.75);
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-display: 'Unbounded', sans-serif;
|
||||||
|
--font-body: 'Inter', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
--space-xs: 4px;
|
||||||
|
--space-sm: 8px;
|
||||||
|
--space-md: 16px;
|
||||||
|
--space-lg: 24px;
|
||||||
|
--space-xl: 32px;
|
||||||
|
|
||||||
|
/* Radius */
|
||||||
|
--radius-card: 20px;
|
||||||
|
--radius-sm: 10px;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
--ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--duration-card: 600ms;
|
||||||
|
--duration-fast: 200ms;
|
||||||
|
--duration-normal: 300ms;
|
||||||
|
|
||||||
|
/* Bottom bar height */
|
||||||
|
--bar-height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Global Reset ---------- */
|
||||||
|
*, *::before, *::after {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-bg);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
user-select: none;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Scrollbar (for panels) ---------- */
|
||||||
|
.custom-scroll::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scroll::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- App Shell ---------- */
|
||||||
|
.app {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Reduced Motion ---------- */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
const reportWebVitals = onPerfEntry => {
|
||||||
|
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||||
|
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||||
|
getCLS(onPerfEntry);
|
||||||
|
getFID(onPerfEntry);
|
||||||
|
getFCP(onPerfEntry);
|
||||||
|
getLCP(onPerfEntry);
|
||||||
|
getTTFB(onPerfEntry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default reportWebVitals;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* API service — all backend requests for This OR That.
|
||||||
|
* All endpoints return { result, error } (BaseResponse).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE_URL = process.env.REACT_APP_API_URL || '/api';
|
||||||
|
|
||||||
|
async function request(method, path, body = null) {
|
||||||
|
const options = {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
};
|
||||||
|
if (body) {
|
||||||
|
options.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${BASE_URL}${path}`, options);
|
||||||
|
const data = await response.json();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GET = (path) => request('GET', path);
|
||||||
|
const POST = (path, body) => request('POST', path, body);
|
||||||
|
const PATCH = (path, body) => request('PATCH', path, body);
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
// Users
|
||||||
|
checkUser: (userId) =>
|
||||||
|
GET(`/check_user?user_id=${userId}`),
|
||||||
|
|
||||||
|
getUser: (userId) =>
|
||||||
|
GET(`/get_user?user_id=${userId}`),
|
||||||
|
|
||||||
|
addUser: ({ user_id, username, first_name, last_name, photo_url }) =>
|
||||||
|
POST('/add_user', { user_id, username, first_name, last_name, photo_url }),
|
||||||
|
|
||||||
|
// Cards
|
||||||
|
getCard: (cardId) =>
|
||||||
|
GET(`/get_card?card_id=${cardId}`),
|
||||||
|
|
||||||
|
getRandomCards: (userId) =>
|
||||||
|
GET(`/get_random_cards?user_id=${userId}`),
|
||||||
|
|
||||||
|
selectChoice: (userId, cardId, choice) =>
|
||||||
|
PATCH('/select_choice', { user_id: userId, card_id: cardId, choice }),
|
||||||
|
|
||||||
|
addCard: (choiceA, choiceB, authorId) =>
|
||||||
|
POST('/add_card', { choice_A: choiceA, choice_B: choiceB, author_id: authorId }),
|
||||||
|
|
||||||
|
// Reactions
|
||||||
|
likeCard: (userId, cardId) =>
|
||||||
|
PATCH('/like_card', { user_id: userId, card_id: cardId }),
|
||||||
|
|
||||||
|
dislikeCard: (userId, cardId) =>
|
||||||
|
PATCH('/dislike_card', { user_id: userId, card_id: cardId }),
|
||||||
|
|
||||||
|
// Comments
|
||||||
|
addComment: (authorId, cardId, commentText) =>
|
||||||
|
POST('/comment', { author_id: authorId, card_id: cardId, comment_text: commentText }),
|
||||||
|
|
||||||
|
// TODO: GET /get_comments — endpoint not yet implemented on backend
|
||||||
|
getComments: (cardId) => {
|
||||||
|
console.warn('GET /get_comments not implemented on backend yet');
|
||||||
|
return Promise.resolve({ result: [], error: false });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Auth service — detects Telegram WebApp user or falls back to mock.
|
||||||
|
* Auto-registers user on backend if not yet registered.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MOCK_USER = {
|
||||||
|
id: 999995,
|
||||||
|
first_name: 'Dev',
|
||||||
|
last_name: 'User',
|
||||||
|
username: 'devuser',
|
||||||
|
photo_url: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getTelegramUser() {
|
||||||
|
try {
|
||||||
|
const tg = window.Telegram?.WebApp;
|
||||||
|
const user = tg?.initDataUnsafe?.user;
|
||||||
|
if (user && user.id) {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
first_name: user.first_name || '',
|
||||||
|
last_name: user.last_name || '',
|
||||||
|
username: user.username || '',
|
||||||
|
photo_url: user.photo_url || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Telegram SDK not available
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const currentUser = getTelegramUser() ?? MOCK_USER;
|
||||||
|
export const isTelegram = !!getTelegramUser();
|
||||||
|
|
||||||
|
export function initTelegramApp() {
|
||||||
|
const tg = window.Telegram?.WebApp;
|
||||||
|
if (tg) {
|
||||||
|
tg.ready();
|
||||||
|
tg.expand();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showBackButton(onBack) {
|
||||||
|
const tg = window.Telegram?.WebApp;
|
||||||
|
if (tg?.BackButton) {
|
||||||
|
tg.BackButton.show();
|
||||||
|
tg.BackButton.onClick(onBack);
|
||||||
|
return () => {
|
||||||
|
tg.BackButton.offClick(onBack);
|
||||||
|
tg.BackButton.hide();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return () => { };
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||||
|
// 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';
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
fastapi==0.115.7
|
|
||||||
pymongo==4.10.1
|
|
||||||
Reference in New Issue
Block a user
commet -> comment