diff --git a/src/components/blocks/BlockRenderer.tsx b/src/components/blocks/BlockRenderer.tsx index fde5344..390d0d0 100644 --- a/src/components/blocks/BlockRenderer.tsx +++ b/src/components/blocks/BlockRenderer.tsx @@ -25,7 +25,7 @@ const BlockRenderer: React.FC = ({ block, onAction, extraPro return ; case 'uploadPhoto': return { const tempUrl = URL.createObjectURL(file); window.history.replaceState( diff --git a/src/components/blocks/UploadPhotoBlock.tsx b/src/components/blocks/UploadPhotoBlock.tsx index f3e34b8..f0cadd4 100644 --- a/src/components/blocks/UploadPhotoBlock.tsx +++ b/src/components/blocks/UploadPhotoBlock.tsx @@ -14,6 +14,10 @@ const UploadPhotoBlock: React.FC = ({ onPhotoSelect, prev const handleFileSelect = (file: File) => { if (file && file.type.startsWith('image/')) { + // Очищаем предыдущие данные изображения при загрузке нового + localStorage.removeItem('stickerPreviewUrl'); + localStorage.removeItem('stickerImageData'); + onPhotoSelect?.(file); navigate('/crop-photo', { state: { file } }); } diff --git a/src/components/shared/NotificationModal.module.css b/src/components/shared/NotificationModal.module.css new file mode 100644 index 0000000..013a393 --- /dev/null +++ b/src/components/shared/NotificationModal.module.css @@ -0,0 +1,113 @@ +.overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal { + background-color: var(--color-background); + border-radius: var(--border-radius); + padding: var(--spacing-medium); + width: calc(100% - var(--spacing-medium) * 2); + max-width: 400px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + margin: 0 var(--spacing-medium); +} + +.header { + margin-bottom: var(--spacing-medium); +} + +.title { + font-size: 18px; + font-weight: 600; + margin-bottom: var(--spacing-small); +} + +.message { + font-size: 14px; + margin-bottom: var(--spacing-small); + line-height: 1.4; +} + +.promptContainer { + background-color: var(--color-border); + border-radius: var(--border-radius); + padding: var(--spacing-small); + margin-bottom: var(--spacing-medium); + max-height: 100px; + overflow-y: auto; +} + +.promptLabel { + font-size: 12px; + font-weight: 600; + margin-bottom: 4px; + color: var(--color-text-secondary); +} + +.promptText { + font-size: 12px; + word-break: break-word; + font-family: monospace; +} + +.buttons { + display: flex; + justify-content: space-between; + gap: var(--spacing-small); +} + +.button { + flex: 1; + padding: var(--spacing-small); + border: none; + border-radius: var(--border-radius); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s; +} + +.primaryButton { + background-color: var(--color-primary); + color: white; +} + +.primaryButton:hover { + background-color: #1976D2; +} + +.secondaryButton { + background-color: var(--color-border); + color: var(--color-text); +} + +.secondaryButton:hover { + background-color: #d0d0d0; +} + +.spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid rgba(var(--color-text-rgb), 0.1); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 1s linear infinite; + margin-right: var(--spacing-small); + vertical-align: middle; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/components/shared/NotificationModal.tsx b/src/components/shared/NotificationModal.tsx new file mode 100644 index 0000000..feca8f5 --- /dev/null +++ b/src/components/shared/NotificationModal.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import styles from './NotificationModal.module.css'; + +interface NotificationModalProps { + isVisible: boolean; + title: string; + message: string; + isLoading?: boolean; + promptText?: string; + onGalleryClick: () => void; + onContinueClick: () => void; +} + +const NotificationModal: React.FC = ({ + isVisible, + title, + message, + isLoading = false, + promptText, + onGalleryClick, + onContinueClick +}) => { + if (!isVisible) return null; + + return ( +
+
+
+
+ {isLoading && } + {title} +
+
{message}
+
+ + {promptText && ( +
+
Использованный промпт:
+
{promptText}
+
+ )} + +
+ + +
+
+
+ ); +}; + +export default NotificationModal; diff --git a/src/screens/CropPhoto.tsx b/src/screens/CropPhoto.tsx index c1b7ba8..6e6c2bf 100644 --- a/src/screens/CropPhoto.tsx +++ b/src/screens/CropPhoto.tsx @@ -263,6 +263,11 @@ const CropPhoto: React.FC = () => { // Передаем не только URL, но и base64 данные // Убираем префикс data:image/jpeg;base64, оставляем только данные const imageData = previewUrl.split(',')[1]; + + // Сохраняем данные в localStorage для сохранения между сеансами навигации + localStorage.setItem('stickerPreviewUrl', previewUrl); + localStorage.setItem('stickerImageData', imageData); + navigate('/', { state: { previewUrl, diff --git a/src/screens/Home.tsx b/src/screens/Home.tsx index 589401e..38de5b4 100644 --- a/src/screens/Home.tsx +++ b/src/screens/Home.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import BlockRenderer from '../components/blocks/BlockRenderer'; // import UploadPhotoBlock from '../components/blocks/UploadPhotoBlock'; // Не используется @@ -6,26 +6,44 @@ import styles from './Home.module.css'; import { homeScreenConfig } from '../config/homeScreen'; import { stylePresets } from '../config/stylePresets'; import apiService from '../services/api'; +import NotificationModal from '../components/shared/NotificationModal'; const Home: React.FC = () => { const navigate = useNavigate(); // eslint-disable-next-line @typescript-eslint/no-unused-vars const [previewUrl, setPreviewUrl] = useState(() => { - // Проверяем, есть ли превью в состоянии навигации + // Проверяем, есть ли превью в состоянии навигации или localStorage const state = window.history.state?.usr; - return state?.previewUrl; + return state?.previewUrl || localStorage.getItem('stickerPreviewUrl') || undefined; }); const [imageData, _setImageData] = useState(() => { const state = window.history.state?.usr; - return state?.imageData; + return state?.imageData || localStorage.getItem('stickerImageData') || undefined; }); const [isInputVisible, setIsInputVisible] = useState(false); const [selectedStyle, setSelectedStyle] = useState('chibi'); // По умолчанию выбран первый стиль const [selectedButtonId, setSelectedButtonId] = useState(undefined); // Для хранения ID выбранной кнопки стиля const [customPrompt, setCustomPrompt] = useState(''); // Для хранения пользовательского промпта + // Состояния для модального окна уведомления + const [isNotificationVisible, setIsNotificationVisible] = useState(false); + const [notificationTitle, setNotificationTitle] = useState(''); + const [notificationMessage, setNotificationMessage] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [promptText, setPromptText] = useState(''); + + // Обработчики для модального окна + const handleGalleryClick = useCallback(() => { + setIsNotificationVisible(false); + navigate('/gallery'); + }, [navigate]); + + const handleContinueClick = useCallback(() => { + setIsNotificationVisible(false); + }, []); + const handleBlockAction = useCallback(async (actionType: string, actionValue: string, _blockId?: string, buttonId?: string) => { if (actionType === 'function') { if (actionValue === 'startGeneration') { @@ -35,30 +53,58 @@ const Home: React.FC = () => { } try { + // Показываем уведомление о начале генерации + setNotificationTitle('Генерация стикера'); + setNotificationMessage('Отправка запроса...'); + setIsLoading(true); + setPromptText(''); + setIsNotificationVisible(true); + // Если выбран "Свой промпт" и введен текст, используем его const userPrompt = selectedButtonId === 'customPrompt' && customPrompt ? customPrompt : undefined; - const result = await apiService.generateImage(imageData, selectedStyle, selectedButtonId, userPrompt); - console.log('Generation started:', result); - // Показываем уведомление о позиции в очереди - if (result.queue_position !== undefined) { - const estimatedTime = apiService.calculateEstimatedWaitTime(result.queue_position); - const minutes = Math.floor(estimatedTime / 60); - const seconds = estimatedTime % 60; - const timeString = minutes > 0 - ? `${minutes} мин ${seconds} сек` - : `${seconds} сек`; - - alert(`Ваша задача отправлена на генерацию!\nПозиция в очереди: ${result.queue_position}\nПримерное время ожидания: ${timeString}`); - } else { - alert('Ваша задача отправлена на генерацию!'); + // Отправляем запрос на генерацию + const response = await apiService.generateImage(imageData, selectedStyle, selectedButtonId, userPrompt); + console.log('Generation response:', response); + + // Проверяем, была ли ошибка перевода + if (response.translationFailed) { + setNotificationTitle('Ошибка перевода'); + setNotificationMessage('Не удалось перевести промпт. Генерация отменена.'); + setIsLoading(false); + return; } - // Перенаправляем пользователя в галерею - navigate('/gallery'); + // Если нет ошибки перевода, продолжаем обработку результата + if (response.result && response.usedPrompt) { + // Получаем результат и использованный промпт + const { result, usedPrompt } = response; + + // Обновляем уведомление с информацией о позиции в очереди + if (result.queue_position !== undefined) { + const estimatedTime = apiService.calculateEstimatedWaitTime(result.queue_position); + const minutes = Math.floor(estimatedTime / 60); + const seconds = estimatedTime % 60; + const timeString = minutes > 0 + ? `${minutes} мин ${seconds} сек` + : `${seconds} сек`; + + setNotificationMessage(`Ваша задача отправлена на генерацию!\nПозиция в очереди: ${result.queue_position}\nПримерное время ожидания: ${timeString}`); + } else { + setNotificationMessage('Ваша задача отправлена на генерацию!'); + } + + // Устанавливаем использованный промпт и убираем индикатор загрузки + setPromptText(usedPrompt); + } + + setIsLoading(false); } catch (error) { console.error('Generation failed:', error); - alert('Не удалось начать генерацию'); + setNotificationTitle('Ошибка'); + setNotificationMessage('Не удалось начать генерацию'); + setIsLoading(false); + setIsNotificationVisible(true); } return; } @@ -101,6 +147,27 @@ const Home: React.FC = () => { setIsInputVisible(false); }, [navigate, imageData, selectedStyle, selectedButtonId, customPrompt]); + // Эффект для обновления window.history.state при загрузке из localStorage + useEffect(() => { + // Если есть данные в localStorage, но нет в history.state, обновляем history.state + const state = window.history.state?.usr; + const localStoragePreviewUrl = localStorage.getItem('stickerPreviewUrl'); + const localStorageImageData = localStorage.getItem('stickerImageData'); + + if (!state?.previewUrl && localStoragePreviewUrl && localStorageImageData) { + window.history.replaceState( + { + usr: { + previewUrl: localStoragePreviewUrl, + imageData: localStorageImageData + } + }, + '', + window.location.pathname + ); + } + }, []); + // Функция для получения кнопок в зависимости от блока const getBlockButtons = useCallback((block: any) => { if (block.id === 'quickActions') { @@ -114,6 +181,17 @@ const Home: React.FC = () => { return (
+ {/* Модальное окно уведомления */} + +
{/* Блоки из конфигурации */}
diff --git a/src/services/api.ts b/src/services/api.ts index 6154bb7..e1c5b84 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1,4 +1,4 @@ -import { GenerationResponse, ApiError as ApiErrorType, GeneratedImage, PendingTask } from '../types/api'; +import { GenerationResponse, ApiError as ApiErrorType, GeneratedImage, PendingTask, GenerationResult } from '../types/api'; import { baseWorkflow } from '../constants/baseWorkflow'; import { prompts } from '../assets/prompts'; import translateService from './translateService'; @@ -133,79 +133,98 @@ const apiService = { } }, - async generateImage(imageData: string, style?: string, promptId?: string, userPrompt?: string) { - try { - // Создаем копию базового воркфлоу - const workflow = JSON.parse(JSON.stringify(baseWorkflow)); +async generateImage(imageData: string, style?: string, promptId?: string, userPrompt?: string): Promise { + try { + // Создаем копию базового воркфлоу + const workflow = JSON.parse(JSON.stringify(baseWorkflow)); + + // Вставляем изображение в base64 формате в узел 563 + workflow['563'].inputs.image = imageData; + + // Переменная для хранения использованного промпта + let usedPrompt = ''; + let translationFailed = false; + + // Если указан пользовательский промпт и выбрана кнопка "Свой промпт" + if (userPrompt && promptId === 'customPrompt') { + console.log('Переводим пользовательский промпт:', userPrompt); - // Вставляем изображение в base64 формате в узел 563 - workflow['563'].inputs.image = imageData; + // Переводим промпт с 3 попытками + const translationResult = await translateService.translateToEnglish(userPrompt, 3); - // Если указан пользовательский промпт и выбрана кнопка "Свой промпт" - if (userPrompt && promptId === 'customPrompt') { - console.log('Переводим пользовательский промпт:', userPrompt); + if (translationResult.success) { + // Успешный перевод + console.log('Переведенный промпт:', translationResult.text); + workflow['316'].inputs.prompt_1 = translationResult.text; + usedPrompt = translationResult.text; + } else { + // Перевод не удался после всех попыток + console.error('Не удалось перевести промпт после нескольких попыток'); + translationFailed = true; - try { - // Переводим промпт и ждем результата - const translatedPrompt = await translateService.translateToEnglish(userPrompt); - console.log('Переведенный промпт:', translatedPrompt); - - // Явно заменяем промпт в воркфлоу - workflow['316'].inputs.prompt_1 = translatedPrompt; - - // Проверяем, что промпт действительно заменен - console.log('Промпт в воркфлоу после замены:', workflow['316'].inputs.prompt_1); - } catch (translationError) { - console.error('Ошибка при переводе:', translationError); - // В случае ошибки перевода используем исходный промпт - workflow['316'].inputs.prompt_1 = userPrompt; - console.log('Используем исходный промпт из-за ошибки перевода:', userPrompt); - } - } - // Иначе используем предустановленный промпт - else if (promptId && prompts[promptId]) { - workflow['316'].inputs.prompt_1 = prompts[promptId]; - console.log('Используем предустановленный промпт:', prompts[promptId]); + // Не продолжаем генерацию, возвращаем ошибку + return { + translationFailed: true + }; } - - // Создаем строку JSON для workflow ПОСЛЕ того, как все изменения внесены - const workflowJson = JSON.stringify(workflow); - - // Определяем тег на основе выбранного стиля - const tag = styleToTagMap[style || 'chibi'] || 'chibi'; - console.log(`Используем тег "${tag}" для стиля "${style || 'chibi'}"`); - - // Создаем тело запроса как строку JSON вручную - const requestBodyJson = `{"tag":"${tag}","user_id":${getCurrentUserId()},"workflow":${workflowJson}}`; - - // Сохраняем JSON для отладки только при локальной разработке - if (!isTelegramWebAppAvailable()) { - const blob = new Blob([requestBodyJson], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'generation_request.json'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - } - - // Отправляем запрос - const response = await fetch(`${API_BASE_URL}/generate_image`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: requestBodyJson - }); + } + // Иначе используем предустановленный промпт (они уже переведены) + else if (promptId && prompts[promptId]) { + workflow['316'].inputs.prompt_1 = prompts[promptId]; + usedPrompt = prompts[promptId]; + console.log('Используем предустановленный промпт:', prompts[promptId]); + } + + // Если был сбой перевода, не продолжаем генерацию + if (translationFailed) { + return { translationFailed: true }; + } + + // Создаем строку JSON для workflow ПОСЛЕ того, как все изменения внесены + const workflowJson = JSON.stringify(workflow); + + // Определяем тег на основе выбранного стиля + const tag = styleToTagMap[style || 'chibi'] || 'chibi'; + console.log(`Используем тег "${tag}" для стиля "${style || 'chibi'}"`); + + // Создаем тело запроса как строку JSON вручную + const requestBodyJson = `{"tag":"${tag}","user_id":${getCurrentUserId()},"workflow":${workflowJson}}`; + + // Сохраняем JSON для отладки только при локальной разработке + if (!isTelegramWebAppAvailable()) { + const blob = new Blob([requestBodyJson], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'generation_request.json'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + // Отправляем запрос + const response = await fetch(`${API_BASE_URL}/generate_image`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: requestBodyJson + }); - if (!response.ok) { - const errorData: ApiErrorType = await response.json(); - throw new GenerationError(errorData.detail); - } + if (!response.ok) { + const errorData: ApiErrorType = await response.json(); + throw new GenerationError(errorData.detail); + } - return await response.json() as GenerationResponse; + const result = await response.json() as GenerationResponse; + + // Возвращаем результат и использованный промпт + return { + result, + usedPrompt, + translationFailed: false + }; } catch (error) { if (error instanceof GenerationError) { throw error; diff --git a/src/services/translateService.ts b/src/services/translateService.ts index 682e9fa..d3b8820 100644 --- a/src/services/translateService.ts +++ b/src/services/translateService.ts @@ -10,32 +10,46 @@ interface TranslateResponse { } const translateService = { - async translateToEnglish(text: string): Promise { - try { - const response = await fetch(TRANSLATE_API_URL, { - method: 'POST', - body: JSON.stringify({ - q: text, - source: 'auto', - target: 'en', - format: 'text', - alternatives: 3, - api_key: '' - }), - headers: { 'Content-Type': 'application/json' } - }); + async translateToEnglish(text: string, maxRetries = 3): Promise<{ success: boolean; text: string }> { + let retries = 0; - if (!response.ok) { - throw new Error('Translation failed'); + while (retries <= maxRetries) { + try { + // Добавляем задержку перед повторными попытками + if (retries > 0) { + await new Promise(resolve => setTimeout(resolve, 1000)); // 1 секунда между попытками + } + + const response = await fetch(TRANSLATE_API_URL, { + method: 'POST', + body: JSON.stringify({ + q: text, + source: 'auto', + target: 'en', + format: 'text', + alternatives: 3, + api_key: '' + }), + headers: { 'Content-Type': 'application/json' } + }); + + if (!response.ok) { + throw new Error(`Translation failed with status: ${response.status}`); + } + + const data: TranslateResponse = await response.json(); + + // Успешный перевод + return { success: true, text: data.translatedText }; + } catch (error) { + console.error(`Translation attempt ${retries + 1} failed:`, error); + retries++; } - - const data: TranslateResponse = await response.json(); - return data.translatedText; - } catch (error) { - console.error('Translation error:', error); - // В случае ошибки возвращаем исходный текст - return text; } + + // Все попытки исчерпаны, возвращаем исходный текст с флагом неудачи + console.error(`All ${maxRetries} translation attempts failed`); + return { success: false, text: text }; } }; diff --git a/src/types/api.ts b/src/types/api.ts index a56a794..7036b5b 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -60,3 +60,10 @@ export interface StickerSetResponse { set_name: string; user_id: number; } + +// Интерфейс для ответа от apiService.generateImage +export interface GenerationResult { + result?: GenerationResponse; + usedPrompt?: string; + translationFailed: boolean; +}