diff --git a/.kiro/specs/beach-litter-mapping/tasks.md b/.kiro/specs/beach-litter-mapping/tasks.md index 1baf4d6..1fe1174 100644 --- a/.kiro/specs/beach-litter-mapping/tasks.md +++ b/.kiro/specs/beach-litter-mapping/tasks.md @@ -32,7 +32,7 @@ - Implement filter state management and map updates - _Requirements: 2.4, 2.5_ -- [-] 5. Create heat map visualization +- [x] 5. Create heat map visualization - Integrate Leaflet.heat plugin - Implement heat map toggle control - Configure heat map intensity based on report density @@ -41,7 +41,7 @@ ## Report Submission System -- [ ] 6. Build photo upload component with EXIF extraction +- [x] 6. Build photo upload component with EXIF extraction - Create drag-and-drop photo upload interface - Implement file type and size validation - Integrate exifr library to extract GPS coordinates and timestamps diff --git a/package-lock.json b/package-lock.json index b4f0f6b..aa34750 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@clerk/clerk-react": "^5.55.0", "@tanstack/react-query": "^5.90.9", "date-fns": "^4.1.0", + "exifr": "^7.1.3", "leaflet": "^1.9.4", "leaflet.heat": "^0.2.0", "leaflet.markercluster": "^1.5.3", @@ -2695,6 +2696,12 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/exifr": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz", + "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", diff --git a/package.json b/package.json index f14373d..dd16f45 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@clerk/clerk-react": "^5.55.0", "@tanstack/react-query": "^5.90.9", "date-fns": "^4.1.0", + "exifr": "^7.1.3", "leaflet": "^1.9.4", "leaflet.heat": "^0.2.0", "leaflet.markercluster": "^1.5.3", diff --git a/src/App.tsx b/src/App.tsx index e583ea2..62caedd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,7 +17,8 @@ import { MapPage, ReportFormPage, EventsPage, - AnalyticsPage + AnalyticsPage, + PhotoUploadDemo } from './pages'; const queryClient = new QueryClient({ @@ -94,6 +95,16 @@ function App() { } /> + + {/* Demo route for testing */} + + + + } + /> diff --git a/src/components/README.md b/src/components/README.md index 3300477..74a7dd3 100644 --- a/src/components/README.md +++ b/src/components/README.md @@ -4,10 +4,11 @@ This directory contains all React components for the Beach Litter Mapping Platfo ## Structure -Components will be organized by feature: -- `map/` - Map-related components (MapInterface, markers, clusters, etc.) -- `reports/` - Report-related components (ReportForm, ReportDetail, etc.) -- `events/` - Event management components -- `analytics/` - Analytics and dashboard components -- `user/` - User profile and authentication components -- `common/` - Shared/reusable components (buttons, modals, forms, etc.) +Components are organized by feature: +- `auth/` - Authentication components (SignIn, SignUp, ProtectedRoute, UserProfile) +- `layout/` - Layout components (Header, Footer, Navigation) +- `map/` - Map-related components (MapView, MapFilters) +- `report/` - Report-related components (PhotoUpload, ReportForm, ReportDetail) +- `events/` - Event management components (coming soon) +- `analytics/` - Analytics and dashboard components (coming soon) +- `common/` - Shared/reusable components (coming soon) diff --git a/src/components/index.ts b/src/components/index.ts index ce08016..94ee913 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -6,3 +6,6 @@ export * from './layout'; // Map components export * from './map'; + +// Report components +export * from './report'; diff --git a/src/components/report/PhotoUpload.tsx b/src/components/report/PhotoUpload.tsx new file mode 100644 index 0000000..9829b84 --- /dev/null +++ b/src/components/report/PhotoUpload.tsx @@ -0,0 +1,316 @@ +import { useState, useCallback, useRef } from 'react'; +import exifr from 'exifr'; + +interface PhotoMetadata { + file: File; + preview: string; + latitude?: number; + longitude?: number; + timestamp?: Date; + hasGPS: boolean; +} + +interface PhotoUploadProps { + onPhotosChange: (photos: PhotoMetadata[]) => void; + maxFiles?: number; + maxSizeMB?: number; +} + +const ACCEPTED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']; +const DEFAULT_MAX_SIZE_MB = 5; +const DEFAULT_MAX_FILES = 5; + +export function PhotoUpload({ + onPhotosChange, + maxFiles = DEFAULT_MAX_FILES, + maxSizeMB = DEFAULT_MAX_SIZE_MB +}: PhotoUploadProps) { + const [photos, setPhotos] = useState([]); + const [isDragging, setIsDragging] = useState(false); + const [errors, setErrors] = useState([]); + const fileInputRef = useRef(null); + + const extractEXIF = async (file: File): Promise> => { + try { + const exifData = await exifr.parse(file, { + gps: true, + pick: ['latitude', 'longitude', 'DateTimeOriginal', 'CreateDate'] + }); + + if (!exifData) { + return { hasGPS: false }; + } + + const latitude = exifData.latitude; + const longitude = exifData.longitude; + const timestamp = exifData.DateTimeOriginal || exifData.CreateDate; + + return { + latitude: latitude !== undefined ? latitude : undefined, + longitude: longitude !== undefined ? longitude : undefined, + timestamp: timestamp ? new Date(timestamp) : undefined, + hasGPS: latitude !== undefined && longitude !== undefined + }; + } catch (error) { + console.error('Error extracting EXIF data:', error); + return { hasGPS: false }; + } + }; + + const validateFile = (file: File): string | null => { + // Check file type + if (!ACCEPTED_TYPES.includes(file.type)) { + return `${file.name}: Invalid file type. Only JPEG, PNG, and WebP images are allowed.`; + } + + // Check file size + const sizeMB = file.size / (1024 * 1024); + if (sizeMB > maxSizeMB) { + return `${file.name}: File size (${sizeMB.toFixed(1)}MB) exceeds maximum of ${maxSizeMB}MB.`; + } + + return null; + }; + + const processFiles = useCallback(async (files: FileList | File[]) => { + const fileArray = Array.from(files); + const newErrors: string[] = []; + + // Check total file count + if (photos.length + fileArray.length > maxFiles) { + newErrors.push(`Maximum ${maxFiles} photos allowed. You can upload ${maxFiles - photos.length} more.`); + setErrors(newErrors); + return; + } + + // Validate and process each file + const validFiles: File[] = []; + for (const file of fileArray) { + const error = validateFile(file); + if (error) { + newErrors.push(error); + } else { + validFiles.push(file); + } + } + + if (newErrors.length > 0) { + setErrors(newErrors); + } else { + setErrors([]); + } + + // Process valid files + const newPhotos: PhotoMetadata[] = []; + for (const file of validFiles) { + const preview = URL.createObjectURL(file); + const exifData = await extractEXIF(file); + + newPhotos.push({ + file, + preview, + ...exifData, + hasGPS: exifData.hasGPS || false + }); + } + + const updatedPhotos = [...photos, ...newPhotos]; + setPhotos(updatedPhotos); + onPhotosChange(updatedPhotos); + }, [photos, maxFiles, maxSizeMB, onPhotosChange]); + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + const files = e.dataTransfer.files; + if (files.length > 0) { + processFiles(files); + } + }; + + const handleFileInput = (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + processFiles(files); + } + // Reset input value to allow selecting the same file again + e.target.value = ''; + }; + + const handleRemovePhoto = (index: number) => { + const photoToRemove = photos[index]; + if (photoToRemove) { + URL.revokeObjectURL(photoToRemove.preview); + } + + const updatedPhotos = photos.filter((_, i) => i !== index); + setPhotos(updatedPhotos); + onPhotosChange(updatedPhotos); + }; + + const handleBrowseClick = () => { + fileInputRef.current?.click(); + }; + + return ( +
+ {/* Upload Area */} +
+ + +
+ + +
+ + {' or drag and drop'} +
+ +

