Initial commit

This commit is contained in:
kazachilo 2025-03-13 15:51:19 +03:00
commit 6f48c3b5fc
131 changed files with 14446 additions and 0 deletions

24
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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

View 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)

BIN
ava.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

28
eslint.config.js Normal file
View 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 },
],
},
},
)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

17
index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View 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
View 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
View 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
View 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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
src/assets/250x_santa.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
src/assets/ahare_bot250.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
src/assets/balerina250x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

BIN
src/assets/balloon250x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
src/assets/bicecle250x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
src/assets/book250x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
src/assets/coctail250x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
src/assets/coffee250x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
src/assets/cook250x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
src/assets/cowboy250x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

BIN
src/assets/detektiv250x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
src/assets/dog250x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
src/assets/fairy250x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
src/assets/faq250.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
src/assets/fire250x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
src/assets/flowers250x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
src/assets/gift250x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
src/assets/icecream250x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

BIN
src/assets/knight250x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
src/assets/moto250x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
src/assets/onboard1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
src/assets/prompt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

25
src/assets/prompts.md Normal file
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
src/assets/shield-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
src/assets/shorts250.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
src/assets/sience250x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
src/assets/sportcar250x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
src/assets/surfing250x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

BIN
src/assets/umbrella250x.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View 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;

View File

@ -0,0 +1,5 @@
.divider {
width: 100%;
height: 1px;
background-color: rgba(0, 0, 0, 0.1);
}

View 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;

View 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;
}

View 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;

View 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;
}

View 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;

View 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;
}

View 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;

View 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;
}

View 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;

View 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;
}

View 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;

View 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);
}

View 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;

View 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);
}

View 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;

View 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;
}

View 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;

View 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);
}

View 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;

View 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; /* Предотвращаем перенос текста */
}

View 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;

View 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;

View 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; /* Немного уменьшаем высоту на мобильных */
}
}

View 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}>
&times;
</button>
<img
src={imageUrl}
alt="Полноэкранный просмотр"
className={styles.fullImage}
style={{ transform: `translateY(-${translateY}px)` }}
/>
</div>
</div>
);
};
export default ImageViewer;

View 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;
}
}

View 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;

View 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);
}

View 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
View 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
View 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'
}
}
]
}
};

View 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": "Stickerchibi: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,tattooingdenimstreet dancedancing",
"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
View 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
View 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
View 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
View 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>
);
}

View 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;
}

View 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;

View 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;
}

View 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;

View 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;
}

View 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;

View 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
View 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;

View 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;
}

Some files were not shown because too many files have changed in this diff Show More