|
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); |
|
}); |
|
}; |
|
|
|
|
|
useEffect(() => { |
|
if (inViewport && isVisible) { |
|
fetchCaption(); |
|
} |
|
}, [inViewport, isVisible]); |
|
|
|
useEffect(() => { |
|
|
|
const observer = new IntersectionObserver( |
|
entries => { |
|
if (entries[0].isIntersecting) { |
|
setInViewport(true); |
|
|
|
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 (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> |
|
|
|
{} |
|
<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; |