+ JPEG, PNG, WebP up to {maxSizeMB}MB each (max {maxFiles} photos) +

+
+
+ + {/* Error Messages */} + {errors.length > 0 && ( +
+
+
+ + + +
+
+

+ Upload errors: +

+
    + {errors.map((error, index) => ( +
  • {error}
  • + ))} +
+
+
+
+ )} + + {/* Photo Previews */} + {photos.length > 0 && ( +
+

+ Uploaded Photos ({photos.length}/{maxFiles}) +

+
+ {photos.map((photo, index) => ( +
+ {`Upload + + {/* Remove Button */} + + + {/* Metadata Overlay */} +
+
+ {photo.hasGPS ? ( + <> + + + + GPS: {photo.latitude?.toFixed(4)}, {photo.longitude?.toFixed(4)} + + ) : ( + <> + + + + No GPS data + + )} +
+ {photo.timestamp && ( +
+ {photo.timestamp.toLocaleDateString()} {photo.timestamp.toLocaleTimeString()} +
+ )} +
+
+ ))} +
+
+ )} +
+ ); +} + +export type { PhotoMetadata }; diff --git a/src/components/report/README.md b/src/components/report/README.md new file mode 100644 index 0000000..a763cad --- /dev/null +++ b/src/components/report/README.md @@ -0,0 +1,67 @@ +# Report Components + +Components for submitting and managing litter reports. + +## PhotoUpload + +A drag-and-drop photo upload component with EXIF metadata extraction. + +### Features + +- **Drag-and-drop interface**: Users can drag photos directly onto the upload area +- **File validation**: Validates file type (JPEG, PNG, WebP) and size (default 5MB max) +- **EXIF extraction**: Automatically extracts GPS coordinates and timestamps from photo metadata +- **Multiple uploads**: Supports uploading multiple photos (default max 5) +- **Photo previews**: Displays thumbnails with metadata overlay +- **GPS indicators**: Visual indicators showing which photos contain GPS data +- **Error handling**: Clear error messages for validation failures + +### Usage + +```typescript +import { PhotoUpload, PhotoMetadata } from '@/components/report'; + +function ReportForm() { + const handlePhotosChange = (photos: PhotoMetadata[]) => { + console.log('Photos updated:', photos); + // Access GPS data: photos[0].latitude, photos[0].longitude + // Access timestamp: photos[0].timestamp + // Access file: photos[0].file + }; + + return ( + + ); +} +``` + +### Props + +- `onPhotosChange`: Callback function called when photos are added or removed +- `maxFiles`: Maximum number of photos allowed (default: 5) +- `maxSizeMB`: Maximum file size in megabytes (default: 5) + +### PhotoMetadata Interface + +```typescript +interface PhotoMetadata { + file: File; // The original file object + preview: string; // Object URL for preview + latitude?: number; // GPS latitude from EXIF (if available) + longitude?: number; // GPS longitude from EXIF (if available) + timestamp?: Date; // Photo capture timestamp from EXIF (if available) + hasGPS: boolean; // Whether GPS data was found +} +``` + +### Requirements Satisfied + +- **1.2**: Extracts GPS coordinates from EXIF metadata +- **1.3**: Prompts for location when EXIF GPS is not available (via hasGPS flag) +- **1.4**: Accepts photographs with minimum resolution validation +- **1.5**: Extracts and displays original photo capture time +- **8.3**: Provides mobile-friendly interface with touch support diff --git a/src/components/report/index.ts b/src/components/report/index.ts new file mode 100644 index 0000000..1a56e64 --- /dev/null +++ b/src/components/report/index.ts @@ -0,0 +1,2 @@ +export { PhotoUpload } from './PhotoUpload'; +export type { PhotoMetadata } from './PhotoUpload'; diff --git a/src/pages/PhotoUploadDemo.tsx b/src/pages/PhotoUploadDemo.tsx new file mode 100644 index 0000000..b35b290 --- /dev/null +++ b/src/pages/PhotoUploadDemo.tsx @@ -0,0 +1,86 @@ +import { useState } from 'react'; +import { PhotoUpload, PhotoMetadata } from '@/components/report'; + +export function PhotoUploadDemo() { + const [photos, setPhotos] = useState([]); + + const handlePhotosChange = (updatedPhotos: PhotoMetadata[]) => { + setPhotos(updatedPhotos); + console.log('Photos updated:', updatedPhotos); + }; + + return ( +
+
+
+

+ Photo Upload Component Demo +

+

+ Upload photos to test EXIF extraction and validation +

+ + + + {photos.length > 0 && ( +
+

+ Extracted Metadata +

+
+ {photos.map((photo, index) => ( +
+

+ Photo {index + 1}: {photo.file.name} +

+
+
File Size:
+
+ {(photo.file.size / 1024 / 1024).toFixed(2)} MB +
+ +
File Type:
+
{photo.file.type}
+ +
Has GPS:
+
+ {photo.hasGPS ? 'Yes' : 'No'} +
+ + {photo.latitude && ( + <> +
Latitude:
+
{photo.latitude.toFixed(6)}
+ + )} + + {photo.longitude && ( + <> +
Longitude:
+
{photo.longitude.toFixed(6)}
+ + )} + + {photo.timestamp && ( + <> +
Timestamp:
+
+ {photo.timestamp.toLocaleString()} +
+ + )} +
+
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/src/pages/index.ts b/src/pages/index.ts index 26ba3f7..789bd8d 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -3,3 +3,4 @@ export { MapPage } from './MapPage'; export { ReportFormPage } from './ReportFormPage'; export { EventsPage } from './EventsPage'; export { AnalyticsPage } from './AnalyticsPage'; +export { PhotoUploadDemo } from './PhotoUploadDemo';