Initial commit
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
622
API_DOCUMENTATION.md
Normal file
@ -0,0 +1,622 @@
|
|||||||
|
# API Документация
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
API предоставляет функциональность для работы со стикерами в Telegram, включая генерацию изображений, создание стикерпаков и управление ими. Также API поддерживает отслеживание позиций задач в очереди.
|
||||||
|
|
||||||
|
Базовый URL: `http://localhost:8001`
|
||||||
|
|
||||||
|
## Эндпоинты
|
||||||
|
|
||||||
|
### Проверка работоспособности
|
||||||
|
|
||||||
|
#### GET /health
|
||||||
|
|
||||||
|
Проверяет работоспособность API.
|
||||||
|
|
||||||
|
**Ответ:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Управление пользователями
|
||||||
|
|
||||||
|
#### POST /register
|
||||||
|
|
||||||
|
Регистрирует нового пользователя в системе.
|
||||||
|
|
||||||
|
**Тело запроса:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": 123456789,
|
||||||
|
"username": "example_user",
|
||||||
|
"chat_id": 123456789,
|
||||||
|
"balance": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
- `user_id` (integer, обязательный): Уникальный ID пользователя (например, из Telegram)
|
||||||
|
- `username` (string, обязательный): Имя пользователя
|
||||||
|
- `chat_id` (integer, обязательный): ID чата
|
||||||
|
- `balance` (integer, опциональный, по умолчанию 0): Начальный баланс пользователя
|
||||||
|
|
||||||
|
**Ответ (201 Created):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": 123456789,
|
||||||
|
"username": "example_user",
|
||||||
|
"chat_id": 123456789,
|
||||||
|
"balance": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ошибки:**
|
||||||
|
- 400 Bad Request: Пользователь с таким chat_id уже существует
|
||||||
|
|
||||||
|
#### GET /users/{user_id}
|
||||||
|
|
||||||
|
Получает информацию о пользователе по его ID.
|
||||||
|
|
||||||
|
**Параметры пути:**
|
||||||
|
- `user_id` (integer, обязательный): ID пользователя
|
||||||
|
|
||||||
|
**Ответ (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": 123456789,
|
||||||
|
"username": "example_user",
|
||||||
|
"chat_id": 123456789,
|
||||||
|
"balance": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ошибки:**
|
||||||
|
- 404 Not Found: Пользователь не найден
|
||||||
|
|
||||||
|
### Генерация изображений и управление очередью
|
||||||
|
|
||||||
|
#### POST /generate_image
|
||||||
|
|
||||||
|
Создает задачу на генерацию изображения с использованием ComfyUI.
|
||||||
|
|
||||||
|
**Тело запроса:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": 123456789,
|
||||||
|
"workflow": {
|
||||||
|
// Объект workflow для ComfyUI
|
||||||
|
},
|
||||||
|
"tag": "image_generation"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
- `user_id` (integer, обязательный): ID пользователя
|
||||||
|
- `workflow` (object, обязательный): Объект workflow для ComfyUI
|
||||||
|
- `tag` (string, обязательный): Тег для типа задачи (допустимое значение: "image_generation")
|
||||||
|
|
||||||
|
**Ответ (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Ваше изображение генерируется, пожалуйста подождите, готовое изображение будет у вас в галереи",
|
||||||
|
"Task_ID": "123",
|
||||||
|
"queue_position": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ошибки:**
|
||||||
|
- 400 Bad Request: Недопустимый тег
|
||||||
|
- 404 Not Found: Пользователь не найден
|
||||||
|
- 500 Internal Server Error: Ошибка при создании задачи или отправке сообщения
|
||||||
|
|
||||||
|
#### GET /user_pending_tasks/{user_id}
|
||||||
|
|
||||||
|
Получает список задач пользователя в статусе PENDING с их позициями в очереди.
|
||||||
|
|
||||||
|
**Параметры пути:**
|
||||||
|
- `user_id` (integer, обязательный): ID пользователя
|
||||||
|
|
||||||
|
**Ответ (200 OK):**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"task_id": 123,
|
||||||
|
"prompt_id": "prompt_id_from_comfyui",
|
||||||
|
"status": "PENDING",
|
||||||
|
"created_at": "2025-03-13T11:15:00",
|
||||||
|
"queue_position": 3,
|
||||||
|
"updated_at": "2025-03-13T11:20:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"task_id": 124,
|
||||||
|
"prompt_id": "prompt_id_from_comfyui_2",
|
||||||
|
"status": "STARTED",
|
||||||
|
"created_at": "2025-03-13T11:10:00",
|
||||||
|
"queue_position": null,
|
||||||
|
"updated_at": "2025-03-13T11:20:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ошибки:**
|
||||||
|
- 404 Not Found: Пользователь не найден
|
||||||
|
- 500 Internal Server Error: Внутренняя ошибка сервера
|
||||||
|
|
||||||
|
#### GET /task_position/{task_id}
|
||||||
|
|
||||||
|
Получает текущую позицию задачи в очереди.
|
||||||
|
|
||||||
|
**Параметры пути:**
|
||||||
|
- `task_id` (integer, обязательный): ID задачи
|
||||||
|
|
||||||
|
**Ответ (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"task_id": 123,
|
||||||
|
"status": "PENDING",
|
||||||
|
"queue_position": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ошибки:**
|
||||||
|
- 404 Not Found: Задача не найдена
|
||||||
|
- 500 Internal Server Error: Внутренняя ошибка сервера
|
||||||
|
|
||||||
|
#### GET /images_links/{user_id}
|
||||||
|
|
||||||
|
Получает список ссылок на изображения пользователя.
|
||||||
|
|
||||||
|
**Параметры пути:**
|
||||||
|
- `user_id` (integer, обязательный): ID пользователя
|
||||||
|
|
||||||
|
**Ответ (200 OK):**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"link": "file_id_from_telegram",
|
||||||
|
"prompt_id": "prompt_id_from_comfyui",
|
||||||
|
"status": "COMPLETED",
|
||||||
|
"created_at": "2025-03-12T12:00:00",
|
||||||
|
"sticker_set_id": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ошибки:**
|
||||||
|
- 404 Not Found: Пользователь не найден
|
||||||
|
|
||||||
|
### Управление стикерами
|
||||||
|
|
||||||
|
#### POST /upload_sticker
|
||||||
|
|
||||||
|
Загружает файл стикера на сервер Telegram.
|
||||||
|
|
||||||
|
**Тело запроса:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": 123456789,
|
||||||
|
"link": "https://example.com/image.png"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
- `user_id` (integer, обязательный): ID пользователя
|
||||||
|
- `link` (string, обязательный): URL или путь к файлу стикера
|
||||||
|
|
||||||
|
**Ответ (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"file_id": "file_id_from_telegram"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ошибки:**
|
||||||
|
- 500 Internal Server Error: Ошибка при загрузке файла на сервер Telegram
|
||||||
|
|
||||||
|
#### POST /create_sticker_set
|
||||||
|
|
||||||
|
Создает новый набор стикеров.
|
||||||
|
|
||||||
|
**Тело запроса:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": 123456789,
|
||||||
|
"sticker_set_name": "example_set",
|
||||||
|
"title": "Example Sticker Set",
|
||||||
|
"file_id": "file_id_from_telegram",
|
||||||
|
"emojis": "😀",
|
||||||
|
"is_animated": false,
|
||||||
|
"is_video": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
- `user_id` (integer, обязательный): ID пользователя
|
||||||
|
- `sticker_set_name` (string, обязательный): Название набора стикеров (без суффикса _by_bot)
|
||||||
|
- `title` (string, обязательный): Заголовок набора стикеров
|
||||||
|
- `file_id` (string, обязательный): File_id загруженного стикера
|
||||||
|
- `emojis` (string, обязательный): Список эмодзи для стикера
|
||||||
|
- `is_animated` (boolean, опциональный, по умолчанию false): Флаг анимированного стикера
|
||||||
|
- `is_video` (boolean, опциональный, по умолчанию false): Флаг видео-стикера
|
||||||
|
|
||||||
|
**Ответ (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Новый набор стикеров успешно создан!",
|
||||||
|
"name": "example_set_by_bot_name"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ошибки:**
|
||||||
|
- 500 Internal Server Error: Ошибка при создании набора стикеров
|
||||||
|
|
||||||
|
#### POST /add_sticker_to_set
|
||||||
|
|
||||||
|
Добавляет стикер в существующий набор.
|
||||||
|
|
||||||
|
**Тело запроса:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": 123456789,
|
||||||
|
"sticker_set_name": "example_set",
|
||||||
|
"file_id": "file_id_from_telegram",
|
||||||
|
"emojis": "😀",
|
||||||
|
"is_animated": false,
|
||||||
|
"is_video": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
- `user_id` (integer, обязательный): ID пользователя
|
||||||
|
- `sticker_set_name` (string, обязательный): Название набора стикеров (без суффикса _by_bot)
|
||||||
|
- `file_id` (string, обязательный): File_id загруженного стикера
|
||||||
|
- `emojis` (string, обязательный): Список эмодзи для стикера
|
||||||
|
- `is_animated` (boolean, опциональный, по умолчанию false): Флаг анимированного стикера
|
||||||
|
- `is_video` (boolean, опциональный, по умолчанию false): Флаг видео-стикера
|
||||||
|
|
||||||
|
**Ответ (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Стикер успешно добавлен в набор!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ошибки:**
|
||||||
|
- 500 Internal Server Error: Ошибка при добавлении стикера в набор
|
||||||
|
|
||||||
|
#### GET /check_sticker_set_name/{sticker_set_name}
|
||||||
|
|
||||||
|
Проверяет, существует ли набор стикеров с указанным именем.
|
||||||
|
|
||||||
|
**Параметры пути:**
|
||||||
|
- `sticker_set_name` (string, обязательный): Название набора стикеров
|
||||||
|
|
||||||
|
**Ответ (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exists": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ошибки:**
|
||||||
|
- 500 Internal Server Error: Ошибка при проверке существования набора стикеров
|
||||||
|
|
||||||
|
#### POST /delete_sticker_from_set
|
||||||
|
|
||||||
|
Удаляет стикер из набора.
|
||||||
|
|
||||||
|
**Тело запроса:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"file_id": "file_id_from_telegram"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
- `file_id` (string, обязательный): File_id стикера
|
||||||
|
|
||||||
|
**Ответ (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Стикер успешно удален из набора!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ошибки:**
|
||||||
|
- 500 Internal Server Error: Ошибка при удалении стикера из набора
|
||||||
|
|
||||||
|
#### POST /set_sticker_position_in_set
|
||||||
|
|
||||||
|
Изменяет позицию стикера в наборе.
|
||||||
|
|
||||||
|
**Тело запроса:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sticker_file_id": "file_id_from_telegram",
|
||||||
|
"position": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
- `sticker_file_id` (string, обязательный): File_id стикера
|
||||||
|
- `position` (integer, обязательный): Новая позиция стикера (0 - первая позиция)
|
||||||
|
|
||||||
|
**Ответ (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Позиция стикера успешно изменена!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ошибки:**
|
||||||
|
- 500 Internal Server Error: Ошибка при изменении позиции стикера
|
||||||
|
|
||||||
|
#### POST /delete_sticker_set
|
||||||
|
|
||||||
|
Удаляет набор стикеров.
|
||||||
|
|
||||||
|
**Тело запроса:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sticker_set_name": "example_set_by_bot_name"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
- `sticker_set_name` (string, обязательный): Полное название набора стикеров (включая суффикс _by_bot)
|
||||||
|
|
||||||
|
**Ответ (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Набор стикеров успешно удален!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ошибки:**
|
||||||
|
- 500 Internal Server Error: Ошибка при удалении набора стикеров
|
||||||
|
|
||||||
|
#### POST /create_sticker_set_db
|
||||||
|
|
||||||
|
Создает запись о стикерпаке в базе данных.
|
||||||
|
|
||||||
|
**Тело запроса:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": 123456789,
|
||||||
|
"sticker_set_name": "example_set_by_bot_name"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
- `user_id` (integer, обязательный): ID пользователя
|
||||||
|
- `sticker_set_name` (string, обязательный): Полное название набора стикеров (включая суффикс _by_bot)
|
||||||
|
|
||||||
|
**Ответ (201 Created):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"set_name": "example_set_by_bot_name",
|
||||||
|
"user_id": 123456789
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ошибки:**
|
||||||
|
- 404 Not Found: Пользователь не найден
|
||||||
|
- 400 Bad Request: Ошибка валидации
|
||||||
|
- 500 Internal Server Error: Внутренняя ошибка сервера
|
||||||
|
|
||||||
|
#### GET /user_sticker_sets/{user_id}
|
||||||
|
|
||||||
|
Возвращает список названий всех стикерпаков пользователя.
|
||||||
|
|
||||||
|
**Параметры пути:**
|
||||||
|
- `user_id` (integer, обязательный): ID пользователя
|
||||||
|
|
||||||
|
**Ответ (200 OK):**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"set_name": "example_set_by_bot_name",
|
||||||
|
"user_id": 123456789
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ошибки:**
|
||||||
|
- 404 Not Found: Пользователь не найден
|
||||||
|
- 500 Internal Server Error: Внутренняя ошибка сервера
|
||||||
|
|
||||||
|
### Прокси для Telegram API
|
||||||
|
|
||||||
|
#### GET /stickers/packs/{pack_name}
|
||||||
|
|
||||||
|
Получает информацию о стикерпаке из Telegram.
|
||||||
|
|
||||||
|
**Параметры пути:**
|
||||||
|
- `pack_name` (string, обязательный): Название стикерпака
|
||||||
|
|
||||||
|
**Ответ (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "example_set_by_bot_name",
|
||||||
|
"title": "Example Sticker Set",
|
||||||
|
"sticker_type": "regular",
|
||||||
|
"thumbnail_url": "http://localhost:8001/stickers/proxy/sticker/file_id_from_telegram",
|
||||||
|
"share_url": "https://t.me/addstickers/example_set_by_bot_name",
|
||||||
|
"stickers": [
|
||||||
|
{
|
||||||
|
"file_id": "file_id_from_telegram",
|
||||||
|
"emoji": "😀",
|
||||||
|
"position": 0,
|
||||||
|
"file_url": "http://localhost:8001/stickers/proxy/sticker/file_id_from_telegram"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /stickers/proxy/sticker/{file_id}
|
||||||
|
|
||||||
|
Прокси для получения изображения стикера без раскрытия токена бота.
|
||||||
|
|
||||||
|
**Параметры пути:**
|
||||||
|
- `file_id` (string, обязательный): File ID стикера
|
||||||
|
|
||||||
|
**Ответ (200 OK):**
|
||||||
|
Изображение стикера в формате WebP с заголовками:
|
||||||
|
- Content-Type: image/webp
|
||||||
|
- Content-Disposition: inline
|
||||||
|
- Cache-Control: public, max-age=86400
|
||||||
|
|
||||||
|
**Ошибки:**
|
||||||
|
- 404 Not Found: Стикер не найден
|
||||||
|
- 500 Internal Server Error: Ошибка при получении стикера
|
||||||
|
|
||||||
|
#### POST /stickers/test/upload
|
||||||
|
|
||||||
|
Тестовый эндпоинт для загрузки изображения в Telegram.
|
||||||
|
|
||||||
|
**Форма запроса:**
|
||||||
|
- `user_id` (integer, обязательный): Telegram ID пользователя
|
||||||
|
- `image_url` (string, обязательный): URL изображения для загрузки
|
||||||
|
|
||||||
|
**Ответ (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"file_id": "file_id_from_telegram",
|
||||||
|
"file_url": "http://localhost:8001/stickers/proxy/sticker/file_id_from_telegram"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ошибки:**
|
||||||
|
- 500 Internal Server Error: Ошибка при загрузке изображения
|
||||||
|
|
||||||
|
## Модели данных
|
||||||
|
|
||||||
|
### UserBase
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": 123456789,
|
||||||
|
"username": "example_user",
|
||||||
|
"chat_id": 123456789,
|
||||||
|
"balance": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### UserCreate
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": 123456789,
|
||||||
|
"username": "example_user",
|
||||||
|
"chat_id": 123456789,
|
||||||
|
"balance": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ImageBase
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"link": "file_id_from_telegram",
|
||||||
|
"prompt_id": "prompt_id_from_comfyui",
|
||||||
|
"status": "COMPLETED",
|
||||||
|
"created_at": "2025-03-12T12:00:00",
|
||||||
|
"sticker_set_id": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### StickerUploadRequest
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": 123456789,
|
||||||
|
"link": "https://example.com/image.png"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### StickerSetRequest
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": 123456789,
|
||||||
|
"sticker_set_name": "example_set",
|
||||||
|
"title": "Example Sticker Set",
|
||||||
|
"file_id": "file_id_from_telegram",
|
||||||
|
"emojis": "😀",
|
||||||
|
"is_animated": false,
|
||||||
|
"is_video": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### StickerFileID
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"file_id": "file_id_from_telegram"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### StickerSetPosition
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sticker_file_id": "file_id_from_telegram",
|
||||||
|
"position": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### StickerSetName
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sticker_set_name": "example_set_by_bot_name"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GenerateImageRequest
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tag": "image_generation",
|
||||||
|
"user_id": 123456789,
|
||||||
|
"workflow": {
|
||||||
|
// Объект workflow для ComfyUI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CreateStickerSetDBRequest
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": 123456789,
|
||||||
|
"sticker_set_name": "example_set_by_bot_name"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### StickerSetResponse
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"set_name": "example_set_by_bot_name",
|
||||||
|
"user_id": 123456789
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### TaskPosition
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"task_id": 123,
|
||||||
|
"status": "PENDING",
|
||||||
|
"queue_position": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### PendingTask
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"task_id": 123,
|
||||||
|
"prompt_id": "prompt_id_from_comfyui",
|
||||||
|
"status": "PENDING",
|
||||||
|
"created_at": "2025-03-13T11:15:00",
|
||||||
|
"queue_position": 3,
|
||||||
|
"updated_at": "2025-03-13T11:20:00"
|
||||||
|
}
|
||||||
218
ARCHITECTURE.md
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
# Архитектура Telegram MiniApp для генерации стикеров
|
||||||
|
|
||||||
|
## Общее описание
|
||||||
|
|
||||||
|
Приложение представляет собой Telegram MiniApp для генерации стикеров из пользовательских изображений. Архитектура построена на принципе разделения клиентской и серверной логики, где все API запросы выполняются через сервер.
|
||||||
|
|
||||||
|
## Компоненты системы
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[Клиент/MiniApp] --> B[Telegram WebApp API]
|
||||||
|
A --> C[Ваш сервер]
|
||||||
|
C --> D[API генерации]
|
||||||
|
|
||||||
|
B -- "user_id, username" --> C
|
||||||
|
C -- "статус, результаты" --> A
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Эндпоинты
|
||||||
|
|
||||||
|
### 1. Регистрация пользователя
|
||||||
|
```http
|
||||||
|
POST /users
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"user_id": 12345,
|
||||||
|
"username": "john_doe",
|
||||||
|
"chat_id": 123456789,
|
||||||
|
"balance": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Response (201):
|
||||||
|
{
|
||||||
|
"user_id": 1,
|
||||||
|
"username": "john_doe",
|
||||||
|
"chat_id": 123456789,
|
||||||
|
"balance": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Получение информации о пользователе
|
||||||
|
```http
|
||||||
|
GET /users
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"user_id": "12345"
|
||||||
|
}
|
||||||
|
|
||||||
|
Response (200):
|
||||||
|
{
|
||||||
|
"user_id": 12345,
|
||||||
|
"username": "john_doe",
|
||||||
|
"chat_id": 123456789,
|
||||||
|
"balance": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Генерация изображения
|
||||||
|
```http
|
||||||
|
POST /generate_image/{user_id}
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
|
||||||
|
file: binary
|
||||||
|
|
||||||
|
Response (200):
|
||||||
|
"string" (prompt_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Получение списка изображений
|
||||||
|
```http
|
||||||
|
GET /images_links/{user_id}
|
||||||
|
|
||||||
|
Response (200):
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"link": "string",
|
||||||
|
"path": "string",
|
||||||
|
"prompt_id": "string",
|
||||||
|
"status": "string",
|
||||||
|
"created_at": "2025-02-24T13:22:11.690Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Процесс генерации стикера
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[Клиент] -->|1. Выбор фото| B[Локальная обработка]
|
||||||
|
B -->|2. Кроп фото| C[Подготовка данных]
|
||||||
|
C -->|3. WebApp.sendData| D[Ваш сервер]
|
||||||
|
D -->|4. Запрос к API| E[API генерации]
|
||||||
|
E -->|5. Результат| D
|
||||||
|
D -->|6. Ответ боту| F[Telegram бот]
|
||||||
|
F -->|7. Уведомление| G[Пользователь]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структуры данных
|
||||||
|
|
||||||
|
### Клиентская часть
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Интерфейс для работы с Telegram WebApp
|
||||||
|
interface WebAppUser {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
// другие поля из WebApp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запрос на генерацию
|
||||||
|
interface GenerationRequest {
|
||||||
|
photo: Blob;
|
||||||
|
style: string;
|
||||||
|
userId: number;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Результат генерации
|
||||||
|
interface GenerationResult {
|
||||||
|
status: 'success' | 'error';
|
||||||
|
stickerUrl?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Информация об изображении
|
||||||
|
interface GeneratedImage {
|
||||||
|
id: number;
|
||||||
|
link: string;
|
||||||
|
path: string;
|
||||||
|
prompt_id: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Взаимодействие с Telegram WebApp
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/services/webApp.ts
|
||||||
|
const webAppService = {
|
||||||
|
// Получение информации о пользователе
|
||||||
|
getUserInfo(): WebAppUser {
|
||||||
|
return window.Telegram.WebApp.initDataUnsafe.user;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Показ уведомления
|
||||||
|
showAlert(message: string): void {
|
||||||
|
window.Telegram.WebApp.showAlert(message);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Показ подтверждения
|
||||||
|
showConfirm(message: string): Promise<boolean> {
|
||||||
|
return window.Telegram.WebApp.showConfirm(message);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Отправка данных на сервер
|
||||||
|
sendData(data: GenerationRequest): void {
|
||||||
|
window.Telegram.WebApp.sendData(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Процесс обработки изображения
|
||||||
|
|
||||||
|
1. **Клиентская часть:**
|
||||||
|
- Загрузка изображения
|
||||||
|
- Локальная обработка (кроп, ресайз)
|
||||||
|
- Подготовка данных для отправки
|
||||||
|
- Отправка через WebApp.sendData()
|
||||||
|
|
||||||
|
2. **Серверная часть:**
|
||||||
|
- Получение данных от WebApp
|
||||||
|
- Валидация пользователя
|
||||||
|
- Проверка баланса
|
||||||
|
- Отправка запроса на генерацию
|
||||||
|
- Сохранение результата
|
||||||
|
- Отправка уведомления через бота
|
||||||
|
|
||||||
|
## Обработка ошибок
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
enum ApiErrorType {
|
||||||
|
USER_NOT_FOUND = 'USER_NOT_FOUND',
|
||||||
|
VALIDATION_ERROR = 'VALIDATION_ERROR',
|
||||||
|
NETWORK_ERROR = 'NETWORK_ERROR',
|
||||||
|
GENERATION_FAILED = 'GENERATION_FAILED'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiError {
|
||||||
|
type: ApiErrorType;
|
||||||
|
message: string;
|
||||||
|
details?: any;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
1. Все API запросы выполняются только через сервер
|
||||||
|
2. Клиент не имеет прямого доступа к API генерации
|
||||||
|
3. Валидация пользователя происходит на сервере
|
||||||
|
4. Проверка баланса выполняется на сервере перед генерацией
|
||||||
|
|
||||||
|
## Оптимизации
|
||||||
|
|
||||||
|
1. Локальная обработка изображений перед отправкой
|
||||||
|
2. Кэширование результатов в галерее
|
||||||
|
3. Отложенная загрузка изображений
|
||||||
|
4. Обработка состояний загрузки и ошибок
|
||||||
|
|
||||||
|
## Дальнейшее развитие
|
||||||
|
|
||||||
|
1. Добавление новых стилей генерации
|
||||||
|
2. Система рейтинга стикеров
|
||||||
|
3. Социальные функции (шаринг, коллекции)
|
||||||
|
4. Интеграция с другими сервисами Telegram
|
||||||
244
PROJECT_OVERVIEW.md
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
# Обзор проекта StickerBot
|
||||||
|
|
||||||
|
## Общее описание
|
||||||
|
|
||||||
|
StickerBot - это Telegram MiniApp для генерации стикеров из пользовательских изображений. Приложение позволяет пользователям загружать свои фотографии, обрезать их, выбирать стиль и образ для генерации стикеров, а затем получать готовые стикеры.
|
||||||
|
|
||||||
|
## Архитектура проекта
|
||||||
|
|
||||||
|
Проект построен на принципе разделения клиентской и серверной логики:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[Клиент/MiniApp] --> B[Telegram WebApp API]
|
||||||
|
A --> C[Сервер]
|
||||||
|
C --> D[API генерации]
|
||||||
|
|
||||||
|
B -- "user_id, username" --> C
|
||||||
|
C -- "статус, результаты" --> A
|
||||||
|
```
|
||||||
|
|
||||||
|
### Технологический стек
|
||||||
|
|
||||||
|
- **Фронтенд**: React + TypeScript + Vite
|
||||||
|
- **Маршрутизация**: React Router
|
||||||
|
- **Стили**: CSS Modules
|
||||||
|
- **API**: REST API
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
### Основные директории
|
||||||
|
|
||||||
|
- **src/components/** - компоненты пользовательского интерфейса
|
||||||
|
- **blocks/** - переиспользуемые блоки UI (кнопки, поля ввода, загрузка фото)
|
||||||
|
- **layout/** - компоненты макета (заголовок, навигация)
|
||||||
|
- **shared/** - общие компоненты (просмотр изображений, обработка ошибок)
|
||||||
|
- **src/screens/** - экраны приложения
|
||||||
|
- **src/config/** - конфигурации (стили, экраны)
|
||||||
|
- **src/constants/** - константы (базовый воркфлоу)
|
||||||
|
- **src/services/** - сервисы для работы с API
|
||||||
|
- **src/types/** - типы TypeScript
|
||||||
|
- **src/assets/** - статические ресурсы (изображения, промпты)
|
||||||
|
|
||||||
|
### Ключевые файлы
|
||||||
|
|
||||||
|
- **src/App.tsx** - основной компонент приложения с маршрутизацией
|
||||||
|
- **src/main.tsx** - точка входа в приложение
|
||||||
|
- **src/screens/Home.tsx** - домашний экран
|
||||||
|
- **src/screens/CropPhoto.tsx** - экран обрезки фото
|
||||||
|
- **src/services/api.ts** - сервис для работы с API
|
||||||
|
- **src/config/homeScreen.ts** - конфигурация домашнего экрана
|
||||||
|
- **src/config/stylePresets.ts** - предустановленные стили для стикеров
|
||||||
|
- **src/constants/baseWorkflow.ts** - базовый воркфлоу для генерации стикеров
|
||||||
|
- **src/assets/prompts.ts** - промпты для генерации стикеров
|
||||||
|
|
||||||
|
## Маршрутизация
|
||||||
|
|
||||||
|
Приложение использует React Router для навигации между экранами:
|
||||||
|
|
||||||
|
- **Онбординг**:
|
||||||
|
- `/onboarding/welcome` - приветственный экран
|
||||||
|
- `/onboarding/how-to` - инструкции по использованию
|
||||||
|
- `/onboarding/sticker-packs` - информация о наборах стикеров
|
||||||
|
- **Основные экраны**:
|
||||||
|
- `/` - домашний экран
|
||||||
|
- `/create` - создание стикера
|
||||||
|
- `/gallery` - галерея сгенерированных стикеров
|
||||||
|
- `/packs` - наборы стикеров
|
||||||
|
- `/profile` - профиль пользователя
|
||||||
|
- `/history` - история генераций
|
||||||
|
- `/crop-photo` - обрезка фото
|
||||||
|
|
||||||
|
## Компонентная структура
|
||||||
|
|
||||||
|
### Блоки UI
|
||||||
|
|
||||||
|
Приложение использует компонентный подход с блоками UI, которые рендерятся через компонент BlockRenderer:
|
||||||
|
|
||||||
|
- **ScrollableButtonsBlock** - блок с прокручиваемыми кнопками
|
||||||
|
- **GridButtonsBlock** - блок с сеткой кнопок
|
||||||
|
- **UploadPhotoBlock** - блок для загрузки фото
|
||||||
|
- **DividerBlock** - разделитель
|
||||||
|
- **TextInputBlock** - блок для ввода текста
|
||||||
|
- **GenerateButton** - кнопка для генерации стикеров
|
||||||
|
- **StepTitle** - заголовок шага
|
||||||
|
|
||||||
|
### Макет
|
||||||
|
|
||||||
|
Компонент Layout обеспечивает общую структуру интерфейса:
|
||||||
|
|
||||||
|
- **Header** - заголовок
|
||||||
|
- **Navigation** - навигация
|
||||||
|
- **ErrorBoundary** - обработка ошибок
|
||||||
|
- **Suspense** - обработка состояния загрузки
|
||||||
|
|
||||||
|
## Процесс генерации стикера
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[Загрузка фото] -->|Перенаправление на экран обрезки| B[Обрезка фото]
|
||||||
|
B -->|Возврат на главный экран| C[Выбор стиля]
|
||||||
|
C --> D[Выбор образа]
|
||||||
|
D --> E[Генерация стикера]
|
||||||
|
E -->|API запрос| F[Получение результата]
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Загрузка изображения**:
|
||||||
|
- Пользователь загружает фото через компонент UploadPhotoBlock
|
||||||
|
- Поддерживается перетаскивание и выбор файла
|
||||||
|
|
||||||
|
2. **Обрезка изображения**:
|
||||||
|
- Пользователь перенаправляется на экран CropPhoto
|
||||||
|
- Возможность масштабирования и перемещения изображения
|
||||||
|
- Выбор области для стикера
|
||||||
|
- Преобразование в base64 и передача на главный экран
|
||||||
|
|
||||||
|
3. **Выбор стиля**:
|
||||||
|
- Доступные стили: чиби, реализм, эмоции
|
||||||
|
- Каждый стиль имеет свои предустановленные образы
|
||||||
|
|
||||||
|
4. **Выбор образа**:
|
||||||
|
- Для стиля "чиби": спорткар, скейтборд, кофе, цветы и т.д.
|
||||||
|
- Для стиля "реализм": деловой, повседневный, спортивный и т.д.
|
||||||
|
- Для стиля "эмоции": радость, грусть, злость и т.д.
|
||||||
|
|
||||||
|
5. **Генерация стикера**:
|
||||||
|
- Отправка запроса на сервер через API
|
||||||
|
- Использование базового воркфлоу с заменой изображения и промпта
|
||||||
|
|
||||||
|
## Взаимодействие с API
|
||||||
|
|
||||||
|
### API Эндпоинты
|
||||||
|
|
||||||
|
- **POST /generate_image** - генерация изображения
|
||||||
|
- **GET /images_links/{user_id}** - получение списка изображений
|
||||||
|
|
||||||
|
### Сервис API
|
||||||
|
|
||||||
|
Сервис apiService предоставляет методы:
|
||||||
|
|
||||||
|
- **getGeneratedImages** - получение списка сгенерированных изображений
|
||||||
|
- **generateImage** - генерация нового изображения
|
||||||
|
|
||||||
|
### Процесс генерации
|
||||||
|
|
||||||
|
1. Создание копии базового воркфлоу
|
||||||
|
2. Вставка изображения в формате base64
|
||||||
|
3. Замена промпта в зависимости от выбранного образа
|
||||||
|
4. Отправка запроса на сервер
|
||||||
|
5. Получение результата
|
||||||
|
|
||||||
|
## Стили и образы
|
||||||
|
|
||||||
|
### Стили
|
||||||
|
|
||||||
|
- **chibi** - стиль чиби (мультяшный, милый)
|
||||||
|
- **realism** - реалистичный стиль
|
||||||
|
- **emotions** - стиль эмоций
|
||||||
|
|
||||||
|
### Образы для стиля "чиби"
|
||||||
|
|
||||||
|
- спорткар
|
||||||
|
- скейтборд
|
||||||
|
- кофе
|
||||||
|
- цветы
|
||||||
|
- шарик
|
||||||
|
- книга
|
||||||
|
- мороженое
|
||||||
|
- зонт
|
||||||
|
- коктейль
|
||||||
|
- подарок
|
||||||
|
- собака
|
||||||
|
- газета
|
||||||
|
- велосипед
|
||||||
|
- серфер
|
||||||
|
- детектив
|
||||||
|
- байкер
|
||||||
|
- фея
|
||||||
|
- ученый
|
||||||
|
- ковбой
|
||||||
|
- рыцарь
|
||||||
|
- балерина
|
||||||
|
- пожарный
|
||||||
|
- шеф-повар
|
||||||
|
|
||||||
|
### Образы для стиля "реализм"
|
||||||
|
|
||||||
|
- деловой
|
||||||
|
- повседневный
|
||||||
|
- спортивный
|
||||||
|
- вечерний
|
||||||
|
- пляжный
|
||||||
|
- зимний
|
||||||
|
- ретро
|
||||||
|
- киберпанк
|
||||||
|
- военный
|
||||||
|
- стимпанк
|
||||||
|
|
||||||
|
### Образы для стиля "эмоции"
|
||||||
|
|
||||||
|
- радость
|
||||||
|
- грусть
|
||||||
|
- злость
|
||||||
|
- любовь
|
||||||
|
- удивление
|
||||||
|
- страх
|
||||||
|
- сонный
|
||||||
|
- крутой
|
||||||
|
- глупый
|
||||||
|
- думающий
|
||||||
|
|
||||||
|
## Технические особенности
|
||||||
|
|
||||||
|
### Конфигурационный подход
|
||||||
|
|
||||||
|
Интерфейс строится на основе конфигурации (homeScreenConfig), что позволяет легко изменять структуру экранов без изменения кода.
|
||||||
|
|
||||||
|
### Компонент BlockRenderer
|
||||||
|
|
||||||
|
Рендерит различные блоки в зависимости от их типа, что обеспечивает гибкость и переиспользуемость компонентов.
|
||||||
|
|
||||||
|
### Воркфлоу генерации
|
||||||
|
|
||||||
|
Сложная конфигурация для нейросети (baseWorkflow), которая включает в себя различные модели и операции для генерации стикеров.
|
||||||
|
|
||||||
|
### Обработка изображений
|
||||||
|
|
||||||
|
- Локальная обработка изображений перед отправкой
|
||||||
|
- Кэширование результатов в галерее
|
||||||
|
- Отложенная загрузка изображений
|
||||||
|
- Обработка состояний загрузки и ошибок
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
1. Все API запросы выполняются только через сервер
|
||||||
|
2. Клиент не имеет прямого доступа к API генерации
|
||||||
|
3. Валидация пользователя происходит на сервере
|
||||||
|
4. Проверка баланса выполняется на сервере перед генерацией
|
||||||
|
|
||||||
|
## Дальнейшее развитие
|
||||||
|
|
||||||
|
1. Добавление новых стилей генерации
|
||||||
|
2. Система рейтинга стикеров
|
||||||
|
3. Социальные функции (шаринг, коллекции)
|
||||||
|
4. Интеграция с другими сервисами Telegram
|
||||||
112
README.md
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
# StickerAI Front
|
||||||
|
|
||||||
|
Фронтенд для приложения генерации стикеров для Telegram. Это Telegram MiniApp, которое позволяет пользователям загружать свои фотографии, обрезать их, выбирать стиль и образ для генерации стикеров, а затем получать готовые стикеры.
|
||||||
|
|
||||||
|
## Технологический стек
|
||||||
|
|
||||||
|
- **Фронтенд**: React + TypeScript + Vite
|
||||||
|
- **Маршрутизация**: React Router
|
||||||
|
- **Стили**: CSS Modules
|
||||||
|
- **API**: REST API
|
||||||
|
|
||||||
|
## Установка и запуск
|
||||||
|
|
||||||
|
### Требования
|
||||||
|
- Node.js 16+ и npm
|
||||||
|
|
||||||
|
### Локальная разработка
|
||||||
|
```bash
|
||||||
|
# Установка зависимостей
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Запуск в режиме разработки
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Сборка для продакшена
|
||||||
|
```bash
|
||||||
|
# Сборка проекта
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Предпросмотр собранного проекта
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## Деплой на сервер Ubuntu
|
||||||
|
|
||||||
|
### Подготовка сервера
|
||||||
|
```bash
|
||||||
|
# Обновление пакетов
|
||||||
|
sudo apt update
|
||||||
|
sudo apt upgrade -y
|
||||||
|
|
||||||
|
# Установка Git
|
||||||
|
sudo apt install -y git
|
||||||
|
|
||||||
|
# Установка Node.js и npm
|
||||||
|
sudo apt install -y curl
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||||
|
sudo apt install -y nodejs
|
||||||
|
|
||||||
|
# Проверка установки
|
||||||
|
node -v
|
||||||
|
npm -v
|
||||||
|
|
||||||
|
# Установка PM2
|
||||||
|
npm install -g pm2
|
||||||
|
```
|
||||||
|
|
||||||
|
### Клонирование и настройка проекта
|
||||||
|
```bash
|
||||||
|
# Клонирование репозитория
|
||||||
|
git clone https://git.gymnasticstuff.uk/kazachilo/StickerAI-Front.git
|
||||||
|
cd StickerAI-Front
|
||||||
|
|
||||||
|
# Установка зависимостей
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Сборка проекта
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск приложения с PM2
|
||||||
|
```bash
|
||||||
|
# Запуск приложения через PM2
|
||||||
|
cd /path/to/StickerAI-Front
|
||||||
|
pm2 serve --spa dist 3000 --name sticker-app
|
||||||
|
|
||||||
|
# Настройка автозапуска PM2 при перезагрузке сервера
|
||||||
|
pm2 startup
|
||||||
|
# Выполните команду, которую выдаст предыдущая инструкция
|
||||||
|
pm2 save
|
||||||
|
```
|
||||||
|
|
||||||
|
### Обновление проекта в будущем
|
||||||
|
```bash
|
||||||
|
# Переход в директорию проекта
|
||||||
|
cd /path/to/StickerAI-Front
|
||||||
|
|
||||||
|
# Получение последних изменений
|
||||||
|
git pull
|
||||||
|
|
||||||
|
# Установка новых зависимостей (если были добавлены)
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Сборка проекта
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Перезапуск PM2
|
||||||
|
pm2 restart sticker-app
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Эндпоинты
|
||||||
|
|
||||||
|
Приложение взаимодействует с API по адресу:
|
||||||
|
- https://stickerserver.gymnasticstuff.uk - для основных операций
|
||||||
|
- https://translate.maxdev.keenetic.pro/translate - для перевода текста
|
||||||
|
|
||||||
|
## Дополнительная документация
|
||||||
|
|
||||||
|
Для более подробной информации о проекте смотрите:
|
||||||
|
- [PROJECT_OVERVIEW.md](PROJECT_OVERVIEW.md) - обзор проекта
|
||||||
|
- [ARCHITECTURE.md](ARCHITECTURE.md) - архитектура проекта
|
||||||
414
TG miniapps API official.md
Normal file
@ -0,0 +1,414 @@
|
|||||||
|
Telegram Mini Apps Documentation
|
||||||
|
Introduction
|
||||||
|
Telegram Mini Apps позволяют разработчикам создавать высокофункциональные интерфейсы с использованием JavaScript, которые могут быть запущены непосредственно внутри Telegram и потенциально заменить традиционные веб-сайты. Mini Apps поддерживают seamless authorization, платежи через сторонних провайдеров (включая Google Pay и Apple Pay), а также персонализированные push-уведомления и многое другое.
|
||||||
|
|
||||||
|
Recent Updates
|
||||||
|
November 17, 2024 - Bot API 8.0
|
||||||
|
Full-Screen Mode: Mini Apps теперь могут переходить в полноэкранный режим в портретной и ландшафтной ориентации.
|
||||||
|
|
||||||
|
Методы:
|
||||||
|
requestFullscreen()
|
||||||
|
exitFullscreen()
|
||||||
|
Поля:
|
||||||
|
safeAreaInset: Объект, представляющий отступы безопасной области устройства, учитывающие системные элементы интерфейса, такие как вырезы или панели навигации.
|
||||||
|
top: Целое число, представляющее отступ сверху в пикселях.
|
||||||
|
bottom: Целое число, представляющее отступ снизу в пикселях.
|
||||||
|
left: Целое число, представляющее отступ слева в пикселях.
|
||||||
|
right: Целое число, представляющее отступ справа в пикселях.
|
||||||
|
contentSafeAreaInset: Объект, представляющий безопасную область для отображения контента внутри приложения, свободную от перекрывающихся элементов интерфейса Telegram.
|
||||||
|
top: Целое число, представляющее отступ сверху в пикселях.
|
||||||
|
bottom: Целое число, представляющее отступ снизу в пикселях.
|
||||||
|
left: Целое число, представляющее отступ слева в пикселях.
|
||||||
|
right: Целое число, представляющее отступ справа в пикселях.
|
||||||
|
isActive: Логическое значение, указывающее, активно ли приложение в данный момент.
|
||||||
|
isFullscreen: Логическое значение, указывающее, находится ли Mini App в настоящее время в полноэкранном режиме.
|
||||||
|
События:
|
||||||
|
activated
|
||||||
|
deactivated
|
||||||
|
safeAreaChanged
|
||||||
|
contentSafeAreaChanged
|
||||||
|
fullscreenChanged
|
||||||
|
fullscreenFailed
|
||||||
|
Homescreen Shortcuts: Mini Apps теперь могут быть добавлены в качестве ярлыков на главный экран мобильных устройств.
|
||||||
|
|
||||||
|
Методы:
|
||||||
|
addToHomeScreen()
|
||||||
|
checkHomeScreenStatus([callback])
|
||||||
|
События:
|
||||||
|
homeScreenAdded
|
||||||
|
homeScreenChecked
|
||||||
|
Emoji Status: Mini Apps теперь могут предлагать пользователям установить статус в виде emoji или запросить доступ для автоматического обновления статуса.
|
||||||
|
|
||||||
|
Методы:
|
||||||
|
setEmojiStatus(custom_emoji_id[, params, callback])
|
||||||
|
requestEmojiStatusAccess([callback])
|
||||||
|
События:
|
||||||
|
emojiStatusSet
|
||||||
|
emojiStatusFailed
|
||||||
|
emojiStatusAccessRequested
|
||||||
|
Media Sharing and File Downloads: Пользователи теперь могут делиться медиа напрямую из Mini Apps.
|
||||||
|
|
||||||
|
Методы:
|
||||||
|
shareMessage(msg_id[, callback])
|
||||||
|
downloadFile(params[, callback])
|
||||||
|
События:
|
||||||
|
shareMessageSent
|
||||||
|
shareMessageFailed
|
||||||
|
fileDownloadRequested
|
||||||
|
Geolocation Access: Mini Apps теперь могут запрашивать доступ к геолокации пользователя.
|
||||||
|
|
||||||
|
Поле:
|
||||||
|
LocationManager
|
||||||
|
События:
|
||||||
|
locationManagerUpdated
|
||||||
|
locationRequested
|
||||||
|
Device Motion Tracking: Mini Apps теперь могут отслеживать подробные данные о движении устройства.
|
||||||
|
|
||||||
|
Поля:
|
||||||
|
isOrientationLocked
|
||||||
|
Accelerometer
|
||||||
|
DeviceOrientation
|
||||||
|
Gyroscope
|
||||||
|
Методы:
|
||||||
|
lockOrientation()
|
||||||
|
unlockOrientation()
|
||||||
|
События:
|
||||||
|
accelerometerStarted
|
||||||
|
accelerometerStopped
|
||||||
|
accelerometerChanged
|
||||||
|
accelerometerFailed
|
||||||
|
deviceOrientationStarted
|
||||||
|
deviceOrientationStopped
|
||||||
|
deviceOrientationChanged
|
||||||
|
deviceOrientationFailed
|
||||||
|
gyroscopeStarted
|
||||||
|
gyroscopeStopped
|
||||||
|
gyroscopeChanged
|
||||||
|
gyroscopeFailed
|
||||||
|
Subscription Plans and Gifts for Telegram Stars: Mini Apps теперь поддерживают платные подписки, работающие на базе Telegram Stars.
|
||||||
|
|
||||||
|
Детали: Смотрите документацию Bot API для реализации платных подписок и подарков.
|
||||||
|
Loading Screen Customization: Mini Apps могут настраивать экран загрузки, добавляя свой собственный значок и специфические цвета для светлой и темной темы.
|
||||||
|
|
||||||
|
Доступ: Настройки доступны в @BotFather.
|
||||||
|
Hardware-specific Optimizations: Mini Apps, работающие на Android, теперь могут получать базовую информацию о аппаратном обеспечении устройства.
|
||||||
|
|
||||||
|
Информация: OS, версии App и SDK, модель устройства и класс производительности.
|
||||||
|
General:
|
||||||
|
|
||||||
|
Поле photo_url в классе WebAppUser теперь доступно для всех Mini Apps.
|
||||||
|
Третьи стороны (например, строители Mini App, внешние SDK и т.д.) могут проверять данные, не зная токена бота приложения.
|
||||||
|
Отладка расширена для полной поддержки устройств iOS.
|
||||||
|
Safe Area Inset
|
||||||
|
Описание
|
||||||
|
Поля safeAreaInset и contentSafeAreaInset предоставляют информацию о безопасных областях экрана, чтобы избежать перекрытия с системными элементами интерфейса, такими как вырезы или панели навигации.
|
||||||
|
|
||||||
|
Поля
|
||||||
|
top: Целое число, представляющее отступ сверху в пикселях.
|
||||||
|
bottom: Целое число, представляющее отступ снизу в пикселях.
|
||||||
|
left: Целое число, представляющее отступ слева в пикселях.
|
||||||
|
right: Целое число, представляющее отступ справа в пикселях.
|
||||||
|
CSS Variables
|
||||||
|
--tg-safe-area-inset-top
|
||||||
|
--tg-safe-area-inset-bottom
|
||||||
|
--tg-safe-area-inset-left
|
||||||
|
--tg-safe-area-inset-right
|
||||||
|
Additional Information
|
||||||
|
ThemeParams
|
||||||
|
Mini Apps могут адаптировать внешний вид интерфейса в реальном времени в соответствии с настройками темы пользователя.
|
||||||
|
|
||||||
|
bg_color: Цвет фона.
|
||||||
|
text_color: Основной цвет текста.
|
||||||
|
hint_color: Цвет подсказок.
|
||||||
|
link_color: Цвет ссылок.
|
||||||
|
button_color: Цвет кнопок.
|
||||||
|
button_text_color: Цвет текста кнопок.
|
||||||
|
secondary_bg_color: Дополнительный цвет фона.
|
||||||
|
header_bg_color: Цвет фона заголовка.
|
||||||
|
bottom_bar_bg_color: Цвет фона нижней панели.
|
||||||
|
accent_text_color: Цвет акцентного текста.
|
||||||
|
section_bg_color: Цвет фона секции.
|
||||||
|
section_header_text_color: Цвет текста заголовка секции.
|
||||||
|
section_separator_color: Цвет разделителя секции.
|
||||||
|
subtitle_text_color: Цвет текста подзаголовка.
|
||||||
|
destructive_text_color: Цвет текста для деструктивных действий.
|
||||||
|
WebAppUser
|
||||||
|
id: Уникальный идентификатор пользователя.
|
||||||
|
is_bot: Логическое значение, указывающее, является ли пользователь ботом.
|
||||||
|
first_name: Имя пользователя.
|
||||||
|
last_name: Фамилия пользователя.
|
||||||
|
username: Имя пользователя в Telegram.
|
||||||
|
language_code: Код языка пользователя.
|
||||||
|
is_premium: Логическое значение, указывающее, является ли пользователь пользователем Telegram Premium.
|
||||||
|
added_to_attachment_menu: Логическое значение, указывающее, добавлен ли бот в меню вложений.
|
||||||
|
allows_write_to_pm: Логическое значение, указывающее, разрешено ли боту отправлять сообщения пользователю.
|
||||||
|
photo_url: URL-адрес профильной фотографии пользователя.
|
||||||
|
Conclusion
|
||||||
|
Обновленная документация предоставляет более полное и детализированное описание функциональности Telegram Mini Apps, включая важные аспекты, такие как safe area inset, ThemeParams и другие. Это должно помочь ИИ-агенту программирования лучше понимать и использовать возможности Telegram Mini Apps.
|
||||||
|
|
||||||
|
References
|
||||||
|
Telegram Bot API Documentation
|
||||||
|
Telegram Mini Apps Overview
|
||||||
|
ты исключил все остальное, мне нужна вся информация в одном тексте
|
||||||
|
Понял вас. Давайте объединим всю информацию в одном тексте, чтобы создать полную и структурированную документацию для Telegram Mini Apps.
|
||||||
|
|
||||||
|
Telegram Mini Apps: Полная Документация
|
||||||
|
Введение
|
||||||
|
Telegram Mini Apps предоставляют разработчикам возможность создавать гибкие и интерактивные интерфейсы с использованием JavaScript, которые могут быть запущены непосредственно внутри приложения Telegram. Эти приложения могут заменить традиционные веб-сайты, предлагая пользователям возможность взаимодействовать с сервисами без необходимости выхода из мессенджера. Mini Apps поддерживают:
|
||||||
|
|
||||||
|
Seamless Authorization: Авторизация без необходимости ввода данных вручную.
|
||||||
|
Payments: Платежи через сторонних провайдеров, включая Google Pay и Apple Pay.
|
||||||
|
Push Notifications: Персонализированные push-уведомления.
|
||||||
|
Custom Interfaces: Пользовательские интерфейсы, которые могут быть адаптированы под нужды пользователя.
|
||||||
|
Основные Возможности
|
||||||
|
1. Full-Screen Mode (Полноэкранный Режим)
|
||||||
|
Mini Apps теперь могут работать в полноэкранном режиме, поддерживая как портретную, так и ландшафтную ориентацию. Это позволяет размещать больше контента, включая игры и медиа в широкоэкранном формате.
|
||||||
|
|
||||||
|
Методы:
|
||||||
|
requestFullscreen(): Запрашивает открытие Mini App в полноэкранном режиме.
|
||||||
|
exitFullscreen(): Запрашивает выход из полноэкранного режима.
|
||||||
|
Поля:
|
||||||
|
safeAreaInset: Объект, представляющий отступы безопасной области устройства.
|
||||||
|
top: Целое число, представляющее отступ сверху в пикселях.
|
||||||
|
bottom: Целое число, представляющее отступ снизу в пикселях.
|
||||||
|
left: Целое число, представляющее отступ слева в пикселях.
|
||||||
|
right: Целое число, представляющее отступ справа в пикселях.
|
||||||
|
contentSafeAreaInset: Объект, представляющий безопасную область для отображения контента.
|
||||||
|
top: Целое число, представляющее отступ сверху в пикселях.
|
||||||
|
bottom: Целое число, представляющее отступ снизу в пикселях.
|
||||||
|
left: Целое число, представляющее отступ слева в пикселях.
|
||||||
|
right: Целое число, представляющее отступ справа в пикселях.
|
||||||
|
isActive: Логическое значение, указывающее, активно ли приложение.
|
||||||
|
isFullscreen: Логическое значение, указывающее, находится ли Mini App в полноэкранном режиме.
|
||||||
|
События:
|
||||||
|
activated: Срабатывает, когда Mini App становится активным.
|
||||||
|
deactivated: Срабатывает, когда Mini App становится неактивным.
|
||||||
|
safeAreaChanged: Срабатывает при изменении безопасной области.
|
||||||
|
contentSafeAreaChanged: Срабатывает при изменении безопасной области для контента.
|
||||||
|
fullscreenChanged: Срабатывает при изменении состояния полноэкранного режима.
|
||||||
|
fullscreenFailed: Срабатывает, если запрос на переход в полноэкранный режим не удался.
|
||||||
|
2. Homescreen Shortcuts (Ярлыки на Главный Экран)
|
||||||
|
Пользователи могут добавлять Mini Apps в качестве ярлыков на главный экран своих устройств для быстрого доступа.
|
||||||
|
|
||||||
|
Методы:
|
||||||
|
addToHomeScreen(): Предлагает пользователю добавить Mini App на главный экран.
|
||||||
|
checkHomeScreenStatus([callback]): Проверяет, поддерживается ли функция добавления на главный экран и добавлен ли уже ярлык.
|
||||||
|
События:
|
||||||
|
homeScreenAdded: Срабатывает, когда ярлык успешно добавлен на главный экран.
|
||||||
|
homeScreenChecked: Срабатывает после проверки статуса ярлыка на главном экране.
|
||||||
|
3. Emoji Status (Статус в Виде Emoji)
|
||||||
|
Mini Apps могут предлагать пользователям установить статус в виде emoji или запросить доступ для автоматического обновления статуса.
|
||||||
|
|
||||||
|
Методы:
|
||||||
|
setEmojiStatus(custom_emoji_id[, params, callback]): Открывает диалоговое окно для установки пользователем указанного emoji в качестве статуса.
|
||||||
|
requestEmojiStatusAccess([callback]): Запрашивает разрешение на управление статусом пользователя в виде emoji.
|
||||||
|
События:
|
||||||
|
emojiStatusSet: Срабатывает, когда статус в виде emoji успешно установлен.
|
||||||
|
emojiStatusFailed: Срабатывает, если установка статуса в виде emoji не удалась.
|
||||||
|
emojiStatusAccessRequested: Срабатывает при запросе разрешения на управление статусом в виде emoji.
|
||||||
|
4. Media Sharing and File Downloads (Обмен Медиа и Загрузка Файлов)
|
||||||
|
Пользователи могут делиться медиа и загружать файлы напрямую из Mini Apps.
|
||||||
|
|
||||||
|
Методы:
|
||||||
|
shareMessage(msg_id[, callback]): Открывает диалоговое окно для отправки сообщения, предоставленного ботом.
|
||||||
|
downloadFile(params[, callback]): Открывает нативное всплывающее окно, предлагающее пользователю загрузить файл.
|
||||||
|
События:
|
||||||
|
shareMessageSent: Срабатывает, когда сообщение успешно отправлено.
|
||||||
|
shareMessageFailed: Срабатывает, если отправка сообщения не удалась.
|
||||||
|
fileDownloadRequested: Срабатывает, когда пользователь отвечает на запрос загрузки файла.
|
||||||
|
5. Geolocation Access (Доступ к Геолокации)
|
||||||
|
Mini Apps могут запрашивать доступ к геолокации пользователя, что позволяет создавать различные сервисы, основанные на местоположении.
|
||||||
|
|
||||||
|
Поле:
|
||||||
|
LocationManager: Объект для управления доступом к местоположению.
|
||||||
|
События:
|
||||||
|
locationManagerUpdated: Срабатывает при изменении объекта LocationManager.
|
||||||
|
locationRequested: Срабатывает при запросе данных о местоположении.
|
||||||
|
6. Device Motion Tracking (Отслеживание Движения Устройства)
|
||||||
|
Mini Apps могут отслеживать данные о движении устройства, что позволяет реализовать более продвинутые инструменты и возможности.
|
||||||
|
|
||||||
|
Поля:
|
||||||
|
isOrientationLocked: Логическое значение, указывающее, заблокирована ли ориентация.
|
||||||
|
Accelerometer: Объект для доступа к данным акселерометра.
|
||||||
|
DeviceOrientation: Объект для доступа к данным об ориентации устройства.
|
||||||
|
Gyroscope: Объект для доступа к данным гироскопа.
|
||||||
|
Методы:
|
||||||
|
lockOrientation(): Блокирует ориентацию Mini App в текущем режиме.
|
||||||
|
unlockOrientation(): Разблокирует ориентацию Mini App, позволяя ей изменяться в зависимости от вращения устройства.
|
||||||
|
События:
|
||||||
|
accelerometerStarted: Срабатывает, когда отслеживание акселерометра успешно начато.
|
||||||
|
accelerometerStopped: Срабатывает, когда отслеживание акселерометра остановлено.
|
||||||
|
accelerometerChanged: Срабатывает с заданной частотой после вызова метода start, отправляя текущие данные акселерометра.
|
||||||
|
accelerometerFailed: Срабатывает, если запрос на начало отслеживания акселерометра не удался.
|
||||||
|
deviceOrientationStarted: Срабатывает, когда отслеживание ориентации устройства успешно начато.
|
||||||
|
deviceOrientationStopped: Срабатывает, когда отслеживание ориентации устройства остановлено.
|
||||||
|
deviceOrientationChanged: Срабатывает с заданной частотой после вызова метода start, отправляя текущие данные об ориентации устройства.
|
||||||
|
deviceOrientationFailed: Срабатывает, если запрос на начало отслеживания ориентации устройства не удался.
|
||||||
|
gyroscopeStarted: Срабатывает, когда отслеживание гироскопа успешно начато.
|
||||||
|
gyroscopeStopped: Срабатывает, когда отслеживание гироскопа остановлено.
|
||||||
|
gyroscopeChanged: Срабатывает с заданной частотой после вызова метода start, отправляя текущие данные гироскопа.
|
||||||
|
gyroscopeFailed: Срабатывает, если запрос на начало отслеживания гироскопа не удался.
|
||||||
|
7. Subscription Plans and Gifts for Telegram Stars (Платные Подписки и Подарки)
|
||||||
|
Mini Apps теперь поддерживают платные подписки, работающие на базе Telegram Stars, что позволяет разработчикам монетизировать свои приложения с помощью различных уровней контента и функций.
|
||||||
|
|
||||||
|
Детали: Смотрите документацию Bot API для реализации платных подписок и подарков.
|
||||||
|
8. Loading Screen Customization (Настройка Экрана Загрузки)
|
||||||
|
Mini Apps могут настраивать свой экран загрузки, добавляя свой собственный значок и специфические цвета для светлой и темной темы.
|
||||||
|
|
||||||
|
Доступ: Настройки доступны в @BotFather.
|
||||||
|
9. Hardware-specific Optimizations (Оптимизация для Аппаратного Обеспечения)
|
||||||
|
Mini Apps, работающие на Android, могут получать базовую информацию о аппаратном обеспечении устройства, что позволяет оптимизировать пользовательский опыт в зависимости от возможностей устройства.
|
||||||
|
|
||||||
|
Информация: OS, версии App и SDK, модель устройства и класс производительности.
|
||||||
|
Дополнительные Возможности
|
||||||
|
Color Schemes (Цветовые Схемы)
|
||||||
|
Mini Apps получают данные о текущей цветовой теме пользователя в реальном времени, что позволяет адаптировать внешний вид интерфейса.
|
||||||
|
|
||||||
|
Design Guidelines (Рекомендации по Дизайну)
|
||||||
|
Responsiveness: Все элементы должны быть отзывчивыми и разработанными с учетом мобильного-first подхода.
|
||||||
|
UI Consistency: Интерактивные элементы должны соответствовать стилю, поведению и намерениям существующих компонентов UI.
|
||||||
|
Performance: Все анимации должны быть плавными, желательно 60fps.
|
||||||
|
Accessibility: Все вводы и изображения должны содержать метки для обеспечения доступности.
|
||||||
|
Theme Awareness: Приложение должно обеспечивать бесшовный опыт, отслеживая динамические тематические цвета, предоставляемые API, и используя их соответствующим образом.
|
||||||
|
Safe Area: Убедитесь, что интерфейс приложения учитывает безопасную область и безопасную область контента, чтобы избежать перекрытия с элементами управления, особенно при использовании полноэкранного режима.
|
||||||
|
Device Optimization: Для устройств Android, учитывайте дополнительную информацию в User-Agent и адаптируйтесь к классу производительности устройства, отключая анимации и визуальные эффекты на устройствах с низкой производительностью для обеспечения плавной работы.
|
||||||
|
Реализация Mini Apps
|
||||||
|
Telegram поддерживает семь различных способов запуска Mini Apps:
|
||||||
|
|
||||||
|
1.
|
||||||
|
Main Mini App from Profile Button (Основное Mini App из кнопки профиля)
|
||||||
|
2.
|
||||||
|
Keyboard Button Mini Apps (Mini Apps из кнопки клавиатуры)
|
||||||
|
Описание: Mini Apps, запускаемые из кнопки клавиатуры типа web_app, могут отправлять данные обратно боту в служебном сообщении с помощью Telegram.WebApp.sendData.
|
||||||
|
Использование: Пользовательские интерфейсы для ввода данных, многоразовые компоненты.
|
||||||
|
3.
|
||||||
|
Inline Button Mini Apps (Mini Apps из кнопки инлайн)
|
||||||
|
Описание: Для более интерактивных Mini Apps используйте кнопку инлайн типа web_app.
|
||||||
|
Использование: Полноценные веб-сервисы и интеграции.
|
||||||
|
4.
|
||||||
|
Launching Mini Apps from the Menu Button (Запуск Mini Apps из кнопки меню)
|
||||||
|
Описание: Mini Apps могут быть запущены из настраиваемой кнопки меню.
|
||||||
|
Использование: Быстрый доступ к приложению.
|
||||||
|
5.
|
||||||
|
Inline Mode Mini Apps (Mini Apps в режиме инлайн)
|
||||||
|
Описание: Mini Apps, запускаемые через кнопку web_app в ответе на инлайн-запрос, могут использоваться в любом месте в режиме инлайн.
|
||||||
|
Использование: Полноценные веб-сервисы и интеграции в режиме инлайн.
|
||||||
|
6.
|
||||||
|
Direct Link Mini Apps (Mini Apps по прямой ссылке)
|
||||||
|
Описание: Mini App Bots могут быть запущены по прямой ссылке в любом чате.
|
||||||
|
Использование: Полноценные веб-сервисы и интеграции, которые любой пользователь может открыть в один клик.
|
||||||
|
7.
|
||||||
|
Launching Mini Apps from the Attachment Menu (Запуск Mini Apps из меню вложений)
|
||||||
|
Описание: Mini App Bots могут быть добавлены прямо в меню вложений пользователя, что позволяет быстро запускать их из любого чата.
|
||||||
|
Использование: Быстрый доступ к приложению.
|
||||||
|
Инициализация Mini Apps
|
||||||
|
Для подключения вашего Mini App к клиенту Telegram, поместите скрипт telegram-web-app.js в тег <head> перед любыми другими скриптами:
|
||||||
|
|
||||||
|
html
|
||||||
|
<script src="https://telegram.org/js/telegram-web-app.js?56"></script>
|
||||||
|
Доступные Поля
|
||||||
|
initData: Строка с необработанными данными, переданными в Mini App.
|
||||||
|
initDataUnsafe: Объект с входными данными, переданными в Mini App.
|
||||||
|
version: Версия Bot API, доступная в Telegram-приложении пользователя.
|
||||||
|
platform: Название платформы Telegram-приложения пользователя.
|
||||||
|
colorScheme: Текущая цветовая схема, используемая в Telegram-приложении.
|
||||||
|
themeParams: Объект, содержащий текущие настройки темы, используемые в Telegram-приложении.
|
||||||
|
isActive: Логическое значение, указывающее, активно ли Mini App в данный момент.
|
||||||
|
isExpanded: Логическое значение, указывающее, развернуто ли Mini App до максимально доступной высоты.
|
||||||
|
viewportHeight: Текущая высота видимой области Mini App.
|
||||||
|
viewportStableHeight: Высота видимой области Mini App в последнем стабильном состоянии.
|
||||||
|
headerColor: Текущий цвет заголовка.
|
||||||
|
backgroundColor: Текущий цвет фона.
|
||||||
|
bottomBarColor: Текущий цвет нижней панели.
|
||||||
|
isClosingConfirmationEnabled: Логическое значение, указывающее, включен ли диалог подтверждения при закрытии Mini App.
|
||||||
|
isVerticalSwipesEnabled: Логическое значение, указывающее, включены ли вертикальные смахивания для закрытия или минимизации Mini App.
|
||||||
|
isFullscreen: Логическое значение, указывающее, находится ли Mini App в настоящее время в полноэкранном режиме.
|
||||||
|
isOrientationLocked: Логическое значение, указывающее, заблокирована ли ориентация Mini App.
|
||||||
|
safeAreaInset: Объект, представляющий отступы безопасной области устройства.
|
||||||
|
contentSafeAreaInset: Объект, представляющий безопасную область для отображения контента.
|
||||||
|
Доступные Методы
|
||||||
|
isVersionAtLeast(version): Возвращает true, если версия приложения пользователя поддерживает Bot API, равную или выше указанной.
|
||||||
|
setHeaderColor(color): Устанавливает цвет заголовка приложения.
|
||||||
|
setBackgroundColor(color): Устанавливает цвет фона приложения.
|
||||||
|
setBottomBarColor(color): Устанавливает цвет нижней панели приложения.
|
||||||
|
enableClosingConfirmation(): Включает диалог подтверждения при закрытии Mini App.
|
||||||
|
disableClosingConfirmation(): Отключает диалог подтверждения при закрытии Mini App.
|
||||||
|
enableVerticalSwipes(): Включает вертикальные смахивания для закрытия или минимизации Mini App.
|
||||||
|
disableVerticalSwipes(): Отключает вертикальные смахивания для закрытия или минимизации Mini App.
|
||||||
|
requestFullscreen(): Запрашивает открытие Mini App в полноэкранном режиме.
|
||||||
|
exitFullscreen(): Запрашивает выход из полноэкранного режима.
|
||||||
|
lockOrientation(): Блокирует ориентацию Mini App в текущем режиме.
|
||||||
|
unlockOrientation(): Разблокирует ориентацию Mini App.
|
||||||
|
addToHomeScreen(): Предлагает пользователю добавить Mini App на главный экран.
|
||||||
|
checkHomeScreenStatus([callback]): Проверяет, поддерживается ли функция добавления на главный экран и добавлен ли уже ярлык.
|
||||||
|
sendData(data): Отправляет данные боту.
|
||||||
|
switchInlineQuery(query[, choose_chat_types]): Вставляет имя бота и указанный инлайн-запрос в поле ввода текущего чата.
|
||||||
|
openLink(url[, options]): Открывает ссылку во внешнем браузере.
|
||||||
|
openTelegramLink(url): Открывает ссылку в приложении Telegram.
|
||||||
|
openInvoice(url[, callback]): Открывает счет с использованием указанной ссылки.
|
||||||
|
shareToStory(media_url[, params]): Открывает нативный редактор историй с указанной медиа.
|
||||||
|
shareMessage(msg_id[, callback]): Открывает диалоговое окно, позволяющее пользователю поделиться сообщением.
|
||||||
|
setEmojiStatus(custom_emoji_id[, params, callback]): Открывает диалоговое окно для установки указанного emoji в качестве статуса.
|
||||||
|
requestEmojiStatusAccess([callback]): Запрашивает разрешение для бота на управление статусом пользователя.
|
||||||
|
downloadFile(params[, callback]): Отображает нативное всплывающее окно, предлагающее пользователю загрузить файл.
|
||||||
|
showPopup(params[, callback]): Отображает нативное всплывающее окно.
|
||||||
|
showAlert(message[, callback]): Отображает сообщение в простом всплывающем окне с кнопкой 'Закрыть'.
|
||||||
|
showConfirm(message[, callback]): Отображает сообщение в простом окне подтверждения с кнопками 'OK' и 'Cancel'.
|
||||||
|
showScanQrPopup(params[, callback]): Отображает нативное всплывающее окно для сканирования QR-кода.
|
||||||
|
closeScanQrPopup(): Закрывает нативное всплывающее окно для сканирования QR-кода.
|
||||||
|
readTextFromClipboard([callback]): Запрашивает текст из буфера обмена.
|
||||||
|
requestWriteAccess([callback]): Запрашивает разрешение для бота на отправку сообщений пользователю.
|
||||||
|
requestContact([callback]): Запрашивает у пользователя его номер телефона.
|
||||||
|
События
|
||||||
|
Mini Apps могут получать события от Telegram-приложения, к которым можно прикрепить обработчик с помощью метода Telegram.WebApp.onEvent(eventType, eventHandler). Внутри eventHandler объект this ссылается на Telegram.WebApp, а набор параметров, передаваемых в обработчик, зависит от типа события.
|
||||||
|
|
||||||
|
Список Событий
|
||||||
|
activated: Срабатывает, когда Mini App становится активным.
|
||||||
|
deactivated: Срабатывает, когда Mini App становится неактивным.
|
||||||
|
themeChanged: Срабатывает при изменении настроек темы в Telegram-приложении.
|
||||||
|
viewportChanged: Срабатывает при изменении видимой части Mini App.
|
||||||
|
safeAreaChanged: Срабатывает при изменении отступов безопасной области устройства.
|
||||||
|
contentSafeAreaChanged: Срабатывает при изменении отступов безопасной области для контента.
|
||||||
|
mainButtonClicked: Срабатывает при нажатии на главную кнопку.
|
||||||
|
secondaryButtonClicked: Срабатывает при нажатии на дополнительную кнопку.
|
||||||
|
backButtonClicked: Срабатывает при нажатии на кнопку "Назад".
|
||||||
|
settingsButtonClicked: Срабатывает при нажатии на элемент "Настройки" в контекстном меню.
|
||||||
|
invoiceClosed: Срабатывает при закрытии открытого счета.
|
||||||
|
popupClosed: Срабатывает при закрытии открытого всплывающего окна.
|
||||||
|
qrTextReceived: Срабатывает, когда сканер QR-кода захватывает код с текстовыми данными.
|
||||||
|
scanQrPopupClosed: Срабатывает, когда пользователь закрывает всплывающее окно сканера QR-кода.
|
||||||
|
clipboardTextReceived: Срабатывает при вызове метода readTextFromClipboard.
|
||||||
|
writeAccessRequested: Срабатывает при запросе разрешения на запись.
|
||||||
|
contactRequested: Срабатывает при запросе номера телефона пользователя.
|
||||||
|
biometricManagerUpdated: Срабатывает при изменении объекта BiometricManager.
|
||||||
|
biometricAuthRequested: Срабатывает при запросе биометрической аутентификации.
|
||||||
|
biometricTokenUpdated: Срабатывает при обновлении биометрического токена.
|
||||||
|
fullscreenChanged: Срабатывает при входе или выходе из полноэкранного режима.
|
||||||
|
fullscreenFailed: Срабатывает, если запрос на вход в полноэкранный режим не удался.
|
||||||
|
homeScreenAdded: Срабатывает, когда Mini App успешно добавлен на главный экран.
|
||||||
|
homeScreenChecked: Срабатывает после проверки статуса главного экрана.
|
||||||
|
accelerometerStarted: Срабатывает, когда отслеживание акселерометра успешно начато.
|
||||||
|
accelerometerStopped: Срабатывает, когда отслеживание акселерометра остановлено.
|
||||||
|
accelerometerChanged: Срабатывает с заданной частотой после вызова метода start.
|
||||||
|
accelerometerFailed: Срабатывает, если запрос на начало отслеживания акселерометра не удался.
|
||||||
|
deviceOrientationStarted: Срабатывает, когда отслеживание ориентации устройства успешно начато.
|
||||||
|
deviceOrientationStopped: Срабатывает, когда отслеживание ориентации устройства остановлено.
|
||||||
|
deviceOrientationChanged: Срабатывает с заданной частотой после вызова метода start.
|
||||||
|
gyroscopeStarted: Срабатывает, когда отслеживание гироскопа успешно начато.
|
||||||
|
gyroscopeStopped: Срабатывает, когда отслеживание гироскопа остановлено.
|
||||||
|
gyroscopeChanged: Срабатывает с заданной частотой после вызова метода start.
|
||||||
|
gyroscopeFailed: Срабатывает, если запрос на начало отслеживания гироскопа не удался.
|
||||||
|
locationManagerUpdated: Срабатывает при изменении объекта LocationManager.
|
||||||
|
locationRequested: Срабатывает при запросе данных о местоположении.
|
||||||
|
shareMessageSent: Срабатывает, когда сообщение успешно отправлено.
|
||||||
|
shareMessageFailed: Срабатывает, если отправка сообщения не удалась.
|
||||||
|
emojiStatusSet: Срабатывает, когда статус в виде emoji успешно установлен.
|
||||||
|
emojiStatusFailed: Срабатывает, если установка статуса в виде emoji не удалась.
|
||||||
|
emojiStatusAccessRequested: Срабатывает при запросе разрешения на управление статусом в виде emoji.
|
||||||
|
fileDownloadRequested: Срабатывает, когда пользователь отвечает на запрос загрузки файла.
|
||||||
|
Заключение
|
||||||
|
Telegram Mini Apps предоставляют мощную платформу для разработчиков для создания интерактивных и функциональных приложений внутри экосистемы Telegram. Благодаря обширному API и предоставленным рекомендациям, разработчики могут создавать инновационные решения, которые улучшают пользовательский опыт и вовлеченность.
|
||||||
|
|
||||||
|
Ссылки
|
||||||
|
Telegram Bot API Documentation
|
||||||
|
Telegram Mini Apps Overview
|
||||||
210
TG_bot_API_official_stickers.md
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
Stickers
|
||||||
|
The following methods and objects allow your bot to handle stickers and sticker sets.
|
||||||
|
|
||||||
|
Sticker
|
||||||
|
This object represents a sticker.
|
||||||
|
|
||||||
|
Field Type Description
|
||||||
|
file_id String Identifier for this file, which can be used to download or reuse the file
|
||||||
|
file_unique_id String Unique identifier for this file, which is supposed to be the same over time and for different bots. Can't be used to download or reuse the file.
|
||||||
|
type String Type of the sticker, currently one of “regular”, “mask”, “custom_emoji”. The type of the sticker is independent from its format, which is determined by the fields is_animated and is_video.
|
||||||
|
width Integer Sticker width
|
||||||
|
height Integer Sticker height
|
||||||
|
is_animated Boolean True, if the sticker is animated
|
||||||
|
is_video Boolean True, if the sticker is a video sticker
|
||||||
|
thumbnail PhotoSize Optional. Sticker thumbnail in the .WEBP or .JPG format
|
||||||
|
emoji String Optional. Emoji associated with the sticker
|
||||||
|
set_name String Optional. Name of the sticker set to which the sticker belongs
|
||||||
|
premium_animation File Optional. For premium regular stickers, premium animation for the sticker
|
||||||
|
mask_position MaskPosition Optional. For mask stickers, the position where the mask should be placed
|
||||||
|
custom_emoji_id String Optional. For custom emoji stickers, unique identifier of the custom emoji
|
||||||
|
needs_repainting True Optional. True, if the sticker must be repainted to a text color in messages, the color of the Telegram Premium badge in emoji status, white color on chat photos, or another appropriate color in other places
|
||||||
|
file_size Integer Optional. File size in bytes
|
||||||
|
StickerSet
|
||||||
|
This object represents a sticker set.
|
||||||
|
|
||||||
|
Field Type Description
|
||||||
|
name String Sticker set name
|
||||||
|
title String Sticker set title
|
||||||
|
sticker_type String Type of stickers in the set, currently one of “regular”, “mask”, “custom_emoji”
|
||||||
|
stickers Array of Sticker List of all set stickers
|
||||||
|
thumbnail PhotoSize Optional. Sticker set thumbnail in the .WEBP, .TGS, or .WEBM format
|
||||||
|
MaskPosition
|
||||||
|
This object describes the position on faces where a mask should be placed by default.
|
||||||
|
|
||||||
|
Field Type Description
|
||||||
|
point String The part of the face relative to which the mask should be placed. One of “forehead”, “eyes”, “mouth”, or “chin”.
|
||||||
|
x_shift Float Shift by X-axis measured in widths of the mask scaled to the face size, from left to right. For example, choosing -1.0 will place mask just to the left of the default mask position.
|
||||||
|
y_shift Float Shift by Y-axis measured in heights of the mask scaled to the face size, from top to bottom. For example, 1.0 will place the mask just below the default mask position.
|
||||||
|
scale Float Mask scaling coefficient. For example, 2.0 means double size.
|
||||||
|
InputSticker
|
||||||
|
This object describes a sticker to be added to a sticker set.
|
||||||
|
|
||||||
|
Field Type Description
|
||||||
|
sticker InputFile or String The added sticker. Pass a file_id as a String to send a file that already exists on the Telegram servers, pass an HTTP URL as a String for Telegram to get a file from the Internet, upload a new one using multipart/form-data, or pass “attach://<file_attach_name>” to upload a new one using multipart/form-data under <file_attach_name> name. Animated and video stickers can't be uploaded via HTTP URL. More information on Sending Files »
|
||||||
|
format String Format of the added sticker, must be one of “static” for a .WEBP or .PNG image, “animated” for a .TGS animation, “video” for a .WEBM video
|
||||||
|
emoji_list Array of String List of 1-20 emoji associated with the sticker
|
||||||
|
mask_position MaskPosition Optional. Position where the mask should be placed on faces. For “mask” stickers only.
|
||||||
|
keywords Array of String Optional. List of 0-20 search keywords for the sticker with total length of up to 64 characters. For “regular” and “custom_emoji” stickers only.
|
||||||
|
sendSticker
|
||||||
|
Use this method to send static .WEBP, animated .TGS, or video .WEBM stickers. On success, the sent Message is returned.
|
||||||
|
|
||||||
|
Parameter Type Required Description
|
||||||
|
business_connection_id String Optional Unique identifier of the business connection on behalf of which the message will be sent
|
||||||
|
chat_id Integer or String Yes Unique identifier for the target chat or username of the target channel (in the format @channelusername)
|
||||||
|
message_thread_id Integer Optional Unique identifier for the target message thread (topic) of the forum; for forum supergroups only
|
||||||
|
sticker InputFile or String Yes Sticker to send. Pass a file_id as String to send a file that exists on the Telegram servers (recommended), pass an HTTP URL as a String for Telegram to get a .WEBP sticker from the Internet, or upload a new .WEBP, .TGS, or .WEBM sticker using multipart/form-data. More information on Sending Files ». Video and animated stickers can't be sent via an HTTP URL.
|
||||||
|
emoji String Optional Emoji associated with the sticker; only for just uploaded stickers
|
||||||
|
disable_notification Boolean Optional Sends the message silently. Users will receive a notification with no sound.
|
||||||
|
protect_content Boolean Optional Protects the contents of the sent message from forwarding and saving
|
||||||
|
allow_paid_broadcast Boolean Optional Pass True to allow up to 1000 messages per second, ignoring broadcasting limits for a fee of 0.1 Telegram Stars per message. The relevant Stars will be withdrawn from the bot's balance
|
||||||
|
message_effect_id String Optional Unique identifier of the message effect to be added to the message; for private chats only
|
||||||
|
reply_parameters ReplyParameters Optional Description of the message to reply to
|
||||||
|
reply_markup InlineKeyboardMarkup or ReplyKeyboardMarkup or ReplyKeyboardRemove or ForceReply Optional Additional interface options. A JSON-serialized object for an inline keyboard, custom reply keyboard, instructions to remove a reply keyboard or to force a reply from the user
|
||||||
|
getStickerSet
|
||||||
|
Use this method to get a sticker set. On success, a StickerSet object is returned.
|
||||||
|
|
||||||
|
Parameter Type Required Description
|
||||||
|
name String Yes Name of the sticker set
|
||||||
|
getCustomEmojiStickers
|
||||||
|
Use this method to get information about custom emoji stickers by their identifiers. Returns an Array of Sticker objects.
|
||||||
|
|
||||||
|
Parameter Type Required Description
|
||||||
|
custom_emoji_ids Array of String Yes A JSON-serialized list of custom emoji identifiers. At most 200 custom emoji identifiers can be specified.
|
||||||
|
uploadStickerFile
|
||||||
|
Use this method to upload a file with a sticker for later use in the createNewStickerSet, addStickerToSet, or replaceStickerInSet methods (the file can be used multiple times). Returns the uploaded File on success.
|
||||||
|
|
||||||
|
Parameter Type Required Description
|
||||||
|
user_id Integer Yes User identifier of sticker file owner
|
||||||
|
sticker InputFile Yes A file with the sticker in .WEBP, .PNG, .TGS, or .WEBM format. See https://core.telegram.org/stickers for technical requirements. More information on Sending Files »
|
||||||
|
sticker_format String Yes Format of the sticker, must be one of “static”, “animated”, “video”
|
||||||
|
createNewStickerSet
|
||||||
|
Use this method to create a new sticker set owned by a user. The bot will be able to edit the sticker set thus created. Returns True on success.
|
||||||
|
|
||||||
|
Parameter Type Required Description
|
||||||
|
user_id Integer Yes User identifier of created sticker set owner
|
||||||
|
name String Yes Short name of sticker set, to be used in t.me/addstickers/ URLs (e.g., animals). Can contain only English letters, digits and underscores. Must begin with a letter, can't contain consecutive underscores and must end in "_by_<bot_username>". <bot_username> is case insensitive. 1-64 characters.
|
||||||
|
title String Yes Sticker set title, 1-64 characters
|
||||||
|
stickers Array of InputSticker Yes A JSON-serialized list of 1-50 initial stickers to be added to the sticker set
|
||||||
|
sticker_type String Optional Type of stickers in the set, pass “regular”, “mask”, or “custom_emoji”. By default, a regular sticker set is created.
|
||||||
|
needs_repainting Boolean Optional Pass True if stickers in the sticker set must be repainted to the color of text when used in messages, the accent color if used as emoji status, white on chat photos, or another appropriate color based on context; for custom emoji sticker sets only
|
||||||
|
addStickerToSet
|
||||||
|
Use this method to add a new sticker to a set created by the bot. Emoji sticker sets can have up to 200 stickers. Other sticker sets can have up to 120 stickers. Returns True on success.
|
||||||
|
|
||||||
|
Parameter Type Required Description
|
||||||
|
user_id Integer Yes User identifier of sticker set owner
|
||||||
|
name String Yes Sticker set name
|
||||||
|
sticker InputSticker Yes A JSON-serialized object with information about the added sticker. If exactly the same sticker had already been added to the set, then the set isn't changed.
|
||||||
|
setStickerPositionInSet
|
||||||
|
Use this method to move a sticker in a set created by the bot to a specific position. Returns True on success.
|
||||||
|
|
||||||
|
Parameter Type Required Description
|
||||||
|
sticker String Yes File identifier of the sticker
|
||||||
|
position Integer Yes New sticker position in the set, zero-based
|
||||||
|
deleteStickerFromSet
|
||||||
|
Use this method to delete a sticker from a set created by the bot. Returns True on success.
|
||||||
|
|
||||||
|
Parameter Type Required Description
|
||||||
|
sticker String Yes File identifier of the sticker
|
||||||
|
replaceStickerInSet
|
||||||
|
Use this method to replace an existing sticker in a sticker set with a new one. The method is equivalent to calling deleteStickerFromSet, then addStickerToSet, then setStickerPositionInSet. Returns True on success.
|
||||||
|
|
||||||
|
Parameter Type Required Description
|
||||||
|
user_id Integer Yes User identifier of the sticker set owner
|
||||||
|
name String Yes Sticker set name
|
||||||
|
old_sticker String Yes File identifier of the replaced sticker
|
||||||
|
sticker InputSticker Yes A JSON-serialized object with information about the added sticker. If exactly the same sticker had already been added to the set, then the set remains unchanged.
|
||||||
|
setStickerEmojiList
|
||||||
|
Use this method to change the list of emoji assigned to a regular or custom emoji sticker. The sticker must belong to a sticker set created by the bot. Returns True on success.
|
||||||
|
|
||||||
|
Parameter Type Required Description
|
||||||
|
sticker String Yes File identifier of the sticker
|
||||||
|
emoji_list Array of String Yes A JSON-serialized list of 1-20 emoji associated with the sticker
|
||||||
|
setStickerKeywords
|
||||||
|
Use this method to change search keywords assigned to a regular or custom emoji sticker. The sticker must belong to a sticker set created by the bot. Returns True on success.
|
||||||
|
|
||||||
|
Parameter Type Required Description
|
||||||
|
sticker String Yes File identifier of the sticker
|
||||||
|
keywords Array of String Optional A JSON-serialized list of 0-20 search keywords for the sticker with total length of up to 64 characters
|
||||||
|
setStickerMaskPosition
|
||||||
|
Use this method to change the mask position of a mask sticker. The sticker must belong to a sticker set that was created by the bot. Returns True on success.
|
||||||
|
|
||||||
|
Parameter Type Required Description
|
||||||
|
sticker String Yes File identifier of the sticker
|
||||||
|
mask_position MaskPosition Optional A JSON-serialized object with the position where the mask should be placed on faces. Omit the parameter to remove the mask position.
|
||||||
|
setStickerSetTitle
|
||||||
|
Use this method to set the title of a created sticker set. Returns True on success.
|
||||||
|
|
||||||
|
Parameter Type Required Description
|
||||||
|
name String Yes Sticker set name
|
||||||
|
title String Yes Sticker set title, 1-64 characters
|
||||||
|
setStickerSetThumbnail
|
||||||
|
Use this method to set the thumbnail of a regular or mask sticker set. The format of the thumbnail file must match the format of the stickers in the set. Returns True on success.
|
||||||
|
|
||||||
|
Parameter Type Required Description
|
||||||
|
name String Yes Sticker set name
|
||||||
|
user_id Integer Yes User identifier of the sticker set owner
|
||||||
|
thumbnail InputFile or String Optional A .WEBP or .PNG image with the thumbnail, must be up to 128 kilobytes in size and have a width and height of exactly 100px, or a .TGS animation with a thumbnail up to 32 kilobytes in size (see https://core.telegram.org/stickers#animation-requirements for animated sticker technical requirements), or a .WEBM video with the thumbnail up to 32 kilobytes in size; see https://core.telegram.org/stickers#video-requirements for video sticker technical requirements. Pass a file_id as a String to send a file that already exists on the Telegram servers, pass an HTTP URL as a String for Telegram to get a file from the Internet, or upload a new one using multipart/form-data. More information on Sending Files ». Animated and video sticker set thumbnails can't be uploaded via HTTP URL. If omitted, then the thumbnail is dropped and the first sticker is used as the thumbnail.
|
||||||
|
format String Yes Format of the thumbnail, must be one of “static” for a .WEBP or .PNG image, “animated” for a .TGS animation, or “video” for a .WEBM video
|
||||||
|
setCustomEmojiStickerSetThumbnail
|
||||||
|
Use this method to set the thumbnail of a custom emoji sticker set. Returns True on success.
|
||||||
|
|
||||||
|
Parameter Type Required Description
|
||||||
|
name String Yes Sticker set name
|
||||||
|
custom_emoji_id String Optional Custom emoji identifier of a sticker from the sticker set; pass an empty string to drop the thumbnail and use the first sticker as the thumbnail.
|
||||||
|
deleteStickerSet
|
||||||
|
Use this method to delete a sticker set that was created by the bot. Returns True on success.
|
||||||
|
|
||||||
|
Parameter Type Required Description
|
||||||
|
name String Yes Sticker set name
|
||||||
|
Gift
|
||||||
|
This object represents a gift that can be sent by the bot.
|
||||||
|
|
||||||
|
Field Type Description
|
||||||
|
id String Unique identifier of the gift
|
||||||
|
sticker Sticker The sticker that represents the gift
|
||||||
|
star_count Integer The number of Telegram Stars that must be paid to send the sticker
|
||||||
|
upgrade_star_count Integer Optional. The number of Telegram Stars that must be paid to upgrade the gift to a unique one
|
||||||
|
total_count Integer Optional. The total number of the gifts of this type that can be sent; for limited gifts only
|
||||||
|
remaining_count Integer Optional. The number of remaining gifts of this type that can be sent; for limited gifts only
|
||||||
|
Gifts
|
||||||
|
This object represent a list of gifts.
|
||||||
|
|
||||||
|
Field Type Description
|
||||||
|
gifts Array of Gift The list of gifts
|
||||||
|
getAvailableGifts
|
||||||
|
Returns the list of gifts that can be sent by the bot to users and channel chats. Requires no parameters. Returns a Gifts object.
|
||||||
|
|
||||||
|
sendGift
|
||||||
|
Sends a gift to the given user or channel chat. The gift can't be converted to Telegram Stars by the receiver. Returns True on success.
|
||||||
|
|
||||||
|
Parameter Type Required Description
|
||||||
|
user_id Integer Optional Required if chat_id is not specified. Unique identifier of the target user who will receive the gift.
|
||||||
|
chat_id Integer or String Optional Required if user_id is not specified. Unique identifier for the chat or username of the channel (in the format @channelusername) that will receive the gift.
|
||||||
|
gift_id String Yes Identifier of the gift
|
||||||
|
pay_for_upgrade Boolean Optional Pass True to pay for the gift upgrade from the bot's balance, thereby making the upgrade free for the receiver
|
||||||
|
text String Optional Text that will be shown along with the gift; 0-128 characters
|
||||||
|
text_parse_mode String Optional Mode for parsing entities in the text. See formatting options for more details. Entities other than “bold”, “italic”, “underline”, “strikethrough”, “spoiler”, and “custom_emoji” are ignored.
|
||||||
|
text_entities Array of MessageEntity Optional A JSON-serialized list of special entities that appear in the gift text. It can be specified instead of text_parse_mode. Entities other than “bold”, “italic”, “underline”, “strikethrough”, “spoiler”, and “custom_emoji” are ignored.
|
||||||
|
verifyUser
|
||||||
|
Verifies a user on behalf of the organization which is represented by the bot. Returns True on success.
|
||||||
|
|
||||||
|
Parameter Type Required Description
|
||||||
|
user_id Integer Yes Unique identifier of the target user
|
||||||
|
custom_description String Optional Custom description for the verification; 0-70 characters. Must be empty if the organization isn't allowed to provide a custom verification description.
|
||||||
|
verifyChat
|
||||||
|
Verifies a chat on behalf of the organization which is represented by the bot. Returns True on success.
|
||||||
|
|
||||||
|
Parameter Type Required Description
|
||||||
|
chat_id Integer or String Yes Unique identifier for the target chat or username of the target channel (in the format @channelusername)
|
||||||
|
custom_description String Optional Custom description for the verification; 0-70 characters. Must be empty if the organization isn't allowed to provide a custom verification description.
|
||||||
|
removeUserVerification
|
||||||
|
Removes verification from a user who is currently verified on behalf of the organization represented by the bot. Returns True on success.
|
||||||
|
|
||||||
|
Parameter Type Required Description
|
||||||
|
user_id Integer Yes Unique identifier of the target user
|
||||||
|
removeChatVerification
|
||||||
|
Removes verification from a chat that is currently verified on behalf of the organization represented by the bot. Returns True on success.
|
||||||
|
|
||||||
|
Parameter Type Required Description
|
||||||
|
chat_id Integer or String Yes Unique identifier for the target chat or username of the target channel (in the format @channelusername)
|
||||||
28
eslint.config.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
1
generation_request (7) — копия.json
Normal file
1
generation_request (7).json
Normal file
17
index.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="theme-color" content="#2B9CFF" />
|
||||||
|
<meta name="description" content="Создавайте уникальные стикеры для Telegram" />
|
||||||
|
<title>Sticker Generator</title>
|
||||||
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3358
package-lock.json
generated
Normal file
32
package.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "stikerbotfront",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.66.3",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.1.5",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.19.0",
|
||||||
|
"@types/react": "^19.0.8",
|
||||||
|
"@types/react-dom": "^19.0.3",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"eslint": "^9.19.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.18",
|
||||||
|
"globals": "^15.14.0",
|
||||||
|
"typescript": "~5.7.2",
|
||||||
|
"typescript-eslint": "^8.22.0",
|
||||||
|
"vite": "^6.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
42
src/App.css
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
107
src/App.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import React, { lazy, Suspense, useEffect } from 'react';
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate, Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import Layout from './components/layout/Layout';
|
||||||
|
import Home from './screens/Home';
|
||||||
|
|
||||||
|
// Ленивая загрузка компонентов
|
||||||
|
const OnboardingWelcome = lazy(() => import('./screens/onboarding/OnboardingWelcome'));
|
||||||
|
const OnboardingHowTo = lazy(() => import('./screens/onboarding/OnboardingHowTo'));
|
||||||
|
const OnboardingStickerPacks = lazy(() => import('./screens/onboarding/OnboardingStickerPacks'));
|
||||||
|
const TermsAndConditions = lazy(() => import('./screens/onboarding/TermsAndConditions'));
|
||||||
|
const CreateSticker = lazy(() => import('./screens/CreateSticker'));
|
||||||
|
const Gallery = lazy(() => import('./screens/Gallery'));
|
||||||
|
const Profile = lazy(() => import('./screens/Profile'));
|
||||||
|
const StickerPacks = lazy(() => import('./screens/StickerPacks'));
|
||||||
|
const CreateStickerPack = lazy(() => import('./screens/CreateStickerPack'));
|
||||||
|
const AddStickerToPackScreen = lazy(() => import('./screens/AddStickerToPackScreen'));
|
||||||
|
const History = lazy(() => import('./screens/History'));
|
||||||
|
const CropPhoto = lazy(() => import('./screens/CropPhoto'));
|
||||||
|
|
||||||
|
// Компонент для отображения состояния загрузки
|
||||||
|
const LoadingScreen = () => (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh'
|
||||||
|
}}>
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Компонент для проверки онбординга
|
||||||
|
const AppContent: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Проверяем, видел ли пользователь онбординг и принял ли условия
|
||||||
|
const hasSeenOnboarding = localStorage.getItem('hasSeenOnboarding') === 'true';
|
||||||
|
const hasAcceptedTerms = localStorage.getItem('hasAcceptedTerms') === 'true';
|
||||||
|
|
||||||
|
// Если не видел онбординг и не на странице онбординга или условий
|
||||||
|
if (!hasSeenOnboarding && !location.pathname.includes('/onboarding')) {
|
||||||
|
navigate('/onboarding/welcome');
|
||||||
|
}
|
||||||
|
// Если видел онбординг, но не принял условия и не на странице условий
|
||||||
|
else if (hasSeenOnboarding && !hasAcceptedTerms && !location.pathname.includes('/onboarding/terms')) {
|
||||||
|
navigate('/onboarding/terms');
|
||||||
|
}
|
||||||
|
}, [navigate, location.pathname]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
{/* Онбординг */}
|
||||||
|
<Route path="/onboarding">
|
||||||
|
<Route index element={<Navigate to="/onboarding/welcome" replace />} />
|
||||||
|
<Route path="welcome" element={
|
||||||
|
<Suspense fallback={<LoadingScreen />}>
|
||||||
|
<OnboardingWelcome />
|
||||||
|
</Suspense>
|
||||||
|
} />
|
||||||
|
<Route path="how-to" element={
|
||||||
|
<Suspense fallback={<LoadingScreen />}>
|
||||||
|
<OnboardingHowTo />
|
||||||
|
</Suspense>
|
||||||
|
} />
|
||||||
|
<Route path="sticker-packs" element={
|
||||||
|
<Suspense fallback={<LoadingScreen />}>
|
||||||
|
<OnboardingStickerPacks />
|
||||||
|
</Suspense>
|
||||||
|
} />
|
||||||
|
<Route path="terms" element={
|
||||||
|
<Suspense fallback={<LoadingScreen />}>
|
||||||
|
<TermsAndConditions />
|
||||||
|
</Suspense>
|
||||||
|
} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Основные экраны */}
|
||||||
|
<Route element={<Layout>
|
||||||
|
<Suspense fallback={<LoadingScreen />}>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
|
</Layout>}>
|
||||||
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/create" element={<CreateSticker />} />
|
||||||
|
<Route path="/gallery" element={<Gallery />} />
|
||||||
|
<Route path="/packs" element={<StickerPacks />} />
|
||||||
|
<Route path="/create-sticker-pack" element={<CreateStickerPack />} />
|
||||||
|
<Route path="/add-sticker/:packName" element={<AddStickerToPackScreen />} />
|
||||||
|
<Route path="/profile" element={<Profile />} />
|
||||||
|
<Route path="/history" element={<History />} />
|
||||||
|
<Route path="/crop-photo" element={<CropPhoto />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const App: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<AppContent />
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
BIN
src/assets/250x_santa — копия.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
src/assets/250x_santa.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
src/assets/ahare_bot250.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src/assets/balerina250x.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
src/assets/balloon250x.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
src/assets/bicecle250x.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
src/assets/book250x.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
src/assets/coctail250x.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
src/assets/coffee250x.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
src/assets/cook250x.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
src/assets/cowboy250x.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
0
src/assets/default-avatar.svg
Normal file
BIN
src/assets/detektiv250x.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
src/assets/dog250x.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
src/assets/emotions_promo250x.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
src/assets/fairy250x.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
src/assets/faq250.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src/assets/fire250x.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
src/assets/flowers250x.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
src/assets/gift250x.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
src/assets/icecream250x.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
src/assets/knight250x.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
src/assets/moto250x.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
src/assets/newspaper250x.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
src/assets/onboard1.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
src/assets/prompt.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
25
src/assets/prompts.md
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
riding in a sports car
|
||||||
|
Riding a skateboard with the wind in the hair
|
||||||
|
Drinking coffee from a cup and smiling
|
||||||
|
Holding a bouquet of flowers and dancing
|
||||||
|
Holding a balloon and waving
|
||||||
|
Reading a book
|
||||||
|
Holding an ice cream cone
|
||||||
|
Holding an umbrella in the rain
|
||||||
|
with a cocktail in hand
|
||||||
|
Holding a gift
|
||||||
|
Playing with a dog
|
||||||
|
reading a newspaper
|
||||||
|
Riding a bicycle
|
||||||
|
Dressed as a surfer, holding a surfboard
|
||||||
|
Dressed as a detective, holding a magnifying glass
|
||||||
|
Dressed as a biker, holding a motorcycle helmet
|
||||||
|
Dressed as a fairy, holding a magic wand !!
|
||||||
|
Dressed as a scientist, holding a test tube
|
||||||
|
Dressed as a cowboy
|
||||||
|
Dressed as a knight
|
||||||
|
Dressed as a ballerina
|
||||||
|
Dressed as a firefighter
|
||||||
|
Dressed as a chef
|
||||||
|
|
||||||
|
|
||||||
25
src/assets/prompts.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
export const prompts: Record<string, string> = {
|
||||||
|
'chibi-sportscar': 'riding in a sports car',
|
||||||
|
'chibi-skateboard': 'Riding a skateboard with the wind in the hair',
|
||||||
|
'chibi-coffee': 'Drinking coffee from a cup and smiling',
|
||||||
|
'chibi-flowers': 'Holding a bouquet of flowers and dancing',
|
||||||
|
'chibi-balloon': 'Holding a balloon and waving',
|
||||||
|
'chibi-book': 'Reading a book',
|
||||||
|
'chibi-icecream': 'Holding an ice cream cone',
|
||||||
|
'chibi-umbrella': 'Holding an umbrella in the rain',
|
||||||
|
'chibi-cocktail': 'with a cocktail in hand',
|
||||||
|
'chibi-gift': 'Holding a gift',
|
||||||
|
'chibi-dog': 'Playing with a dog',
|
||||||
|
'chibi-newspaper': 'reading a newspaper',
|
||||||
|
'chibi-bicycle': 'Riding a bicycle',
|
||||||
|
'chibi-surfer': 'Dressed as a surfer, holding a surfboard',
|
||||||
|
'chibi-detective': 'Dressed as a detective, holding a magnifying glass',
|
||||||
|
'chibi-biker': 'Dressed as a biker, holding a motorcycle helmet',
|
||||||
|
'chibi-fairy': 'Dressed as a fairy, holding a magic wand',
|
||||||
|
'chibi-scientist': 'Dressed as a scientist, holding a test tube',
|
||||||
|
'chibi-cowboy': 'Dressed as a cowboy',
|
||||||
|
'chibi-knight': 'Dressed as a knight',
|
||||||
|
'chibi-ballerina': 'Dressed as a ballerina',
|
||||||
|
'chibi-firefighter': 'Dressed as a firefighter',
|
||||||
|
'chibi-chef': 'Dressed as a chef'
|
||||||
|
};
|
||||||
1
src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
BIN
src/assets/realism_promo250x.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
src/assets/scateboard250x.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
src/assets/shield-icon.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src/assets/shorts250.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src/assets/sience250x.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
src/assets/sportcar250x.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
src/assets/surfing250x.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
src/assets/umbrella250x.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
62
src/components/blocks/BlockRenderer.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Block, ButtonBlock, DividerBlock as DividerBlockType, TextInputBlock as TextInputBlockType, GenerateButtonBlock, StepTitleBlock } from '../../types/blocks';
|
||||||
|
import ScrollableButtonsBlock from './ScrollableButtonsBlock';
|
||||||
|
import GridButtonsBlock from './GridButtonsBlock';
|
||||||
|
import UploadPhotoBlock from './UploadPhotoBlock';
|
||||||
|
import DividerBlock from './DividerBlock';
|
||||||
|
import TextInputBlock from './TextInputBlock';
|
||||||
|
import GenerateButton from './GenerateButton';
|
||||||
|
import StepTitle from './StepTitle';
|
||||||
|
|
||||||
|
interface BlockRendererProps {
|
||||||
|
block: Block;
|
||||||
|
onAction?: (actionType: string, actionValue: string, blockId?: string) => void;
|
||||||
|
extraProps?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BlockRenderer: React.FC<BlockRendererProps> = ({ block, onAction, extraProps }) => {
|
||||||
|
switch (block.type) {
|
||||||
|
case 'scrollableButtons':
|
||||||
|
case 'gridButtons':
|
||||||
|
const buttonBlock = block as ButtonBlock;
|
||||||
|
if (block.type === 'scrollableButtons') {
|
||||||
|
return <ScrollableButtonsBlock block={buttonBlock} onAction={onAction} />;
|
||||||
|
}
|
||||||
|
return <GridButtonsBlock block={buttonBlock} onAction={onAction} isInputVisible={extraProps?.visible} />;
|
||||||
|
case 'uploadPhoto':
|
||||||
|
return <UploadPhotoBlock
|
||||||
|
previewUrl={window.history.state?.usr?.previewUrl}
|
||||||
|
onPhotoSelect={(file) => {
|
||||||
|
const tempUrl = URL.createObjectURL(file);
|
||||||
|
window.history.replaceState(
|
||||||
|
{ usr: { previewUrl: tempUrl } },
|
||||||
|
'',
|
||||||
|
window.location.pathname
|
||||||
|
);
|
||||||
|
return () => URL.revokeObjectURL(tempUrl);
|
||||||
|
}}
|
||||||
|
/>;
|
||||||
|
case 'divider':
|
||||||
|
return <DividerBlock block={block as DividerBlockType} />;
|
||||||
|
case 'textInput':
|
||||||
|
return <TextInputBlock
|
||||||
|
block={block as TextInputBlockType}
|
||||||
|
visible={!!extraProps?.visible}
|
||||||
|
onTextChange={extraProps?.onTextChange}
|
||||||
|
/>;
|
||||||
|
case 'generateButton':
|
||||||
|
const generateBlock = block as GenerateButtonBlock;
|
||||||
|
return <GenerateButton
|
||||||
|
tokenCount={generateBlock.tokenCount}
|
||||||
|
onGenerate={() => onAction?.('function', 'startGeneration', generateBlock.id)}
|
||||||
|
/>;
|
||||||
|
case 'stepTitle':
|
||||||
|
const stepBlock = block as StepTitleBlock;
|
||||||
|
return <StepTitle number={stepBlock.number} text={stepBlock.text} />;
|
||||||
|
default:
|
||||||
|
console.warn(`Unknown block type: ${block.type}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BlockRenderer;
|
||||||
5
src/components/blocks/DividerBlock.module.css
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
.divider {
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
21
src/components/blocks/DividerBlock.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import styles from './DividerBlock.module.css';
|
||||||
|
import { DividerBlock as DividerBlockType } from '../../types/blocks';
|
||||||
|
|
||||||
|
interface DividerBlockProps {
|
||||||
|
block: DividerBlockType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DividerBlock: React.FC<DividerBlockProps> = ({ block }) => {
|
||||||
|
const { style } = block;
|
||||||
|
const { margin = 16 } = style || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.divider}
|
||||||
|
style={{ margin: `${margin}px 0` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DividerBlock;
|
||||||
37
src/components/blocks/GenerateButton.module.css
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
.generateButton {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 28rem;
|
||||||
|
margin: 24px auto;
|
||||||
|
padding: 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #4CAF50, #45A049);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generateButton:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.generateButton:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.generateButtonText {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tokenCount {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
23
src/components/blocks/GenerateButton.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import styles from './GenerateButton.module.css';
|
||||||
|
|
||||||
|
interface GenerateButtonProps {
|
||||||
|
onGenerate: () => void;
|
||||||
|
tokenCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GenerateButton: React.FC<GenerateButtonProps> = ({ onGenerate, tokenCount }) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={styles.generateButton}
|
||||||
|
onClick={onGenerate}
|
||||||
|
>
|
||||||
|
<span className={styles.generateButtonText}>
|
||||||
|
Начать генерацию
|
||||||
|
<span className={styles.tokenCount}>{tokenCount} токенов</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GenerateButton;
|
||||||
122
src/components/blocks/GridButtonsBlock.module.css
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridContainer {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 48px; /* Место для кнопки */
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
height: auto;
|
||||||
|
transition: height 0.2s ease-in-out;
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
gap: 8px;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonWrapper {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
height: 0;
|
||||||
|
padding-bottom: 100%; /* Это обеспечит квадратную форму */
|
||||||
|
--delay: calc((var(--n) - 1) * 0.05s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonWrapper > * {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Анимации */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonWrapper {
|
||||||
|
animation: fadeIn 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Задержка анимации для первых 6 кнопок */
|
||||||
|
.grid:not(.expanded) .buttonWrapper {
|
||||||
|
animation-delay: var(--delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Задержка анимации для дополнительных кнопок */
|
||||||
|
.grid.expanded .buttonWrapper:nth-child(n+7) {
|
||||||
|
animation-delay: calc((var(--n) - 7) * 0.05s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Кнопка "Показать больше" */
|
||||||
|
.showMoreButton {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 0;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, #2196F3, #1976D2);
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
animation: fadeIn 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showMoreButton:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showMoreButton:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Анимация разворачивания */
|
||||||
|
.grid {
|
||||||
|
transition: max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 240px; /* Высота для 2 рядов */
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid.expanded {
|
||||||
|
max-height: 1200px; /* Увеличенная высота для всех кнопок */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Добавляем тень при наведении на весь блок */
|
||||||
|
.container:hover .buttonWrapper {
|
||||||
|
filter: brightness(0.95);
|
||||||
|
transition: filter 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container:hover .buttonWrapper:hover {
|
||||||
|
filter: brightness(1.05);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
69
src/components/blocks/GridButtonsBlock.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ButtonBlock } from '../../types/blocks';
|
||||||
|
import SquareButton from './SquareButton';
|
||||||
|
import styles from './GridButtonsBlock.module.css';
|
||||||
|
|
||||||
|
interface GridButtonsBlockProps {
|
||||||
|
block: ButtonBlock;
|
||||||
|
onAction?: (actionType: string, actionValue: string, blockId?: string, buttonId?: string) => void;
|
||||||
|
isInputVisible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GridButtonsBlock: React.FC<GridButtonsBlockProps> = ({ block, onAction, isInputVisible }) => {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const { buttons, style } = block;
|
||||||
|
const {
|
||||||
|
gap = 12,
|
||||||
|
padding = 16,
|
||||||
|
buttonSize = 100,
|
||||||
|
columns = 3
|
||||||
|
} = style;
|
||||||
|
|
||||||
|
const visibleButtons = expanded ? buttons : buttons.slice(0, 6);
|
||||||
|
const hasMoreButtons = buttons.length > 6;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.container}
|
||||||
|
style={{ padding }}
|
||||||
|
>
|
||||||
|
<div className={styles.gridContainer}>
|
||||||
|
<div
|
||||||
|
className={`${styles.grid} ${expanded ? styles.expanded : ''}`}
|
||||||
|
style={{
|
||||||
|
gap,
|
||||||
|
gridTemplateColumns: `repeat(${columns}, 1fr)`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{visibleButtons.map((button, index) => (
|
||||||
|
<div
|
||||||
|
key={button.id}
|
||||||
|
className={styles.buttonWrapper}
|
||||||
|
style={{ '--n': index + 1 } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<SquareButton
|
||||||
|
{...button}
|
||||||
|
size={buttonSize}
|
||||||
|
onClick={() => {
|
||||||
|
if (button.action && onAction) {
|
||||||
|
onAction(button.action.type, button.action.value, block.id, button.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{hasMoreButtons && (
|
||||||
|
<button
|
||||||
|
className={styles.showMoreButton}
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
>
|
||||||
|
{expanded ? 'Свернуть' : 'Показать больше'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GridButtonsBlock;
|
||||||
52
src/components/blocks/ScrollableButtonsBlock.module.css
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
margin: 0 calc(-1 * var(--spacing-medium));
|
||||||
|
padding: var(--spacing-medium);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollArea {
|
||||||
|
display: flex;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
gap: calc(var(--spacing-medium) * 0.75);
|
||||||
|
padding-bottom: var(--spacing-small); /* Для градиента */
|
||||||
|
padding-right: var(--spacing-medium); /* Отступ для последнего элемента */
|
||||||
|
margin-right: calc(-1 * var(--spacing-medium)); /* Компенсация отступа контейнера */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Скрываем скроллбар для Chrome, Safari и Opera */
|
||||||
|
.scrollArea::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonWrapper {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 120px; /* Фиксированная ширина для кнопок */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Добавляем градиент-индикатор скролла справа */
|
||||||
|
.container::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: var(--spacing-small);
|
||||||
|
width: 48px;
|
||||||
|
background: linear-gradient(to right,
|
||||||
|
rgba(var(--color-background-rgb), 0) 0%,
|
||||||
|
rgba(var(--color-background-rgb), 0.95) 100%
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
45
src/components/blocks/ScrollableButtonsBlock.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ButtonBlock } from '../../types/blocks';
|
||||||
|
import SquareButton from './SquareButton';
|
||||||
|
import styles from './ScrollableButtonsBlock.module.css';
|
||||||
|
|
||||||
|
interface ScrollableButtonsBlockProps {
|
||||||
|
block: ButtonBlock;
|
||||||
|
onAction?: (actionType: string, actionValue: string, blockId?: string, buttonId?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScrollableButtonsBlock: React.FC<ScrollableButtonsBlockProps> = ({ block, onAction }) => {
|
||||||
|
const { buttons, style } = block;
|
||||||
|
const { gap = 16, padding = 16, buttonSize = 150 } = style;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.container}
|
||||||
|
style={{ padding }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={styles.scrollArea}
|
||||||
|
style={{ gap }}
|
||||||
|
>
|
||||||
|
{buttons.map(button => (
|
||||||
|
<div
|
||||||
|
key={button.id}
|
||||||
|
className={styles.buttonWrapper}
|
||||||
|
>
|
||||||
|
<SquareButton
|
||||||
|
{...button}
|
||||||
|
size={buttonSize}
|
||||||
|
onClick={() => {
|
||||||
|
if (button.action && onAction) {
|
||||||
|
onAction(button.action.type, button.action.value, block.id, button.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScrollableButtonsBlock;
|
||||||
177
src/components/blocks/SquareButton.module.css
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
.buttonWrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
position: relative;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: var(--spacing-small);
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
text-align: left;
|
||||||
|
background-size: 100% 100%;
|
||||||
|
background-position: center;
|
||||||
|
will-change: transform, background-position;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
perspective: 1000px;
|
||||||
|
-webkit-perspective: 1000px;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
-webkit-backface-visibility: hidden;
|
||||||
|
-webkit-transform-style: preserve-3d;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button[disabled] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button[disabled] .content,
|
||||||
|
.button[disabled] .icon,
|
||||||
|
.button[disabled] .iconImage {
|
||||||
|
opacity: 0.5;
|
||||||
|
filter: grayscale(60%);
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button[disabled]::after {
|
||||||
|
display: block;
|
||||||
|
opacity: 1;
|
||||||
|
background: linear-gradient(180deg,
|
||||||
|
rgba(0,0,0,0.2) 0%,
|
||||||
|
rgba(0,0,0,0.3) 50%,
|
||||||
|
rgba(0,0,0,0.4) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button[disabled]::before {
|
||||||
|
content: "Скоро";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
text-shadow: 0 2px 4px rgba(0,0,0,0.4);
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 1;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
margin-bottom: var(--spacing-small);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
perspective: 1000px;
|
||||||
|
-webkit-perspective: 1000px;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconImage {
|
||||||
|
position: absolute;
|
||||||
|
top: 1%;
|
||||||
|
left: 0%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
transform: scale(1) translate3d(0, 0, 0);
|
||||||
|
transform-origin: center;
|
||||||
|
perspective: 1000px;
|
||||||
|
-webkit-perspective: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover .icon,
|
||||||
|
.button:hover .iconImage {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: var(--spacing-small);
|
||||||
|
z-index: 1; /* Поверх оверлея */
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
padding: 0 2px;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
perspective: 1000px;
|
||||||
|
-webkit-perspective: 1000px;
|
||||||
|
text-rendering: geometricPrecision;
|
||||||
|
-webkit-font-smoothing: subpixel-antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin: 0;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
perspective: 1000px;
|
||||||
|
-webkit-perspective: 1000px;
|
||||||
|
text-rendering: geometricPrecision;
|
||||||
|
-webkit-font-smoothing: subpixel-antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Оверлей для изображений */
|
||||||
|
.button::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(180deg,
|
||||||
|
rgba(0,0,0,0) 0%,
|
||||||
|
rgba(0,0,0,0.1) 50%,
|
||||||
|
rgba(0,0,0,0.2) 100%
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
100
src/components/blocks/SquareButton.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { BlockButton } from '../../types/blocks';
|
||||||
|
import styles from './SquareButton.module.css';
|
||||||
|
|
||||||
|
interface SquareButtonProps extends BlockButton {
|
||||||
|
size?: number;
|
||||||
|
onClick?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SquareButton: React.FC<SquareButtonProps> = ({
|
||||||
|
background,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
icon,
|
||||||
|
imageUrl,
|
||||||
|
color,
|
||||||
|
action,
|
||||||
|
size = 100,
|
||||||
|
onClick,
|
||||||
|
disabled
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (onClick) {
|
||||||
|
onClick();
|
||||||
|
} else if (action) {
|
||||||
|
if (action.type === 'route') {
|
||||||
|
navigate(action.value);
|
||||||
|
} else if (action.type === 'function') {
|
||||||
|
// В будущем здесь будет обработка функций
|
||||||
|
console.log('Function action:', action.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBackground = () => {
|
||||||
|
switch (background.type) {
|
||||||
|
case 'image':
|
||||||
|
return {
|
||||||
|
backgroundImage: `url(${background.url})`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
...(background.overlay && {
|
||||||
|
'&::after': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: background.overlay
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
case 'gradient':
|
||||||
|
return {
|
||||||
|
background: background.colors?.length
|
||||||
|
? `linear-gradient(135deg, ${background.colors.join(', ')})`
|
||||||
|
: undefined
|
||||||
|
};
|
||||||
|
case 'color':
|
||||||
|
return {
|
||||||
|
backgroundColor: background.colors?.[0]
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.buttonWrapper}>
|
||||||
|
<button
|
||||||
|
className={styles.button}
|
||||||
|
onClick={handleClick}
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
...getBackground(),
|
||||||
|
color: color || '#FFFFFF'
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{imageUrl ? (
|
||||||
|
<img src={imageUrl} alt={title} className={styles.iconImage} />
|
||||||
|
) : icon ? (
|
||||||
|
<span className={styles.icon}>{icon}</span>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.content}>
|
||||||
|
<span className={styles.title}>{title}</span>
|
||||||
|
{subtitle && <span className={styles.subtitle}>{subtitle}</span>}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SquareButton;
|
||||||
26
src/components/blocks/StepTitle.module.css
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
.stepTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-small);
|
||||||
|
padding: 8px var(--spacing-medium) 2px;
|
||||||
|
color: var(--color-text);
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepNumber {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepText {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
18
src/components/blocks/StepTitle.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import styles from './StepTitle.module.css';
|
||||||
|
|
||||||
|
interface StepTitleProps {
|
||||||
|
number: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepTitle: React.FC<StepTitleProps> = ({ number, text }) => {
|
||||||
|
return (
|
||||||
|
<div className={styles.stepTitle}>
|
||||||
|
<span className={styles.stepNumber}>{number}</span>
|
||||||
|
<span className={styles.stepText}>{text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StepTitle;
|
||||||
47
src/components/blocks/TextInputBlock.module.css
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
.container {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transform: translateY(-10px) scale(0.98);
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
background-color: rgba(var(--color-surface-rgb), 0.8);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
-webkit-backdrop-filter: blur(5px);
|
||||||
|
will-change: transform, max-height, opacity, margin, padding;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 var(--spacing-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.visible {
|
||||||
|
max-height: 200px;
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
margin: var(--spacing-medium) 0;
|
||||||
|
padding: var(--spacing-small) var(--spacing-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: calc(100% - var(--spacing-small) * 2);
|
||||||
|
min-height: 80px;
|
||||||
|
margin: var(--spacing-small);
|
||||||
|
padding: var(--spacing-small);
|
||||||
|
border: 1px solid rgba(var(--color-text-rgb), 0.1);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: none;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::placeholder {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
33
src/components/blocks/TextInputBlock.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import styles from './TextInputBlock.module.css';
|
||||||
|
import { TextInputBlock as TextInputBlockType } from '../../types/blocks';
|
||||||
|
|
||||||
|
interface TextInputBlockProps {
|
||||||
|
block: TextInputBlockType;
|
||||||
|
visible: boolean;
|
||||||
|
onTextChange?: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TextInputBlock: React.FC<TextInputBlockProps> = ({ block, visible, onTextChange }) => {
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const newText = e.target.value;
|
||||||
|
setText(newText);
|
||||||
|
onTextChange?.(newText);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.container} ${visible ? styles.visible : ''}`}>
|
||||||
|
<textarea
|
||||||
|
className={styles.input}
|
||||||
|
placeholder="Опишите, какой стикер вы хотите получить..."
|
||||||
|
rows={3}
|
||||||
|
value={text}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TextInputBlock;
|
||||||
118
src/components/blocks/UploadPhotoBlock.module.css
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadArea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 160px;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border: 2px dashed var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--spacing-medium);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: var(--spacing-medium);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadArea:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background-color: rgba(var(--color-surface-rgb), 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragging {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background-color: rgba(var(--color-primary), 0.05);
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: var(--spacing-small);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadArea:hover .icon {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-small);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileInput {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewImage {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hasPreview {
|
||||||
|
border-style: solid;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changeOverlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hasPreview:hover .changeOverlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.changeText {
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: var(--spacing-small) var(--spacing-medium);
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
border-radius: calc(var(--border-radius) / 2);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
89
src/components/blocks/UploadPhotoBlock.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import styles from './UploadPhotoBlock.module.css';
|
||||||
|
|
||||||
|
interface UploadPhotoBlockProps {
|
||||||
|
onPhotoSelect?: (file: File) => void;
|
||||||
|
previewUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UploadPhotoBlock: React.FC<UploadPhotoBlockProps> = ({ onPhotoSelect, previewUrl }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
const handleFileSelect = (file: File) => {
|
||||||
|
if (file && file.type.startsWith('image/')) {
|
||||||
|
onPhotoSelect?.(file);
|
||||||
|
navigate('/crop-photo', { state: { file } });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) {
|
||||||
|
handleFileSelect(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
handleFileSelect(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div
|
||||||
|
className={`${styles.uploadArea} ${isDragging ? styles.dragging : ''} ${previewUrl ? styles.hasPreview : ''}`}
|
||||||
|
onClick={handleClick}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
>
|
||||||
|
{previewUrl ? (
|
||||||
|
<div className={styles.preview}>
|
||||||
|
<img src={previewUrl} alt="Preview" className={styles.previewImage} />
|
||||||
|
<div className={styles.changeOverlay}>
|
||||||
|
<span className={styles.changeText}>Изменить фото</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={styles.icon}>📷</div>
|
||||||
|
<div className={styles.text}>
|
||||||
|
<span className={styles.title}>Загрузите фото</span>
|
||||||
|
<span className={styles.subtitle}>Перетащите или нажмите для выбора</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className={styles.fileInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UploadPhotoBlock;
|
||||||
110
src/components/layout/Header.module.css
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
.header {
|
||||||
|
background-color: #FFFFFF; /* Непрозрачный белый */
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
/* Удалены эффекты размытия и прозрачности */
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 28rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--spacing-small) var(--spacing-medium);
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-small);
|
||||||
|
padding: var(--spacing-small);
|
||||||
|
margin: calc(-1 * var(--spacing-small));
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile:hover {
|
||||||
|
background-color: rgba(var(--color-text-rgb), 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile:active {
|
||||||
|
background-color: rgba(var(--color-text-rgb), 0.08);
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--color-background);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile:hover .avatar {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarImage {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile:hover .username {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-small);
|
||||||
|
background-color: rgba(var(--color-text-rgb), 0.05);
|
||||||
|
padding: var(--spacing-small) var(--spacing-medium);
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance:hover {
|
||||||
|
background-color: rgba(var(--color-text-rgb), 0.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balanceIcon {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balanceValue {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
64
src/components/layout/Header.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { MOCK_USER } from '../../constants/mock';
|
||||||
|
import { getUserInfo, isTelegramWebAppAvailable } from '../../constants/user';
|
||||||
|
import styles from './Header.module.css';
|
||||||
|
|
||||||
|
const Header: React.FC = () => {
|
||||||
|
const [user, setUser] = useState(MOCK_USER);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Получаем информацию о пользователе
|
||||||
|
const userInfo = getUserInfo();
|
||||||
|
|
||||||
|
// Если есть данные из Telegram, обновляем состояние
|
||||||
|
if (isTelegramWebAppAvailable()) {
|
||||||
|
setUser({
|
||||||
|
telegramId: userInfo.id,
|
||||||
|
username: userInfo.first_name + (userInfo.last_name ? ` ${userInfo.last_name}` : ''),
|
||||||
|
avatarUrl: userInfo.photo_url || MOCK_USER.avatarUrl, // Используем фото из Telegram или дефолтное
|
||||||
|
balance: MOCK_USER.balance // Баланс оставляем из моковых данных
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={styles.header}>
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.content}>
|
||||||
|
{/* Профиль пользователя */}
|
||||||
|
<Link to="/profile" className={styles.profile}>
|
||||||
|
<div className={styles.avatar}>
|
||||||
|
<img
|
||||||
|
src={user.avatarUrl}
|
||||||
|
alt="Avatar"
|
||||||
|
className={styles.avatarImage}
|
||||||
|
onError={(e) => {
|
||||||
|
e.currentTarget.onerror = null;
|
||||||
|
e.currentTarget.src = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23999"><circle cx="12" cy="8" r="4"/><path d="M12 13c-4 0-8 2-8 6v1h16v-1c0-4-4-6-8-6z"/></svg>';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={styles.username}>
|
||||||
|
{user.username}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Баланс токенов */}
|
||||||
|
<button
|
||||||
|
className={styles.balance}
|
||||||
|
onClick={() => alert('Пополнить баланс')}
|
||||||
|
title="Нажмите чтобы пополнить баланс"
|
||||||
|
>
|
||||||
|
<span className={styles.balanceIcon}>💎</span>
|
||||||
|
<span className={styles.balanceValue}>
|
||||||
|
{user.balance}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
||||||
62
src/components/layout/Layout.module.css
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
.layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: var(--color-background);
|
||||||
|
padding-bottom: 4rem; /* Отступ для навигации */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 28rem;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.error {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 200px;
|
||||||
|
padding: var(--spacing-large);
|
||||||
|
text-align: center;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error h2 {
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-bottom: var(--spacing-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error button {
|
||||||
|
padding: var(--spacing-small) var(--spacing-medium);
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error button:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
53
src/components/layout/Layout.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import React, { Suspense } from 'react';
|
||||||
|
import ErrorBoundary from '../shared/ErrorBoundary';
|
||||||
|
import Header from './Header';
|
||||||
|
import Navigation from './Navigation';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import styles from './Layout.module.css';
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ErrorFallback = () => (
|
||||||
|
<div className={styles.error}>
|
||||||
|
<h2>Что-то пошло не так</h2>
|
||||||
|
<button onClick={() => window.location.reload()}>
|
||||||
|
Обновить страницу
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const LoadingFallback = () => (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const isOnboarding = location.pathname.includes('/onboarding');
|
||||||
|
|
||||||
|
// Не показываем header и navigation на экранах онбординга
|
||||||
|
if (isOnboarding) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.layout}>
|
||||||
|
<Header />
|
||||||
|
<main className={styles.main}>
|
||||||
|
<div className={styles.container}>
|
||||||
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
|
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||||
|
{children}
|
||||||
|
</ErrorBoundary>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<Navigation />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
84
src/components/layout/Navigation.module.css
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
.nav {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background-color: #FFFFFF; /* Непрозрачный белый, как у хедера */
|
||||||
|
border-top: 1px solid var(--color-border); /* Добавляем верхнюю границу как у хедера */
|
||||||
|
z-index: 100;
|
||||||
|
/* Удалены эффекты размытия и прозрачности */
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 28rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 4px var(--spacing-medium); /* Уменьшенный верхний и нижний отступ */
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 0; /* Уменьшенный верхний и нижний отступ */
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
width: 70px; /* Фиксированная ширина */
|
||||||
|
height: 70px; /* Фиксированная высота */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
border: none; /* Убираем обводку */
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background: linear-gradient(135deg, #ffffff, #e0e0e0); /* Более контрастный градиент */
|
||||||
|
color: var(--color-text);
|
||||||
|
opacity: 0.8;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
background: linear-gradient(135deg, var(--color-primary), #1976D2); /* Более яркий градиент */
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 2px 8px rgba(43, 156, 255, 0.3);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 32px; /* Увеличенная высота */
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
/* Убедимся, что все иконки имеют одинаковый размер */
|
||||||
|
min-width: 24px;
|
||||||
|
min-height: 24px;
|
||||||
|
max-width: 24px;
|
||||||
|
max-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap; /* Предотвращаем перенос текста */
|
||||||
|
}
|
||||||
73
src/components/layout/Navigation.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import styles from './Navigation.module.css';
|
||||||
|
|
||||||
|
const NavigationComponent: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const isActive = (path: string) => location.pathname === path;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className={styles.nav}>
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.list}>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className={`${styles.item} ${isActive('/') ? styles.active : ''}`}
|
||||||
|
>
|
||||||
|
<span className={styles.icon}>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4 10l8-6 8 6v10a2 2 0 01-2 2H6a2 2 0 01-2-2V10z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M9 22V12h6v10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className={styles.label}>Главная</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/gallery')}
|
||||||
|
className={`${styles.item} ${isActive('/gallery') ? styles.active : ''}`}
|
||||||
|
>
|
||||||
|
<span className={styles.icon}>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor"/>
|
||||||
|
<path d="M20 16l-4-4L6 20" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className={styles.label}>Галерея</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/packs')}
|
||||||
|
className={`${styles.item} ${isActive('/packs') ? styles.active : ''}`}
|
||||||
|
>
|
||||||
|
<span className={styles.icon}>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 16V8a2 2 0 00-1-1.73l-6-3.45a2 2 0 00-2 0l-6 3.45A2 2 0 004 8v8a2 2 0 001 1.73l6 3.45a2 2 0 002 0l6-3.45A2 2 0 0020 16z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M4 8l8 4 8-4M12 12v8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className={styles.label}>Стикерпаки</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/profile')}
|
||||||
|
className={`${styles.item} ${isActive('/profile') ? styles.active : ''}`}
|
||||||
|
>
|
||||||
|
<span className={styles.icon}>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<circle cx="12" cy="7" r="4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className={styles.label}>Профиль</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NavigationComponent;
|
||||||
35
src/components/shared/ErrorBoundary.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
FallbackComponent: React.ComponentType<{ error?: Error }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorBoundary extends Component<Props, State> {
|
||||||
|
public state: State = {
|
||||||
|
hasError: false
|
||||||
|
};
|
||||||
|
|
||||||
|
public static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('Uncaught error:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return <this.props.FallbackComponent error={this.state.error} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary;
|
||||||
53
src/components/shared/ImageViewer.module.css
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.9);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullImage {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 90vh;
|
||||||
|
object-fit: contain;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeButton {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 2rem;
|
||||||
|
width: 44px; /* Минимальный размер для удобного нажатия на мобильных */
|
||||||
|
height: 44px; /* Минимальный размер для удобного нажатия на мобильных */
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1001;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Медиа-запрос для мобильных устройств */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.fullImage {
|
||||||
|
max-height: 80vh; /* Немного уменьшаем высоту на мобильных */
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/components/shared/ImageViewer.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import styles from './ImageViewer.module.css';
|
||||||
|
|
||||||
|
interface ImageViewerProps {
|
||||||
|
imageUrl: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageViewer: React.FC<ImageViewerProps> = ({ imageUrl, onClose }) => {
|
||||||
|
const [touchStart, setTouchStart] = useState<number | null>(null);
|
||||||
|
const [touchEnd, setTouchEnd] = useState<number | null>(null);
|
||||||
|
const [translateY, setTranslateY] = useState(0);
|
||||||
|
|
||||||
|
// Минимальное расстояние свайпа для закрытия (в пикселях)
|
||||||
|
const minSwipeDistance = 50;
|
||||||
|
|
||||||
|
// Закрытие при клике на фон
|
||||||
|
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обработка свайпа вверх для закрытия (для мобильных устройств)
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
setTouchEnd(null);
|
||||||
|
setTouchStart(e.targetTouches[0].clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e: React.TouchEvent) => {
|
||||||
|
setTouchEnd(e.targetTouches[0].clientY);
|
||||||
|
|
||||||
|
if (touchStart) {
|
||||||
|
// Вычисляем смещение (ограничиваем максимальное значение)
|
||||||
|
const currentDiff = touchStart - e.targetTouches[0].clientY;
|
||||||
|
// Используем положительное значение для движения вверх
|
||||||
|
const newTranslate = Math.max(0, Math.min(currentDiff, 100));
|
||||||
|
setTranslateY(newTranslate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
if (!touchStart || !touchEnd) return;
|
||||||
|
|
||||||
|
const distance = touchStart - touchEnd;
|
||||||
|
const isUpSwipe = distance > minSwipeDistance;
|
||||||
|
|
||||||
|
if (isUpSwipe) {
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
// Если свайп не завершен, плавно возвращаем изображение на место
|
||||||
|
setTranslateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сбрасываем значения
|
||||||
|
setTouchStart(null);
|
||||||
|
setTouchEnd(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Закрытие при нажатии Escape
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Блокируем прокрутку страницы при открытом модальном окне
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleEscKey);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Восстанавливаем прокрутку при закрытии
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
window.removeEventListener('keydown', handleEscKey);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.backdrop}
|
||||||
|
onClick={handleBackdropClick}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<button className={styles.closeButton} onClick={onClose}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt="Полноэкранный просмотр"
|
||||||
|
className={styles.fullImage}
|
||||||
|
style={{ transform: `translateY(-${translateY}px)` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageViewer;
|
||||||
166
src/components/shared/OnboardingLayout.module.css
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
.container {
|
||||||
|
min-height: 100vh;
|
||||||
|
height: 100vh; /* Фиксированная высота */
|
||||||
|
overflow: hidden; /* Предотвращает скроллинг */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--spacing-medium);
|
||||||
|
background-color: var(--color-background);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
max-width: 28rem;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 90vh; /* Ограничение высоты */
|
||||||
|
overflow: auto; /* Если контент всё же не помещается, добавляем скролл только внутри контейнера */
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: var(--spacing-large) var(--spacing-medium);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
color: var(--color-text);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: fadeIn 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: var(--spacing-medium);
|
||||||
|
color: var(--color-text);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: var(--spacing-large);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageContainer {
|
||||||
|
margin: var(--spacing-medium) 0;
|
||||||
|
text-align: center;
|
||||||
|
max-height: 30vh; /* Ограничение высоты контейнера */
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%; /* Ограничение высоты изображения */
|
||||||
|
object-fit: contain; /* Сохраняет пропорции */
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.childrenContainer {
|
||||||
|
margin: var(--spacing-medium) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonsContainer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: var(--spacing-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryButton {
|
||||||
|
padding: var(--spacing-small) var(--spacing-medium);
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryButton:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryButton:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondaryButton {
|
||||||
|
padding: var(--spacing-small) var(--spacing-medium);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondaryButton:hover {
|
||||||
|
background-color: rgba(var(--color-primary-rgb), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивность для мобильных устройств */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.content {
|
||||||
|
padding: var(--spacing-small);
|
||||||
|
max-height: 95vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: var(--spacing-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: var(--spacing-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageContainer {
|
||||||
|
max-height: 25vh;
|
||||||
|
margin: var(--spacing-small) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonsContainer {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
gap: var(--spacing-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryButton, .secondaryButton {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--spacing-small);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивность для очень маленьких экранов */
|
||||||
|
@media (max-height: 600px) {
|
||||||
|
.title {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: var(--spacing-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: var(--spacing-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageContainer {
|
||||||
|
max-height: 20vh;
|
||||||
|
margin: var(--spacing-small) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryButton, .secondaryButton {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/components/shared/OnboardingLayout.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import ProgressDots from './ProgressDots';
|
||||||
|
import styles from './OnboardingLayout.module.css';
|
||||||
|
|
||||||
|
interface OnboardingLayoutProps {
|
||||||
|
title: string;
|
||||||
|
image?: string;
|
||||||
|
description?: string;
|
||||||
|
currentStep: number;
|
||||||
|
totalSteps: number;
|
||||||
|
children?: ReactNode;
|
||||||
|
primaryButtonText: string;
|
||||||
|
secondaryButtonText?: string;
|
||||||
|
onPrimaryClick: () => void;
|
||||||
|
onSecondaryClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OnboardingLayout: React.FC<OnboardingLayoutProps> = ({
|
||||||
|
title,
|
||||||
|
image,
|
||||||
|
description,
|
||||||
|
currentStep,
|
||||||
|
totalSteps,
|
||||||
|
children,
|
||||||
|
primaryButtonText,
|
||||||
|
secondaryButtonText,
|
||||||
|
onPrimaryClick,
|
||||||
|
onSecondaryClick
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<h1 className={styles.title}>{title}</h1>
|
||||||
|
|
||||||
|
{image && (
|
||||||
|
<div className={styles.imageContainer}>
|
||||||
|
<img src={image} alt="" className={styles.image} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{description && (
|
||||||
|
<p className={styles.description}>{description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{children && (
|
||||||
|
<div className={styles.childrenContainer}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ProgressDots currentStep={currentStep} totalSteps={totalSteps} />
|
||||||
|
|
||||||
|
<div className={styles.buttonsContainer}>
|
||||||
|
<button
|
||||||
|
className={styles.primaryButton}
|
||||||
|
onClick={onPrimaryClick}
|
||||||
|
>
|
||||||
|
{primaryButtonText}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{secondaryButtonText && onSecondaryClick && (
|
||||||
|
<button
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
onClick={onSecondaryClick}
|
||||||
|
>
|
||||||
|
{secondaryButtonText}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OnboardingLayout;
|
||||||
21
src/components/shared/ProgressDots.module.css
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-small);
|
||||||
|
margin: var(--spacing-medium) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--color-border);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.active {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
}
|
||||||
22
src/components/shared/ProgressDots.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import styles from './ProgressDots.module.css';
|
||||||
|
|
||||||
|
interface ProgressDotsProps {
|
||||||
|
totalSteps: number;
|
||||||
|
currentStep: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProgressDots: React.FC<ProgressDotsProps> = ({ totalSteps, currentStep }) => {
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{Array.from({ length: totalSteps }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`${styles.dot} ${index === currentStep - 1 ? styles.active : ''}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProgressDots;
|
||||||
186
src/config/homeScreen.ts
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import { AppConfig } from '../types/blocks';
|
||||||
|
|
||||||
|
export const homeScreenConfig: AppConfig = {
|
||||||
|
homeScreen: {
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: 'scrollableButtons',
|
||||||
|
id: 'mainActions',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
id: 'invite',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#FF69B4', '#FF1493']
|
||||||
|
},
|
||||||
|
title: 'Поделиться',
|
||||||
|
imageUrl: '/src/assets/ahare_bot250.png',
|
||||||
|
action: {
|
||||||
|
type: 'function',
|
||||||
|
value: 'inviteFriends'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chooseStyle',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#2B9CFF', '#1E88E5']
|
||||||
|
},
|
||||||
|
title: 'Инструкция',
|
||||||
|
imageUrl: '/src/assets/faq250.png',
|
||||||
|
action: {
|
||||||
|
type: 'route',
|
||||||
|
value: '/onboarding/welcome'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'createPack',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#8A2BE2', '#9400D3']
|
||||||
|
},
|
||||||
|
title: 'ShortsLoader',
|
||||||
|
imageUrl: '/src/assets/shorts250.png',
|
||||||
|
action: {
|
||||||
|
type: 'function',
|
||||||
|
value: 'openTelegramBot'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
style: {
|
||||||
|
gap: 12,
|
||||||
|
padding: 16,
|
||||||
|
buttonSize: 120
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'divider',
|
||||||
|
id: 'mainDivider',
|
||||||
|
style: {
|
||||||
|
margin: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'stepTitle',
|
||||||
|
id: 'step1',
|
||||||
|
number: 1,
|
||||||
|
text: 'Загрузи фото с лицом'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'uploadPhoto',
|
||||||
|
id: 'photoUpload',
|
||||||
|
style: {
|
||||||
|
padding: "4px 16px 16px"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'stepTitle',
|
||||||
|
id: 'step2',
|
||||||
|
number: 2,
|
||||||
|
text: 'Выбери стиль'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'scrollableButtons',
|
||||||
|
id: 'styleActions',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
id: 'chibi',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#FF69B4', '#FF1493']
|
||||||
|
},
|
||||||
|
title: 'Чиби',
|
||||||
|
imageUrl: '/src/assets/balerina250x.png',
|
||||||
|
action: {
|
||||||
|
type: 'selectStyle',
|
||||||
|
value: 'chibi'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'emotions',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#2B9CFF', '#1E88E5']
|
||||||
|
},
|
||||||
|
title: 'Эмоции',
|
||||||
|
imageUrl: '/src/assets/emotions_promo250x.png',
|
||||||
|
action: {
|
||||||
|
type: 'selectStyle',
|
||||||
|
value: 'emotions'
|
||||||
|
},
|
||||||
|
disabled: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'realism',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#4CAF50', '#45A049']
|
||||||
|
},
|
||||||
|
title: 'Реализм',
|
||||||
|
imageUrl: '/src/assets/realism_promo250x.png',
|
||||||
|
action: {
|
||||||
|
type: 'selectStyle',
|
||||||
|
value: 'realism'
|
||||||
|
},
|
||||||
|
disabled: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
style: {
|
||||||
|
gap: 12,
|
||||||
|
padding: "4px 16px 16px",
|
||||||
|
buttonSize: 120
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'stepTitle',
|
||||||
|
id: 'step3',
|
||||||
|
number: 3,
|
||||||
|
text: 'Выбери образ'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'textInput',
|
||||||
|
id: 'customPrompt',
|
||||||
|
style: {
|
||||||
|
padding: "4px 16px 16px"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'gridButtons',
|
||||||
|
id: 'quickActions',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
id: 'customPrompt',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#2196F3', '#1976D2']
|
||||||
|
},
|
||||||
|
title: 'Свой промпт',
|
||||||
|
imageUrl: '/src/assets/prompt.png',
|
||||||
|
action: {
|
||||||
|
type: 'function',
|
||||||
|
value: 'toggleInput'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
style: {
|
||||||
|
gap: 8,
|
||||||
|
padding: "4px 8px 16px",
|
||||||
|
buttonSize: 100,
|
||||||
|
columns: 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'generateButton',
|
||||||
|
id: 'generateStickers',
|
||||||
|
tokenCount: 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
624
src/config/stylePresets.ts
Normal file
@ -0,0 +1,624 @@
|
|||||||
|
import { BlockButton } from '../types/blocks';
|
||||||
|
|
||||||
|
interface StylePresets {
|
||||||
|
[key: string]: {
|
||||||
|
buttons: BlockButton[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stylePresets: StylePresets = {
|
||||||
|
chibi: {
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
id: 'chibi-sportscar',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#FF5722', '#F4511E']
|
||||||
|
},
|
||||||
|
title: 'Спорткар',
|
||||||
|
imageUrl: '/src/assets/sportcar250x.png',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'sportscar'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chibi-skateboard',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#4CAF50', '#45A049']
|
||||||
|
},
|
||||||
|
title: 'Скейтборд',
|
||||||
|
imageUrl: '/src/assets/scateboard250x.png',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'skateboard'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chibi-coffee',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#795548', '#5D4037']
|
||||||
|
},
|
||||||
|
title: 'Кофе',
|
||||||
|
imageUrl: '/src/assets/coffee250x.png',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'coffee'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chibi-flowers',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#FF69B4', '#FF1493']
|
||||||
|
},
|
||||||
|
title: 'Цветы',
|
||||||
|
imageUrl: '/src/assets/flowers250x.png',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'flowers'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chibi-balloon',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#2196F3', '#1976D2']
|
||||||
|
},
|
||||||
|
title: 'Шарик',
|
||||||
|
imageUrl: '/src/assets/balloon250x.png',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'balloon'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chibi-book',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#9C27B0', '#7B1FA2']
|
||||||
|
},
|
||||||
|
title: 'Книга',
|
||||||
|
imageUrl: '/src/assets/book250x.png',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'book'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chibi-icecream',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#FF69B4', '#FF1493']
|
||||||
|
},
|
||||||
|
title: 'Мороженое',
|
||||||
|
imageUrl: '/src/assets/icecream250x.png',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'icecream'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chibi-umbrella',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#3F51B5', '#303F9F']
|
||||||
|
},
|
||||||
|
title: 'Зонт',
|
||||||
|
imageUrl: '/src/assets/umbrella250x.png',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'umbrella'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chibi-cocktail',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#E91E63', '#C2185B']
|
||||||
|
},
|
||||||
|
title: 'Коктейль',
|
||||||
|
imageUrl: '/src/assets/coctail250x.png',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'cocktail'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chibi-gift',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#FF69B4', '#FF1493']
|
||||||
|
},
|
||||||
|
title: 'Подарок',
|
||||||
|
imageUrl: '/src/assets/gift250x.png',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'gift'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chibi-dog',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#795548', '#5D4037']
|
||||||
|
},
|
||||||
|
title: 'Собака',
|
||||||
|
imageUrl: '/src/assets/dog250x.png',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'dog'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chibi-newspaper',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#607D8B', '#455A64']
|
||||||
|
},
|
||||||
|
title: 'Газета',
|
||||||
|
imageUrl: '/src/assets/newspaper250x.png',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'newspaper'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chibi-bicycle',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#4CAF50', '#45A049']
|
||||||
|
},
|
||||||
|
title: 'Велосипед',
|
||||||
|
imageUrl: '/src/assets/bicecle250x.png',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'bicycle'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chibi-surfer',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#00BCD4', '#0097A7']
|
||||||
|
},
|
||||||
|
title: 'Серфер',
|
||||||
|
imageUrl: '/src/assets/surfing250x.png',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'surfer'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chibi-detective',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#9E9E9E', '#757575']
|
||||||
|
},
|
||||||
|
title: 'Детектив',
|
||||||
|
imageUrl: '/src/assets/detektiv250x.png',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'detective'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chibi-biker',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#212121', '#000000']
|
||||||
|
},
|
||||||
|
title: 'Байкер',
|
||||||
|
imageUrl: '/src/assets/moto250x.png',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'biker'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chibi-fairy',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#FF69B4', '#FF1493']
|
||||||
|
},
|
||||||
|
title: 'Фея',
|
||||||
|
imageUrl: '/src/assets/fairy250x.png',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'fairy'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chibi-scientist',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#3F51B5', '#303F9F']
|
||||||
|
},
|
||||||
|
title: 'Ученый',
|
||||||
|
imageUrl: '/src/assets/sience250x.png',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'scientist'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chibi-cowboy',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#795548', '#5D4037']
|
||||||
|
},
|
||||||
|
title: 'Ковбой',
|
||||||
|
imageUrl: '/src/assets/cowboy250x.png',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'cowboy'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chibi-knight',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#607D8B', '#455A64']
|
||||||
|
},
|
||||||
|
title: 'Рыцарь',
|
||||||
|
imageUrl: '/src/assets/knight250x.png',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'knight'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chibi-ballerina',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#FF69B4', '#FF1493']
|
||||||
|
},
|
||||||
|
title: 'Балерина',
|
||||||
|
imageUrl: '/src/assets/balerina250x.png',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'ballerina'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chibi-firefighter',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#FF5722', '#F4511E']
|
||||||
|
},
|
||||||
|
title: 'Пожарный',
|
||||||
|
imageUrl: '/src/assets/fire250x.png',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'firefighter'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chibi-chef',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#FF5722', '#F4511E']
|
||||||
|
},
|
||||||
|
title: 'Шеф-повар',
|
||||||
|
imageUrl: '/src/assets/cook250x.png',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'chef'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
realism: {
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
id: 'realism-business',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#2196F3', '#1976D2']
|
||||||
|
},
|
||||||
|
title: 'Деловой',
|
||||||
|
icon: '💼',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'business'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'realism-casual',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#FF5722', '#F4511E']
|
||||||
|
},
|
||||||
|
title: 'Повседневный',
|
||||||
|
icon: '👕',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'casual'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'realism-sport',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#4CAF50', '#45A049']
|
||||||
|
},
|
||||||
|
title: 'Спортивный',
|
||||||
|
icon: '⚽',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'sport'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'realism-evening',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#9C27B0', '#7B1FA2']
|
||||||
|
},
|
||||||
|
title: 'Вечерний',
|
||||||
|
icon: '🌙',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'evening'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'realism-beach',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#00BCD4', '#0097A7']
|
||||||
|
},
|
||||||
|
title: 'Пляжный',
|
||||||
|
icon: '🏖️',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'beach'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'realism-winter',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#90CAF9', '#64B5F6']
|
||||||
|
},
|
||||||
|
title: 'Зимний',
|
||||||
|
icon: '❄️',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'winter'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'realism-retro',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#795548', '#5D4037']
|
||||||
|
},
|
||||||
|
title: 'Ретро',
|
||||||
|
icon: '🎸',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'retro'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'realism-cyberpunk',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#E91E63', '#9C27B0']
|
||||||
|
},
|
||||||
|
title: 'Киберпанк',
|
||||||
|
icon: '🤖',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'cyberpunk'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'realism-military',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#4CAF50', '#2E7D32']
|
||||||
|
},
|
||||||
|
title: 'Военный',
|
||||||
|
icon: '🪖',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'military'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'realism-steampunk',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#795548', '#3E2723']
|
||||||
|
},
|
||||||
|
title: 'Стимпанк',
|
||||||
|
icon: '⚙️',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'steampunk'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
emotions: {
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
id: 'emotions-happy',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#FFD700', '#FFA500']
|
||||||
|
},
|
||||||
|
title: 'Радость',
|
||||||
|
icon: '😊',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'happy'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'emotions-sad',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#2196F3', '#1976D2']
|
||||||
|
},
|
||||||
|
title: 'Грусть',
|
||||||
|
icon: '😢',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'sad'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'emotions-angry',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#FF5722', '#F4511E']
|
||||||
|
},
|
||||||
|
title: 'Злость',
|
||||||
|
icon: '😠',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'angry'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'emotions-love',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#FF69B4', '#FF1493']
|
||||||
|
},
|
||||||
|
title: 'Любовь',
|
||||||
|
icon: '❤️',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'love'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'emotions-surprise',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#9C27B0', '#7B1FA2']
|
||||||
|
},
|
||||||
|
title: 'Удивление',
|
||||||
|
icon: '😲',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'surprise'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'emotions-fear',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#607D8B', '#455A64']
|
||||||
|
},
|
||||||
|
title: 'Страх',
|
||||||
|
icon: '😱',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'fear'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'emotions-sleepy',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#9575CD', '#7E57C2']
|
||||||
|
},
|
||||||
|
title: 'Сонный',
|
||||||
|
icon: '😴',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'sleepy'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'emotions-cool',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#212121', '#000000']
|
||||||
|
},
|
||||||
|
title: 'Крутой',
|
||||||
|
icon: '😎',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'cool'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'emotions-silly',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#4CAF50', '#2E7D32']
|
||||||
|
},
|
||||||
|
title: 'Глупый',
|
||||||
|
icon: '🤪',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'silly'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'emotions-thinking',
|
||||||
|
type: 'square',
|
||||||
|
background: {
|
||||||
|
type: 'gradient',
|
||||||
|
colors: ['#FF9800', '#F57C00']
|
||||||
|
},
|
||||||
|
title: 'Думающий',
|
||||||
|
icon: '🤔',
|
||||||
|
action: {
|
||||||
|
type: 'selectPreset',
|
||||||
|
value: 'thinking'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
609
src/constants/baseWorkflow.ts
Normal file
@ -0,0 +1,609 @@
|
|||||||
|
export const baseWorkflow = {
|
||||||
|
"305": {
|
||||||
|
"inputs": {
|
||||||
|
"ckpt_name": "SDXL\\dreamshaperXL_lightningDPMSDE.safetensors"
|
||||||
|
},
|
||||||
|
"class_type": "CheckpointLoaderSimple",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Загрузить контрольную точку"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"307": {
|
||||||
|
"inputs": {
|
||||||
|
"share_norm": "both",
|
||||||
|
"share_attn": "q+k",
|
||||||
|
"scale": 0.7000000000000001,
|
||||||
|
"model": [
|
||||||
|
"309",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "StyleAlignedBatchAlign",
|
||||||
|
"_meta": {
|
||||||
|
"title": "StyleAligned Batch Align"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"308": {
|
||||||
|
"inputs": {
|
||||||
|
"text_positive": "Sticker,chibi:2,Flat:1.5,(full body:2),Simple details,(Empty Background:2)",
|
||||||
|
"text_negative": "Realism, photo-realism, real materials,(full body:2)",
|
||||||
|
"style": "sai-cinematic",
|
||||||
|
"log_prompt": false,
|
||||||
|
"style_positive": true,
|
||||||
|
"style_negative": true
|
||||||
|
},
|
||||||
|
"class_type": "SDXLPromptStyler",
|
||||||
|
"_meta": {
|
||||||
|
"title": "SDXL Prompt Styler"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"309": {
|
||||||
|
"inputs": {
|
||||||
|
"b1": 1.3,
|
||||||
|
"b2": 1.4000000000000001,
|
||||||
|
"s1": 0.9,
|
||||||
|
"s2": 0.2,
|
||||||
|
"model": [
|
||||||
|
"406",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "FreeU_V2",
|
||||||
|
"_meta": {
|
||||||
|
"title": "FreeU_V2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"312": {
|
||||||
|
"inputs": {
|
||||||
|
"text": [
|
||||||
|
"308",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"clip": [
|
||||||
|
"406",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "CLIPTextEncode",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Кодирование текста CLIP (Запрос)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"313": {
|
||||||
|
"inputs": {
|
||||||
|
"prompt_1": "Boxers, with red boxing gloves, swinging their fists",
|
||||||
|
"prompt_2": "Dressed in a Superman costume",
|
||||||
|
"prompt_3": "Dress up as Santa Claus with a rattle in your hand",
|
||||||
|
"prompt_4": "Holding a bouquet of roses in his hand and wearing a gown",
|
||||||
|
"prompt_5": "Dressed in a wide hip hop suit,Playing the guitar,tattooing,denim,street dance,dancing",
|
||||||
|
"simple_prompt_list": [
|
||||||
|
"316",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "CR Simple Prompt List",
|
||||||
|
"_meta": {
|
||||||
|
"title": "CR Simple Prompt List (Legacy)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"314": {
|
||||||
|
"inputs": {
|
||||||
|
"keyframe_interval": 1,
|
||||||
|
"loops": 1,
|
||||||
|
"transition_type": "Default",
|
||||||
|
"transition_speed": "Default",
|
||||||
|
"transition_profile": "Default",
|
||||||
|
"keyframe_format": "Deforum",
|
||||||
|
"simple_prompt_list": [
|
||||||
|
"313",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "CR Simple Prompt List Keyframes",
|
||||||
|
"_meta": {
|
||||||
|
"title": "CR Simple Prompt List Keyframes (Legacy)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"316": {
|
||||||
|
"inputs": {
|
||||||
|
"prompt_1": "military clothes",
|
||||||
|
"prompt_2": "Pirate captain with a sword",
|
||||||
|
"prompt_3": "Drinking coffee from a coffee cup and smiling",
|
||||||
|
"prompt_4": "Dressed in a spacesuit and wearing a glass helmet",
|
||||||
|
"prompt_5": "Wearing luxurious clothes, holding a red heart-shaped balloon and smiling"
|
||||||
|
},
|
||||||
|
"class_type": "CR Simple Prompt List",
|
||||||
|
"_meta": {
|
||||||
|
"title": "CR Simple Prompt List (Legacy)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"318": {
|
||||||
|
"inputs": {},
|
||||||
|
"class_type": "Anything Everywhere3",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Anything Everywhere3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"326": {
|
||||||
|
"inputs": {
|
||||||
|
"image": "photo_2025-02-03_14-06-19.jpg",
|
||||||
|
"upload": "image"
|
||||||
|
},
|
||||||
|
"class_type": "LoadImage",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Загрузить изображение"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"328": {
|
||||||
|
"inputs": {
|
||||||
|
"seed": [
|
||||||
|
"404",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"steps": 7,
|
||||||
|
"cfg": 2,
|
||||||
|
"sampler_name": "dpmpp_2m",
|
||||||
|
"scheduler": "karras",
|
||||||
|
"denoise": 1,
|
||||||
|
"preview_method": "auto",
|
||||||
|
"vae_decode": "true",
|
||||||
|
"model": [
|
||||||
|
"347",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"positive": [
|
||||||
|
"339",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"negative": [
|
||||||
|
"312",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"latent_image": [
|
||||||
|
"348",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"optional_vae": [
|
||||||
|
"305",
|
||||||
|
2
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "KSampler (Efficient)",
|
||||||
|
"_meta": {
|
||||||
|
"title": "KSampler (Efficient)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"339": {
|
||||||
|
"inputs": {
|
||||||
|
"text": [
|
||||||
|
"314",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"max_frames": 10,
|
||||||
|
"print_output": false,
|
||||||
|
"pre_text": [
|
||||||
|
"308",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"app_text": [
|
||||||
|
"523",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"start_frame": 0,
|
||||||
|
"end_frame": 0,
|
||||||
|
"clip": [
|
||||||
|
"406",
|
||||||
|
1
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "BatchPromptSchedule",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Batch Prompt Schedule 📅🅕🅝"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"344": {
|
||||||
|
"inputs": {
|
||||||
|
"pulid_file": "ip-adapter_pulid_sdxl_fp16.safetensors"
|
||||||
|
},
|
||||||
|
"class_type": "PulidModelLoader",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Load PuLID Model"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"345": {
|
||||||
|
"inputs": {
|
||||||
|
"provider": "CUDA"
|
||||||
|
},
|
||||||
|
"class_type": "PulidInsightFaceLoader",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Load InsightFace (PuLID)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"346": {
|
||||||
|
"inputs": {},
|
||||||
|
"class_type": "PulidEvaClipLoader",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Load Eva Clip (PuLID)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"347": {
|
||||||
|
"inputs": {
|
||||||
|
"method": "fidelity",
|
||||||
|
"weight": 1,
|
||||||
|
"start_at": 0,
|
||||||
|
"end_at": 1,
|
||||||
|
"model": [
|
||||||
|
"307",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"pulid": [
|
||||||
|
"344",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"eva_clip": [
|
||||||
|
"346",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"face_analysis": [
|
||||||
|
"345",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"image": [
|
||||||
|
"384",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "ApplyPulid",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Apply PuLID"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"348": {
|
||||||
|
"inputs": {
|
||||||
|
"width": 768,
|
||||||
|
"height": 768,
|
||||||
|
"batch_size": 1
|
||||||
|
},
|
||||||
|
"class_type": "EmptyLatentImage",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Пустое латентное изображение"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"376": {
|
||||||
|
"inputs": {
|
||||||
|
"rmbgmodel": [
|
||||||
|
"377",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"image": [
|
||||||
|
"378",
|
||||||
|
5
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "BRIA_RMBG_Zho",
|
||||||
|
"_meta": {
|
||||||
|
"title": "🧹BRIA RMBG"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"377": {
|
||||||
|
"inputs": {},
|
||||||
|
"class_type": "BRIA_RMBG_ModelLoader_Zho",
|
||||||
|
"_meta": {
|
||||||
|
"title": "🧹BRIA_RMBG Model Loader"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"378": {
|
||||||
|
"inputs": {
|
||||||
|
"seed": [
|
||||||
|
"404",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"steps": 6,
|
||||||
|
"cfg": 2,
|
||||||
|
"sampler_name": "dpmpp_2m",
|
||||||
|
"scheduler": "karras",
|
||||||
|
"denoise": 0.6,
|
||||||
|
"preview_method": "auto",
|
||||||
|
"vae_decode": "true",
|
||||||
|
"model": [
|
||||||
|
"382",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"positive": [
|
||||||
|
"382",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"negative": [
|
||||||
|
"382",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"latent_image": [
|
||||||
|
"417",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"optional_vae": [
|
||||||
|
"305",
|
||||||
|
2
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "KSampler (Efficient)",
|
||||||
|
"_meta": {
|
||||||
|
"title": "KSampler (Efficient)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"379": {
|
||||||
|
"inputs": {
|
||||||
|
"instantid_file": "ip-adapter.bin"
|
||||||
|
},
|
||||||
|
"class_type": "InstantIDModelLoader",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Load InstantID Model"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"380": {
|
||||||
|
"inputs": {
|
||||||
|
"control_net_name": "diffusion_pytorch_model.safetensors"
|
||||||
|
},
|
||||||
|
"class_type": "ControlNetLoader",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Загрузить модель ControlNet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"381": {
|
||||||
|
"inputs": {
|
||||||
|
"provider": "CUDA"
|
||||||
|
},
|
||||||
|
"class_type": "InstantIDFaceAnalysis",
|
||||||
|
"_meta": {
|
||||||
|
"title": "InstantID Face Analysis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"382": {
|
||||||
|
"inputs": {
|
||||||
|
"weight": 1,
|
||||||
|
"start_at": 0,
|
||||||
|
"end_at": 1,
|
||||||
|
"instantid": [
|
||||||
|
"379",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"insightface": [
|
||||||
|
"381",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"control_net": [
|
||||||
|
"380",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"image": [
|
||||||
|
"384",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"model": [
|
||||||
|
"307",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"positive": [
|
||||||
|
"328",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"negative": [
|
||||||
|
"328",
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"image_kps": [
|
||||||
|
"328",
|
||||||
|
5
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "ApplyInstantID",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Apply InstantID"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"384": {
|
||||||
|
"inputs": {
|
||||||
|
"side_length": 512,
|
||||||
|
"side": "Longest",
|
||||||
|
"upscale_method": "nearest-exact",
|
||||||
|
"crop": "disabled",
|
||||||
|
"image": [
|
||||||
|
"563",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "DF_Image_scale_to_side",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Image scale to side"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"inputs": {
|
||||||
|
"seed": 0
|
||||||
|
},
|
||||||
|
"class_type": "Seed Everywhere",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Seed Everywhere"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"405": {
|
||||||
|
"inputs": {
|
||||||
|
"toggle": true,
|
||||||
|
"mode": "simple",
|
||||||
|
"num_loras": 3,
|
||||||
|
"lora_1_name": "StickersRedmond.safetensors",
|
||||||
|
"lora_1_strength": 1,
|
||||||
|
"lora_1_model_strength": 2,
|
||||||
|
"lora_1_clip_strength": 2,
|
||||||
|
"lora_2_name": "cartoon_style.pt",
|
||||||
|
"lora_2_strength": 2,
|
||||||
|
"lora_2_model_strength": 1,
|
||||||
|
"lora_2_clip_strength": 1,
|
||||||
|
"lora_3_name": "smiling.pt",
|
||||||
|
"lora_3_strength": 2,
|
||||||
|
"lora_3_model_strength": 2,
|
||||||
|
"lora_3_clip_strength": 1,
|
||||||
|
"lora_4_name": "None",
|
||||||
|
"lora_4_strength": 1,
|
||||||
|
"lora_4_model_strength": 1,
|
||||||
|
"lora_4_clip_strength": 1,
|
||||||
|
"lora_5_name": "None",
|
||||||
|
"lora_5_strength": 1,
|
||||||
|
"lora_5_model_strength": 1,
|
||||||
|
"lora_5_clip_strength": 1,
|
||||||
|
"lora_6_name": "None",
|
||||||
|
"lora_6_strength": 1,
|
||||||
|
"lora_6_model_strength": 1,
|
||||||
|
"lora_6_clip_strength": 1,
|
||||||
|
"lora_7_name": "None",
|
||||||
|
"lora_7_strength": 1,
|
||||||
|
"lora_7_model_strength": 1,
|
||||||
|
"lora_7_clip_strength": 1,
|
||||||
|
"lora_8_name": "None",
|
||||||
|
"lora_8_strength": 1,
|
||||||
|
"lora_8_model_strength": 1,
|
||||||
|
"lora_8_clip_strength": 1,
|
||||||
|
"lora_9_name": "None",
|
||||||
|
"lora_9_strength": 1,
|
||||||
|
"lora_9_model_strength": 1,
|
||||||
|
"lora_9_clip_strength": 1,
|
||||||
|
"lora_10_name": "None",
|
||||||
|
"lora_10_strength": 1,
|
||||||
|
"lora_10_model_strength": 1,
|
||||||
|
"lora_10_clip_strength": 1
|
||||||
|
},
|
||||||
|
"class_type": "easy loraStack",
|
||||||
|
"_meta": {
|
||||||
|
"title": "EasyLoraStack"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"406": {
|
||||||
|
"inputs": {
|
||||||
|
"model": [
|
||||||
|
"305",
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"clip": [
|
||||||
|
"305",
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"lora_stack": [
|
||||||
|
"405",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "CR Apply LoRA Stack",
|
||||||
|
"_meta": {
|
||||||
|
"title": "💊 CR Apply LoRA Stack"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"417": {
|
||||||
|
"inputs": {
|
||||||
|
"upscale_method": "nearest-exact",
|
||||||
|
"width": 1024,
|
||||||
|
"height": 1024,
|
||||||
|
"crop": "disabled",
|
||||||
|
"samples": [
|
||||||
|
"328",
|
||||||
|
3
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "LatentUpscale",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Увеличить латент"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"523": {
|
||||||
|
"inputs": {
|
||||||
|
"text": [
|
||||||
|
"536",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "ShowText|pysssss",
|
||||||
|
"_meta": {
|
||||||
|
"title": "✴️ U-NAI Get Text"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"536": {
|
||||||
|
"inputs": {
|
||||||
|
"text_input": "Identify if the person in the image is a man, woman, boy or girl. Answer with just one word.",
|
||||||
|
"model_name": "Qwen2-VL-2B",
|
||||||
|
"memory_mode": "Balanced (8-bit)",
|
||||||
|
"max_new_tokens": 512,
|
||||||
|
"temperature": 0.7000000000000001,
|
||||||
|
"top_p": 0.8,
|
||||||
|
"fps": 1,
|
||||||
|
"image": [
|
||||||
|
"555",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "Qwen2VLNode",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Qwen2-VL Model"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"539": {
|
||||||
|
"inputs": {
|
||||||
|
"aggressive": false,
|
||||||
|
"image": [
|
||||||
|
"376",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "FreeMemoryImage",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Free Memory (Image)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"555": {
|
||||||
|
"inputs": {
|
||||||
|
"width": 762,
|
||||||
|
"height": 762,
|
||||||
|
"method": "lanczos",
|
||||||
|
"images": [
|
||||||
|
"563",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "ImageTransformResizeAbsolute",
|
||||||
|
"_meta": {
|
||||||
|
"title": "ImageTransformResizeAbsolute"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"556": {
|
||||||
|
"inputs": {
|
||||||
|
"images": [
|
||||||
|
"555",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "PreviewImage",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Предварительный просмотр изображения"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"562": {
|
||||||
|
"inputs": {
|
||||||
|
"Actions": "Send to websocket",
|
||||||
|
"images": [
|
||||||
|
"539",
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"class_type": "ImageOutput",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Image(s) To Websocket (Base64)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"563": {
|
||||||
|
"inputs": {
|
||||||
|
"image": ""
|
||||||
|
},
|
||||||
|
"class_type": "Load Image (Base64)",
|
||||||
|
"_meta": {
|
||||||
|
"title": "Load Image (Base64)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
53
src/constants/mock.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { MockUser, StyleOption, Prompts } from '../types/mock';
|
||||||
|
|
||||||
|
export const MOCK_USER: MockUser = {
|
||||||
|
telegramId: 12345678,
|
||||||
|
username: "TestUser",
|
||||||
|
avatarUrl: "/ava.jpg",
|
||||||
|
balance: 1000
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MOCK_STYLES: StyleOption[] = [
|
||||||
|
{
|
||||||
|
id: 'emoji',
|
||||||
|
name: 'Эмодзи',
|
||||||
|
icon: '😊',
|
||||||
|
color: '#4CAF50'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'art',
|
||||||
|
name: 'Арт',
|
||||||
|
icon: '🎨',
|
||||||
|
color: '#FF5722'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'anime',
|
||||||
|
name: 'Аниме',
|
||||||
|
icon: '✨',
|
||||||
|
color: '#FF69B4'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MOCK_PROMPTS: Prompts = {
|
||||||
|
emoji: [
|
||||||
|
'Радостный',
|
||||||
|
'Крутой',
|
||||||
|
'Думает',
|
||||||
|
'Сон',
|
||||||
|
'Безумие'
|
||||||
|
],
|
||||||
|
art: [
|
||||||
|
'Акварель',
|
||||||
|
'Масло',
|
||||||
|
'Скетч',
|
||||||
|
'Поп-арт',
|
||||||
|
'Минимализм'
|
||||||
|
],
|
||||||
|
anime: [
|
||||||
|
'Чиби',
|
||||||
|
'Каваи',
|
||||||
|
'Шонен',
|
||||||
|
'SD',
|
||||||
|
'Манга'
|
||||||
|
]
|
||||||
|
};
|
||||||
56
src/constants/user.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Константы для работы с пользователями
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Единый ID для всех операций с API
|
||||||
|
export const DEFAULT_USER_ID = 296487847;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, доступен ли объект Telegram WebApp и данные пользователя
|
||||||
|
*/
|
||||||
|
export const isTelegramWebAppAvailable = (): boolean => {
|
||||||
|
return Boolean(
|
||||||
|
window.Telegram?.WebApp?.initDataUnsafe?.user?.id
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает ID пользователя для всех операций с API
|
||||||
|
* Если доступен Telegram WebApp, возвращает реальный ID пользователя
|
||||||
|
* В противном случае возвращает тестовый ID
|
||||||
|
*/
|
||||||
|
export const getUserId = (): number => {
|
||||||
|
if (isTelegramWebAppAvailable() && window.Telegram?.WebApp?.initDataUnsafe?.user?.id) {
|
||||||
|
return window.Telegram.WebApp.initDataUnsafe.user.id;
|
||||||
|
}
|
||||||
|
// Для локальной разработки используем тестовый ID
|
||||||
|
console.log('Используется тестовый ID пользователя:', DEFAULT_USER_ID);
|
||||||
|
return DEFAULT_USER_ID;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает ID пользователя в виде строки
|
||||||
|
* Используется для совместимости с API, требующими строковый ID
|
||||||
|
*/
|
||||||
|
export const getUserIdString = (): string => {
|
||||||
|
return getUserId().toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает информацию о пользователе Telegram
|
||||||
|
* Если Telegram WebApp недоступен, возвращает тестовые данные
|
||||||
|
*/
|
||||||
|
export const getUserInfo = () => {
|
||||||
|
if (isTelegramWebAppAvailable() && window.Telegram?.WebApp?.initDataUnsafe?.user) {
|
||||||
|
return window.Telegram.WebApp.initDataUnsafe.user;
|
||||||
|
}
|
||||||
|
// Тестовые данные для локальной разработки
|
||||||
|
return {
|
||||||
|
id: DEFAULT_USER_ID,
|
||||||
|
first_name: 'Test',
|
||||||
|
last_name: 'User',
|
||||||
|
username: 'testuser',
|
||||||
|
language_code: 'ru',
|
||||||
|
photo_url: '/ava.jpg' // Добавляем тестовую аватарку
|
||||||
|
};
|
||||||
|
};
|
||||||
127
src/index.css
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
:root {
|
||||||
|
/* Цветовая схема */
|
||||||
|
--color-primary: #2B9CFF;
|
||||||
|
--color-secondary: #FF69B4;
|
||||||
|
--color-accent1: #8A2BE2;
|
||||||
|
--color-accent2: #4CAF50;
|
||||||
|
--color-text: #333333;
|
||||||
|
--color-text-rgb: 51, 51, 51;
|
||||||
|
--color-text-secondary: #666666;
|
||||||
|
--color-background: #F5F5F5;
|
||||||
|
--color-background-rgb: 245, 245, 245;
|
||||||
|
--color-surface: #FFFFFF;
|
||||||
|
--color-surface-rgb: 255, 255, 255;
|
||||||
|
--color-border: #E5E5E5;
|
||||||
|
|
||||||
|
/* Размеры */
|
||||||
|
--border-radius: 12px;
|
||||||
|
--spacing-small: 0.5rem;
|
||||||
|
--spacing-medium: 1rem;
|
||||||
|
--spacing-large: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: var(--color-background);
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Базовые стили */
|
||||||
|
html {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background-color: var(--color-background);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
-webkit-font-smoothing: subpixel-antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: geometricPrecision;
|
||||||
|
overflow-x: hidden;
|
||||||
|
position: fixed;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
perspective: 1000px;
|
||||||
|
-webkit-perspective: 1000px;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
-webkit-backface-visibility: hidden;
|
||||||
|
-webkit-transform-style: preserve-3d;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
text-size-adjust: 100%;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
overflow-y: scroll;
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
}
|
||||||
|
|
||||||
|
html::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome, Safari, Opera */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Утилиты */
|
||||||
|
.safe-top {
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
}
|
||||||
|
|
||||||
|
.safe-bottom {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Анимации */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fadeIn 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Скроллбар */
|
||||||
|
* {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Общие компоненты */
|
||||||
|
.button {
|
||||||
|
padding: var(--spacing-medium);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
border: none;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: var(--spacing-medium);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
14
src/main.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
const root = document.getElementById('root');
|
||||||
|
|
||||||
|
if (root) {
|
||||||
|
ReactDOM.createRoot(root).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
src/screens/AddStickerToPackScreen.module.css
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-large);
|
||||||
|
padding: calc(3rem + var(--spacing-small)) var(--spacing-medium) var(--spacing-large);
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--spacing-small) 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backButton {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: var(--spacing-small);
|
||||||
|
margin-right: var(--spacing-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--color-text);
|
||||||
|
flex-grow: 1;
|
||||||
|
text-align: center;
|
||||||
|
margin-right: var(--spacing-medium); /* Компенсация для кнопки назад */
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-large);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: var(--spacing-large);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.imagesGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: var(--spacing-small);
|
||||||
|
margin-top: var(--spacing-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageItem {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: transform 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageItem:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emojiSelector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-small);
|
||||||
|
padding: var(--spacing-medium);
|
||||||
|
background-color: var(--color-background);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emojiInput {
|
||||||
|
width: 5rem;
|
||||||
|
height: 3rem;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emojiHelp {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
opacity: 0.7;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: var(--spacing-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addButton {
|
||||||
|
padding: var(--spacing-small) var(--spacing-large);
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addButton:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addButton:active:not(:disabled) {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addButton:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--color-error, #ff3b30);
|
||||||
|
background-color: var(--color-error-bg, #ffeeee);
|
||||||
|
padding: var(--spacing-small);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .noImages {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--spacing-medium);
|
||||||
|
color: var(--color-text);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
189
src/screens/AddStickerToPackScreen.tsx
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import styles from './AddStickerToPackScreen.module.css';
|
||||||
|
import { stickerService } from '../services/stickerService';
|
||||||
|
import apiService from '../services/api';
|
||||||
|
import { GeneratedImage } from '../types/api';
|
||||||
|
import { getUserIdString } from '../constants/user';
|
||||||
|
|
||||||
|
const AddStickerToPackScreen: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { packName } = useParams<{ packName: string }>();
|
||||||
|
const [selectedImage, setSelectedImage] = useState<GeneratedImage | null>(null);
|
||||||
|
const [emoji, setEmoji] = useState('😊');
|
||||||
|
const [availableImages, setAvailableImages] = useState<GeneratedImage[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [packTitle, setPackTitle] = useState('');
|
||||||
|
|
||||||
|
// Загрузка доступных изображений и информации о стикерпаке
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Загружаем изображения
|
||||||
|
const images = await apiService.getGeneratedImages();
|
||||||
|
setAvailableImages(images);
|
||||||
|
|
||||||
|
// Загружаем информацию о стикерпаке
|
||||||
|
if (packName) {
|
||||||
|
const packInfo = await stickerService.getStickerPack(packName);
|
||||||
|
setPackTitle(packInfo.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка при загрузке данных:', err);
|
||||||
|
setError('Не удалось загрузить данные');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [packName]);
|
||||||
|
|
||||||
|
// Обработчик выбора изображения
|
||||||
|
const handleImageSelect = (image: GeneratedImage) => {
|
||||||
|
setSelectedImage(image);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обработчик добавления стикера
|
||||||
|
const handleAddSticker = async () => {
|
||||||
|
if (!selectedImage) {
|
||||||
|
setError('Выберите изображение для стикера');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!emoji.trim()) {
|
||||||
|
setError('Введите эмодзи для стикера');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!packName) {
|
||||||
|
setError('Не указано имя стикерпака');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setAdding(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Проверяем, что link существует и является строкой
|
||||||
|
if (typeof selectedImage.link !== 'string') {
|
||||||
|
console.error('Некорректный формат link:', selectedImage.link);
|
||||||
|
setError('Некорректный формат изображения');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем стикер в стикерпак, используя file_id изображения
|
||||||
|
await stickerService.addStickerToPack(
|
||||||
|
packName,
|
||||||
|
getUserIdString(),
|
||||||
|
selectedImage.link,
|
||||||
|
emoji,
|
||||||
|
packTitle // Передаем заголовок стикерпака
|
||||||
|
);
|
||||||
|
|
||||||
|
// Возвращаемся на страницу стикерпаков
|
||||||
|
navigate('/packs');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка при добавлении стикера:', err);
|
||||||
|
setError('Не удалось добавить стикер');
|
||||||
|
} finally {
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<button
|
||||||
|
className={styles.backButton}
|
||||||
|
onClick={() => navigate('/packs')}
|
||||||
|
>
|
||||||
|
← Назад
|
||||||
|
</button>
|
||||||
|
<h1 className={styles.title}>
|
||||||
|
Добавление стикера в "{packTitle || packName}"
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.content}>
|
||||||
|
{loading && (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<p>Загрузка данных...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className={styles.error}>
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && (
|
||||||
|
<>
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>1. Выберите изображение</h2>
|
||||||
|
|
||||||
|
{availableImages.length === 0 ? (
|
||||||
|
<p className={styles.noImages}>
|
||||||
|
У вас пока нет сгенерированных изображений.
|
||||||
|
Сначала создайте изображения в разделе "Создать стикер".
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className={styles.imagesGrid}>
|
||||||
|
{availableImages.map((image, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`${styles.imageItem} ${selectedImage?.id === image.id ? styles.selected : ''}`}
|
||||||
|
onClick={() => handleImageSelect(image)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={image.url || ''}
|
||||||
|
alt={`Изображение ${index + 1}`}
|
||||||
|
className={styles.image}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>2. Выберите эмодзи</h2>
|
||||||
|
<div className={styles.emojiSelector}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={emoji}
|
||||||
|
onChange={(e) => setEmoji(e.target.value)}
|
||||||
|
className={styles.emojiInput}
|
||||||
|
maxLength={2}
|
||||||
|
placeholder="😊"
|
||||||
|
/>
|
||||||
|
<p className={styles.emojiHelp}>
|
||||||
|
Введите один или два эмодзи, которые будут ассоциироваться со стикером
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
className={styles.addButton}
|
||||||
|
onClick={handleAddSticker}
|
||||||
|
disabled={adding || !selectedImage}
|
||||||
|
>
|
||||||
|
{adding ? 'Добавление...' : 'Добавить стикер'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddStickerToPackScreen;
|
||||||
61
src/screens/CreateSticker.module.css
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-large);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: var(--spacing-medium) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadArea {
|
||||||
|
padding: var(--spacing-medium);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadBox {
|
||||||
|
padding: var(--spacing-large);
|
||||||
|
border: 2px dashed var(--color-border);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-small);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadBox:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background-color: var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadIcon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: var(--spacing-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadText {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadHint {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
31
src/screens/CreateSticker.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import styles from './CreateSticker.module.css';
|
||||||
|
|
||||||
|
const CreateSticker: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<h1 className={styles.title}>
|
||||||
|
Создание стикера
|
||||||
|
</h1>
|
||||||
|
<p className={styles.subtitle}>
|
||||||
|
Загрузите фотографию для создания стикера
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.uploadArea}>
|
||||||
|
<div className={styles.uploadBox}>
|
||||||
|
<span className={styles.uploadIcon}>📷</span>
|
||||||
|
<span className={styles.uploadText}>
|
||||||
|
Нажмите чтобы выбрать фото
|
||||||
|
</span>
|
||||||
|
<span className={styles.uploadHint}>
|
||||||
|
или перетащите файл сюда
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateSticker;
|
||||||
157
src/screens/CreateStickerPack.module.css
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-large);
|
||||||
|
padding: calc(3rem + var(--spacing-small)) var(--spacing-medium) var(--spacing-large);
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--spacing-small) 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backButton {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: var(--spacing-small);
|
||||||
|
margin-right: var(--spacing-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--color-text);
|
||||||
|
flex-grow: 1;
|
||||||
|
text-align: center;
|
||||||
|
margin-right: var(--spacing-medium); /* Компенсация для кнопки назад */
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-large);
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: var(--spacing-large);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
padding: var(--spacing-small);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-size: 1rem;
|
||||||
|
background-color: var(--color-background);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.imagesGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: var(--spacing-small);
|
||||||
|
margin-top: var(--spacing-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageItem {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: transform 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageItem:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emojiSelector {
|
||||||
|
position: absolute;
|
||||||
|
bottom: var(--spacing-small);
|
||||||
|
right: var(--spacing-small);
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emojiInput {
|
||||||
|
width: 2.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: var(--spacing-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.createButton {
|
||||||
|
padding: var(--spacing-small) var(--spacing-large);
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.createButton:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.createButton:active:not(:disabled) {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.createButton:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--color-error, #ff3b30);
|
||||||
|
background-color: var(--color-error-bg, #ffeeee);
|
||||||
|
padding: var(--spacing-small);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading, .noImages {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--spacing-medium);
|
||||||
|
color: var(--color-text);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
247
src/screens/CreateStickerPack.tsx
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import styles from './CreateStickerPack.module.css';
|
||||||
|
import { stickerService } from '../services/stickerService';
|
||||||
|
import apiService from '../services/api';
|
||||||
|
import { GeneratedImage } from '../types/api';
|
||||||
|
import { getUserIdString } from '../constants/user';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Транслитерирует кириллический текст в латиницу.
|
||||||
|
* Заменяет все недопустимые символы на подчеркивания.
|
||||||
|
* Заменяет множественные подчеркивания на одиночные.
|
||||||
|
*/
|
||||||
|
function transliterate(text: string): string {
|
||||||
|
const cyrillicToLatin: Record<string, string> = {
|
||||||
|
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'e',
|
||||||
|
'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm',
|
||||||
|
'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
|
||||||
|
'ф': 'f', 'х': 'kh', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch',
|
||||||
|
'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya',
|
||||||
|
// Добавляем заглавные буквы
|
||||||
|
'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D', 'Е': 'E', 'Ё': 'E',
|
||||||
|
'Ж': 'Zh', 'З': 'Z', 'И': 'I', 'Й': 'Y', 'К': 'K', 'Л': 'L', 'М': 'M',
|
||||||
|
'Н': 'N', 'О': 'O', 'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T', 'У': 'U',
|
||||||
|
'Ф': 'F', 'Х': 'Kh', 'Ц': 'Ts', 'Ч': 'Ch', 'Ш': 'Sh', 'Щ': 'Sch',
|
||||||
|
'Ъ': '', 'Ы': 'Y', 'Ь': '', 'Э': 'E', 'Ю': 'Yu', 'Я': 'Ya'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Транслитерация
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const char = text[i];
|
||||||
|
result += cyrillicToLatin[char] || char;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заменяем недопустимые символы на подчеркивания и приводим к нижнему регистру
|
||||||
|
result = result.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
||||||
|
|
||||||
|
// Заменяем множественные подчеркивания на одиночные
|
||||||
|
result = result.replace(/_+/g, '_');
|
||||||
|
|
||||||
|
// Убираем подчеркивания в начале и конце
|
||||||
|
result = result.replace(/^_+|_+$/g, '');
|
||||||
|
|
||||||
|
// Ограничиваем длину
|
||||||
|
return result.substring(0, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateStickerPack: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [selectedImages, setSelectedImages] = useState<GeneratedImage[]>([]);
|
||||||
|
const [emojis, setEmojis] = useState<string[]>([]);
|
||||||
|
const [availableImages, setAvailableImages] = useState<GeneratedImage[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Загрузка доступных изображений
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchImages = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const images = await apiService.getGeneratedImages();
|
||||||
|
setAvailableImages(images);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка при загрузке изображений:', err);
|
||||||
|
setError('Не удалось загрузить изображения');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchImages();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Обработчик выбора изображения
|
||||||
|
const handleImageSelect = (image: GeneratedImage) => {
|
||||||
|
// Проверяем, выбрано ли уже изображение
|
||||||
|
const isSelected = selectedImages.some(img => img.id === image.id);
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
// Если изображение уже выбрано, удаляем его из выбранных
|
||||||
|
setSelectedImages(prev => prev.filter(img => img.id !== image.id));
|
||||||
|
setEmojis(prev => {
|
||||||
|
const index = selectedImages.findIndex(img => img.id === image.id);
|
||||||
|
return prev.filter((_, i) => i !== index);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Если изображение не выбрано, добавляем его в выбранные
|
||||||
|
setSelectedImages(prev => [...prev, image]);
|
||||||
|
setEmojis(prev => [...prev, '😊']); // Добавляем эмодзи по умолчанию
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обработчик изменения эмодзи
|
||||||
|
const handleEmojiChange = (index: number, emoji: string) => {
|
||||||
|
setEmojis(prev => {
|
||||||
|
const newEmojis = [...prev];
|
||||||
|
newEmojis[index] = emoji;
|
||||||
|
return newEmojis;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обработчик создания стикерпака
|
||||||
|
const handleCreateStickerPack = async () => {
|
||||||
|
if (!title.trim()) {
|
||||||
|
setError('Введите название стикерпака');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedImages.length === 0) {
|
||||||
|
setError('Выберите хотя бы одно изображение');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setCreating(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Используем file_id изображений для создания стикеров
|
||||||
|
const fileIds = selectedImages.map(img => {
|
||||||
|
// Проверяем, что link существует и является строкой
|
||||||
|
if (typeof img.link !== 'string') {
|
||||||
|
console.error('Некорректный формат link:', img.link);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return img.link;
|
||||||
|
}).filter(fileId => fileId !== ''); // Удаляем пустые значения
|
||||||
|
|
||||||
|
// Генерируем имя стикерпака на основе заголовка с использованием транслитерации
|
||||||
|
// для корректной обработки кириллицы
|
||||||
|
const packName = transliterate(title);
|
||||||
|
|
||||||
|
console.log(`Создание стикерпака: ${title}, имя: ${packName}`);
|
||||||
|
|
||||||
|
// Создаем стикерпак
|
||||||
|
await stickerService.createStickerPack(
|
||||||
|
title,
|
||||||
|
getUserIdString(),
|
||||||
|
fileIds,
|
||||||
|
emojis,
|
||||||
|
packName
|
||||||
|
);
|
||||||
|
|
||||||
|
// Переходим на страницу стикерпаков
|
||||||
|
navigate('/packs');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка при создании стикерпака:', err);
|
||||||
|
setError('Не удалось создать стикерпак');
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<button
|
||||||
|
className={styles.backButton}
|
||||||
|
onClick={() => navigate('/packs')}
|
||||||
|
>
|
||||||
|
← Назад
|
||||||
|
</button>
|
||||||
|
<h1 className={styles.title}>Создание стикерпака</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.form}>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label htmlFor="title" className={styles.label}>Название стикерпака</label>
|
||||||
|
<input
|
||||||
|
id="title"
|
||||||
|
type="text"
|
||||||
|
className={styles.input}
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Введите название стикерпака"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label className={styles.label}>Выберите изображения для стикеров</label>
|
||||||
|
|
||||||
|
{loading && <p className={styles.loading}>Загрузка изображений...</p>}
|
||||||
|
|
||||||
|
{!loading && availableImages.length === 0 && (
|
||||||
|
<p className={styles.noImages}>
|
||||||
|
У вас пока нет сгенерированных изображений.
|
||||||
|
Сначала создайте изображения в разделе "Создать стикер".
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && availableImages.length > 0 && (
|
||||||
|
<div className={styles.imagesGrid}>
|
||||||
|
{availableImages.map((image, index) => {
|
||||||
|
const isSelected = selectedImages.some(img => img.id === image.id);
|
||||||
|
const selectedIndex = selectedImages.findIndex(img => img.id === image.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`${styles.imageItem} ${isSelected ? styles.selected : ''}`}
|
||||||
|
onClick={() => handleImageSelect(image)}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={image.url || ''}
|
||||||
|
alt={`Изображение ${index + 1}`}
|
||||||
|
className={styles.image}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isSelected && (
|
||||||
|
<div className={styles.emojiSelector}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={emojis[selectedIndex]}
|
||||||
|
onChange={(e) => handleEmojiChange(selectedIndex, e.target.value)}
|
||||||
|
className={styles.emojiInput}
|
||||||
|
maxLength={2}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
className={styles.createButton}
|
||||||
|
onClick={handleCreateStickerPack}
|
||||||
|
disabled={creating || selectedImages.length === 0}
|
||||||
|
>
|
||||||
|
{creating ? 'Создание...' : 'Создать стикерпак'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateStickerPack;
|
||||||
196
src/screens/CropPhoto.module.css
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: #000;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
height: 3.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 var(--spacing-medium);
|
||||||
|
background: var(--color-surface);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
animation: slideUp 0.3s ease-out;
|
||||||
|
position: relative;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backButton,
|
||||||
|
.confirmButton {
|
||||||
|
padding: var(--spacing-small) var(--spacing-medium);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backButton {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
padding: var(--spacing-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmButton {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cropContainer {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--spacing-medium);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewportContainer {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport {
|
||||||
|
width: min(90vw, 90vh);
|
||||||
|
height: min(90vw, 90vh);
|
||||||
|
position: relative;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background: transparent;
|
||||||
|
overflow: visible;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageWrapper {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
will-change: transform;
|
||||||
|
transition: transform 0.1s ease-out;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: move;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
will-change: transform;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.8);
|
||||||
|
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.7);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Направляющие */
|
||||||
|
.frameCross {
|
||||||
|
position: absolute;
|
||||||
|
inset: 2px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frameCross::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
transform: translateX(-0.5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frameCross::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
transform: translateY(-0.5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
width: min(90vw, 90vh);
|
||||||
|
padding: var(--spacing-medium);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-small);
|
||||||
|
animation: slideUp 0.3s ease-out;
|
||||||
|
position: relative;
|
||||||
|
z-index: 100;
|
||||||
|
margin-top: var(--spacing-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoomSlider {
|
||||||
|
width: 100%;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoomSlider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid white;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
343
src/screens/CropPhoto.tsx
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import styles from './CropPhoto.module.css';
|
||||||
|
|
||||||
|
const CropPhoto: React.FC = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const imageRef = useRef<HTMLImageElement>(null);
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||||
|
const [scale, setScale] = useState(1);
|
||||||
|
const [minScale, setMinScale] = useState(1);
|
||||||
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||||
|
const [initialTouch, setInitialTouch] = useState<{ distance: number; scale: number } | null>(null);
|
||||||
|
|
||||||
|
// Эффект для создания и очистки blob URL
|
||||||
|
useEffect(() => {
|
||||||
|
const file = (location.state as any)?.file;
|
||||||
|
if (!file) {
|
||||||
|
navigate('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем URL только если его еще нет
|
||||||
|
if (!imageUrl) {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
setImageUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очищаем URL при размонтировании компонента
|
||||||
|
return () => {
|
||||||
|
if (imageUrl) {
|
||||||
|
URL.revokeObjectURL(imageUrl);
|
||||||
|
setImageUrl(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [location.state, navigate, imageUrl]);
|
||||||
|
|
||||||
|
// Отдельный эффект для обработки загруженного изображения
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageUrl || !viewportRef.current) return;
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
const viewport = viewportRef.current;
|
||||||
|
if (!viewport) return;
|
||||||
|
|
||||||
|
const frame = viewport.querySelector(`.${styles.frame}`);
|
||||||
|
if (!frame) return;
|
||||||
|
|
||||||
|
const viewportRect = viewport.getBoundingClientRect();
|
||||||
|
const frameRect = frame.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Используем размеры рамки обрезки
|
||||||
|
const frameWidth = frameRect.width;
|
||||||
|
const frameHeight = frameRect.height;
|
||||||
|
|
||||||
|
// Вычисляем масштаб, чтобы изображение полностью покрывало область обрезки
|
||||||
|
const scaleX = frameWidth / img.naturalWidth;
|
||||||
|
const scaleY = frameHeight / img.naturalHeight;
|
||||||
|
const baseScale = Math.max(scaleX, scaleY);
|
||||||
|
|
||||||
|
setMinScale(baseScale);
|
||||||
|
setScale(baseScale);
|
||||||
|
|
||||||
|
// Центрируем изображение относительно рамки
|
||||||
|
const scaledWidth = img.naturalWidth * baseScale;
|
||||||
|
const scaledHeight = img.naturalHeight * baseScale;
|
||||||
|
|
||||||
|
// Учитываем отступы от рамки до viewport
|
||||||
|
const frameLeft = frameRect.left - viewportRect.left;
|
||||||
|
const frameTop = frameRect.top - viewportRect.top;
|
||||||
|
|
||||||
|
setPosition({
|
||||||
|
x: frameLeft + (frameWidth - scaledWidth) / 2,
|
||||||
|
y: frameTop + (frameHeight - scaledHeight) / 2
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
// В случае ошибки загрузки изображения
|
||||||
|
console.error('Ошибка загрузки изображения');
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = imageUrl;
|
||||||
|
}, [imageUrl, navigate]);
|
||||||
|
|
||||||
|
const handleStart = (e: React.MouseEvent | React.TouchEvent) => {
|
||||||
|
if ('touches' in e) {
|
||||||
|
if (e.touches.length === 2) {
|
||||||
|
const distance = getDistance(e.touches);
|
||||||
|
setInitialTouch({ distance, scale });
|
||||||
|
setIsDragging(false);
|
||||||
|
} else {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
setIsDragging(true);
|
||||||
|
setDragStart({ x: touch.clientX - position.x, y: touch.clientY - position.y });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIsDragging(true);
|
||||||
|
setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMove = (e: React.MouseEvent | React.TouchEvent) => {
|
||||||
|
if ('touches' in e) {
|
||||||
|
if (e.touches.length === 2 && initialTouch) {
|
||||||
|
// Масштабирование двумя пальцами
|
||||||
|
const distance = getDistance(e.touches);
|
||||||
|
const newScale = Math.min(
|
||||||
|
Math.max(
|
||||||
|
(initialTouch.scale * distance) / initialTouch.distance,
|
||||||
|
minScale
|
||||||
|
),
|
||||||
|
minScale * 3
|
||||||
|
);
|
||||||
|
handleScale(newScale);
|
||||||
|
} else if (isDragging) {
|
||||||
|
const touch = e.touches[0];
|
||||||
|
handleDrag(touch.clientX, touch.clientY);
|
||||||
|
}
|
||||||
|
} else if (isDragging) {
|
||||||
|
handleDrag(e.clientX, e.clientY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnd = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
setInitialTouch(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDistance = (touches: React.TouchList) => {
|
||||||
|
const dx = touches[0].clientX - touches[1].clientX;
|
||||||
|
const dy = touches[0].clientY - touches[1].clientY;
|
||||||
|
return Math.sqrt(dx * dx + dy * dy);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrag = (clientX: number, clientY: number) => {
|
||||||
|
if (!viewportRef.current || !imageRef.current) return;
|
||||||
|
|
||||||
|
const viewport = viewportRef.current;
|
||||||
|
const frame = viewport.querySelector(`.${styles.frame}`);
|
||||||
|
if (!frame) return;
|
||||||
|
|
||||||
|
const viewportRect = viewport.getBoundingClientRect();
|
||||||
|
const frameRect = frame.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Получаем позицию рамки относительно viewport
|
||||||
|
const frameLeft = frameRect.left - viewportRect.left;
|
||||||
|
const frameTop = frameRect.top - viewportRect.top;
|
||||||
|
|
||||||
|
// Вычисляем размеры масштабированного изображения
|
||||||
|
const scaledWidth = imageRef.current.naturalWidth * scale;
|
||||||
|
const scaledHeight = imageRef.current.naturalHeight * scale;
|
||||||
|
|
||||||
|
// Вычисляем новую позицию
|
||||||
|
const newX = clientX - dragStart.x;
|
||||||
|
const newY = clientY - dragStart.y;
|
||||||
|
|
||||||
|
// Ограничиваем перемещение
|
||||||
|
const minX = frameLeft - (scaledWidth - frameRect.width);
|
||||||
|
const maxX = frameLeft;
|
||||||
|
const minY = frameTop - (scaledHeight - frameRect.height);
|
||||||
|
const maxY = frameTop;
|
||||||
|
|
||||||
|
setPosition({
|
||||||
|
x: Math.max(minX, Math.min(maxX, newX)),
|
||||||
|
y: Math.max(minY, Math.min(maxY, newY))
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScale = (newScale: number) => {
|
||||||
|
if (!viewportRef.current || !imageRef.current) return;
|
||||||
|
|
||||||
|
const viewport = viewportRef.current;
|
||||||
|
const frame = viewport.querySelector(`.${styles.frame}`);
|
||||||
|
if (!frame) return;
|
||||||
|
|
||||||
|
const viewportRect = viewport.getBoundingClientRect();
|
||||||
|
const frameRect = frame.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Получаем позицию рамки относительно viewport
|
||||||
|
const frameLeft = frameRect.left - viewportRect.left;
|
||||||
|
const frameTop = frameRect.top - viewportRect.top;
|
||||||
|
|
||||||
|
// Вычисляем центр рамки
|
||||||
|
const frameCenterX = frameLeft + frameRect.width / 2;
|
||||||
|
const frameCenterY = frameTop + frameRect.height / 2;
|
||||||
|
|
||||||
|
// Вычисляем текущий центр изображения
|
||||||
|
const currentCenterX = position.x + (imageRef.current.naturalWidth * scale) / 2;
|
||||||
|
const currentCenterY = position.y + (imageRef.current.naturalHeight * scale) / 2;
|
||||||
|
|
||||||
|
// Вычисляем смещение для сохранения центра
|
||||||
|
const dx = (frameCenterX - currentCenterX) * (newScale / scale - 1);
|
||||||
|
const dy = (frameCenterY - currentCenterY) * (newScale / scale - 1);
|
||||||
|
|
||||||
|
// Вычисляем новые размеры изображения
|
||||||
|
const newScaledWidth = imageRef.current.naturalWidth * newScale;
|
||||||
|
const newScaledHeight = imageRef.current.naturalHeight * newScale;
|
||||||
|
|
||||||
|
// Вычисляем новую позицию
|
||||||
|
let newX = position.x + dx;
|
||||||
|
let newY = position.y + dy;
|
||||||
|
|
||||||
|
// Ограничиваем позицию
|
||||||
|
const minX = frameLeft - (newScaledWidth - frameRect.width);
|
||||||
|
const maxX = frameLeft;
|
||||||
|
const minY = frameTop - (newScaledHeight - frameRect.height);
|
||||||
|
const maxY = frameTop;
|
||||||
|
|
||||||
|
setScale(newScale);
|
||||||
|
setPosition({
|
||||||
|
x: Math.max(minX, Math.min(maxX, newX)),
|
||||||
|
y: Math.max(minY, Math.min(maxY, newY))
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoom = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
handleScale(parseFloat(e.target.value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPreview = () => {
|
||||||
|
if (!imageRef.current || !viewportRef.current) return null;
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 768;
|
||||||
|
canvas.height = 768;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return null;
|
||||||
|
|
||||||
|
const viewport = viewportRef.current;
|
||||||
|
const frame = viewport.querySelector(`.${styles.frame}`);
|
||||||
|
if (!frame) return null;
|
||||||
|
|
||||||
|
const frameRect = frame.getBoundingClientRect();
|
||||||
|
const image = imageRef.current;
|
||||||
|
|
||||||
|
ctx.drawImage(
|
||||||
|
image,
|
||||||
|
-position.x / scale,
|
||||||
|
-position.y / scale,
|
||||||
|
frameRect.width / scale,
|
||||||
|
frameRect.height / scale,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
768,
|
||||||
|
768
|
||||||
|
);
|
||||||
|
|
||||||
|
return canvas.toDataURL('image/jpeg', 0.95);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
const previewUrl = createPreview();
|
||||||
|
if (previewUrl) {
|
||||||
|
// Передаем не только URL, но и base64 данные
|
||||||
|
// Убираем префикс data:image/jpeg;base64, оставляем только данные
|
||||||
|
const imageData = previewUrl.split(',')[1];
|
||||||
|
navigate('/', {
|
||||||
|
state: {
|
||||||
|
previewUrl,
|
||||||
|
imageData
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<button className={styles.backButton} onClick={() => navigate('/')}>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<h1 className={styles.title}>Обрезка фото</h1>
|
||||||
|
<button className={styles.confirmButton} onClick={handleConfirm}>
|
||||||
|
Готово
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.cropContainer}>
|
||||||
|
<div className={styles.viewportContainer}>
|
||||||
|
<div
|
||||||
|
ref={viewportRef}
|
||||||
|
className={styles.viewport}
|
||||||
|
onTouchStart={handleStart}
|
||||||
|
onTouchMove={handleMove}
|
||||||
|
onTouchEnd={handleEnd}
|
||||||
|
onTouchCancel={handleEnd}
|
||||||
|
onMouseDown={handleStart}
|
||||||
|
onMouseMove={handleMove}
|
||||||
|
onMouseUp={handleEnd}
|
||||||
|
onMouseLeave={handleEnd}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{imageUrl && (
|
||||||
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
|
className={styles.imageWrapper}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
ref={imageRef}
|
||||||
|
src={imageUrl}
|
||||||
|
alt="Crop"
|
||||||
|
className={styles.image}
|
||||||
|
draggable={false}
|
||||||
|
style={{
|
||||||
|
transform: `matrix(${scale}, 0, 0, ${scale}, ${position.x}, ${position.y})`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.frame} />
|
||||||
|
<div className={styles.frameCross} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.controls}>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={minScale}
|
||||||
|
max={minScale * 3}
|
||||||
|
step="0.01"
|
||||||
|
value={scale}
|
||||||
|
onChange={handleZoom}
|
||||||
|
className={styles.zoomSlider}
|
||||||
|
/>
|
||||||
|
<div className={styles.hint}>
|
||||||
|
Отрегулируйте масштаб и положение фото
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CropPhoto;
|
||||||
193
src/screens/Gallery.module.css
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
:root {
|
||||||
|
--test: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pullToRefreshContainer {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-large);
|
||||||
|
padding: calc(3rem + var(--spacing-small)) var(--spacing-medium) var(--spacing-large);
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-y: auto;
|
||||||
|
height: 100%;
|
||||||
|
-webkit-overflow-scrolling: touch; /* Для плавного скролла на iOS */
|
||||||
|
transform: translateY(var(--pull-distance, 0px));
|
||||||
|
transition: transform 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refreshIndicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transform: translateY(calc(-100% + var(--pull-distance, 0px)));
|
||||||
|
transition: transform 0.3s ease-out;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
z-index: 10;
|
||||||
|
border-bottom-left-radius: var(--border-radius);
|
||||||
|
border-bottom-right-radius: var(--border-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refreshSpinner {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: var(--color-primary, #3498db);
|
||||||
|
opacity: calc(var(--pull-distance, 0) / 80); /* Постепенно появляется */
|
||||||
|
transform: rotate(calc(var(--pull-distance, 0) * 4.5deg)); /* Вращается при вытягивании */
|
||||||
|
}
|
||||||
|
|
||||||
|
.refreshing .refreshSpinner {
|
||||||
|
opacity: 1;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refreshIndicator span {
|
||||||
|
margin-left: var(--spacing-small);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: var(--spacing-small) 0;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--color-text);
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
padding: var(--spacing-medium);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: var(--spacing-medium);
|
||||||
|
margin-top: 0;
|
||||||
|
animation: fadeUp 0.6s ease-out 0.2s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageItem {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent; /* Убираем подсветку при тапе на мобильных */
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background-color: var(--color-error-bg, #ffebee);
|
||||||
|
color: var(--color-error, #d32f2f);
|
||||||
|
padding: var(--spacing-medium);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для отображения задач в очереди */
|
||||||
|
.pendingTasksSection {
|
||||||
|
margin-bottom: var(--spacing-large);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: var(--spacing-small);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pendingTasksGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: var(--spacing-medium);
|
||||||
|
animation: fadeUp 0.6s ease-out 0.2s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pendingTaskItem {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pendingTaskPlaceholder {
|
||||||
|
height: 150px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--color-surface-variant, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 4px solid rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: var(--color-primary, #3498db);
|
||||||
|
animation: spin 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.pendingTaskInfo {
|
||||||
|
padding: var(--spacing-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pendingTaskStatus {
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0 0 5px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pendingTaskTime {
|
||||||
|
color: var(--color-text-secondary, #666);
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||