feat: обновлен механизм удаления стикеров в галерее
- обавлено удаление стикеров по file_id - зменен механизм выхода из режима удаления (теперь по касанию в любое место кроме крестика) - далена кнопка 'отово' и связанные стили - лучшен UX режима удаления
This commit is contained in:
parent
6426b05597
commit
b9c026a23c
108
src/components/shared/ImageWithFallback.module.css
Normal file
108
src/components/shared/ImageWithFallback.module.css
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--color-surface-variant, #f0f0f0);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingContainer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: var(--color-surface-variant, #f0f0f0);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 3px 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); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorContainer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: var(--color-surface-variant, #f0f0f0);
|
||||||
|
z-index: 2;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorIcon {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--color-error, #d32f2f);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorMessage {
|
||||||
|
color: var(--color-text, #333);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retryButton {
|
||||||
|
background-color: var(--color-primary, #3498db);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius, 4px);
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retryButton:hover {
|
||||||
|
background-color: var(--color-primary-dark, #2980b9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Для мобильных устройств делаем кнопку больше для удобства нажатия */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.retryButton {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
150
src/components/shared/ImageWithFallback.tsx
Normal file
150
src/components/shared/ImageWithFallback.tsx
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import styles from './ImageWithFallback.module.css';
|
||||||
|
|
||||||
|
interface ImageWithFallbackProps {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
maxRetries?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageWithFallback: React.FC<ImageWithFallbackProps> = ({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
className = '',
|
||||||
|
onClick,
|
||||||
|
maxRetries = 2 // По умолчанию 2 попытки автоматической перезагрузки
|
||||||
|
}) => {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
|
const [imageSrc, setImageSrc] = useState(src);
|
||||||
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
|
|
||||||
|
// Сбрасываем состояние при изменении src
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(false);
|
||||||
|
setRetryCount(0);
|
||||||
|
setImageSrc(src);
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
// Проверяем, загружено ли изображение из кэша
|
||||||
|
useEffect(() => {
|
||||||
|
// Если изображение уже загружено (из кэша), сразу устанавливаем состояние
|
||||||
|
if (imgRef.current && imgRef.current.complete && imgRef.current.naturalWidth > 0) {
|
||||||
|
console.log('Изображение уже загружено (из кэша):', src);
|
||||||
|
setLoading(false);
|
||||||
|
setError(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем таймаут для предотвращения бесконечной загрузки
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (loading) {
|
||||||
|
console.log('Таймаут загрузки изображения:', src);
|
||||||
|
// Проверяем, загружено ли изображение фактически
|
||||||
|
if (imgRef.current && imgRef.current.complete && imgRef.current.naturalWidth > 0) {
|
||||||
|
// Изображение загружено, но событие onLoad не сработало
|
||||||
|
console.log('Изображение фактически загружено, но событие onLoad не сработало:', src);
|
||||||
|
setLoading(false);
|
||||||
|
setError(false);
|
||||||
|
} else {
|
||||||
|
// Изображение действительно не загрузилось
|
||||||
|
console.error('Изображение не загрузилось после таймаута:', src);
|
||||||
|
setLoading(false);
|
||||||
|
setError(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 5000); // 5 секунд
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [loading, src]);
|
||||||
|
|
||||||
|
// Функция для перезагрузки изображения
|
||||||
|
const handleRetry = () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(false);
|
||||||
|
// Добавляем случайный параметр к URL для предотвращения кэширования
|
||||||
|
setImageSrc(`${src}${src.includes('?') ? '&' : '?'}retry=${Date.now()}`);
|
||||||
|
setRetryCount(prevCount => prevCount + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обработчик успешной загрузки
|
||||||
|
const handleLoad = () => {
|
||||||
|
console.log('Изображение успешно загружено:', src);
|
||||||
|
setLoading(false);
|
||||||
|
setError(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обработчик ошибки загрузки
|
||||||
|
const handleError = () => {
|
||||||
|
console.error('Ошибка загрузки изображения:', src);
|
||||||
|
setLoading(false);
|
||||||
|
setError(true);
|
||||||
|
|
||||||
|
// Автоматически пытаемся перезагрузить изображение, если не превышено максимальное количество попыток
|
||||||
|
if (retryCount < maxRetries) {
|
||||||
|
console.log(`Автоматическая попытка перезагрузки изображения (${retryCount + 1}/${maxRetries}):`, src);
|
||||||
|
// Увеличиваем задержку с каждой попыткой (экспоненциальный backoff)
|
||||||
|
const delay = Math.pow(2, retryCount) * 1000;
|
||||||
|
setTimeout(handleRetry, delay); // Пауза перед повторной попыткой
|
||||||
|
} else {
|
||||||
|
console.error('Не удалось загрузить изображение после нескольких попыток:', src);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обработчик клика по изображению
|
||||||
|
const handleClick = () => {
|
||||||
|
// Если есть ошибка и пользователь кликает на изображение с ошибкой, пытаемся перезагрузить
|
||||||
|
if (error) {
|
||||||
|
handleRetry();
|
||||||
|
} else if (onClick) {
|
||||||
|
// Иначе вызываем переданный обработчик клика
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.container} ${className}`}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{/* Показываем индикатор загрузки, если изображение загружается */}
|
||||||
|
{loading && (
|
||||||
|
<div className={styles.loadingContainer}>
|
||||||
|
<div className={styles.spinner}></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Показываем изображение */}
|
||||||
|
<img
|
||||||
|
ref={imgRef}
|
||||||
|
src={imageSrc}
|
||||||
|
alt={alt}
|
||||||
|
className={`${styles.image} ${error ? styles.hidden : ''}`}
|
||||||
|
onLoad={handleLoad}
|
||||||
|
onError={handleError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Показываем сообщение об ошибке и кнопку перезагрузки, если произошла ошибка */}
|
||||||
|
{error && (
|
||||||
|
<div className={styles.errorContainer}>
|
||||||
|
<div className={styles.errorIcon}>!</div>
|
||||||
|
<div className={styles.errorMessage}>Ошибка загрузки</div>
|
||||||
|
<button
|
||||||
|
className={styles.retryButton}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation(); // Предотвращаем всплытие события
|
||||||
|
handleRetry();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Перезагрузить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageWithFallback;
|
||||||
@ -44,6 +44,7 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-bottom: var(--spacing-small);
|
margin-bottom: var(--spacing-small);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
|
|
||||||
.promptContainer {
|
.promptContainer {
|
||||||
|
|||||||
@ -9,7 +9,9 @@ interface NotificationModalProps {
|
|||||||
promptText?: string;
|
promptText?: string;
|
||||||
onGalleryClick: () => void;
|
onGalleryClick: () => void;
|
||||||
onContinueClick: () => void;
|
onContinueClick: () => void;
|
||||||
showGalleryButton?: boolean; // Новый параметр для управления видимостью кнопки "В галерею"
|
showGalleryButton?: boolean; // Параметр для управления видимостью кнопки "В галерею"
|
||||||
|
showButtons?: boolean; // Новый параметр для управления видимостью всех кнопок
|
||||||
|
continueButtonText?: string; // Новый параметр для изменения текста кнопки "Продолжить"
|
||||||
}
|
}
|
||||||
|
|
||||||
const NotificationModal: React.FC<NotificationModalProps> = ({
|
const NotificationModal: React.FC<NotificationModalProps> = ({
|
||||||
@ -20,7 +22,9 @@ const NotificationModal: React.FC<NotificationModalProps> = ({
|
|||||||
promptText,
|
promptText,
|
||||||
onGalleryClick,
|
onGalleryClick,
|
||||||
onContinueClick,
|
onContinueClick,
|
||||||
showGalleryButton = true // По умолчанию кнопка видима
|
showGalleryButton = true, // По умолчанию кнопка "В галерею" видима
|
||||||
|
showButtons = true, // По умолчанию все кнопки видимы
|
||||||
|
continueButtonText = 'Продолжить' // По умолчанию текст кнопки "Продолжить"
|
||||||
}) => {
|
}) => {
|
||||||
if (!isVisible) return null;
|
if (!isVisible) return null;
|
||||||
|
|
||||||
@ -42,22 +46,24 @@ const NotificationModal: React.FC<NotificationModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.buttons}>
|
{showButtons && (
|
||||||
{showGalleryButton && (
|
<div className={styles.buttons}>
|
||||||
|
{showGalleryButton && (
|
||||||
|
<button
|
||||||
|
className={`${styles.button} ${styles.primaryButton}`}
|
||||||
|
onClick={onGalleryClick}
|
||||||
|
>
|
||||||
|
В галерею
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className={`${styles.button} ${styles.primaryButton}`}
|
className={`${styles.button} ${!showGalleryButton ? styles.primaryButton : styles.secondaryButton}`}
|
||||||
onClick={onGalleryClick}
|
onClick={onContinueClick}
|
||||||
>
|
>
|
||||||
В галерею
|
{continueButtonText}
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
<button
|
)}
|
||||||
className={`${styles.button} ${!showGalleryButton ? styles.primaryButton : styles.secondaryButton}`}
|
|
||||||
onClick={onContinueClick}
|
|
||||||
>
|
|
||||||
Продолжить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -114,6 +114,52 @@
|
|||||||
background-color: var(--color-surface);
|
background-color: var(--color-surface);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
-webkit-tap-highlight-color: transparent; /* Убираем подсветку при тапе на мобильных */
|
-webkit-tap-highlight-color: transparent; /* Убираем подсветку при тапе на мобильных */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteMode .imageItem {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteButton {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 2;
|
||||||
|
color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteControls {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 16px;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exitDeleteModeButton {
|
||||||
|
padding: 10px 24px;
|
||||||
|
background-color: var(--color-primary, #3498db);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image {
|
.image {
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
import styles from './Gallery.module.css';
|
import styles from './Gallery.module.css';
|
||||||
import apiService from '../services/api';
|
import apiService from '../services/api';
|
||||||
import { GeneratedImage, PendingTask } from '../types/api';
|
import { GeneratedImage, PendingTask } from '../types/api';
|
||||||
import ImageViewer from '../components/shared/ImageViewer';
|
import ImageViewer from '../components/shared/ImageViewer';
|
||||||
|
import ImageWithFallback from '../components/shared/ImageWithFallback';
|
||||||
|
import NotificationModal from '../components/shared/NotificationModal';
|
||||||
|
|
||||||
const GalleryScreen: React.FC = () => {
|
const GalleryScreen: React.FC = () => {
|
||||||
const [images, setImages] = useState<GeneratedImage[]>([]);
|
const [images, setImages] = useState<GeneratedImage[]>([]);
|
||||||
@ -17,6 +19,12 @@ const GalleryScreen: React.FC = () => {
|
|||||||
const startY = useRef<number | null>(null);
|
const startY = useRef<number | null>(null);
|
||||||
const threshold = 80; // Порог для активации обновления
|
const threshold = 80; // Порог для активации обновления
|
||||||
|
|
||||||
|
// Состояния для режима удаления
|
||||||
|
const [isDeleteMode, setIsDeleteMode] = useState(false);
|
||||||
|
const [selectedForDelete, setSelectedForDelete] = useState<GeneratedImage | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const longPressTimer = useRef<number | null>(null);
|
||||||
|
|
||||||
// Функция для расчета времени ожидания
|
// Функция для расчета времени ожидания
|
||||||
const getEstimatedWaitTime = (queuePosition: number | null): string => {
|
const getEstimatedWaitTime = (queuePosition: number | null): string => {
|
||||||
if (queuePosition === null) return 'Генерация началась';
|
if (queuePosition === null) return 'Генерация началась';
|
||||||
@ -26,8 +34,66 @@ const GalleryScreen: React.FC = () => {
|
|||||||
return `${Math.floor(seconds / 60)} мин ${seconds % 60} сек`;
|
return `${Math.floor(seconds / 60)} мин ${seconds % 60} сек`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Обработчики для режима удаления
|
||||||
|
const handleLongPress = useCallback((image: GeneratedImage) => {
|
||||||
|
setIsDeleteMode(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startLongPressTimer = useCallback((image: GeneratedImage) => {
|
||||||
|
longPressTimer.current = setTimeout(() => {
|
||||||
|
handleLongPress(image);
|
||||||
|
}, 800); // 800ms для долгого нажатия
|
||||||
|
}, [handleLongPress]);
|
||||||
|
|
||||||
|
const cancelLongPressTimer = useCallback(() => {
|
||||||
|
if (longPressTimer.current) {
|
||||||
|
clearTimeout(longPressTimer.current);
|
||||||
|
longPressTimer.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDeleteClick = useCallback((image: GeneratedImage) => {
|
||||||
|
setSelectedForDelete(image);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleConfirmDelete = useCallback(async () => {
|
||||||
|
if (selectedForDelete) {
|
||||||
|
try {
|
||||||
|
setIsDeleting(true);
|
||||||
|
await apiService.deleteImage(selectedForDelete.link);
|
||||||
|
|
||||||
|
// Обновляем список изображений
|
||||||
|
setImages(prevImages =>
|
||||||
|
prevImages.filter(img => img.id !== selectedForDelete.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
setIsDeleting(false);
|
||||||
|
setSelectedForDelete(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting image:', error);
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedForDelete]);
|
||||||
|
|
||||||
|
const exitDeleteMode = useCallback(() => {
|
||||||
|
setIsDeleteMode(false);
|
||||||
|
setSelectedForDelete(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Обработчик клика по контейнеру для выхода из режима удаления
|
||||||
|
const handleGridClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
// Проверяем, что клик был не по крестику удаления
|
||||||
|
if (isDeleteMode && !(e.target as HTMLElement).closest(`.${styles.deleteButton}`)) {
|
||||||
|
setIsDeleteMode(false);
|
||||||
|
setSelectedForDelete(null);
|
||||||
|
}
|
||||||
|
}, [isDeleteMode]);
|
||||||
|
|
||||||
// Функция для обновления всех данных
|
// Функция для обновления всех данных
|
||||||
const refreshAll = async () => {
|
const refreshAll = async () => {
|
||||||
|
// Выходим из режима удаления при обновлении
|
||||||
|
setIsDeleteMode(false);
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
try {
|
try {
|
||||||
// Загружаем изображения
|
// Загружаем изображения
|
||||||
@ -227,22 +293,38 @@ const GalleryScreen: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && images.length > 0 && (
|
{!loading && !error && images.length > 0 && (
|
||||||
<div className={styles.imageGrid}>
|
<div
|
||||||
|
className={`${styles.imageGrid} ${isDeleteMode ? styles.deleteMode : ''}`}
|
||||||
|
onClick={handleGridClick}
|
||||||
|
>
|
||||||
{images.map((image, index) => (
|
{images.map((image, index) => (
|
||||||
<div
|
<div
|
||||||
key={image.id}
|
key={image.id}
|
||||||
className={styles.imageItem}
|
className={styles.imageItem}
|
||||||
onClick={() => image.url && setSelectedImage(image.url)}
|
onTouchStart={() => !isDeleteMode && startLongPressTimer(image)}
|
||||||
|
onTouchEnd={() => !isDeleteMode && cancelLongPressTimer()}
|
||||||
|
onTouchMove={() => !isDeleteMode && cancelLongPressTimer()}
|
||||||
>
|
>
|
||||||
<img
|
<ImageWithFallback
|
||||||
src={image.url || ''}
|
src={image.url || ''}
|
||||||
alt={`Стикер ${index + 1}`}
|
alt={`Стикер ${index + 1}`}
|
||||||
className={styles.image}
|
className={styles.image}
|
||||||
|
onClick={() => !isDeleteMode && image.url && setSelectedImage(image.url)}
|
||||||
|
maxRetries={3}
|
||||||
/>
|
/>
|
||||||
|
{isDeleteMode && (
|
||||||
|
<div
|
||||||
|
className={styles.deleteButton}
|
||||||
|
onClick={() => handleDeleteClick(image)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Полноэкранный просмотр */}
|
{/* Полноэкранный просмотр */}
|
||||||
@ -252,6 +334,18 @@ const GalleryScreen: React.FC = () => {
|
|||||||
onClose={() => setSelectedImage(null)}
|
onClose={() => setSelectedImage(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Модальное окно подтверждения удаления */}
|
||||||
|
<NotificationModal
|
||||||
|
isVisible={!!selectedForDelete}
|
||||||
|
title="Удаление стикера"
|
||||||
|
message="Вы уверены, что хотите удалить этот стикер?"
|
||||||
|
isLoading={isDeleting}
|
||||||
|
showGalleryButton={false}
|
||||||
|
continueButtonText="Удалить"
|
||||||
|
onContinueClick={handleConfirmDelete}
|
||||||
|
onGalleryClick={() => setSelectedForDelete(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -45,6 +45,8 @@ const Home: React.FC = () => {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [promptText, setPromptText] = useState('');
|
const [promptText, setPromptText] = useState('');
|
||||||
const [showGalleryButton, setShowGalleryButton] = useState(true);
|
const [showGalleryButton, setShowGalleryButton] = useState(true);
|
||||||
|
const [showButtons, setShowButtons] = useState(true); // Новое состояние для управления видимостью всех кнопок
|
||||||
|
const [continueButtonText, setContinueButtonText] = useState('Продолжить'); // Новое состояние для текста кнопки "Продолжить"
|
||||||
|
|
||||||
// Состояние для хранения данных о последней успешной генерации
|
// Состояние для хранения данных о последней успешной генерации
|
||||||
const [lastGenerationData, setLastGenerationData] = useState<LastGenerationData>({});
|
const [lastGenerationData, setLastGenerationData] = useState<LastGenerationData>({});
|
||||||
@ -82,7 +84,9 @@ const Home: React.FC = () => {
|
|||||||
setNotificationTitle('Внимание');
|
setNotificationTitle('Внимание');
|
||||||
setNotificationMessage('Сначала загрузите изображение');
|
setNotificationMessage('Сначала загрузите изображение');
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setShowGalleryButton(true); // Показываем кнопку "В галерею" для уведомлений
|
setShowGalleryButton(false); // Скрываем кнопку "В галерею", так как генерация не была запущена
|
||||||
|
setShowButtons(true); // Показываем кнопки
|
||||||
|
setContinueButtonText('Закрыть'); // Устанавливаем текст кнопки "Закрыть"
|
||||||
setIsNotificationVisible(true);
|
setIsNotificationVisible(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -92,7 +96,9 @@ const Home: React.FC = () => {
|
|||||||
setNotificationTitle('Внимание');
|
setNotificationTitle('Внимание');
|
||||||
setNotificationMessage('Выберите образ для генерации');
|
setNotificationMessage('Выберите образ для генерации');
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setShowGalleryButton(true); // Показываем кнопку "В галерею" для уведомлений
|
setShowGalleryButton(false); // Скрываем кнопку "В галерею", так как генерация не была запущена
|
||||||
|
setShowButtons(true); // Показываем кнопки
|
||||||
|
setContinueButtonText('Закрыть'); // Устанавливаем текст кнопки "Закрыть"
|
||||||
setIsNotificationVisible(true);
|
setIsNotificationVisible(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -102,7 +108,9 @@ const Home: React.FC = () => {
|
|||||||
setNotificationTitle('Внимание');
|
setNotificationTitle('Внимание');
|
||||||
setNotificationMessage('Введите текст промпта');
|
setNotificationMessage('Введите текст промпта');
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setShowGalleryButton(true); // Показываем кнопку "В галерею" для уведомлений
|
setShowGalleryButton(false); // Скрываем кнопку "В галерею", так как генерация не была запущена
|
||||||
|
setShowButtons(true); // Показываем кнопки
|
||||||
|
setContinueButtonText('Закрыть'); // Устанавливаем текст кнопки "Закрыть"
|
||||||
setIsNotificationVisible(true);
|
setIsNotificationVisible(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -136,7 +144,9 @@ const Home: React.FC = () => {
|
|||||||
setNotificationTitle('Внимание');
|
setNotificationTitle('Внимание');
|
||||||
setNotificationMessage('Нельзя отправить одну и ту же комбинацию изображения и образа подряд. Пожалуйста, измените изображение или выберите другой образ.');
|
setNotificationMessage('Нельзя отправить одну и ту же комбинацию изображения и образа подряд. Пожалуйста, измените изображение или выберите другой образ.');
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setShowGalleryButton(true); // Показываем кнопку "В галерею" для уведомлений
|
setShowGalleryButton(false); // Скрываем кнопку "В галерею", так как генерация не была запущена
|
||||||
|
setShowButtons(true); // Показываем кнопки
|
||||||
|
setContinueButtonText('Закрыть'); // Устанавливаем текст кнопки "Закрыть"
|
||||||
setIsNotificationVisible(true);
|
setIsNotificationVisible(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -148,6 +158,8 @@ const Home: React.FC = () => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setPromptText('');
|
setPromptText('');
|
||||||
setShowGalleryButton(true); // Показываем кнопку "В галерею" для уведомлений о генерации
|
setShowGalleryButton(true); // Показываем кнопку "В галерею" для уведомлений о генерации
|
||||||
|
setShowButtons(false); // Скрываем все кнопки во время отправки запроса
|
||||||
|
setContinueButtonText('Продолжить'); // Сбрасываем текст кнопки на значение по умолчанию
|
||||||
setIsNotificationVisible(true);
|
setIsNotificationVisible(true);
|
||||||
|
|
||||||
// Если выбран "Свой промпт" и введен текст, используем его
|
// Если выбран "Свой промпт" и введен текст, используем его
|
||||||
@ -176,6 +188,9 @@ const Home: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
setShowGalleryButton(false); // Скрываем кнопку "В галерею", так как генерация не была успешно запущена
|
||||||
|
setShowButtons(true); // Показываем кнопки в случае ошибки перевода
|
||||||
|
setContinueButtonText('Закрыть'); // Меняем текст кнопки на "Закрыть"
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,13 +208,24 @@ const Home: React.FC = () => {
|
|||||||
? `${minutes} мин ${seconds} сек`
|
? `${minutes} мин ${seconds} сек`
|
||||||
: `${seconds} сек`;
|
: `${seconds} сек`;
|
||||||
|
|
||||||
setNotificationMessage(`Ваша задача отправлена на генерацию!\nПозиция в очереди: ${result.queue_position}\nПримерное время ожидания: ${timeString}`);
|
// Форматируем сообщение в выбранном формате (Вариант 2)
|
||||||
|
setNotificationMessage(
|
||||||
|
`Создание стикеров началось!\n` +
|
||||||
|
`Позиция в очереди: ${result.queue_position}\n` +
|
||||||
|
`Время ожидания: ${timeString}\n\n` +
|
||||||
|
`Результат будет доступен в галерее после завершения генерации.`
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setNotificationMessage('Ваша задача отправлена на генерацию!');
|
setNotificationMessage(
|
||||||
|
`Создание стикеров началось!\n\n` +
|
||||||
|
`Результат будет доступен в галерее после завершения генерации.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Устанавливаем использованный промпт и убираем индикатор загрузки
|
// Устанавливаем использованный промпт, показываем кнопки и меняем текст кнопки "Продолжить" на "Закрыть"
|
||||||
setPromptText(usedPrompt);
|
setPromptText(usedPrompt);
|
||||||
|
setShowButtons(true);
|
||||||
|
setContinueButtonText('Закрыть');
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@ -208,7 +234,9 @@ const Home: React.FC = () => {
|
|||||||
setNotificationTitle('Ошибка');
|
setNotificationTitle('Ошибка');
|
||||||
setNotificationMessage('Не удалось начать генерацию');
|
setNotificationMessage('Не удалось начать генерацию');
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setShowGalleryButton(true); // Показываем кнопку "В галерею" для уведомлений
|
setShowGalleryButton(false); // Скрываем кнопку "В галерею", так как генерация не была успешно запущена
|
||||||
|
setShowButtons(true); // Показываем кнопки в случае ошибки
|
||||||
|
setContinueButtonText('Закрыть'); // Меняем текст кнопки на "Закрыть"
|
||||||
setIsNotificationVisible(true);
|
setIsNotificationVisible(true);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@ -307,6 +335,8 @@ const Home: React.FC = () => {
|
|||||||
onGalleryClick={handleGalleryClick}
|
onGalleryClick={handleGalleryClick}
|
||||||
onContinueClick={handleContinueClick}
|
onContinueClick={handleContinueClick}
|
||||||
showGalleryButton={showGalleryButton}
|
showGalleryButton={showGalleryButton}
|
||||||
|
showButtons={showButtons}
|
||||||
|
continueButtonText={continueButtonText}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Компонент обработки обратной связи */}
|
{/* Компонент обработки обратной связи */}
|
||||||
@ -318,6 +348,8 @@ const Home: React.FC = () => {
|
|||||||
setNotificationMessage('Ваше сообщение успешно отправлено');
|
setNotificationMessage('Ваше сообщение успешно отправлено');
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setShowGalleryButton(false); // Скрываем кнопку "В галерею" для уведомления об обратной связи
|
setShowGalleryButton(false); // Скрываем кнопку "В галерею" для уведомления об обратной связи
|
||||||
|
setShowButtons(true); // Показываем кнопки
|
||||||
|
setContinueButtonText('Закрыть'); // Устанавливаем текст кнопки "Закрыть"
|
||||||
setIsNotificationVisible(true);
|
setIsNotificationVisible(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -111,122 +111,148 @@ const apiService = {
|
|||||||
throw new Error('Failed to fetch images');
|
throw new Error('Failed to fetch images');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получаем массив строк с file_id
|
// Получаем массив массивов [file_id, created_at]
|
||||||
const fileIds = await response.json() as string[];
|
const rawData = await response.json() as [string, string][];
|
||||||
|
|
||||||
// Преобразуем массив строк в массив объектов GeneratedImage
|
// Преобразуем в массив объектов GeneratedImage
|
||||||
const images = fileIds.map((fileId, index) => ({
|
const images = rawData.map((item, index) => {
|
||||||
id: index + 1,
|
const [fileId, createdAt] = item;
|
||||||
link: fileId,
|
return {
|
||||||
prompt_id: '',
|
id: index + 1,
|
||||||
status: 'COMPLETED',
|
link: fileId,
|
||||||
created_at: new Date().toISOString(),
|
prompt_id: '',
|
||||||
sticker_set_id: null,
|
status: 'COMPLETED',
|
||||||
url: `${API_BASE_URL}/stickers/proxy/sticker/${encodeURIComponent(fileId)}`
|
created_at: createdAt,
|
||||||
}));
|
sticker_set_id: null,
|
||||||
|
url: `${API_BASE_URL}/stickers/proxy/sticker/${encodeURIComponent(fileId)}`
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Сортируем изображения от новых к старым (по id в обратном порядке)
|
// Сортируем изображения от новых к старым по дате создания
|
||||||
return images.sort((a, b) => b.id - a.id);
|
return images.sort((a, b) =>
|
||||||
|
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching images:', error);
|
console.error('Error fetching images:', error);
|
||||||
throw new Error('Failed to fetch images');
|
throw new Error('Failed to fetch images');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async generateImage(imageData: string, style?: string, promptId?: string, userPrompt?: string): Promise<GenerationResult> {
|
// Удаление изображения по file_id
|
||||||
try {
|
async deleteImage(fileId: string): Promise<string> {
|
||||||
// Создаем копию базового воркфлоу
|
try {
|
||||||
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
|
const response = await fetch(`${API_BASE_URL}/delete-by-link?link=${encodeURIComponent(fileId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'accept': 'application/json',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Вставляем изображение в base64 формате в узел 563
|
if (!response.ok) {
|
||||||
workflow['563'].inputs.image = imageData;
|
throw new Error('Failed to delete image');
|
||||||
|
|
||||||
// Переменная для хранения использованного промпта
|
|
||||||
let usedPrompt = '';
|
|
||||||
let translationFailed = false;
|
|
||||||
|
|
||||||
// Если указан пользовательский промпт и выбрана кнопка "Свой промпт"
|
|
||||||
if (userPrompt && promptId === 'customPrompt') {
|
|
||||||
console.log('Переводим пользовательский промпт:', userPrompt);
|
|
||||||
|
|
||||||
// Используем новый метод перевода через LLM
|
|
||||||
const translationResult = await translateService.translateWithLLM(userPrompt);
|
|
||||||
|
|
||||||
if (translationResult.success) {
|
|
||||||
// Успешный перевод
|
|
||||||
console.log('Переведенный промпт:', translationResult.text);
|
|
||||||
workflow['316'].inputs.prompt_1 = translationResult.text;
|
|
||||||
usedPrompt = translationResult.text;
|
|
||||||
} else {
|
|
||||||
// Перевод не удался после всех попыток
|
|
||||||
console.error('Не удалось перевести промпт:', translationResult.text);
|
|
||||||
translationFailed = true;
|
|
||||||
|
|
||||||
// Не продолжаем генерацию, возвращаем ошибку с сообщением
|
|
||||||
return {
|
|
||||||
translationFailed: true,
|
|
||||||
usedPrompt: 'Недопустимый промпт', // Сообщение для пользователя
|
|
||||||
errorDetails: translationResult.text // Детали ошибки для отладки
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting image:', error);
|
||||||
|
throw new Error('Failed to delete image');
|
||||||
}
|
}
|
||||||
// Иначе используем предустановленный промпт (они уже переведены)
|
},
|
||||||
else if (promptId && prompts[promptId]) {
|
|
||||||
workflow['316'].inputs.prompt_1 = prompts[promptId];
|
|
||||||
usedPrompt = prompts[promptId];
|
|
||||||
console.log('Используем предустановленный промпт:', prompts[promptId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Если был сбой перевода, не продолжаем генерацию
|
async generateImage(imageData: string, style?: string, promptId?: string, userPrompt?: string): Promise<GenerationResult> {
|
||||||
if (translationFailed) {
|
try {
|
||||||
return { translationFailed: true };
|
// Создаем копию базового воркфлоу
|
||||||
}
|
const workflow = JSON.parse(JSON.stringify(baseWorkflow));
|
||||||
|
|
||||||
// Создаем строку JSON для workflow ПОСЛЕ того, как все изменения внесены
|
// Вставляем изображение в base64 формате в узел 563
|
||||||
const workflowJson = JSON.stringify(workflow);
|
workflow['563'].inputs.image = imageData;
|
||||||
|
|
||||||
// Определяем тег на основе выбранного стиля
|
// Переменная для хранения использованного промпта
|
||||||
const tag = styleToTagMap[style || 'chibi'] || 'chibi';
|
let usedPrompt = '';
|
||||||
console.log(`Используем тег "${tag}" для стиля "${style || 'chibi'}"`);
|
let translationFailed = false;
|
||||||
|
|
||||||
// Создаем тело запроса как строку JSON вручную
|
// Если указан пользовательский промпт и выбрана кнопка "Свой промпт"
|
||||||
const requestBodyJson = `{"tag":"${tag}","user_id":${getCurrentUserId()},"workflow":${workflowJson}}`;
|
if (userPrompt && promptId === 'customPrompt') {
|
||||||
|
console.log('Переводим пользовательский промпт:', userPrompt);
|
||||||
|
|
||||||
// Сохраняем JSON для отладки только при локальной разработке
|
// Используем новый метод перевода через LLM
|
||||||
if (!isTelegramWebAppAvailable()) {
|
const translationResult = await translateService.translateWithLLM(userPrompt);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Отправляем запрос
|
if (translationResult.success) {
|
||||||
const response = await fetch(`${API_BASE_URL}/generate_image`, {
|
// Успешный перевод
|
||||||
method: 'POST',
|
console.log('Переведенный промпт:', translationResult.text);
|
||||||
headers: {
|
workflow['316'].inputs.prompt_1 = translationResult.text;
|
||||||
'Content-Type': 'application/json',
|
usedPrompt = translationResult.text;
|
||||||
},
|
} else {
|
||||||
body: requestBodyJson
|
// Перевод не удался после всех попыток
|
||||||
});
|
console.error('Не удалось перевести промпт:', translationResult.text);
|
||||||
|
translationFailed = true;
|
||||||
|
|
||||||
if (!response.ok) {
|
// Не продолжаем генерацию, возвращаем ошибку с сообщением
|
||||||
const errorData: ApiErrorType = await response.json();
|
return {
|
||||||
throw new GenerationError(errorData.detail);
|
translationFailed: true,
|
||||||
}
|
usedPrompt: 'Недопустимый промпт', // Сообщение для пользователя
|
||||||
|
errorDetails: translationResult.text // Детали ошибки для отладки
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Иначе используем предустановленный промпт (они уже переведены)
|
||||||
|
else if (promptId && prompts[promptId]) {
|
||||||
|
workflow['316'].inputs.prompt_1 = prompts[promptId];
|
||||||
|
usedPrompt = prompts[promptId];
|
||||||
|
console.log('Используем предустановленный промпт:', prompts[promptId]);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await response.json() as GenerationResponse;
|
// Если был сбой перевода, не продолжаем генерацию
|
||||||
|
if (translationFailed) {
|
||||||
|
return { translationFailed: true };
|
||||||
|
}
|
||||||
|
|
||||||
// Возвращаем результат и использованный промпт
|
// Создаем строку JSON для workflow ПОСЛЕ того, как все изменения внесены
|
||||||
return {
|
const workflowJson = JSON.stringify(workflow);
|
||||||
result,
|
|
||||||
usedPrompt,
|
// Определяем тег на основе выбранного стиля
|
||||||
translationFailed: false
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json() as GenerationResponse;
|
||||||
|
|
||||||
|
// Возвращаем результат и использованный промпт
|
||||||
|
return {
|
||||||
|
result,
|
||||||
|
usedPrompt,
|
||||||
|
translationFailed: false
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof GenerationError) {
|
if (error instanceof GenerationError) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user