Spaces:
Running
Running
| import React, { useRef, useEffect, useState, ReactNode, KeyboardEvent } from 'react'; | |
| import { FaTrashAlt, FaEye, FaEyeSlash } from 'react-icons/fa'; | |
| import { openConfirm } from './ConfirmModal'; | |
| import classNames from 'classnames'; | |
| import { apiClient } from '@/utils/api'; | |
| import { isVideo } from '@/utils/basic'; | |
| interface DatasetImageCardProps { | |
| imageUrl: string; | |
| alt: string; | |
| children?: ReactNode; | |
| className?: string; | |
| onDelete?: () => void; | |
| } | |
| const DatasetImageCard: React.FC<DatasetImageCardProps> = ({ | |
| imageUrl, | |
| alt, | |
| children, | |
| className = '', | |
| onDelete = () => {}, | |
| }) => { | |
| const cardRef = useRef<HTMLDivElement>(null); | |
| const [isVisible, setIsVisible] = useState<boolean>(false); | |
| const [inViewport, setInViewport] = useState<boolean>(false); | |
| const [loaded, setLoaded] = useState<boolean>(false); | |
| const [isCaptionLoaded, setIsCaptionLoaded] = useState<boolean>(false); | |
| const [caption, setCaption] = useState<string>(''); | |
| const [savedCaption, setSavedCaption] = useState<string>(''); | |
| const isGettingCaption = useRef<boolean>(false); | |
| const fetchCaption = async () => { | |
| if (isGettingCaption.current || isCaptionLoaded) return; | |
| isGettingCaption.current = true; | |
| apiClient | |
| .get(`/api/caption/${encodeURIComponent(imageUrl)}`) | |
| .then(res => res.data) | |
| .then(data => { | |
| console.log('Caption fetched:', data); | |
| setCaption(data || ''); | |
| setSavedCaption(data || ''); | |
| setIsCaptionLoaded(true); | |
| }) | |
| .catch(error => { | |
| console.error('Error fetching caption:', error); | |
| }) | |
| .finally(() => { | |
| isGettingCaption.current = false; | |
| }); | |
| }; | |
| const saveCaption = () => { | |
| const trimmedCaption = caption.trim(); | |
| if (trimmedCaption === savedCaption) return; | |
| apiClient | |
| .post('/api/img/caption', { imgPath: imageUrl, caption: trimmedCaption }) | |
| .then(res => res.data) | |
| .then(data => { | |
| console.log('Caption saved:', data); | |
| setSavedCaption(trimmedCaption); | |
| }) | |
| .catch(error => { | |
| console.error('Error saving caption:', error); | |
| }); | |
| }; | |
| // Only fetch caption when the component is both in viewport and visible | |
| useEffect(() => { | |
| if (inViewport && isVisible) { | |
| fetchCaption(); | |
| } | |
| }, [inViewport, isVisible]); | |
| useEffect(() => { | |
| // Create intersection observer to check viewport visibility | |
| const observer = new IntersectionObserver( | |
| entries => { | |
| if (entries[0].isIntersecting) { | |
| setInViewport(true); | |
| // Initialize isVisible to true when first coming into view | |
| if (!isVisible) { | |
| setIsVisible(true); | |
| } | |
| } else { | |
| setInViewport(false); | |
| } | |
| }, | |
| { threshold: 0.1 }, | |
| ); | |
| if (cardRef.current) { | |
| observer.observe(cardRef.current); | |
| } | |
| return () => { | |
| observer.disconnect(); | |
| }; | |
| }, []); | |
| const toggleVisibility = (): void => { | |
| setIsVisible(prev => !prev); | |
| if (!isVisible && !isCaptionLoaded) { | |
| fetchCaption(); | |
| } | |
| }; | |
| const handleLoad = (): void => { | |
| setLoaded(true); | |
| }; | |
| const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => { | |
| // If Enter is pressed without Shift, prevent default behavior and save | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| saveCaption(); | |
| } | |
| }; | |
| const isCaptionCurrent = caption.trim() === savedCaption; | |
| const isItAVideo = isVideo(imageUrl); | |
| return ( | |
| <div className={`flex flex-col ${className}`}> | |
| {/* Square image container */} | |
| <div | |
| ref={cardRef} | |
| className="relative w-full" | |
| style={{ paddingBottom: '100%' }} // Make it square | |
| > | |
| <div className="absolute inset-0 rounded-t-lg shadow-md"> | |
| {inViewport && isVisible && ( | |
| <> | |
| {isItAVideo ? ( | |
| <video | |
| src={`/api/img/${encodeURIComponent(imageUrl)}`} | |
| className={`w-full h-full object-contain`} | |
| autoPlay={false} | |
| loop | |
| muted | |
| controls | |
| /> | |
| ) : ( | |
| <img | |
| src={`/api/img/${encodeURIComponent(imageUrl)}`} | |
| alt={alt} | |
| onLoad={handleLoad} | |
| className={`w-full h-full object-contain transition-opacity duration-300 ${ | |
| loaded ? 'opacity-100' : 'opacity-0' | |
| }`} | |
| /> | |
| )} | |
| </> | |
| )} | |
| {!isVisible && ( | |
| <div className="absolute inset-0 flex items-center justify-center bg-gray-800 bg-opacity-75 rounded-t-lg"> | |
| <span className="text-white text-lg"></span> | |
| </div> | |
| )} | |
| {children && <div className="absolute inset-0 flex items-center justify-center">{children}</div>} | |
| <div className="absolute top-1 right-1 flex space-x-2"> | |
| <button | |
| className="bg-gray-800 rounded-full p-2" | |
| onClick={() => { | |
| openConfirm({ | |
| title: `Delete ${isItAVideo ? 'video' : 'image'}`, | |
| message: `Are you sure you want to delete this ${isItAVideo ? 'video' : 'image'}? This action cannot be undone.`, | |
| type: 'warning', | |
| confirmText: 'Delete', | |
| onConfirm: () => { | |
| apiClient | |
| .post('/api/img/delete', { imgPath: imageUrl }) | |
| .then(() => { | |
| console.log('Image deleted:', imageUrl); | |
| onDelete(); | |
| }) | |
| .catch(error => { | |
| console.error('Error deleting image:', error); | |
| }); | |
| }, | |
| }); | |
| }} | |
| > | |
| <FaTrashAlt /> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Text area below the image */} | |
| <div | |
| className={classNames('w-full p-2 bg-gray-800 text-white text-sm rounded-b-lg h-[75px]', { | |
| 'border-blue-500 border-2': !isCaptionCurrent, | |
| 'border-transparent border-2': isCaptionCurrent, | |
| })} | |
| > | |
| {inViewport && isVisible && isCaptionLoaded && ( | |
| <form | |
| onSubmit={e => { | |
| e.preventDefault(); | |
| saveCaption(); | |
| }} | |
| onBlur={saveCaption} | |
| > | |
| <textarea | |
| className="w-full bg-transparent resize-none outline-none focus:ring-0 focus:outline-none" | |
| value={caption} | |
| rows={3} | |
| onChange={e => setCaption(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| /> | |
| </form> | |
| )} | |
| {(!inViewport || !isVisible) && isCaptionLoaded && ( | |
| <div className="w-full h-full flex items-center justify-center text-gray-400"> | |
| {isVisible ? "Scroll into view to edit caption" : "Show content to edit caption"} | |
| </div> | |
| )} | |
| {!isCaptionLoaded && ( | |
| <div className="w-full h-full flex items-center justify-center text-gray-400"> | |
| Loading caption... | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default DatasetImageCard; |