Spaces:
Running
Running
import { PageContainer, Container, Button, Spinner, SegmentInput, TextInput, SelectInput, MultiSelectInput } from '@ifrc-go/ui'; | |
import { useParams, useNavigate } from 'react-router-dom'; | |
import { useState, useEffect, useMemo, useCallback } from 'react'; | |
import { ChevronLeftLineIcon, ChevronRightLineIcon, DeleteBinLineIcon } from '@ifrc-go/icons'; | |
import styles from './MapDetailPage.module.css'; | |
import { useFilterContext } from '../../hooks/useFilterContext'; | |
import { useAdmin } from '../../hooks/useAdmin'; | |
import ExportModal from '../../components/ExportModal'; | |
import { FullSizeImageModal } from '../../components/upload/ModalComponents'; | |
import FilterBar from '../../components/FilterBar'; | |
interface MapOut { | |
image_id: string; | |
file_key: string; | |
sha256: string; | |
source: string; | |
event_type: string; | |
epsg: string; | |
image_type: string; | |
image_url: string; | |
detail_url?: string; // URL to medium quality version (800x600px) | |
countries: Array<{ | |
c_code: string; | |
label: string; | |
r_code: string; | |
}>; | |
title?: string; | |
prompt?: string; | |
model?: string; | |
schema_id?: string; | |
raw_json?: { | |
extracted_metadata?: { | |
description?: string; | |
analysis?: string; | |
recommended_actions?: string; | |
metadata?: Record<string, unknown>; | |
}; | |
fallback_info?: Record<string, unknown>; | |
[key: string]: unknown; | |
}; | |
generated?: string; | |
edited?: string; | |
accuracy?: number; | |
context?: number; | |
usability?: number; | |
starred?: boolean; | |
created_at?: string; | |
updated_at?: string; | |
// Multi-upload fields | |
all_image_ids?: string[]; | |
image_count?: number; | |
} | |
export default function MapDetailPage() { | |
const { mapId } = useParams<{ mapId: string }>(); | |
const navigate = useNavigate(); | |
const { isAuthenticated } = useAdmin(); | |
// Debug: Log the current URL and mapId for production debugging | |
console.log('MapDetailsPage: Current URL:', window.location.href); | |
console.log('MapDetailsPage: Hash:', window.location.hash); | |
console.log('MapDetailsPage: mapId from useParams:', mapId); | |
console.log('MapDetailsPage: mapId type:', typeof mapId); | |
console.log('MapDetailsPage: mapId length:', mapId?.length); | |
console.log('MapDetailsPage: mapId value:', JSON.stringify(mapId)); | |
// Early validation - if mapId is invalid, show error immediately | |
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; | |
if (!mapId || mapId === 'undefined' || mapId === 'null' || mapId.trim() === '' || !uuidRegex.test(mapId)) { | |
return ( | |
<PageContainer> | |
<div className="flex flex-col items-center gap-4 text-center py-12"> | |
<div className="text-4xl">⚠️</div> | |
<div className="text-xl font-semibold">Invalid Map ID</div> | |
<div>The map ID provided is not valid.</div> | |
<div className="text-sm text-gray-500 mt-2"> | |
Debug Info: mapId = "{mapId}" (type: {typeof mapId}) | |
</div> | |
<Button | |
name="back-to-explore" | |
variant="secondary" | |
onClick={() => navigate('/explore')} | |
> | |
Return to Explore | |
</Button> | |
</div> | |
</PageContainer> | |
); | |
} | |
const [view, setView] = useState<'explore' | 'mapDetails'>('mapDetails'); | |
const [map, setMap] = useState<MapOut | null>(null); | |
const [loading, setLoading] = useState(true); | |
const [error, setError] = useState<string | null>(null); | |
const [sources, setSources] = useState<{s_code: string, label: string}[]>([]); | |
const [types, setTypes] = useState<{t_code: string, label: string}[]>([]); | |
const [imageTypes, setImageTypes] = useState<{image_type: string, label: string}[]>([]); | |
const [regions, setRegions] = useState<{r_code: string, label: string}[]>([]); | |
const [countries, setCountries] = useState<{c_code: string, label: string, r_code: string}[]>([]); | |
const [hasPrevious, setHasPrevious] = useState(false); | |
const [hasNext, setHasNext] = useState(false); | |
const [isNavigating, setIsNavigating] = useState(false); | |
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); | |
const [showExportModal, setShowExportModal] = useState(false); | |
const [isExporting, setIsExporting] = useState(false); | |
const [exportSuccess, setExportSuccess] = useState(false); | |
const [exportMode, setExportMode] = useState<'standard' | 'fine-tuning'>('standard'); | |
const [trainSplit, setTrainSplit] = useState(80); | |
const [testSplit, setTestSplit] = useState(10); | |
const [valSplit, setValSplit] = useState(10); | |
const [crisisMapsSelected, setCrisisMapsSelected] = useState(true); | |
const [droneImagesSelected, setDroneImagesSelected] = useState(true); | |
const [isDeleting, setIsDeleting] = useState(false); | |
// Full-size image modal state | |
const [showFullSizeModal, setShowFullSizeModal] = useState(false); | |
const [selectedImageForModal, setSelectedImageForModal] = useState<MapOut | null>(null); | |
const [isLoadingFullSizeImage, setIsLoadingFullSizeImage] = useState(false); | |
// Carousel state for multi-upload | |
const [allImages, setAllImages] = useState<MapOut[]>([]); | |
const [currentImageIndex, setCurrentImageIndex] = useState(0); | |
const [isLoadingImages, setIsLoadingImages] = useState(false); | |
const { | |
search, setSearch, | |
srcFilter, setSrcFilter, | |
catFilter, setCatFilter, | |
regionFilter, setRegionFilter, | |
countryFilter, setCountryFilter, | |
imageTypeFilter, setImageTypeFilter, | |
uploadTypeFilter, setUploadTypeFilter, | |
showReferenceExamples, setShowReferenceExamples, | |
clearAllFilters | |
} = useFilterContext(); | |
const viewOptions = [ | |
{ key: 'explore' as const, label: 'List' }, | |
{ key: 'mapDetails' as const, label: 'Carousel' } | |
]; | |
const fetchMapData = useCallback(async (id: string) => { | |
console.log('fetchMapData called with id:', id); | |
console.log('fetchMapData id type:', typeof id); | |
// Validate the ID before making the request | |
if (!id || id === 'undefined' || id === 'null' || id.trim() === '') { | |
console.log('fetchMapData: Invalid ID detected:', id); | |
setError('Invalid Map ID'); | |
setLoading(false); | |
return; | |
} | |
// Additional UUID format validation | |
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; | |
if (!uuidRegex.test(id)) { | |
console.log('fetchMapData: Invalid UUID format:', id); | |
setError('Invalid Map ID format'); | |
setLoading(false); | |
return; | |
} | |
console.log('fetchMapData: Making API call for id:', id); | |
setIsNavigating(true); | |
setLoading(true); | |
try { | |
const response = await fetch(`/api/images/${id}`); | |
if (!response.ok) { | |
throw new Error('Map not found'); | |
} | |
const data = await response.json(); | |
setMap(data); | |
// If this is a multi-upload item, fetch all images | |
if (data.all_image_ids && data.all_image_ids.length > 1) { | |
await fetchAllImages(data.all_image_ids); | |
} else if (data.image_count && data.image_count > 1) { | |
// Multi-upload but no all_image_ids, try to fetch from grouped endpoint | |
console.log('Multi-upload detected but no all_image_ids, trying grouped endpoint'); | |
try { | |
const groupedResponse = await fetch('/api/images/grouped'); | |
if (groupedResponse.ok) { | |
const groupedData = await groupedResponse.json(); | |
const matchingItem = groupedData.find((item: any) => | |
item.all_image_ids && item.all_image_ids.includes(data.image_id) | |
); | |
if (matchingItem && matchingItem.all_image_ids) { | |
await fetchAllImages(matchingItem.all_image_ids); | |
} else { | |
setAllImages([data]); | |
setCurrentImageIndex(0); | |
} | |
} else { | |
setAllImages([data]); | |
setCurrentImageIndex(0); | |
} | |
} catch (err) { | |
console.error('Failed to fetch from grouped endpoint:', err); | |
setAllImages([data]); | |
setCurrentImageIndex(0); | |
} | |
} else { | |
setAllImages([data]); | |
setCurrentImageIndex(0); | |
} | |
await checkNavigationAvailability(id); | |
} catch (err: unknown) { | |
setError(err instanceof Error ? err.message : 'Unknown error occurred'); | |
} finally { | |
setLoading(false); | |
setIsNavigating(false); | |
} | |
}, []); | |
const fetchAllImages = useCallback(async (imageIds: string[]) => { | |
console.log('fetchAllImages called with imageIds:', imageIds); | |
setIsLoadingImages(true); | |
try { | |
const imagePromises = imageIds.map(async (imageId) => { | |
const response = await fetch(`/api/images/${imageId}`); | |
if (!response.ok) { | |
throw new Error(`Failed to fetch image ${imageId}`); | |
} | |
return response.json(); | |
}); | |
const images = await Promise.all(imagePromises); | |
setAllImages(images); | |
setCurrentImageIndex(0); | |
console.log('fetchAllImages: Loaded', images.length, 'images'); | |
} catch (err: unknown) { | |
console.error('fetchAllImages error:', err); | |
setError(err instanceof Error ? err.message : 'Failed to load all images'); | |
} finally { | |
setIsLoadingImages(false); | |
} | |
}, []); | |
// Carousel navigation functions | |
const goToPrevious = useCallback(() => { | |
if (allImages.length > 1) { | |
setCurrentImageIndex((prev) => (prev > 0 ? prev - 1 : allImages.length - 1)); | |
} | |
}, [allImages.length]); | |
const goToNext = useCallback(() => { | |
if (allImages.length > 1) { | |
setCurrentImageIndex((prev) => (prev < allImages.length - 1 ? prev + 1 : 0)); | |
} | |
}, [allImages.length]); | |
const goToImage = useCallback((index: number) => { | |
if (index >= 0 && index < allImages.length) { | |
setCurrentImageIndex(index); | |
} | |
}, [allImages.length]); | |
// Full-size image modal functions | |
const handleViewFullSize = useCallback(async (image?: MapOut) => { | |
const imageToShow = image || (allImages.length > 0 ? allImages[currentImageIndex] : map); | |
if (imageToShow) { | |
setIsLoadingFullSizeImage(true); | |
setSelectedImageForModal(imageToShow); | |
setShowFullSizeModal(true); | |
// Preload the full-size image | |
try { | |
const img = new Image(); | |
img.onload = () => { | |
setIsLoadingFullSizeImage(false); | |
}; | |
img.onerror = () => { | |
setIsLoadingFullSizeImage(false); | |
}; | |
img.src = imageToShow.image_url; | |
} catch (error) { | |
console.error('Error preloading full-size image:', error); | |
setIsLoadingFullSizeImage(false); | |
} | |
} | |
}, [allImages, currentImageIndex, map]); | |
const handleCloseFullSizeModal = useCallback(() => { | |
setShowFullSizeModal(false); | |
setSelectedImageForModal(null); | |
setIsLoadingFullSizeImage(false); | |
}, []); | |
useEffect(() => { | |
console.log('MapDetailsPage: mapId from useParams:', mapId); | |
console.log('MapDetailsPage: mapId type:', typeof mapId); | |
console.log('MapDetailsPage: mapId value:', mapId); | |
if (!mapId || | |
mapId === 'undefined' || | |
mapId === 'null' || | |
mapId.trim() === '' || | |
mapId === undefined || | |
mapId === null) { | |
console.log('MapDetailsPage: Invalid mapId, setting error'); | |
setError('Map ID is required'); | |
setLoading(false); | |
return; | |
} | |
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; | |
if (!uuidRegex.test(mapId)) { | |
console.log('MapDetailsPage: Invalid UUID format:', mapId); | |
setError('Invalid Map ID format'); | |
setLoading(false); | |
return; | |
} | |
console.log('MapDetailsPage: Fetching data for mapId:', mapId); | |
fetchMapData(mapId); | |
}, [mapId, fetchMapData]); | |
// Auto-navigate to first matching item when filters change | |
useEffect(() => { | |
if (!map || loading || isDeleting) return; | |
if (!mapId || mapId === 'undefined' || mapId === 'null' || mapId.trim() === '') { | |
console.log('Auto-navigation skipped: Invalid mapId'); | |
return; | |
} | |
// Validate current mapId format | |
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; | |
if (!uuidRegex.test(mapId)) { | |
console.log('Auto-navigation skipped: Invalid mapId format'); | |
return; | |
} | |
const currentMapMatches = () => { | |
const matchesSearch = !search || | |
map.title?.toLowerCase().includes(search.toLowerCase()) || | |
map.generated?.toLowerCase().includes(search.toLowerCase()) || | |
map.source?.toLowerCase().includes(search.toLowerCase()) || | |
map.event_type?.toLowerCase().includes(search.toLowerCase()); | |
const matchesSource = !srcFilter || map.source === srcFilter; | |
const matchesCategory = !catFilter || map.event_type === catFilter; | |
const matchesRegion = !regionFilter || | |
map.countries.some(country => country.r_code === regionFilter); | |
const matchesCountry = !countryFilter || | |
map.countries.some(country => country.c_code === countryFilter); | |
const matchesImageType = !imageTypeFilter || map.image_type === imageTypeFilter; | |
const matchesReferenceExamples = !showReferenceExamples || map.starred === true; | |
const matches = matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry && matchesImageType && matchesReferenceExamples; | |
console.log('Auto-navigation check:', { | |
mapId, | |
search, | |
srcFilter, | |
catFilter, | |
regionFilter, | |
countryFilter, | |
imageTypeFilter, | |
showReferenceExamples, | |
matchesSearch, | |
matchesSource, | |
matchesCategory, | |
matchesRegion, | |
matchesCountry, | |
matchesImageType, | |
matchesReferenceExamples, | |
matches | |
}); | |
return matches; | |
}; | |
if (!currentMapMatches()) { | |
console.log('Current map does not match filters, looking for first matching item'); | |
// Find first matching item and navigate to it | |
fetch('/api/images') | |
.then(r => r.json()) | |
.then(images => { | |
console.log('Auto-navigation: Received images from API:', images.length); | |
console.log('Auto-navigation: First few images:', images.slice(0, 3).map((img: any) => ({ image_id: img.image_id, title: img.title }))); | |
const firstMatching = images.find((img: any) => { | |
const matchesSearch = !search || | |
img.title?.toLowerCase().includes(search.toLowerCase()) || | |
img.generated?.toLowerCase().includes(search.toLowerCase()) || | |
img.source?.toLowerCase().includes(search.toLowerCase()) || | |
img.event_type?.toLowerCase().includes(search.toLowerCase()); | |
const matchesSource = !srcFilter || img.source === srcFilter; | |
const matchesCategory = !catFilter || img.event_type === catFilter; | |
const matchesRegion = !regionFilter || | |
img.countries?.some((country: any) => country.r_code === regionFilter); | |
const matchesCountry = !countryFilter || | |
img.countries?.some((country: any) => country.c_code === countryFilter); | |
const matchesImageType = !imageTypeFilter || img.image_type === imageTypeFilter; | |
const matchesReferenceExamples = !showReferenceExamples || img.starred === true; | |
return matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry && matchesImageType && matchesReferenceExamples; | |
}); | |
console.log('Auto-navigation: Found first matching image:', firstMatching ? { | |
image_id: firstMatching.image_id, | |
title: firstMatching.title, | |
source: firstMatching.source | |
} : 'No matching image found'); | |
if (firstMatching && | |
firstMatching.image_id && | |
firstMatching.image_id !== 'undefined' && | |
firstMatching.image_id !== 'null' && | |
firstMatching.image_id.trim() !== '' && | |
firstMatching.image_id !== mapId) { | |
// Additional UUID validation | |
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; | |
if (uuidRegex.test(firstMatching.image_id)) { | |
console.log('Auto-navigating to:', firstMatching.image_id); | |
navigate(`/map/${firstMatching.image_id}`); | |
} else { | |
console.error('Auto-navigation blocked: Invalid image_id format:', firstMatching.image_id); | |
} | |
} | |
}) | |
.catch(console.error); | |
} | |
}, [map, search, srcFilter, catFilter, regionFilter, countryFilter, imageTypeFilter, showReferenceExamples, mapId, navigate, loading, isDeleting]); | |
const checkNavigationAvailability = async (currentId: string) => { | |
// Validate the ID before making the request | |
if (!currentId || currentId === 'undefined' || currentId === 'null' || currentId.trim() === '') { | |
return; | |
} | |
try { | |
const response = await fetch('/api/images/grouped'); | |
if (response.ok) { | |
const images = await response.json(); | |
const filteredImages = images.filter((img: any) => { | |
const matchesSearch = !search || | |
img.title?.toLowerCase().includes(search.toLowerCase()) || | |
img.generated?.toLowerCase().includes(search.toLowerCase()) || | |
img.source?.toLowerCase().includes(search.toLowerCase()) || | |
img.event_type?.toLowerCase().includes(search.toLowerCase()); | |
const matchesSource = !srcFilter || img.source === srcFilter; | |
const matchesCategory = !catFilter || img.event_type === catFilter; | |
const matchesRegion = !regionFilter || | |
img.countries?.some((country: any) => country.r_code === regionFilter); | |
const matchesCountry = !countryFilter || | |
img.countries?.some((country: any) => country.c_code === countryFilter); | |
const matchesImageType = !imageTypeFilter || img.image_type === imageTypeFilter; | |
const matchesUploadType = !uploadTypeFilter || | |
(uploadTypeFilter === 'single' && (!img.image_count || img.image_count <= 1)) || | |
(uploadTypeFilter === 'multiple' && img.image_count && img.image_count > 1); | |
const matchesReferenceExamples = !showReferenceExamples || img.starred === true; | |
return matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry && matchesImageType && matchesUploadType && matchesReferenceExamples; | |
}); | |
const currentIndex = filteredImages.findIndex((img: { image_id: string }) => img.image_id === currentId); | |
setHasPrevious(filteredImages.length > 1 && currentIndex > 0); | |
setHasNext(filteredImages.length > 1 && currentIndex < filteredImages.length - 1); | |
} | |
} catch (error) { | |
console.error('Failed to check navigation availability:', error); | |
} | |
}; | |
const navigateToItem = async (direction: 'previous' | 'next') => { | |
if (isNavigating) return; | |
setIsNavigating(true); | |
try { | |
const response = await fetch('/api/images/grouped'); | |
if (response.ok) { | |
const images = await response.json(); | |
const filteredImages = images.filter((img: any) => { | |
const matchesSearch = !search || | |
img.title?.toLowerCase().includes(search.toLowerCase()) || | |
img.generated?.toLowerCase().includes(search.toLowerCase()) || | |
img.source?.toLowerCase().includes(search.toLowerCase()) || | |
img.event_type?.toLowerCase().includes(search.toLowerCase()); | |
const matchesSource = !srcFilter || img.source === srcFilter; | |
const matchesCategory = !catFilter || img.event_type === catFilter; | |
const matchesRegion = !regionFilter || | |
img.countries?.some((country: any) => country.r_code === regionFilter); | |
const matchesCountry = !countryFilter || | |
img.countries?.some((country: any) => country.c_code === countryFilter); | |
const matchesImageType = !imageTypeFilter || img.image_type === imageTypeFilter; | |
const matchesUploadType = !uploadTypeFilter || | |
(uploadTypeFilter === 'single' && (!img.image_count || img.image_count <= 1)) || | |
(uploadTypeFilter === 'multiple' && img.image_count && img.image_count > 1); | |
const matchesReferenceExamples = !showReferenceExamples || img.starred === true; | |
return matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry && matchesImageType && matchesUploadType && matchesReferenceExamples; | |
}); | |
const currentIndex = filteredImages.findIndex((img: { image_id: string }) => img.image_id === mapId); | |
// If current image is not in filtered list, add it temporarily for navigation | |
if (currentIndex === -1) { | |
const currentImage = images.find((img: any) => img.image_id === mapId); | |
if (currentImage) { | |
filteredImages.push(currentImage); | |
} | |
} | |
const adjustedCurrentIndex = filteredImages.findIndex((img: { image_id: string }) => img.image_id === mapId); | |
if (adjustedCurrentIndex === -1) { | |
console.error('Current image not found in filtered list'); | |
return; | |
} | |
let targetIndex: number; | |
if (direction === 'previous') { | |
targetIndex = adjustedCurrentIndex > 0 ? adjustedCurrentIndex - 1 : filteredImages.length - 1; | |
} else { | |
targetIndex = adjustedCurrentIndex < filteredImages.length - 1 ? adjustedCurrentIndex + 1 : 0; | |
} | |
const targetImage = filteredImages[targetIndex]; | |
if (targetImage && | |
targetImage.image_id && | |
targetImage.image_id !== 'undefined' && | |
targetImage.image_id !== 'null' && | |
targetImage.image_id.trim() !== '') { | |
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; | |
if (uuidRegex.test(targetImage.image_id)) { | |
console.log('Carousel navigating to:', targetImage.image_id); | |
navigate(`/map/${targetImage.image_id}`); | |
} else { | |
console.error('Carousel navigation blocked: Invalid image_id format:', targetImage.image_id); | |
} | |
} | |
} | |
} catch (error) { | |
console.error('Failed to navigate to item:', error); | |
} finally { | |
setIsNavigating(false); | |
} | |
}; | |
// Check navigation availability when filters change | |
useEffect(() => { | |
if (map && mapId && !loading && !isDeleting) { | |
checkNavigationAvailability(mapId); | |
} | |
}, [map, mapId, search, srcFilter, catFilter, regionFilter, countryFilter, imageTypeFilter, uploadTypeFilter, showReferenceExamples, loading, isDeleting, checkNavigationAvailability]); | |
useEffect(() => { | |
Promise.all([ | |
fetch('/api/sources').then(r => r.json()), | |
fetch('/api/types').then(r => r.json()), | |
fetch('/api/image-types').then(r => r.json()), | |
fetch('/api/regions').then(r => r.json()), | |
fetch('/api/countries').then(r => r.json()), | |
]).then(([sourcesData, typesData, imageTypesData, regionsData, countriesData]) => { | |
setSources(sourcesData); | |
setTypes(typesData); | |
setImageTypes(imageTypesData); | |
setRegions(regionsData); | |
setCountries(countriesData); | |
}).catch(console.error); | |
}, []); | |
// delete function | |
const handleDelete = async () => { | |
if (!map) return; | |
setShowDeleteConfirm(true); | |
}; | |
const toggleStarred = async () => { | |
if (!map) return; | |
try { | |
const response = await fetch(`/api/images/${map.image_id}`, { | |
method: "PUT", | |
headers: { | |
'Content-Type': 'application/json', | |
}, | |
body: JSON.stringify({ | |
starred: !map.starred | |
}) | |
}); | |
if (response.ok) { | |
setMap(prev => prev ? { ...prev, starred: !prev.starred } : null); | |
} else { | |
console.error('Failed to toggle starred status'); | |
} | |
} catch (error) { | |
console.error('Error toggling starred status:', error); | |
} | |
}; | |
const confirmDelete = async () => { | |
if (!map) return; | |
setIsDeleting(true); | |
try { | |
console.log('Deleting image with ID:', map.image_id); | |
const response = await fetch(`/api/images/${map.image_id}`, { | |
method: 'DELETE', | |
}); | |
if (response.ok) { | |
setMap(prev => prev ? { ...prev, starred: !prev.starred } : null); | |
setShowDeleteConfirm(false); | |
try { | |
const response = await fetch('/api/images/grouped'); | |
if (response.ok) { | |
const images = await response.json(); | |
const filteredImages = images.filter((img: any) => { | |
const matchesSearch = !search || | |
img.title?.toLowerCase().includes(search.toLowerCase()) || | |
img.generated?.toLowerCase().includes(search.toLowerCase()) || | |
img.source?.toLowerCase().includes(search.toLowerCase()) || | |
img.event_type?.toLowerCase().includes(search.toLowerCase()); | |
const matchesSource = !srcFilter || img.source === srcFilter; | |
const matchesCategory = !catFilter || img.event_type === catFilter; | |
const matchesRegion = !regionFilter || | |
img.countries?.some((country: any) => country.r_code === regionFilter); | |
const matchesCountry = !countryFilter || | |
img.countries?.some((country: any) => country.c_code === countryFilter); | |
const matchesImageType = !imageTypeFilter || img.image_type === imageTypeFilter; | |
const matchesUploadType = !uploadTypeFilter || | |
(uploadTypeFilter === 'single' && (!img.image_count || img.image_count <= 1)) || | |
(uploadTypeFilter === 'multiple' && img.image_count && img.image_count > 1); | |
const matchesReferenceExamples = !showReferenceExamples || img.starred === true; | |
return matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry && matchesImageType && matchesUploadType && matchesReferenceExamples; | |
}); | |
const remainingImages = filteredImages.filter((img: any) => img.image_id !== map.image_id); | |
if (remainingImages.length > 0) { | |
const currentIndex = filteredImages.findIndex((img: any) => img.image_id === map.image_id); | |
let targetIndex: number; | |
if (currentIndex === filteredImages.length - 1) { | |
targetIndex = currentIndex - 1; | |
} else { | |
targetIndex = currentIndex; | |
} | |
console.log('Navigation target:', { currentIndex, targetIndex, targetId: remainingImages[targetIndex]?.image_id }); | |
if (targetIndex >= 0 && targetIndex < remainingImages.length) { | |
const nextImage = remainingImages[targetIndex]; | |
if (nextImage && | |
nextImage.image_id && | |
nextImage.image_id !== 'undefined' && | |
nextImage.image_id !== 'null' && | |
nextImage.image_id.trim() !== '') { | |
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; | |
if (uuidRegex.test(nextImage.image_id)) { | |
console.log('Navigating to:', nextImage.image_id); | |
navigate(`/map/${nextImage.image_id}`); | |
} else { | |
console.error('Navigation blocked: Invalid image_id format:', nextImage.image_id); | |
navigate('/explore'); | |
} | |
} else { | |
console.error('Navigation blocked: Invalid image_id:', nextImage?.image_id); | |
navigate('/explore'); | |
} | |
} else if (remainingImages[0] && | |
remainingImages[0].image_id && | |
remainingImages[0].image_id !== 'undefined' && | |
remainingImages[0].image_id !== 'null' && | |
remainingImages[0].image_id.trim() !== '') { | |
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; | |
if (uuidRegex.test(remainingImages[0].image_id)) { | |
console.log('Fallback navigation to first item:', remainingImages[0].image_id); | |
navigate(`/map/${remainingImages[0].image_id}`); | |
} else { | |
console.error('Fallback navigation blocked: Invalid image_id format:', remainingImages[0].image_id); | |
navigate('/explore'); | |
} | |
} else { | |
console.log('No valid remaining items, going to explore page'); | |
navigate('/explore'); | |
} | |
} else { | |
console.log('No remaining items, going to explore page'); | |
navigate('/explore'); | |
} | |
} else { | |
navigate('/explore'); | |
} | |
} catch (error) { | |
console.error('Failed to navigate to next item:', error); | |
navigate('/explore'); | |
} finally { | |
setIsDeleting(false); | |
} | |
} else { | |
console.error('Delete failed'); | |
setIsDeleting(false); | |
} | |
} catch (error) { | |
console.error('Delete failed:', error); | |
setIsDeleting(false); | |
} | |
}; | |
const filteredMap = useMemo(() => { | |
if (!map) return null; | |
if (!search && !srcFilter && !catFilter && !regionFilter && !countryFilter && !imageTypeFilter && !uploadTypeFilter && !showReferenceExamples) { | |
return map; | |
} | |
const matchesSearch = !search || | |
map.title?.toLowerCase().includes(search.toLowerCase()) || | |
map.generated?.toLowerCase().includes(search.toLowerCase()) || | |
map.source?.toLowerCase().includes(search.toLowerCase()) || | |
map.event_type?.toLowerCase().includes(search.toLowerCase()); | |
const matchesSource = !srcFilter || map.source === srcFilter; | |
const matchesCategory = !catFilter || map.event_type === catFilter; | |
const matchesRegion = !regionFilter || | |
map.countries.some(country => country.r_code === regionFilter); | |
const matchesCountry = !countryFilter || | |
map.countries.some(country => country.c_code === countryFilter); | |
const matchesImageType = !imageTypeFilter || map.image_type === imageTypeFilter; | |
const matchesUploadType = !uploadTypeFilter || | |
(uploadTypeFilter === 'single' && (!map.image_count || map.image_count <= 1)) || | |
(uploadTypeFilter === 'multiple' && map.image_count && map.image_count > 1); | |
const matchesReferenceExamples = !showReferenceExamples || map.starred === true; | |
const matches = matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry && matchesImageType && matchesUploadType && matchesReferenceExamples; | |
// If current map doesn't match filters, navigate to a matching image | |
if (!matches && (search || srcFilter || catFilter || regionFilter || countryFilter || imageTypeFilter || uploadTypeFilter || showReferenceExamples)) { | |
// Navigate to a matching image after a short delay to avoid infinite loops | |
setTimeout(() => { | |
navigateToMatchingImage(); | |
}, 100); | |
// Return the current map while loading to show loading state instead of "no match found" | |
return map; | |
} | |
return matches ? map : null; | |
}, [map, search, srcFilter, catFilter, regionFilter, countryFilter, imageTypeFilter, uploadTypeFilter, showReferenceExamples]); | |
const navigateToMatchingImage = useCallback(async () => { | |
setLoading(true); | |
try { | |
const response = await fetch('/api/images/grouped'); | |
if (response.ok) { | |
const images = await response.json(); | |
const filteredImages = images.filter((img: any) => { | |
const matchesSearch = !search || | |
img.title?.toLowerCase().includes(search.toLowerCase()) || | |
img.generated?.toLowerCase().includes(search.toLowerCase()) || | |
img.source?.toLowerCase().includes(search.toLowerCase()) || | |
img.event_type?.toLowerCase().includes(search.toLowerCase()); | |
const matchesSource = !srcFilter || img.source === srcFilter; | |
const matchesCategory = !catFilter || img.event_type === catFilter; | |
const matchesRegion = !regionFilter || | |
img.countries?.some((country: any) => country.r_code === regionFilter); | |
const matchesCountry = !countryFilter || | |
img.countries?.some((country: any) => country.c_code === countryFilter); | |
const matchesImageType = !imageTypeFilter || img.image_type === imageTypeFilter; | |
const matchesUploadType = !uploadTypeFilter || | |
(uploadTypeFilter === 'single' && (!img.image_count || img.image_count <= 1)) || | |
(uploadTypeFilter === 'multiple' && img.image_count && img.image_count > 1); | |
const matchesReferenceExamples = !showReferenceExamples || img.starred === true; | |
return matchesSearch && matchesSource && matchesCategory && matchesRegion && matchesCountry && matchesImageType && matchesUploadType && matchesReferenceExamples; | |
}); | |
if (filteredImages.length > 0) { | |
const firstMatchingImage = filteredImages[0]; | |
if (firstMatchingImage && firstMatchingImage.image_id) { | |
navigate(`/map/${firstMatchingImage.image_id}`); | |
} | |
} else { | |
// No matching images, go back to explore | |
navigate('/explore'); | |
} | |
} | |
} catch (error) { | |
console.error('Failed to navigate to matching image:', error); | |
navigate('/explore'); | |
} finally { | |
setLoading(false); | |
} | |
}, [search, srcFilter, catFilter, regionFilter, countryFilter, imageTypeFilter, uploadTypeFilter, showReferenceExamples, navigate]); | |
const handleContribute = () => { | |
if (!map) return; | |
// For single image contribution | |
if (!map.all_image_ids || map.all_image_ids.length <= 1) { | |
const imageIds = [map.image_id]; | |
const url = `/upload?step=1&contribute=true&imageIds=${imageIds.join(',')}`; | |
navigate(url); | |
return; | |
} | |
// For multi-upload contribution | |
const imageIds = map.all_image_ids; | |
const url = `/upload?step=1&contribute=true&imageIds=${imageIds.join(',')}`; | |
navigate(url); | |
}; | |
const createImageData = (map: any, fileName: string) => ({ | |
image: `images/${fileName}`, | |
caption: map.edited || map.generated || '', | |
metadata: { | |
image_id: map.image_count && map.image_count > 1 | |
? map.all_image_ids || [map.image_id] | |
: map.image_id, | |
title: map.title, | |
source: map.source, | |
event_type: map.event_type, | |
image_type: map.image_type, | |
countries: map.countries, | |
starred: map.starred, | |
image_count: map.image_count || 1 | |
} | |
}); | |
const exportDataset = async (mode: 'standard' | 'fine-tuning') => { | |
if (!map) return; | |
setIsExporting(true); | |
setExportSuccess(false); | |
try { | |
const JSZip = (await import('jszip')).default; | |
const zip = new JSZip(); | |
if (map.image_type === 'crisis_map') { | |
const crisisFolder = zip.folder('crisis_maps_dataset'); | |
const crisisImagesFolder = crisisFolder?.folder('images'); | |
if (crisisImagesFolder) { | |
try { | |
// Get all image IDs for this map | |
const imageIds = map.image_count && map.image_count > 1 | |
? map.all_image_ids || [map.image_id] | |
: [map.image_id]; | |
// Fetch all images for this map | |
const imagePromises = imageIds.map(async (imageId, imgIndex) => { | |
try { | |
const response = await fetch(`/api/images/${imageId}/file`); | |
if (!response.ok) throw new Error(`Failed to fetch image ${imageId}`); | |
const blob = await response.blob(); | |
const fileExtension = map.file_key.split('.').pop() || 'jpg'; | |
const fileName = `0001_${String(imgIndex + 1).padStart(2, '0')}.${fileExtension}`; | |
crisisImagesFolder.file(fileName, blob); | |
return { success: true, fileName, imageId }; | |
} catch (error) { | |
console.error(`Failed to process image ${imageId}:`, error); | |
return { success: false, fileName: '', imageId }; | |
} | |
}); | |
const imageResults = await Promise.all(imagePromises); | |
const successfulImages = imageResults.filter(result => result.success); | |
if (successfulImages.length === 0) { | |
throw new Error('No images could be processed'); | |
} | |
if (mode === 'fine-tuning') { | |
const trainData: any[] = []; | |
const testData: any[] = []; | |
const valData: any[] = []; | |
const imageFiles = successfulImages.map(result => `images/${result.fileName}`); | |
const random = Math.random(); | |
const entry = { | |
image: imageFiles.length === 1 ? imageFiles[0] : imageFiles, | |
caption: map.edited || map.generated || '', | |
metadata: { | |
image_id: imageIds, | |
title: map.title, | |
source: map.source, | |
event_type: map.event_type, | |
image_type: map.image_type, | |
countries: map.countries, | |
starred: map.starred, | |
image_count: map.image_count || 1 | |
} | |
}; | |
if (random < trainSplit / 100) { | |
trainData.push(entry); | |
} else if (random < (trainSplit + testSplit) / 100) { | |
testData.push(entry); | |
} else { | |
valData.push(entry); | |
} | |
if (crisisFolder) { | |
crisisFolder.file('train.jsonl', JSON.stringify(trainData, null, 2)); | |
crisisFolder.file('test.jsonl', JSON.stringify(testData, null, 2)); | |
crisisFolder.file('val.jsonl', JSON.stringify(valData, null, 2)); | |
} | |
} else { | |
const imageFiles = successfulImages.map(result => `images/${result.fileName}`); | |
const jsonData = { | |
image: imageFiles.length === 1 ? imageFiles[0] : imageFiles, | |
caption: map.edited || map.generated || '', | |
metadata: { | |
image_id: imageIds, | |
title: map.title, | |
source: map.source, | |
event_type: map.event_type, | |
image_type: map.image_type, | |
countries: map.countries, | |
starred: map.starred, | |
image_count: map.image_count || 1 | |
} | |
}; | |
if (crisisFolder) { | |
crisisFolder.file('0001.json', JSON.stringify(jsonData, null, 2)); | |
} | |
} | |
} catch (error) { | |
console.error(`Failed to process image ${map.image_id}:`, error); | |
throw error; | |
} | |
} | |
} else if (map.image_type === 'drone_image') { | |
const droneFolder = zip.folder('drone_images_dataset'); | |
const droneImagesFolder = droneFolder?.folder('images'); | |
if (droneImagesFolder) { | |
try { | |
const response = await fetch(`/api/images/${map.image_id}/file`); | |
if (!response.ok) throw new Error(`Failed to fetch image ${map.image_id}`); | |
const blob = await response.blob(); | |
const fileExtension = map.file_key.split('.').pop() || 'jpg'; | |
const fileName = `0001.${fileExtension}`; | |
droneImagesFolder.file(fileName, blob); | |
if (mode === 'fine-tuning') { | |
const trainData: any[] = []; | |
const testData: any[] = []; | |
const valData: any[] = []; | |
if (String(map?.image_type) === 'crisis_map') { | |
const random = Math.random(); | |
if (random < trainSplit / 100) { | |
trainData.push(createImageData(map, '0001')); | |
} else if (random < (trainSplit + testSplit) / 100) { | |
testData.push(createImageData(map, '0001')); | |
} else { | |
valData.push(createImageData(map, '0001')); | |
} | |
} else if (String(map?.image_type) === 'drone_image') { | |
const random = Math.random(); | |
if (random < trainSplit / 100) { | |
trainData.push(createImageData(map, '0001')); | |
} else if (random < (trainSplit + testSplit) / 100) { | |
testData.push(createImageData(map, '0001')); | |
} else { | |
valData.push(createImageData(map, '0001')); | |
} | |
} | |
if (droneFolder) { | |
droneFolder.file('train.jsonl', JSON.stringify(trainData, null, 2)); | |
droneFolder.file('test.jsonl', JSON.stringify(testData, null, 2)); | |
droneFolder.file('val.jsonl', JSON.stringify(valData, null, 2)); | |
} | |
} else { | |
const jsonData = { | |
image: `images/${fileName}`, | |
caption: map.edited || map.generated || '', | |
metadata: { | |
image_id: map.image_count && map.image_count > 1 | |
? map.all_image_ids || [map.image_id] | |
: map.image_id, | |
title: map.title, | |
source: map.source, | |
event_type: map.event_type, | |
image_type: map.image_type, | |
countries: map.countries, | |
starred: map.starred, | |
image_count: map.image_count || 1 | |
} | |
}; | |
if (droneFolder) { | |
droneFolder.file('0001.json', JSON.stringify(jsonData, null, 2)); | |
} | |
} | |
} catch (error) { | |
console.error(`Failed to process image ${map.image_id}:`, error); | |
throw error; | |
} | |
} | |
} else { | |
const genericFolder = zip.folder('generic_dataset'); | |
const genericImagesFolder = genericFolder?.folder('images'); | |
if (genericImagesFolder) { | |
try { | |
const response = await fetch(`/api/images/${map.image_id}/file`); | |
if (!response.ok) throw new Error(`Failed to fetch image ${map.image_id}`); | |
const blob = await response.blob(); | |
const fileExtension = map.file_key.split('.').pop() || 'jpg'; | |
const fileName = `0001.${fileExtension}`; | |
genericImagesFolder.file(fileName, blob); | |
if (mode === 'fine-tuning') { | |
const trainData: any[] = []; | |
const testData: any[] = []; | |
const valData: any[] = []; | |
if (String(map?.image_type) === 'crisis_map') { | |
const random = Math.random(); | |
if (random < trainSplit / 100) { | |
trainData.push(createImageData(map, '0001')); | |
} else if (random < (trainSplit + testSplit) / 100) { | |
testData.push(createImageData(map, '0001')); | |
} else { | |
valData.push(createImageData(map, '0001')); | |
} | |
} else if (String(map?.image_type) === 'drone_image') { | |
const random = Math.random(); | |
if (random < trainSplit / 100) { | |
trainData.push(createImageData(map, '0001')); | |
} else if (random < (trainSplit + testSplit) / 100) { | |
testData.push(createImageData(map, '0001')); | |
} else { | |
valData.push(createImageData(map, '0001')); | |
} | |
} | |
if (genericFolder) { | |
genericFolder.file('train.jsonl', JSON.stringify(trainData, null, 2)); | |
genericFolder.file('test.jsonl', JSON.stringify(testData, null, 2)); | |
genericFolder.file('val.jsonl', JSON.stringify(valData, null, 2)); | |
} | |
} else { | |
const jsonData = { | |
image: `images/${fileName}`, | |
caption: map.edited || map.generated || '', | |
metadata: { | |
image_id: map.image_count && map.image_count > 1 | |
? map.all_image_ids || [map.image_id] | |
: map.image_id, | |
title: map.title, | |
source: map.source, | |
event_type: map.event_type, | |
image_type: map.image_type, | |
countries: map.countries, | |
starred: map.starred, | |
image_count: map.image_count || 1 | |
} | |
}; | |
if (genericFolder) { | |
genericFolder.file('0001.json', JSON.stringify(jsonData, null, 2)); | |
} | |
} | |
} catch (error) { | |
console.error(`Failed to process image ${map.image_id}:`, error); | |
throw error; | |
} | |
} | |
} | |
// Generate and download zip | |
const zipBlob = await zip.generateAsync({ type: 'blob' }); | |
const url = URL.createObjectURL(zipBlob); | |
const link = document.createElement('a'); | |
link.href = url; | |
link.download = `dataset_${map.image_type}_${map.image_id}_${mode}_${new Date().toISOString().split('T')[0]}.zip`; | |
document.body.appendChild(link); | |
link.click(); | |
document.body.removeChild(link); | |
URL.revokeObjectURL(url); | |
console.log(`Exported ${map.image_type} dataset with 1 image in ${mode} mode`); | |
setExportSuccess(true); | |
} catch (error) { | |
console.error('Export failed:', error); | |
alert('Failed to export dataset. Please try again.'); | |
} finally { | |
setIsExporting(false); | |
} | |
}; | |
if (loading) { | |
return ( | |
<PageContainer> | |
<div className={styles.loadingContainer}> | |
<div className="flex flex-col items-center gap-4"> | |
<Spinner className="text-ifrcRed" /> | |
<div>Loading map details...</div> | |
</div> | |
</div> | |
</PageContainer> | |
); | |
} | |
if (error || !map) { | |
return ( | |
<PageContainer> | |
<div className={styles.errorContainer}> | |
<div className="flex flex-col items-center gap-4 text-center"> | |
<div className="text-4xl">⚠️</div> | |
<div className="text-xl font-semibold">Unable to load map</div> | |
<div>{error || 'Map not found'}</div> | |
<Button | |
name="back-to-explore" | |
variant="secondary" | |
onClick={() => navigate('/explore')} | |
> | |
Return to Explore | |
</Button> | |
</div> | |
</div> | |
</PageContainer> | |
); | |
} | |
return ( | |
<PageContainer> | |
<div className="max-w-7xl mx-auto"> | |
<div className={styles.tabSelector}> | |
<SegmentInput | |
name="map-details-view" | |
value={view} | |
onChange={(value) => { | |
if (value === 'mapDetails' || value === 'explore') { | |
setView(value); | |
if (value === 'explore') { | |
navigate('/explore'); | |
} | |
} | |
}} | |
options={viewOptions} | |
keySelector={(o) => o.key} | |
labelSelector={(o) => o.label} | |
/> | |
{/* Action Buttons - Right Aligned */} | |
<div className="flex items-center gap-2 ml-auto"> | |
{/* Reference Examples Filter - Available to all users */} | |
<Container withInternalPadding className="bg-white/20 backdrop-blur-sm rounded-md p-2"> | |
<Button | |
name="reference-examples" | |
variant={showReferenceExamples ? "primary" : "secondary"} | |
onClick={() => setShowReferenceExamples(!showReferenceExamples)} | |
className="whitespace-nowrap" | |
> | |
<span className="mr-2"> | |
{showReferenceExamples ? ( | |
<span className="text-yellow-400">★</span> | |
) : ( | |
<span className="text-yellow-400">☆</span> | |
)} | |
</span> | |
Reference Examples | |
</Button> | |
</Container> | |
{/* Export Dataset Button */} | |
<Button | |
name="export-dataset" | |
variant="secondary" | |
onClick={() => setShowExportModal(true)} | |
> | |
Export | |
</Button> | |
</div> | |
</div> | |
{/* Filter Bar */} | |
<FilterBar | |
sources={sources} | |
types={types} | |
regions={regions} | |
countries={countries} | |
imageTypes={imageTypes} | |
isLoadingFilters={false} | |
/> | |
{view === 'mapDetails' ? ( | |
<div className="relative"> | |
{filteredMap ? ( | |
<> | |
<div className={styles.gridLayout}> | |
{/* Image Section */} | |
<Container | |
heading={ | |
<div className="flex items-center gap-2"> | |
<span>{filteredMap.title || "Map Image"}</span> | |
{filteredMap.starred && ( | |
<span className="text-red-500 text-xl" title="Starred image">★</span> | |
)} | |
</div> | |
} | |
headingLevel={2} | |
withHeaderBorder | |
withInternalPadding | |
spacing="comfortable" | |
> | |
<div className={styles.imageContainer}> | |
{(map?.image_count && map.image_count > 1) || allImages.length > 1 ? ( | |
// Multi-upload carousel | |
<div className={styles.carouselContainer}> | |
<div className={styles.carouselImageWrapper}> | |
{isLoadingImages ? ( | |
<div className={styles.imagePlaceholder}> | |
<Spinner className="text-ifrcRed" /> | |
<div>Loading images...</div> | |
</div> | |
) : allImages[currentImageIndex]?.detail_url ? ( | |
<img | |
src={allImages[currentImageIndex].detail_url} | |
alt={allImages[currentImageIndex].file_key} | |
className={styles.carouselImage} | |
onError={(e) => { | |
console.log('MapDetailsPage: Detail image failed to load, falling back to original:', allImages[currentImageIndex].detail_url); | |
// Fallback to original image | |
const target = e.target as HTMLImageElement; | |
if (allImages[currentImageIndex].image_url) { | |
target.src = allImages[currentImageIndex].image_url; | |
} | |
}} | |
onLoad={() => console.log('MapDetailsPage: Detail image loaded successfully:', allImages[currentImageIndex].detail_url)} | |
/> | |
) : allImages[currentImageIndex]?.image_url ? ( | |
<img | |
src={allImages[currentImageIndex].image_url} | |
alt={allImages[currentImageIndex].file_key} | |
className={styles.carouselImage} | |
onLoad={() => console.log('MapDetailsPage: Original image loaded successfully:', allImages[currentImageIndex].image_url)} | |
/> | |
) : ( | |
<div className={styles.imagePlaceholder}> | |
No image available | |
</div> | |
)} | |
</div> | |
{/* Carousel Navigation */} | |
<div className={styles.carouselNavigation}> | |
<Button | |
name="previous-image" | |
variant="tertiary" | |
size={1} | |
onClick={goToPrevious} | |
disabled={isLoadingImages} | |
className={styles.carouselButton} | |
> | |
<ChevronLeftLineIcon className="w-4 h-4" /> | |
</Button> | |
<div className={styles.carouselIndicators}> | |
{allImages.map((_, index) => ( | |
<button | |
key={index} | |
onClick={() => goToImage(index)} | |
className={`${styles.carouselIndicator} ${ | |
index === currentImageIndex ? styles.carouselIndicatorActive : '' | |
}`} | |
disabled={isLoadingImages} | |
> | |
{index + 1} | |
</button> | |
))} | |
</div> | |
<Button | |
name="next-image" | |
variant="tertiary" | |
size={1} | |
onClick={goToNext} | |
disabled={isLoadingImages} | |
className={styles.carouselButton} | |
> | |
<ChevronRightLineIcon className="w-4 h-4" /> | |
</Button> | |
</div> | |
{/* View Image Button for Carousel */} | |
<div className={styles.viewImageButtonContainer}> | |
<Button | |
name="view-full-size-carousel" | |
variant="secondary" | |
size={1} | |
onClick={() => handleViewFullSize(allImages[currentImageIndex])} | |
disabled={isLoadingImages || !allImages[currentImageIndex]?.image_url} | |
> | |
View Image | |
</Button> | |
</div> | |
</div> | |
) : ( | |
// Single image display | |
<div className={styles.singleImageContainer}> | |
{/* Map Details Page: Prioritize detail versions for better quality */} | |
{filteredMap.detail_url ? ( | |
<img | |
src={filteredMap.detail_url} | |
alt={filteredMap.file_key} | |
onError={(e) => { | |
console.log('MapDetailsPage: Detail image failed to load, falling back to original:', filteredMap.detail_url); | |
// Fallback to original image | |
const target = e.target as HTMLImageElement; | |
if (filteredMap.image_url) { | |
target.src = filteredMap.image_url; | |
} | |
}} | |
onLoad={() => console.log('MapDetailsPage: Detail image loaded successfully:', filteredMap.detail_url)} | |
/> | |
) : filteredMap.image_url ? ( | |
<img | |
src={filteredMap.image_url} | |
alt={filteredMap.file_key} | |
onLoad={() => console.log('MapDetailsPage: Original image loaded successfully:', filteredMap.image_url)} | |
/> | |
) : ( | |
<div className={styles.imagePlaceholder}> | |
No image available | |
</div> | |
)} | |
{/* View Image Button for Single Image */} | |
<div className={styles.viewImageButtonContainer}> | |
<Button | |
name="view-full-size-single" | |
variant="secondary" | |
size={1} | |
onClick={() => handleViewFullSize(filteredMap)} | |
disabled={!filteredMap.image_url} | |
> | |
View Image | |
</Button> | |
</div> | |
</div> | |
)} | |
</div> | |
{/* Tags Section - Inside Image Container */} | |
<Container withInternalPadding className="bg-white/20 backdrop-blur-sm rounded-md p-2"> | |
<div className={styles.metadataTags}> | |
{filteredMap.image_type !== 'drone_image' && ( | |
<span className={styles.metadataTag}> | |
{sources.find(s => s.s_code === filteredMap.source)?.label || filteredMap.source} | |
</span> | |
)} | |
<span className={styles.metadataTag}> | |
{types.find(t => t.t_code === filteredMap.event_type)?.label || filteredMap.event_type} | |
</span> | |
<span className={styles.metadataTag}> | |
{imageTypes.find(it => it.image_type === filteredMap.image_type)?.label || filteredMap.image_type} | |
</span> | |
{filteredMap.countries && filteredMap.countries.length > 0 && ( | |
<> | |
<span className={styles.metadataTag}> | |
{regions.find(r => r.r_code === filteredMap.countries[0].r_code)?.label || 'Unknown Region'} | |
</span> | |
<span className={styles.metadataTag}> | |
{filteredMap.countries.map(country => country.label).join(', ')} | |
</span> | |
</> | |
)} | |
{filteredMap.image_count && filteredMap.image_count > 1 && ( | |
<span className={styles.metadataTag} title={`Multi-upload with ${filteredMap.image_count} images`}> | |
📷 {filteredMap.image_count} | |
</span> | |
)} | |
{(!filteredMap.image_count || filteredMap.image_count <= 1) && ( | |
<span className={styles.metadataTag} title="Single Upload"> | |
Single | |
</span> | |
)} | |
</div> | |
</Container> | |
</Container> | |
{/* Details Section */} | |
<div className={styles.detailsSection}> | |
{/* Combined Analysis Structure */} | |
{(filteredMap.edited && filteredMap.edited.includes('Description:')) || | |
(filteredMap.generated && filteredMap.generated.includes('Description:')) ? ( | |
<Container | |
heading="AI Generated Content" | |
headingLevel={3} | |
withHeaderBorder | |
withInternalPadding | |
spacing="comfortable" | |
> | |
<div className={styles.captionContainer}> | |
<div className={styles.captionText}> | |
{(filteredMap.edited || filteredMap.generated || '').split('\n').map((line, index) => ( | |
<div key={index}> | |
{line.startsWith('Description:') || line.startsWith('Analysis:') || line.startsWith('Recommended Actions:') ? ( | |
<h4 className="font-semibold text-gray-800 mt-4 mb-2">{line}</h4> | |
) : line.trim() === '' ? ( | |
<br /> | |
) : ( | |
<p className="mb-2">{line}</p> | |
)} | |
</div> | |
))} | |
</div> | |
</div> | |
</Container> | |
) : ( | |
<Container | |
heading="Description" | |
headingLevel={3} | |
withHeaderBorder | |
withInternalPadding | |
spacing="comfortable" | |
> | |
<div className={styles.captionContainer}> | |
{filteredMap.generated ? ( | |
<div className={styles.captionText}> | |
<p>{filteredMap.edited || filteredMap.generated}</p> | |
</div> | |
) : ( | |
<p>— no caption yet —</p> | |
)} | |
</div> | |
</Container> | |
)} | |
</div> | |
</div> | |
{/* Contribute Section with Navigation Arrows */} | |
<div className="flex items-center justify-center mt-8"> | |
<Container withInternalPadding className="bg-white/20 backdrop-blur-sm rounded-lg p-4"> | |
<div className="flex items-center gap-4"> | |
{hasPrevious && ( | |
<Container withInternalPadding className="rounded-md p-2"> | |
<Button | |
name="previous-item" | |
variant="tertiary" | |
size={1} | |
className={`bg-white/90 hover:bg-white shadow-lg border border-gray-200 ${ | |
isNavigating ? 'opacity-50 cursor-not-allowed' : 'hover:scale-110' | |
}`} | |
onClick={() => navigateToItem('previous')} | |
disabled={isNavigating} | |
> | |
<div className="flex items-center gap-1"> | |
<div className="flex -space-x-1"> | |
<ChevronLeftLineIcon className="w-4 h-4" /> | |
<ChevronLeftLineIcon className="w-4 h-4" /> | |
</div> | |
<span className="font-semibold">Previous</span> | |
</div> | |
</Button> | |
</Container> | |
)} | |
{/* Delete Button - Admin Only */} | |
{isAuthenticated && ( | |
<Container withInternalPadding className="rounded-md p-2"> | |
<Button | |
name="delete" | |
variant="tertiary" | |
size={1} | |
className="bg-red-50 hover:bg-red-100 text-red-700 border border-red-200 hover:border-red-300" | |
onClick={handleDelete} | |
title="Delete" | |
aria-label="Delete saved image" | |
> | |
<DeleteBinLineIcon className="w-4 h-4" /> | |
</Button> | |
</Container> | |
)} | |
<Container withInternalPadding className="rounded-md p-2"> | |
<Button | |
name="contribute" | |
onClick={handleContribute} | |
> | |
Contribute | |
</Button> | |
</Container> | |
{/* Star Toggle Button - Admin Only */} | |
{isAuthenticated && ( | |
<Container withInternalPadding className="rounded-md p-2"> | |
<Button | |
name="toggle-star" | |
variant="tertiary" | |
size={1} | |
className={`${ | |
map?.starred | |
? 'bg-red-100 hover:bg-red-200 text-red-800 border-2 border-red-400' | |
: 'bg-gray-100 hover:bg-gray-200 text-gray-600 border-2 border-gray-300' | |
} w-16 h-8 rounded-full transition-all duration-200 flex items-center justify-center`} | |
onClick={toggleStarred} | |
title={map?.starred ? 'Unstar image' : 'Star image'} | |
aria-label={map?.starred ? 'Unstar image' : 'Star image'} | |
> | |
<span className={`text-lg transition-all duration-200 ${map?.starred ? 'text-red-600' : 'text-gray-500'}`}> | |
{map?.starred ? '★' : '☆'} | |
</span> | |
</Button> | |
</Container> | |
)} | |
{hasNext && ( | |
<Container withInternalPadding className="rounded-md p-2"> | |
<Button | |
name="next-item" | |
variant="tertiary" | |
size={1} | |
className={`bg-white/90 hover:bg-white shadow-lg border border-gray-200 ${ | |
isNavigating ? 'opacity-50 cursor-not-allowed' : 'hover:scale-110' | |
}`} | |
onClick={() => navigateToItem('next')} | |
disabled={isNavigating} | |
> | |
<div className="flex items-center gap-1"> | |
<span className="font-semibold">Next</span> | |
<div className="flex -space-x-1"> | |
<ChevronRightLineIcon className="w-4 h-4" /> | |
<ChevronRightLineIcon className="w-4 h-4" /> | |
</div> | |
</div> | |
</Button> | |
</Container> | |
)} | |
</div> | |
</Container> | |
</div> | |
</> | |
) : ( | |
<div className="text-center py-12"> | |
<div className="text-xl font-semibold text-gray-600 mb-4"> | |
No matches found | |
</div> | |
<div className="mt-4"> | |
<Button | |
name="clear-filters" | |
variant="secondary" | |
onClick={clearAllFilters} | |
> | |
Clear Filters | |
</Button> | |
</div> | |
</div> | |
)} | |
</div> | |
) : null} | |
</div> | |
{/* Delete Confirmation Modal */} | |
{showDeleteConfirm && ( | |
<div className={styles.fullSizeModalOverlay} onClick={() => setShowDeleteConfirm(false)}> | |
<div className={styles.fullSizeModalContent} onClick={(e) => e.stopPropagation()}> | |
<div className={styles.ratingWarningContent}> | |
<h3 className={styles.ratingWarningTitle}>Delete Image?</h3> | |
<p className={styles.ratingWarningText}> | |
This action cannot be undone. Are you sure you want to delete this saved image and all related data? | |
</p> | |
<div className={styles.ratingWarningButtons}> | |
<Button | |
name="confirm-delete" | |
variant="secondary" | |
onClick={confirmDelete} | |
> | |
Delete | |
</Button> | |
<Button | |
name="cancel-delete" | |
variant="tertiary" | |
onClick={() => setShowDeleteConfirm(false)} | |
> | |
Cancel | |
</Button> | |
</div> | |
</div> | |
</div> | |
</div> | |
)} | |
{/* Export Selection Modal */} | |
{showExportModal && ( | |
<ExportModal | |
isOpen={showExportModal} | |
onClose={() => { | |
setShowExportModal(false); | |
setExportSuccess(false); | |
setIsExporting(false); | |
}} | |
onExport={(mode, selectedTypes) => { | |
if (selectedTypes.includes(map.image_type)) { | |
exportDataset(mode); | |
} | |
}} | |
filteredCount={1} | |
totalCount={1} | |
hasFilters={false} | |
crisisMapsCount={map.image_type === 'crisis_map' ? 1 : 0} | |
droneImagesCount={map.image_type === 'drone_image' ? 1 : 0} | |
isLoading={isExporting} | |
exportSuccess={exportSuccess} | |
variant="single" | |
onNavigateToList={() => { | |
setShowExportModal(false); | |
navigate('/explore'); | |
}} | |
onNavigateAndExport={() => { | |
setShowExportModal(false); | |
navigate('/explore?export=true'); | |
}} | |
/> | |
)} | |
{/* Full Size Image Modal */} | |
<FullSizeImageModal | |
isOpen={showFullSizeModal} | |
imageUrl={selectedImageForModal?.image_url || null} | |
preview={null} | |
selectedImageData={null} | |
onClose={handleCloseFullSizeModal} | |
isLoading={isLoadingFullSizeImage} | |
/> | |
</PageContainer> | |
); | |
} |