StickerAI-Front/src/screens/Gallery.tsx

358 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState, useRef, useCallback } from 'react';
import styles from './Gallery.module.css';
import apiService from '../services/api';
import { GeneratedImage, PendingTask } from '../types/api';
import ImageViewer from '../components/shared/ImageViewer';
import ImageWithFallback from '../components/shared/ImageWithFallback';
import NotificationModal from '../components/shared/NotificationModal';
const GalleryScreen: React.FC = () => {
const [images, setImages] = useState<GeneratedImage[]>([]);
const [pendingTasks, setPendingTasks] = useState<PendingTask[]>([]);
const [loading, setLoading] = useState(true);
const [loadingPendingTasks, setLoadingPendingTasks] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [pullDistance, setPullDistance] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const startY = useRef<number | null>(null);
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 => {
if (queuePosition === null) return 'Генерация началась';
const seconds = apiService.calculateEstimatedWaitTime(queuePosition);
if (seconds < 60) return `${seconds} сек`;
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 () => {
// Выходим из режима удаления при обновлении
setIsDeleteMode(false);
setRefreshing(true);
try {
// Загружаем изображения
const fetchedImages = await apiService.getGeneratedImages();
setImages(fetchedImages);
// Загружаем задачи в очереди
const fetchedPendingTasks = await apiService.getUserPendingTasks();
setPendingTasks(fetchedPendingTasks);
setError(null);
} catch (err) {
setError('Не удалось обновить данные');
console.error(err);
} finally {
// Задержка для лучшего UX, чтобы пользователь видел, что происходит обновление
setTimeout(() => {
setRefreshing(false);
}, 500);
}
};
// Обработчики для pull-to-refresh
const handleTouchStart = (e: React.TouchEvent) => {
if (containerRef.current && containerRef.current.scrollTop === 0) {
startY.current = e.touches[0].clientY;
}
};
const handleTouchMove = (e: React.TouchEvent) => {
if (!startY.current) return;
// Только если контейнер в верхней позиции
if (containerRef.current && containerRef.current.scrollTop === 0) {
const currentY = e.touches[0].clientY;
const diff = currentY - startY.current;
// Применяем сопротивление - чем дальше тянем, тем сложнее
if (diff > 0) {
const resistance = 0.4;
const distance = Math.min(diff * resistance, threshold * 1.5);
setPullDistance(distance);
}
}
};
const handleTouchEnd = async () => {
// Если достигли порога, запускаем обновление
if (pullDistance >= threshold) {
setRefreshing(true);
// Сбрасываем расстояние вытягивания
setPullDistance(0);
// Обновляем данные
await refreshAll();
} else {
// Анимированно возвращаем к исходному состоянию
setPullDistance(0);
}
startY.current = null;
};
// Загрузка данных при монтировании компонента
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setLoadingPendingTasks(true);
// Загружаем изображения
const fetchedImages = await apiService.getGeneratedImages();
setImages(fetchedImages);
// Загружаем задачи в очереди
const fetchedPendingTasks = await apiService.getUserPendingTasks();
setPendingTasks(fetchedPendingTasks);
setError(null);
} catch (err) {
setError('Не удалось загрузить данные');
console.error(err);
} finally {
setLoading(false);
setLoadingPendingTasks(false);
}
};
fetchData();
}, []);
// Отслеживание задач в очереди
useEffect(() => {
// Если в очереди нет задач, не запускаем интервал
if (pendingTasks.length === 0) return;
console.log('Запускаем отслеживание задач в очереди:', pendingTasks);
// Запускаем интервал только если есть задачи в очереди
const intervalId = setInterval(async () => {
try {
// Сохраняем предыдущий список задач для сравнения
const previousTasks = [...pendingTasks];
// Получаем обновленный список задач
const fetchedPendingTasks = await apiService.getUserPendingTasks();
console.log('Обновленные задачи в очереди:', fetchedPendingTasks);
// Обновляем список задач
setPendingTasks(fetchedPendingTasks);
// Проверяем, исчезла ли задача из очереди
if (previousTasks.length > fetchedPendingTasks.length) {
console.log('Задача исчезла из очереди, обновляем галерею');
const fetchedImages = await apiService.getGeneratedImages();
setImages(fetchedImages);
}
// Если все задачи завершены, очищаем интервал
if (fetchedPendingTasks.length === 0) {
console.log('Все задачи завершены, останавливаем отслеживание');
clearInterval(intervalId);
}
} catch (err) {
console.error('Ошибка при обновлении статуса задач:', err);
}
}, 15000); // Обновляем каждые 15 секунд
// Очищаем интервал при размонтировании компонента
return () => clearInterval(intervalId);
}, [pendingTasks.length]); // Зависимость от количества задач
return (
<div className={styles.pullToRefreshContainer}>
<div
className={`${styles.refreshIndicator} ${refreshing ? styles.refreshing : ''}`}
style={{ '--pull-distance': `${pullDistance}px` } as React.CSSProperties}
>
<div className={styles.refreshSpinner}></div>
{refreshing && <span>Обновление...</span>}
</div>
<div
ref={containerRef}
className={styles.content}
style={{ '--pull-distance': `${pullDistance}px` } as React.CSSProperties}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div className={styles.header}>
<h1 className={styles.title}>
Галерея стикеров
</h1>
</div>
{loading && (
<div className={styles.placeholder}>
Загрузка изображений...
</div>
)}
{error && (
<div className={styles.error}>
{error}
</div>
)}
{!loading && !error && images.length === 0 && pendingTasks.length === 0 && (
<div className={styles.placeholder}>
У вас пока нет сгенерированных стикеров
</div>
)}
{!loading && !loadingPendingTasks && pendingTasks.length > 0 && (
<div className={styles.pendingTasksSection}>
<h2 className={styles.sectionTitle}>В процессе генерации</h2>
<div className={styles.pendingTasksGrid}>
{pendingTasks.map((task) => (
<div key={task.task_id} className={styles.pendingTaskItem}>
<div className={styles.pendingTaskPlaceholder}>
<div className={styles.spinner}></div>
</div>
<div className={styles.pendingTaskInfo}>
<p className={styles.pendingTaskStatus}>
{task.status === 'PENDING'
? `В очереди: ${task.queue_position}`
: 'Генерация...'}
</p>
<p className={styles.pendingTaskTime}>
Осталось: {getEstimatedWaitTime(task.queue_position)}
</p>
</div>
</div>
))}
</div>
</div>
)}
{!loading && !error && images.length > 0 && (
<div
className={`${styles.imageGrid} ${isDeleteMode ? styles.deleteMode : ''}`}
onClick={handleGridClick}
>
{images.map((image, index) => (
<div
key={image.id}
className={styles.imageItem}
onTouchStart={() => !isDeleteMode && startLongPressTimer(image)}
onTouchEnd={() => !isDeleteMode && cancelLongPressTimer()}
onTouchMove={() => !isDeleteMode && cancelLongPressTimer()}
>
<ImageWithFallback
src={image.url || ''}
alt={`Стикер ${index + 1}`}
className={styles.image}
onClick={() => !isDeleteMode && image.url && setSelectedImage(image.url)}
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) => e.preventDefault()}
maxRetries={3}
isDeleteMode={isDeleteMode}
/>
{isDeleteMode && (
<div
className={styles.deleteButton}
onClick={() => handleDeleteClick(image)}
>
</div>
)}
</div>
))}
</div>
)}
</div>
{/* Полноэкранный просмотр */}
{selectedImage && (
<ImageViewer
imageUrl={selectedImage}
onClose={() => setSelectedImage(null)}
/>
)}
{/* Модальное окно подтверждения удаления */}
<NotificationModal
isVisible={!!selectedForDelete}
title="Удаление стикера"
message="Вы уверены, что хотите удалить этот стикер?"
isLoading={isDeleting}
showGalleryButton={true}
galleryButtonText="Отмена"
continueButtonText="Удалить"
onContinueClick={handleConfirmDelete}
onGalleryClick={() => setSelectedForDelete(null)}
isPrimaryGalleryButton={false}
/>
</div>
);
};
export default GalleryScreen;