import React, { useEffect, useState, ReactNode, KeyboardEvent, useRef } from 'react'; import { FaTrashAlt } from 'react-icons/fa'; import { openConfirm } from './ConfirmModal'; import classNames from 'classnames'; import { apiClient } from '@/utils/api'; import AudioPlayer from './AudioPlayer'; import { isVideo, isAudio } from '@/utils/basic'; import useCaptionBatch, { setCachedCaption } from '@/hooks/useCaptionBatch'; interface DatasetImageCardProps { imageUrl: string; alt: string; isAutoCaptioning: boolean; children?: ReactNode; className?: string; onDelete?: () => void; onImageClick?: () => void; captionRefreshKey?: number; observerRoot?: Element | null; rootMargin?: string; captionExt?: string; } const DatasetImageCard: React.FC = ({ imageUrl, alt, isAutoCaptioning, children, className = '', onDelete = () => {}, onImageClick, captionRefreshKey = 0, observerRoot = null, rootMargin = '200px 0px', captionExt = 'txt', }) => { const [loaded, setLoaded] = useState(false); const [showAudioPlayer, setShowAudioPlayer] = useState(true); const [pollTick, setPollTick] = useState(0); const [blobUrl, setBlobUrl] = useState(null); const [isVisible, setIsVisible] = useState(false); const cardRef = useRef(null); const isItAVideo = isVideo(imageUrl); const isItAudio = isAudio(imageUrl); const isItImage = !isItAVideo && !isItAudio; // Track actual viewport visibility — Virtuoso keeps a buffer of cards mounted // outside the visible region, so we can't rely on mount/unmount alone. useEffect(() => { const el = cardRef.current; if (!el) return; const observer = new IntersectionObserver( entries => { for (const entry of entries) { if (entry.target === el) { setIsVisible(entry.isIntersecting); } } }, { root: observerRoot ?? null, threshold: 0.01, rootMargin, }, ); observer.observe(el); return () => observer.disconnect(); }, [observerRoot, rootMargin]); // Drive image loads through fetch + AbortController so scrolling past actually // cancels in-flight requests. Debounced 80ms so fast scroll-throughs never // start a request. useEffect(() => { if (!isItImage) return; if (!isVisible) return; const controller = new AbortController(); let cancelled = false; let objectUrl: string | null = null; const timer = window.setTimeout(() => { fetch(`/api/img/${encodeURIComponent(imageUrl)}`, { signal: controller.signal }) .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.blob(); }) .then(blob => { if (cancelled) return; objectUrl = URL.createObjectURL(blob); setBlobUrl(objectUrl); setLoaded(true); }) .catch(err => { if (err?.name !== 'AbortError') console.error('Dataset image fetch failed:', err); }); }, 80); return () => { cancelled = true; clearTimeout(timer); controller.abort(); if (objectUrl) URL.revokeObjectURL(objectUrl); setBlobUrl(null); setLoaded(false); }; }, [imageUrl, isItImage, isVisible]); const combinedRefreshKey = captionRefreshKey + pollTick; const { caption: fetchedCaption, isLoaded: isCaptionLoaded } = useCaptionBatch( isVisible ? imageUrl : null, combinedRefreshKey, captionExt, ); const [caption, setCaption] = useState(''); const [savedCaption, setSavedCaption] = useState(''); const dirtyRef = useRef(false); // Cards are keyed by image path, so this never needs resetting. Once loaded, // keep the textarea mounted through poll refreshes — swapping in the // "Loading caption..." placeholder unmounts it and resets scroll. const hasLoadedCaptionRef = useRef(false); if (isCaptionLoaded) hasLoadedCaptionRef.current = true; // Sync from the fetched caption, but don't clobber unsaved local edits. useEffect(() => { if (!isCaptionLoaded) return; if (dirtyRef.current) return; setCaption(fetchedCaption); setSavedCaption(fetchedCaption.trim()); }, [fetchedCaption, isCaptionLoaded]); // Poll while auto-captioning so backend-written captions show up. useEffect(() => { if (!isAutoCaptioning) return; const interval = setInterval(() => setPollTick(t => t + 1), 5000); return () => clearInterval(interval); }, [isAutoCaptioning]); const saveCaption = () => { const trimmedCaption = caption.trim(); if (trimmedCaption === savedCaption) { dirtyRef.current = false; return; } apiClient .post('/api/img/caption', { imgPath: imageUrl, caption: trimmedCaption, ext: captionExt }) .then(() => { setSavedCaption(trimmedCaption); setCachedCaption(imageUrl, trimmedCaption, captionExt); dirtyRef.current = false; }) .catch(error => { console.error('Error saving caption:', error); }); }; // Save any pending edit if the card unmounts (e.g. scrolled out of the virtualized window). const latestRef = useRef({ caption, savedCaption, imageUrl, captionExt }); useEffect(() => { latestRef.current = { caption, savedCaption, imageUrl, captionExt }; }); useEffect(() => { return () => { if (!dirtyRef.current) return; const { caption: c, savedCaption: s, imageUrl: url, captionExt: ext } = latestRef.current; const trimmed = c.trim(); if (trimmed === s) return; apiClient .post('/api/img/caption', { imgPath: url, caption: trimmed, ext }) .then(() => setCachedCaption(url, trimmed, ext)) .catch(err => console.error('Error saving caption on unmount:', err)); }; }, []); const handleKeyDown = (e: KeyboardEvent): void => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveCaption(); } }; const handleCaptionChange = (value: string) => { dirtyRef.current = value.trim() !== savedCaption; setCaption(value); }; const isCaptionCurrent = caption.trim() === savedCaption; return (
{isItAVideo && (
{isCaptionLoaded || hasLoadedCaptionRef.current ? (
{ e.preventDefault(); saveCaption(); }} onBlur={saveCaption} >