SCGR's picture
text box fix
99eb145
raw
history blame
53.1 kB
import { useState, useEffect, useRef, useCallback } from 'react';
import { PageContainer, Heading, Button, Spinner, IconButton } from '@ifrc-go/ui';
import { DeleteBinLineIcon} from '@ifrc-go/icons';
import { useSearchParams, useNavigate } from 'react-router-dom';
import styles from './UploadPage.module.css';
import {
FileUploadSection,
ImagePreviewSection,
MetadataFormSection,
RatingSection,
GeneratedTextSection,
FullSizeImageModal,
RatingWarningModal,
DeleteConfirmModal,
NavigationConfirmModal,
FallbackNotificationModal,
PreprocessingNotificationModal,
PreprocessingModal,
UnsupportedFormatModal,
FileSizeWarningModal,
} from '../../components';
const SELECTED_MODEL_KEY = 'selectedVlmModel';
export default function UploadPage() {
// Safely get search params with fallback
let searchParams: URLSearchParams;
let navigate: any;
try {
[searchParams] = useSearchParams();
navigate = useNavigate();
} catch (error) {
console.warn('Router context not available, using fallback:', error);
searchParams = new URLSearchParams();
navigate = () => {};
}
const [step, setStep] = useState<1 | '2a' | '2b' | 3>(1);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingContribution, setIsLoadingContribution] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const stepRef = useRef(step);
const uploadedImageIdRef = useRef<string | null>(null);
const [preview, setPreview] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [files, setFiles] = useState<File[]>([]);
const [source, setSource] = useState('');
const [eventType, setEventType] = useState('');
const [epsg, setEpsg] = useState('');
const [imageType, setImageType] = useState('crisis_map');
const [countries, setCountries] = useState<string[]>([]);
const [title, setTitle] = useState('');
// Drone metadata fields
const [centerLon, setCenterLon] = useState<string>('');
const [centerLat, setCenterLat] = useState<string>('');
const [amslM, setAmslM] = useState<string>('');
const [aglM, setAglM] = useState<string>('');
const [headingDeg, setHeadingDeg] = useState<string>('');
const [yawDeg, setYawDeg] = useState<string>('');
const [pitchDeg, setPitchDeg] = useState<string>('');
const [rollDeg, setRollDeg] = useState<string>('');
const [rtkFix, setRtkFix] = useState<boolean>(false);
const [stdHM, setStdHM] = useState<string>('');
const [stdVM, setStdVM] = useState<string>('');
// Multi-image metadata arrays
const [metadataArray, setMetadataArray] = useState<Array<{
source: string;
eventType: string;
epsg: string;
countries: string[];
centerLon: string;
centerLat: string;
amslM: string;
aglM: string;
headingDeg: string;
yawDeg: string;
pitchDeg: string;
rollDeg: string;
rtkFix: boolean;
stdHM: string;
stdVM: string;
}>>([]);
const [sources, setSources] = useState<{s_code: string, label: string}[]>([]);
const [types, setTypes] = useState<{t_code: string, label: string}[]>([]);
const [spatialReferences, setSpatialReferences] = useState<{epsg: string, srid: string, proj4: string, wkt: string}[]>([]);
const [imageTypes, setImageTypes] = useState<{image_type: string, label: string}[]>([]);
const [countriesOptions, setCountriesOptions] = useState<{c_code: string, label: string, r_code: string}[]>([]);
const [uploadedImageId, setUploadedImageId] = useState<string | null>(null);
const [uploadedImageIds, setUploadedImageIds] = useState<string[]>([]);
const [imageUrl, setImageUrl] = useState<string|null>(null);
const [draft, setDraft] = useState('');
const [description, setDescription] = useState('');
const [analysis, setAnalysis] = useState('');
const [recommendedActions, setRecommendedActions] = useState('');
const [scores, setScores] = useState({ accuracy: 50, context: 50, usability: 50 });
// Modal states
const [isFullSizeModalOpen, setIsFullSizeModalOpen] = useState(false);
const [selectedImageData, setSelectedImageData] = useState<{ file: File; index: number } | null>(null);
const [isPerformanceConfirmed, setIsPerformanceConfirmed] = useState(false);
const [showRatingWarning, setShowRatingWarning] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showNavigationConfirm, setShowNavigationConfirm] = useState(false);
const [pendingNavigation, setPendingNavigation] = useState<string | null>(null);
const [showFallbackNotification, setShowFallbackNotification] = useState(false);
const [fallbackInfo, setFallbackInfo] = useState<{
originalModel: string;
fallbackModel: string;
reason: string;
} | null>(null);
const [showPreprocessingNotification, setShowPreprocessingNotification] = useState(false);
const [preprocessingInfo, setPreprocessingInfo] = useState<{
original_filename: string;
processed_filename: string;
original_mime_type: string;
processed_mime_type: string;
was_preprocessed: boolean;
error?: string;
} | null>(null);
const [showPreprocessingModal, setShowPreprocessingModal] = useState(false);
const [preprocessingFile, setPreprocessingFile] = useState<File | null>(null);
const [isPreprocessing, setIsPreprocessing] = useState(false);
const [preprocessingProgress, setPreprocessingProgress] = useState<string>('');
const [showUnsupportedFormatModal, setShowUnsupportedFormatModal] = useState(false);
const [unsupportedFile, setUnsupportedFile] = useState<File | null>(null);
const [showFileSizeWarningModal, setShowFileSizeWarningModal] = useState(false);
const [oversizedFile, setOversizedFile] = useState<File | null>(null);
// Carousel state for multi-upload in step 2b
const [currentImageIndex, setCurrentImageIndex] = useState(0);
stepRef.current = step;
uploadedImageIdRef.current = uploadedImageId;
// Helper function to convert source label to code
const convertSourceToCode = (sourceValue: string): string => {
if (!sourceValue || sources.length === 0) return sourceValue;
// Find matching source by code or label
const sourceObj = sources.find(s => s.s_code === sourceValue || s.label === sourceValue);
return sourceObj?.s_code || sourceValue;
};
// Event handlers
const handleSourceChange = (value: string | undefined) => {
setSource(convertSourceToCode(value || ''));
};
const handleEventTypeChange = (value: string | undefined) => setEventType(value || '');
const handleEpsgChange = (value: string | undefined) => setEpsg(value || '');
const handleImageTypeChange = (value: string | undefined) => setImageType(value || '');
const handleCountriesChange = (value: string[] | undefined) => setCountries(Array.isArray(value) ? value : []);
const handleCenterLonChange = (value: string | undefined) => setCenterLon(value || '');
const handleCenterLatChange = (value: string | undefined) => setCenterLat(value || '');
const handleAmslMChange = (value: string | undefined) => setAmslM(value || '');
const handleAglMChange = (value: string | undefined) => setAglM(value || '');
const handleHeadingDegChange = (value: string | undefined) => setHeadingDeg(value || '');
const handleYawDegChange = (value: string | undefined) => setYawDeg(value || '');
const handlePitchDegChange = (value: string | undefined) => setPitchDeg(value || '');
const handleRollDegChange = (value: string | undefined) => setRollDeg(value || '');
const handleRtkFixChange = (value: boolean | undefined) => setRtkFix(value || false);
const handleStdHMChange = (value: string | undefined) => setStdHM(value || '');
const handleStdVMChange = (value: string | undefined) => setStdVM(value || '');
const handleStepChange = (newStep: 1 | '2a' | '2b' | 3) => setStep(newStep);
// Carousel navigation functions for step 2b
const goToPrevious = useCallback(() => {
if (files.length > 1) {
setCurrentImageIndex((prev: number) => (prev > 0 ? prev - 1 : files.length - 1));
}
}, [files.length]);
const goToNext = useCallback(() => {
if (files.length > 1) {
setCurrentImageIndex((prev: number) => (prev < files.length - 1 ? prev + 1 : 0));
}
}, [files.length]);
const goToImage = useCallback((index: number) => {
if (index >= 0 && index < files.length) {
setCurrentImageIndex(index);
}
}, [files.length]);
// Multi-image functions
const addImage = () => {
if (files.length < 5) {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.jpg,.jpeg,.png,.tiff,.tif,.heic,.heif,.webp,.gif,.pdf';
input.onchange = (e) => {
const target = e.target as HTMLInputElement;
if (target.files && target.files[0]) {
const newFile = target.files[0];
onFileChange(newFile);
}
};
input.click();
}
};
const removeImage = (index: number) => {
setFiles(prev => {
const newFiles = prev.filter((_, i) => i !== index);
// If we're back to single file, update the single file state
if (newFiles.length === 1) {
setFile(newFiles[0]);
} else if (newFiles.length === 0) {
setFile(null);
}
return newFiles;
});
setMetadataArray(prev => prev.filter((_, i) => i !== index));
};
const updateMetadataForImage = (index: number, field: string, value: any) => {
setMetadataArray(prev => {
const newArray = [...prev];
if (!newArray[index]) {
newArray[index] = {
source: '', eventType: '', epsg: '', countries: [],
centerLon: '', centerLat: '', amslM: '', aglM: '',
headingDeg: '', yawDeg: '', pitchDeg: '', rollDeg: '',
rtkFix: false, stdHM: '', stdVM: ''
};
}
if (field === 'source') {
newArray[index] = { ...newArray[index], [field]: convertSourceToCode(value) };
} else {
newArray[index] = { ...newArray[index], [field]: value };
}
return newArray;
});
};
// File handling functions
const needsPreprocessing = (file: File): boolean => {
const supportedTypes = ['image/jpeg', 'image/jpg', 'image/png'];
const supportedExtensions = ['.jpg', '.jpeg', '.png'];
let needsPreprocess = !supportedTypes.includes(file.type);
if (!needsPreprocess && file.name) {
const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.'));
needsPreprocess = !supportedExtensions.includes(fileExtension);
}
return needsPreprocess;
};
const isCompletelyUnsupported = (file: File): boolean => {
const completelyUnsupportedTypes = [
'text/html', 'text/css', 'application/javascript', 'application/json',
'text/plain', 'application/xml', 'text/xml', 'application/zip',
'application/x-zip-compressed', 'application/x-rar-compressed',
'application/x-7z-compressed', 'audio/', 'video/', 'text/csv',
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
];
for (const unsupportedType of completelyUnsupportedTypes) {
if (file.type.startsWith(unsupportedType)) {
return true;
}
}
if (file.name) {
const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.'));
const unsupportedExtensions = [
'.html', '.htm', '.css', '.js', '.json', '.txt', '.xml',
'.zip', '.rar', '.7z', '.csv', '.xlsx', '.xls', '.pptx',
'.ppt', '.docx', '.doc', '.mp3', '.mp4', '.avi', '.mov'
];
if (unsupportedExtensions.includes(fileExtension)) {
return true;
}
}
return false;
};
const onFileChange = (file: File | undefined) => {
if (file) {
console.log('File selected:', file.name, 'Type:', file.type, 'Size:', file.size);
const fileSizeMB = file.size / (1024 * 1024);
if (fileSizeMB > 5) {
console.log('File too large, showing size warning modal');
setOversizedFile(file);
setShowFileSizeWarningModal(true);
}
if (isCompletelyUnsupported(file)) {
console.log('File format not supported at all, showing unsupported format modal');
setUnsupportedFile(file);
setShowUnsupportedFormatModal(true);
return;
}
if (needsPreprocessing(file)) {
console.log('File needs preprocessing, showing modal');
setPreprocessingFile(file);
setShowPreprocessingModal(true);
} else {
console.log('File does not need preprocessing, setting directly');
// If this is the first file, set it as the single file
if (files.length === 0) {
setFile(file);
setFiles([file]);
} else {
// If files already exist, add to the array (multi-upload mode)
setFiles(prev => [...prev, file]);
}
}
}
};
const onChangeFile = (file: File | undefined) => {
if (file) {
console.log('File changed:', file.name, 'Type:', file.type, 'Size:', file.size);
const fileSizeMB = file.size / (1024 * 1024);
if (fileSizeMB > 5) {
console.log('File too large, showing size warning modal');
setOversizedFile(file);
setShowFileSizeWarningModal(true);
}
if (isCompletelyUnsupported(file)) {
console.log('File format not supported at all, showing unsupported format modal');
setUnsupportedFile(file);
setShowUnsupportedFormatModal(true);
return;
}
if (needsPreprocessing(file)) {
console.log('File needs preprocessing, showing modal');
setPreprocessingFile(file);
setShowPreprocessingModal(true);
} else {
console.log('File does not need preprocessing, replacing last file');
// Replace only the last file in the array
if (files.length > 1) {
setFiles(prev => {
const newFiles = [...prev];
newFiles[newFiles.length - 1] = file;
return newFiles;
});
// Update single file state if it's a single upload
if (files.length === 1) {
setFile(file);
}
} else {
// If only one file, replace it normally
setFile(file);
setFiles([file]);
}
}
}
};
// API functions
async function readJsonSafely(res: Response): Promise<Record<string, unknown>> {
const text = await res.text();
try {
return text ? JSON.parse(text) : {};
} catch {
return { error: text };
}
}
function handleApiError(err: unknown, operation: string) {
const message = err instanceof Error ? err.message : `Failed to ${operation.toLowerCase()}`;
alert(message);
}
async function handleGenerate() {
if (files.length === 0) return;
setIsLoading(true);
try {
if (files.length === 1) {
await handleSingleUpload();
} else {
await handleMultiUpload();
}
} catch (err) {
handleApiError(err, 'Upload');
} finally {
setIsLoading(false);
}
}
async function handleSingleUpload() {
console.log('DEBUG: Starting single image upload');
const fd = new FormData();
fd.append('file', files[0]);
fd.append('title', title);
fd.append('image_type', imageType);
// Add metadata for single image
if (source) fd.append('source', source);
if (eventType) fd.append('event_type', eventType);
if (epsg) fd.append('epsg', epsg);
if (countries.length > 0) {
countries.forEach(c => fd.append('countries', c));
}
if (imageType === 'drone_image') {
if (centerLon) fd.append('center_lon', centerLon);
if (centerLat) fd.append('center_lat', centerLat);
if (amslM) fd.append('amsl_m', amslM);
if (aglM) fd.append('agl_m', aglM);
if (headingDeg) fd.append('heading_deg', headingDeg);
if (yawDeg) fd.append('yaw_deg', yawDeg);
if (pitchDeg) fd.append('pitch_deg', pitchDeg);
if (rollDeg) fd.append('roll_deg', rollDeg);
if (rtkFix) fd.append('rtk_fix', rtkFix.toString());
if (stdHM) fd.append('std_h_m', stdHM);
if (stdVM) fd.append('std_v_m', stdVM);
}
const modelName = localStorage.getItem(SELECTED_MODEL_KEY);
if (modelName) {
fd.append('model_name', modelName);
}
const mapRes = await fetch('/api/images/', { method: 'POST', body: fd });
const mapJson = await readJsonSafely(mapRes);
if (!mapRes.ok) throw new Error((mapJson.error as string) || 'Upload failed');
console.log('DEBUG: Single upload response:', mapJson);
await processUploadResponse(mapJson, false);
}
async function handleMultiUpload() {
console.log('DEBUG: Starting multi-image upload');
const fd = new FormData();
files.forEach(file => fd.append('files', file));
fd.append('title', title);
fd.append('image_type', imageType);
// Add metadata for each image
metadataArray.forEach((metadata, index) => {
if (metadata.source) fd.append(`source_${index}`, metadata.source);
if (metadata.eventType) fd.append(`event_type_${index}`, metadata.eventType);
if (metadata.epsg) fd.append(`epsg_${index}`, metadata.epsg);
if (metadata.countries.length > 0) {
metadata.countries.forEach(c => fd.append(`countries_${index}`, c));
}
if (imageType === 'drone_image') {
if (metadata.centerLon) fd.append(`center_lon_${index}`, metadata.centerLon);
if (metadata.centerLat) fd.append(`center_lat_${index}`, metadata.centerLat);
if (metadata.amslM) fd.append(`amsl_m_${index}`, metadata.amslM);
if (metadata.aglM) fd.append(`agl_m_${index}`, metadata.aglM);
if (metadata.headingDeg) fd.append(`heading_deg_${index}`, metadata.headingDeg);
if (metadata.yawDeg) fd.append(`yaw_deg_${index}`, metadata.yawDeg);
if (metadata.pitchDeg) fd.append(`pitch_deg_${index}`, metadata.pitchDeg);
if (metadata.rollDeg) fd.append(`roll_deg_${index}`, metadata.rollDeg);
if (metadata.rtkFix) fd.append(`rtk_fix_${index}`, metadata.rtkFix.toString());
if (metadata.stdHM) fd.append(`std_h_m_${index}`, metadata.stdHM);
if (metadata.stdVM) fd.append(`std_v_m_${index}`, metadata.stdVM);
}
});
const modelName = localStorage.getItem(SELECTED_MODEL_KEY);
if (modelName) {
fd.append('model_name', modelName);
}
const mapRes = await fetch('/api/images/multi', { method: 'POST', body: fd });
const mapJson = await readJsonSafely(mapRes);
if (!mapRes.ok) throw new Error((mapJson.error as string) || 'Upload failed');
console.log('DEBUG: Multi upload response:', mapJson);
await processUploadResponse(mapJson, true);
}
async function processUploadResponse(mapJson: Record<string, unknown>, isMultiUpload: boolean) {
setImageUrl(mapJson.image_url as string);
if (mapJson.preprocessing_info &&
typeof mapJson.preprocessing_info === 'object' &&
'was_preprocessed' in mapJson.preprocessing_info &&
mapJson.preprocessing_info.was_preprocessed === true) {
setPreprocessingInfo(mapJson.preprocessing_info as any);
setShowPreprocessingNotification(true);
}
const mapIdVal = mapJson.image_id as string;
if (!mapIdVal) throw new Error('Upload failed: image_id not found');
setUploadedImageId(mapIdVal);
// Store image IDs
if (isMultiUpload) {
if (mapJson.image_ids && Array.isArray(mapJson.image_ids)) {
const imageIds = mapJson.image_ids as string[];
console.log('DEBUG: Storing image IDs for multi-upload:', imageIds);
setUploadedImageIds(imageIds);
} else {
console.log('DEBUG: Multi-upload but no image_ids found, using single ID');
setUploadedImageIds([mapIdVal]);
}
} else {
console.log('DEBUG: Storing single image ID:', mapIdVal);
setUploadedImageIds([mapIdVal]);
}
const capJson = mapJson;
// Check for fallback information in the response
const rawJson = capJson.raw_json as Record<string, unknown>;
const fallbackUsed = rawJson?.fallback_used;
const originalModel = rawJson?.original_model as string;
const fallbackReason = rawJson?.fallback_reason as string;
const currentModel = capJson.model as string;
if (fallbackUsed && originalModel && fallbackReason) {
setFallbackInfo({
originalModel: originalModel,
fallbackModel: currentModel,
reason: fallbackReason
});
setShowFallbackNotification(true);
}
const extractedMetadata = (capJson.raw_json as Record<string, unknown>)?.metadata;
if (extractedMetadata) {
const metadata = (extractedMetadata as Record<string, unknown>).metadata || extractedMetadata;
if (metadata && typeof metadata === 'object') {
const newMetadataArray = [];
if (isMultiUpload) {
// Try to get individual image metadata first
const metadataImages = (metadata as Record<string, unknown>).metadata_images;
if (metadataImages && typeof metadataImages === 'object') {
// Parse individual image metadata
for (let i = 1; i <= files.length; i++) {
const imageKey = `image${i}`;
const imageMetadata = (metadataImages as Record<string, unknown>)[imageKey];
if (imageMetadata && typeof imageMetadata === 'object') {
const imgMeta = imageMetadata as Record<string, unknown>;
// Convert source label to code if needed
const sourceValue = imgMeta.source as string || '';
const sourceCode = convertSourceToCode(sourceValue);
newMetadataArray.push({
source: sourceCode,
eventType: imgMeta.type as string || '',
epsg: imgMeta.epsg as string || '',
countries: Array.isArray(imgMeta.countries) ? imgMeta.countries as string[] : [],
centerLon: '', centerLat: '', amslM: '', aglM: '',
headingDeg: '', yawDeg: '', pitchDeg: '', rollDeg: '',
rtkFix: false, stdHM: '', stdVM: ''
});
} else {
// Fallback to empty metadata for this image
newMetadataArray.push({
source: '', eventType: '', epsg: '', countries: [],
centerLon: '', centerLat: '', amslM: '', aglM: '',
headingDeg: '', yawDeg: '', pitchDeg: '', rollDeg: '',
rtkFix: false, stdHM: '', stdVM: ''
});
}
}
} else {
// Fallback to shared metadata if no individual metadata found
// Convert source label to code if needed
const sourceValue = (metadata as Record<string, unknown>).source as string || '';
const sourceCode = convertSourceToCode(sourceValue);
const sharedMetadata = {
source: sourceCode,
eventType: (metadata as Record<string, unknown>).type as string || '',
epsg: (metadata as Record<string, unknown>).epsg as string || '',
countries: Array.isArray((metadata as Record<string, unknown>).countries)
? (metadata as Record<string, unknown>).countries as string[]
: [],
centerLon: '', centerLat: '', amslM: '', aglM: '',
headingDeg: '', yawDeg: '', pitchDeg: '', rollDeg: '',
rtkFix: false, stdHM: '', stdVM: ''
};
// Create metadata array with shared data for all images
for (let i = 0; i < files.length; i++) {
newMetadataArray.push({ ...sharedMetadata });
}
}
} else {
// Single upload: use shared metadata
// Convert source label to code if needed
const sourceValue = (metadata as Record<string, unknown>).source as string || '';
const sourceCode = convertSourceToCode(sourceValue);
const sharedMetadata = {
source: sourceCode,
eventType: (metadata as Record<string, unknown>).type as string || '',
epsg: (metadata as Record<string, unknown>).epsg as string || '',
countries: Array.isArray((metadata as Record<string, unknown>).countries)
? (metadata as Record<string, unknown>).countries as string[]
: [],
centerLon: '', centerLat: '', amslM: '', aglM: '',
headingDeg: '', yawDeg: '', pitchDeg: '', rollDeg: '',
rtkFix: false, stdHM: '', stdVM: ''
};
newMetadataArray.push(sharedMetadata);
}
setMetadataArray(newMetadataArray);
if (newMetadataArray.length > 0) {
const firstMeta = newMetadataArray[0];
// Set shared title from metadata
if (metadata && typeof metadata === 'object') {
const sharedTitle = (metadata as Record<string, unknown>).title;
if (sharedTitle) {
setTitle(sharedTitle as string || '');
}
}
setSource(firstMeta.source || '');
setEventType(firstMeta.eventType || '');
setEpsg(firstMeta.epsg || '');
setCountries(firstMeta.countries || []);
if (imageType === 'drone_image') {
setCenterLon(firstMeta.centerLon || '');
setCenterLat(firstMeta.centerLat || '');
setAmslM(firstMeta.amslM || '');
setAglM(firstMeta.aglM || '');
setHeadingDeg(firstMeta.headingDeg || '');
setYawDeg(firstMeta.yawDeg || '');
setPitchDeg(firstMeta.pitchDeg || '');
setRollDeg(firstMeta.rollDeg || '');
setRtkFix(firstMeta.rtkFix || false);
setStdHM(firstMeta.stdHM || '');
setStdVM(firstMeta.stdVM || '');
}
}
}
}
// Extract the three parts from the metadata
const extractedMetadataForParts = (capJson.raw_json as Record<string, unknown>)?.metadata;
if (extractedMetadataForParts) {
if ((extractedMetadataForParts as Record<string, unknown>).description) {
setDescription((extractedMetadataForParts as Record<string, unknown>).description as string);
}
if ((extractedMetadataForParts as Record<string, unknown>).analysis) {
setAnalysis((extractedMetadataForParts as Record<string, unknown>).analysis as string);
}
if ((extractedMetadataForParts as Record<string, unknown>).recommended_actions) {
setRecommendedActions((extractedMetadataForParts as Record<string, unknown>).recommended_actions as string);
}
}
if (capJson.generated) {
setDraft(capJson.generated as string);
}
handleStepChange('2a');
}
async function handleSubmit() {
console.log('handleSubmit called with:', { uploadedImageId, title, draft });
if (!uploadedImageId) return alert("No image to submit");
if (!isPerformanceConfirmed) {
setShowRatingWarning(true);
return;
}
setIsSubmitting(true);
try {
// Use stored image IDs for multi-image uploads
const imageIds = uploadedImageIds.length > 0 ? uploadedImageIds : [uploadedImageId!];
console.log('DEBUG: Submit - Using image IDs:', imageIds);
console.log('DEBUG: Submit - uploadedImageIds:', uploadedImageIds);
console.log('DEBUG: Submit - uploadedImageId:', uploadedImageId);
// Update metadata for each image
for (let i = 0; i < imageIds.length; i++) {
const imageId = imageIds[i];
const metadata = metadataArray[i] || {
source: source || 'OTHER',
eventType: eventType || 'OTHER',
epsg: epsg || 'OTHER',
countries: countries || []
};
const metadataBody = {
source: imageType === 'drone_image' ? undefined : (metadata.source || 'OTHER'),
event_type: metadata.eventType || 'OTHER',
epsg: metadata.epsg || 'OTHER',
image_type: imageType,
countries: metadata.countries || [],
};
console.log(`Updating metadata for image ${i + 1}:`, metadataBody);
const metadataRes = await fetch(`/api/images/${imageId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(metadataBody),
});
const metadataJson = await readJsonSafely(metadataRes);
if (!metadataRes.ok) throw new Error((metadataJson.error as string) || `Metadata update failed for image ${i + 1}`);
}
const combinedContent = `Description: ${description}\n\nAnalysis: ${analysis}\n\nRecommended Actions: ${recommendedActions}`;
const captionBody = {
title: title,
edited: combinedContent,
accuracy: scores.accuracy,
context: scores.context,
usability: scores.usability,
};
console.log('Updating caption:', captionBody);
const captionRes = await fetch(`/api/images/${uploadedImageId}/caption`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(captionBody),
});
const captionJson = await readJsonSafely(captionRes);
if (!captionRes.ok) throw new Error((captionJson.error as string) || "Caption update failed");
setUploadedImageId(null);
setUploadedImageIds([]);
handleStepChange(3);
} catch (err) {
handleApiError(err, 'Submit');
} finally {
setIsSubmitting(false);
}
}
async function handleDelete() {
console.log('handleDelete called with uploadedImageId:', uploadedImageId);
if (!uploadedImageId) {
alert('No image to delete. Please try refreshing the page.');
return;
}
setShowDeleteConfirm(true);
}
async function confirmDelete() {
try {
console.log('Deleting image with ID:', uploadedImageId);
const res = await fetch(`/api/images/${uploadedImageId}`, {
method: "DELETE",
});
if (!res.ok) {
const json = await readJsonSafely(res);
throw new Error((json.error as string) || `Delete failed with status ${res.status}`);
}
setShowDeleteConfirm(false);
resetToStep1();
} catch (err) {
handleApiError(err, 'Delete');
}
}
const resetToStep1 = () => {
setIsPerformanceConfirmed(false);
setStep(1);
setFile(null);
setFiles([]);
setPreview(null);
setUploadedImageId(null);
setUploadedImageIds([]);
setImageUrl(null);
setTitle('');
setSource('');
setEventType('');
setEpsg('');
setCountries([]);
setCenterLon('');
setCenterLat('');
setAmslM('');
setAglM('');
setHeadingDeg('');
setYawDeg('');
setPitchDeg('');
setRollDeg('');
setRtkFix(false);
setStdHM('');
setStdVM('');
setScores({ accuracy: 50, context: 50, usability: 50 });
setDraft('');
setDescription('');
setAnalysis('');
setRecommendedActions('');
setMetadataArray([]);
setShowFallbackNotification(false);
setFallbackInfo(null);
setShowPreprocessingNotification(false);
setPreprocessingInfo(null);
setShowPreprocessingModal(false);
setPreprocessingFile(null);
setIsPreprocessing(false);
setPreprocessingProgress('');
setShowUnsupportedFormatModal(false);
setUnsupportedFile(null);
setShowFileSizeWarningModal(false);
setOversizedFile(null);
// Clear URL parameters to prevent re-triggering contribute workflow
navigate('/upload', { replace: true });
};
// Navigation handling
const handleNavigation = useCallback((to: string) => {
if (to === '/upload' || to === '/') {
return;
}
if (uploadedImageIdRef.current) {
setPendingNavigation(to);
setShowNavigationConfirm(true);
} else {
navigate(to);
}
}, [navigate]);
async function confirmNavigation() {
if (pendingNavigation && uploadedImageIdRef.current) {
try {
await fetch(`/api/images/${uploadedImageIdRef.current}`, { method: "DELETE" });
setShowNavigationConfirm(false);
setPendingNavigation(null);
navigate(pendingNavigation);
} catch (error) {
console.error('Failed to delete image before navigation:', error);
setShowNavigationConfirm(false);
setPendingNavigation(null);
navigate(pendingNavigation);
}
}
}
// Preprocessing handlers
const handlePreprocessingConfirm = async () => {
if (!preprocessingFile) return;
setIsPreprocessing(true);
setPreprocessingProgress('Starting file conversion...');
try {
const formData = new FormData();
formData.append('file', preprocessingFile);
formData.append('preprocess_only', 'true');
setPreprocessingProgress('Converting file format...');
const response = await fetch('/api/images/preprocess', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Preprocessing failed');
}
const result = await response.json();
setPreprocessingProgress('Finalizing conversion...');
const processedContent = atob(result.processed_content);
const processedBytes = new Uint8Array(processedContent.length);
for (let i = 0; i < processedContent.length; i++) {
processedBytes[i] = processedContent.charCodeAt(i);
}
const processedFile = new File(
[processedBytes],
result.processed_filename,
{ type: result.processed_mime_type }
);
const previewUrl = URL.createObjectURL(processedFile);
// If this is the first file, set it as the single file
if (files.length === 0) {
setFile(processedFile);
setFiles([processedFile]);
} else {
// If files already exist, add to the array (multi-upload mode)
setFiles(prev => [...prev, processedFile]);
}
setPreview(previewUrl);
setPreprocessingProgress('Conversion complete!');
setTimeout(() => {
setShowPreprocessingModal(false);
setPreprocessingFile(null);
setIsPreprocessing(false);
setPreprocessingProgress('');
}, 1000);
} catch (error) {
console.error('Preprocessing error:', error);
setPreprocessingProgress('Conversion failed. Please try again.');
setTimeout(() => {
setShowPreprocessingModal(false);
setPreprocessingFile(null);
setIsPreprocessing(false);
setPreprocessingProgress('');
}, 2000);
}
};
const handlePreprocessingCancel = () => {
setShowPreprocessingModal(false);
setPreprocessingFile(null);
setIsPreprocessing(false);
setPreprocessingProgress('');
};
// Fetch contributed images from database and convert to File objects
const fetchContributedImages = async (imageIds: string[]) => {
setIsLoadingContribution(true);
try {
const filePromises = imageIds.map(async (imageId) => {
// Fetch image data from the API
const response = await fetch(`/api/images/${imageId}`);
if (!response.ok) {
throw new Error(`Failed to fetch image ${imageId}`);
}
const imageData = await response.json();
// Fetch the actual image file
const fileResponse = await fetch(`/api/images/${imageId}/file`);
if (!fileResponse.ok) {
throw new Error(`Failed to fetch image file ${imageId}`);
}
const blob = await fileResponse.blob();
// Create a File object from the blob
const fileName = imageData.file_key.split('/').pop() || `contributed_${imageId}.png`;
const file = new File([blob], fileName, { type: blob.type });
return { file, imageData };
});
const contributedResults = await Promise.all(filePromises);
const contributedFiles = contributedResults.map(result => result.file);
const firstImageData = contributedResults[0]?.imageData;
setFiles(contributedFiles);
// Set the image IDs for submit process
setUploadedImageIds(imageIds);
if (imageIds.length === 1) {
setUploadedImageId(imageIds[0]);
}
// Set the first file as the main file for single upload compatibility
if (contributedFiles.length >= 1) {
setFile(contributedFiles[0]);
}
// Set the image type based on the contributed image's type
if (firstImageData?.image_type) {
setImageType(firstImageData.image_type);
}
// Stay on step 1 to show the images in the file upload section
} catch (error) {
console.error('Failed to fetch contributed images:', error);
alert(`Failed to load contributed images: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsLoadingContribution(false);
}
};
// Effects
useEffect(() => {
Promise.all([
fetch('/api/sources').then(r => r.json()),
fetch('/api/types').then(r => r.json()),
fetch('/api/spatial-references').then(r => r.json()),
fetch('/api/image-types').then(r => r.json()),
fetch('/api/countries').then(r => r.json()),
fetch('/api/models').then(r => r.json())
]).then(([sourcesData, typesData, spatialData, imageTypesData, countriesData, modelsData]) => {
if (!localStorage.getItem(SELECTED_MODEL_KEY) && modelsData?.length) {
localStorage.setItem(SELECTED_MODEL_KEY, modelsData[0].m_code);
}
setSources(sourcesData);
setTypes(typesData);
setSpatialReferences(spatialData);
setImageTypes(imageTypesData);
setCountriesOptions(countriesData);
if (sourcesData.length > 0) setSource(sourcesData[0].s_code);
setEventType('OTHER');
setEpsg('OTHER');
if (imageTypesData.length > 0 && !searchParams.get('imageType') && !imageType) {
setImageType(imageTypesData[0].image_type);
}
});
}, [searchParams, imageType]);
useEffect(() => {
window.confirmNavigationIfNeeded = (to: string) => {
handleNavigation(to);
};
return () => {
delete window.confirmNavigationIfNeeded;
};
}, [handleNavigation]);
useEffect(() => {
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (uploadedImageIdRef.current) {
const message = 'You have an uploaded image that will be deleted if you leave this page. Are you sure you want to leave?';
event.preventDefault();
event.returnValue = message;
return message;
}
};
const handleCleanup = () => {
if (uploadedImageIdRef.current) {
fetch(`/api/images/${uploadedImageIdRef.current}`, { method: "DELETE" }).catch(console.error);
}
};
const handleGlobalClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
const link = target.closest('a[href]') || target.closest('[data-navigate]');
if (link && uploadedImageIdRef.current) {
const href = link.getAttribute('href') || link.getAttribute('data-navigate');
if (href && href !== '#' && !href.startsWith('javascript:') && !href.startsWith('mailto:')) {
event.preventDefault();
event.stopPropagation();
handleNavigation(href);
}
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
document.addEventListener('click', handleGlobalClick, true);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
document.removeEventListener('click', handleGlobalClick, true);
handleCleanup();
};
}, [handleNavigation]);
useEffect(() => {
if (!file) {
setPreview(null);
return;
}
const url = URL.createObjectURL(file);
setPreview(url);
return () => URL.revokeObjectURL(url);
}, [file]);
// Handle contribute parameter - fetch images from database
useEffect(() => {
const contribute = searchParams.get('contribute');
const imageIds = searchParams.get('imageIds');
if (contribute === 'true' && imageIds) {
const ids = imageIds.split(',').filter(id => id.trim());
if (ids.length > 0) {
fetchContributedImages(ids);
}
}
}, [searchParams]);
// Reset carousel index when entering step 2b
useEffect(() => {
if (step === '2b') {
setCurrentImageIndex(0);
}
}, [step]);
// Render
return (
<PageContainer>
{step !== 3 && (
<div className="max-w-7xl mx-auto">
<div className={styles.uploadContainer} data-step={step}>
{/* Step 1: File Upload */}
{step === 1 && !searchParams.get('step') && !isLoadingContribution && (
<FileUploadSection
files={files}
file={file}
preview={preview}
imageType={imageType}
onFileChange={onFileChange}
onRemoveImage={removeImage}
onAddImage={addImage}
onImageTypeChange={handleImageTypeChange}
onChangeFile={onChangeFile}
/>
)}
{/* Step 1: Contributed Images Display */}
{step === 1 && searchParams.get('contribute') === 'true' && !isLoadingContribution && files.length > 0 && (
<FileUploadSection
files={files}
file={file}
preview={preview}
imageType={imageType}
onFileChange={onFileChange}
onRemoveImage={removeImage}
onAddImage={addImage}
onImageTypeChange={handleImageTypeChange}
onChangeFile={onChangeFile}
/>
)}
{/* Loading States */}
{isLoading && (
<div className={styles.loadingContainer}>
<Spinner className="text-ifrcRed" />
<p className={styles.loadingText}>Generating...</p>
</div>
)}
{isLoadingContribution && (
<div className={styles.loadingContainer}>
<Spinner className="text-ifrcRed" />
<p className={styles.loadingText}>Loading contribution...</p>
</div>
)}
{/* Generate Button */}
{((step === 1 && !isLoading && !isLoadingContribution) || (step === 1 && searchParams.get('contribute') === 'true' && !isLoading && !isLoadingContribution && files.length > 0)) && (
<div className={styles.generateButtonContainer}>
{imageUrl ? (
<Button
name="generate-from-url"
onClick={handleGenerate}
>
Generate Caption
</Button>
) : (
<Button
name="generate"
disabled={files.length === 0}
onClick={handleGenerate}
>
Generate
</Button>
)}
</div>
)}
{/* Step 2A: Metadata */}
{step === '2a' && (
<div className={styles.step2Layout}>
<div className={styles.mapColumn}>
<ImagePreviewSection
files={files}
imageUrl={imageUrl}
preview={preview}
onViewFullSize={(imageData) => {
setSelectedImageData(imageData || null);
setIsFullSizeModalOpen(true);
}}
/>
</div>
<div className={styles.contentColumn}>
<div className={styles.metadataSectionCard}>
<MetadataFormSection
files={files}
imageType={imageType}
title={title}
source={source}
eventType={eventType}
epsg={epsg}
countries={countries}
centerLon={centerLon}
centerLat={centerLat}
amslM={amslM}
aglM={aglM}
headingDeg={headingDeg}
yawDeg={yawDeg}
pitchDeg={pitchDeg}
rollDeg={rollDeg}
rtkFix={rtkFix}
stdHM={stdHM}
stdVM={stdVM}
metadataArray={metadataArray}
sources={sources}
types={types}
spatialReferences={spatialReferences}
imageTypes={imageTypes}
countriesOptions={countriesOptions}
onTitleChange={(value) => setTitle(value || '')}
onSourceChange={handleSourceChange}
onEventTypeChange={handleEventTypeChange}
onEpsgChange={handleEpsgChange}
onCountriesChange={handleCountriesChange}
onCenterLonChange={handleCenterLonChange}
onCenterLatChange={handleCenterLatChange}
onAmslMChange={handleAmslMChange}
onAglMChange={handleAglMChange}
onHeadingDegChange={handleHeadingDegChange}
onYawDegChange={handleYawDegChange}
onPitchDegChange={handlePitchDegChange}
onRollDegChange={handleRollDegChange}
onRtkFixChange={handleRtkFixChange}
onStdHMChange={handleStdHMChange}
onStdVMChange={handleStdVMChange}
onImageTypeChange={handleImageTypeChange}
updateMetadataForImage={updateMetadataForImage}
/>
<div className={styles.confirmSection}>
<IconButton
name="delete"
variant="tertiary"
onClick={handleDelete}
title="Delete"
ariaLabel="Delete uploaded image"
>
<DeleteBinLineIcon />
</IconButton>
<Button
name="confirm-metadata"
onClick={() => handleStepChange('2b')}
>
Next
</Button>
</div>
</div>
</div>
</div>
)}
{/* Step 2B: Rating and Generated Text */}
{step === '2b' && (
<div className={styles.step2bLayout}>
{/* Submit Loading State */}
{isSubmitting && (
<div className={styles.loadingContainer}>
<Spinner className="text-ifrcRed" />
<p className={styles.loadingText}>Submitting...</p>
</div>
)}
<div className={`${styles.topRow} ${isPerformanceConfirmed ? styles.ratingHidden : ''}`}>
<div className={styles.imageSection}>
<ImagePreviewSection
files={files}
imageUrl={imageUrl}
preview={preview}
onViewFullSize={(imageData) => {
setSelectedImageData(imageData || null);
setIsFullSizeModalOpen(true);
}}
currentImageIndex={currentImageIndex}
onGoToPrevious={goToPrevious}
onGoToNext={goToNext}
onGoToImage={goToImage}
showCarousel={true}
/>
</div>
{!isPerformanceConfirmed && (
<div className={styles.metadataSectionCard}>
<RatingSection
isPerformanceConfirmed={isPerformanceConfirmed}
scores={scores}
onScoreChange={(key, value) => setScores(prev => ({ ...prev, [key]: value }))}
onConfirmRatings={() => setIsPerformanceConfirmed(true)}
onEditRatings={() => setIsPerformanceConfirmed(false)}
/>
</div>
)}
</div>
<div className={styles.metadataSectionCard}>
<GeneratedTextSection
description={description}
analysis={analysis}
recommendedActions={recommendedActions}
onDescriptionChange={(value) => setDescription(value || '')}
onAnalysisChange={(value) => setAnalysis(value || '')}
onRecommendedActionsChange={(value) => setRecommendedActions(value || '')}
onBack={() => handleStepChange('2a')}
onDelete={handleDelete}
onSubmit={handleSubmit}
onEditRatings={() => setIsPerformanceConfirmed(false)}
isPerformanceConfirmed={isPerformanceConfirmed}
isSubmitting={isSubmitting}
/>
</div>
</div>
)}
</div>
</div>
)}
{/* Success page */}
{step === 3 && (
<div className={styles.successContainer}>
<Heading level={2} className={styles.successHeading}>Saved!</Heading>
<p className={styles.successText}>
{searchParams.get('contribute') === 'true'
? 'Your contribution has been successfully saved.'
: 'Your caption has been successfully saved.'
}
</p>
<div className={styles.successButton}>
<Button
name="upload-another"
onClick={() => {
resetToStep1();
}}
>
Upload Another
</Button>
</div>
</div>
)}
{/* Modals */}
<FullSizeImageModal
isOpen={isFullSizeModalOpen}
imageUrl={imageUrl}
preview={preview}
selectedImageData={selectedImageData}
onClose={() => {
setIsFullSizeModalOpen(false);
setSelectedImageData(null);
}}
/>
<RatingWarningModal
isOpen={showRatingWarning}
onClose={() => setShowRatingWarning(false)}
/>
<DeleteConfirmModal
isOpen={showDeleteConfirm}
onConfirm={confirmDelete}
onCancel={() => setShowDeleteConfirm(false)}
/>
<NavigationConfirmModal
isOpen={showNavigationConfirm}
onConfirm={confirmNavigation}
onCancel={() => setShowNavigationConfirm(false)}
/>
<FallbackNotificationModal
isOpen={showFallbackNotification}
fallbackInfo={fallbackInfo}
onClose={() => setShowFallbackNotification(false)}
/>
<PreprocessingNotificationModal
isOpen={showPreprocessingNotification}
preprocessingInfo={preprocessingInfo}
onClose={() => setShowPreprocessingNotification(false)}
/>
<PreprocessingModal
isOpen={showPreprocessingModal}
preprocessingFile={preprocessingFile}
isPreprocessing={isPreprocessing}
preprocessingProgress={preprocessingProgress}
onConfirm={handlePreprocessingConfirm}
onCancel={handlePreprocessingCancel}
/>
<UnsupportedFormatModal
isOpen={showUnsupportedFormatModal}
unsupportedFile={unsupportedFile}
onClose={() => setShowUnsupportedFormatModal(false)}
/>
<FileSizeWarningModal
isOpen={showFileSizeWarningModal}
oversizedFile={oversizedFile}
onClose={() => setShowFileSizeWarningModal(false)}
onCancel={() => setShowFileSizeWarningModal(false)}
/>
</PageContainer>
);
}