feat: implement photo upload component with EXIF extraction
- Add PhotoUpload component with drag-and-drop interface - Integrate exifr library for GPS and timestamp extraction - Implement file validation (type, size) - Add photo previews with metadata overlay - Support multiple photo uploads (max 5 files, 5MB each) - Display GPS indicators and extracted metadata - Create demo page for testing component - Satisfies requirements 1.2, 1.3, 1.4, 1.5, 8.3
This commit is contained in:
@@ -32,7 +32,7 @@
|
|||||||
- Implement filter state management and map updates
|
- Implement filter state management and map updates
|
||||||
- _Requirements: 2.4, 2.5_
|
- _Requirements: 2.4, 2.5_
|
||||||
|
|
||||||
- [-] 5. Create heat map visualization
|
- [x] 5. Create heat map visualization
|
||||||
- Integrate Leaflet.heat plugin
|
- Integrate Leaflet.heat plugin
|
||||||
- Implement heat map toggle control
|
- Implement heat map toggle control
|
||||||
- Configure heat map intensity based on report density
|
- Configure heat map intensity based on report density
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
|
|
||||||
## Report Submission System
|
## 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
|
- Create drag-and-drop photo upload interface
|
||||||
- Implement file type and size validation
|
- Implement file type and size validation
|
||||||
- Integrate exifr library to extract GPS coordinates and timestamps
|
- Integrate exifr library to extract GPS coordinates and timestamps
|
||||||
|
|||||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"@clerk/clerk-react": "^5.55.0",
|
"@clerk/clerk-react": "^5.55.0",
|
||||||
"@tanstack/react-query": "^5.90.9",
|
"@tanstack/react-query": "^5.90.9",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"exifr": "^7.1.3",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet.heat": "^0.2.0",
|
"leaflet.heat": "^0.2.0",
|
||||||
"leaflet.markercluster": "^1.5.3",
|
"leaflet.markercluster": "^1.5.3",
|
||||||
@@ -2695,6 +2696,12 @@
|
|||||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@clerk/clerk-react": "^5.55.0",
|
"@clerk/clerk-react": "^5.55.0",
|
||||||
"@tanstack/react-query": "^5.90.9",
|
"@tanstack/react-query": "^5.90.9",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"exifr": "^7.1.3",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet.heat": "^0.2.0",
|
"leaflet.heat": "^0.2.0",
|
||||||
"leaflet.markercluster": "^1.5.3",
|
"leaflet.markercluster": "^1.5.3",
|
||||||
|
|||||||
13
src/App.tsx
13
src/App.tsx
@@ -17,7 +17,8 @@ import {
|
|||||||
MapPage,
|
MapPage,
|
||||||
ReportFormPage,
|
ReportFormPage,
|
||||||
EventsPage,
|
EventsPage,
|
||||||
AnalyticsPage
|
AnalyticsPage,
|
||||||
|
PhotoUploadDemo
|
||||||
} from './pages';
|
} from './pages';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -94,6 +95,16 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Demo route for testing */}
|
||||||
|
<Route
|
||||||
|
path="/demo/photo-upload"
|
||||||
|
element={
|
||||||
|
<Layout>
|
||||||
|
<PhotoUploadDemo />
|
||||||
|
</Layout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ This directory contains all React components for the Beach Litter Mapping Platfo
|
|||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
Components will be organized by feature:
|
Components are organized by feature:
|
||||||
- `map/` - Map-related components (MapInterface, markers, clusters, etc.)
|
- `auth/` - Authentication components (SignIn, SignUp, ProtectedRoute, UserProfile)
|
||||||
- `reports/` - Report-related components (ReportForm, ReportDetail, etc.)
|
- `layout/` - Layout components (Header, Footer, Navigation)
|
||||||
- `events/` - Event management components
|
- `map/` - Map-related components (MapView, MapFilters)
|
||||||
- `analytics/` - Analytics and dashboard components
|
- `report/` - Report-related components (PhotoUpload, ReportForm, ReportDetail)
|
||||||
- `user/` - User profile and authentication components
|
- `events/` - Event management components (coming soon)
|
||||||
- `common/` - Shared/reusable components (buttons, modals, forms, etc.)
|
- `analytics/` - Analytics and dashboard components (coming soon)
|
||||||
|
- `common/` - Shared/reusable components (coming soon)
|
||||||
|
|||||||
@@ -6,3 +6,6 @@ export * from './layout';
|
|||||||
|
|
||||||
// Map components
|
// Map components
|
||||||
export * from './map';
|
export * from './map';
|
||||||
|
|
||||||
|
// Report components
|
||||||
|
export * from './report';
|
||||||
|
|||||||
316
src/components/report/PhotoUpload.tsx
Normal file
316
src/components/report/PhotoUpload.tsx
Normal file
@@ -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<PhotoMetadata[]>([]);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<string[]>([]);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const extractEXIF = async (file: File): Promise<Partial<PhotoMetadata>> => {
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Upload Area */}
|
||||||
|
<div
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
className={`
|
||||||
|
border-2 border-dashed rounded-lg p-8 text-center transition-colors
|
||||||
|
${isDragging
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPTED_TYPES.join(',')}
|
||||||
|
multiple
|
||||||
|
onChange={handleFileInput}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<svg
|
||||||
|
className="mx-auto h-12 w-12 text-gray-400"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 48 48"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBrowseClick}
|
||||||
|
className="font-medium text-blue-600 hover:text-blue-500 focus:outline-none focus:underline"
|
||||||
|
>
|
||||||
|
Browse files
|
||||||
|
</button>
|
||||||
|
{' or drag and drop'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
JPEG, PNG, WebP up to {maxSizeMB}MB each (max {maxFiles} photos)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Messages */}
|
||||||
|
{errors.length > 0 && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-red-800">
|
||||||
|
Upload errors:
|
||||||
|
</h3>
|
||||||
|
<ul className="mt-2 text-sm text-red-700 list-disc list-inside">
|
||||||
|
{errors.map((error, index) => (
|
||||||
|
<li key={index}>{error}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Photo Previews */}
|
||||||
|
{photos.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700">
|
||||||
|
Uploaded Photos ({photos.length}/{maxFiles})
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
{photos.map((photo, index) => (
|
||||||
|
<div key={index} className="relative group">
|
||||||
|
<img
|
||||||
|
src={photo.preview}
|
||||||
|
alt={`Upload ${index + 1}`}
|
||||||
|
className="w-full h-32 object-cover rounded-lg border border-gray-200"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Remove Button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemovePhoto(index)}
|
||||||
|
className="absolute top-2 right-2 bg-red-600 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-red-700"
|
||||||
|
aria-label="Remove photo"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Metadata Overlay */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 bg-black bg-opacity-60 text-white text-xs p-2 rounded-b-lg">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{photo.hasGPS ? (
|
||||||
|
<>
|
||||||
|
<svg className="h-3 w-3 text-green-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-green-400">GPS: {photo.latitude?.toFixed(4)}, {photo.longitude?.toFixed(4)}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="h-3 w-3 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-yellow-400">No GPS data</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{photo.timestamp && (
|
||||||
|
<div className="text-gray-300 mt-1">
|
||||||
|
{photo.timestamp.toLocaleDateString()} {photo.timestamp.toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { PhotoMetadata };
|
||||||
67
src/components/report/README.md
Normal file
67
src/components/report/README.md
Normal file
@@ -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 (
|
||||||
|
<PhotoUpload
|
||||||
|
onPhotosChange={handlePhotosChange}
|
||||||
|
maxFiles={5}
|
||||||
|
maxSizeMB={5}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
2
src/components/report/index.ts
Normal file
2
src/components/report/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { PhotoUpload } from './PhotoUpload';
|
||||||
|
export type { PhotoMetadata } from './PhotoUpload';
|
||||||
86
src/pages/PhotoUploadDemo.tsx
Normal file
86
src/pages/PhotoUploadDemo.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { PhotoUpload, PhotoMetadata } from '@/components/report';
|
||||||
|
|
||||||
|
export function PhotoUploadDemo() {
|
||||||
|
const [photos, setPhotos] = useState<PhotoMetadata[]>([]);
|
||||||
|
|
||||||
|
const handlePhotosChange = (updatedPhotos: PhotoMetadata[]) => {
|
||||||
|
setPhotos(updatedPhotos);
|
||||||
|
console.log('Photos updated:', updatedPhotos);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 py-8">
|
||||||
|
<div className="max-w-4xl mx-auto px-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||||
|
Photo Upload Component Demo
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Upload photos to test EXIF extraction and validation
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<PhotoUpload
|
||||||
|
onPhotosChange={handlePhotosChange}
|
||||||
|
maxFiles={5}
|
||||||
|
maxSizeMB={5}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{photos.length > 0 && (
|
||||||
|
<div className="mt-8 border-t pt-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Extracted Metadata
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{photos.map((photo, index) => (
|
||||||
|
<div key={index} className="bg-gray-50 rounded-lg p-4">
|
||||||
|
<h3 className="font-medium text-gray-900 mb-2">
|
||||||
|
Photo {index + 1}: {photo.file.name}
|
||||||
|
</h3>
|
||||||
|
<dl className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<dt className="text-gray-600">File Size:</dt>
|
||||||
|
<dd className="text-gray-900">
|
||||||
|
{(photo.file.size / 1024 / 1024).toFixed(2)} MB
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt className="text-gray-600">File Type:</dt>
|
||||||
|
<dd className="text-gray-900">{photo.file.type}</dd>
|
||||||
|
|
||||||
|
<dt className="text-gray-600">Has GPS:</dt>
|
||||||
|
<dd className={photo.hasGPS ? 'text-green-600' : 'text-red-600'}>
|
||||||
|
{photo.hasGPS ? 'Yes' : 'No'}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
{photo.latitude && (
|
||||||
|
<>
|
||||||
|
<dt className="text-gray-600">Latitude:</dt>
|
||||||
|
<dd className="text-gray-900">{photo.latitude.toFixed(6)}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{photo.longitude && (
|
||||||
|
<>
|
||||||
|
<dt className="text-gray-600">Longitude:</dt>
|
||||||
|
<dd className="text-gray-900">{photo.longitude.toFixed(6)}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{photo.timestamp && (
|
||||||
|
<>
|
||||||
|
<dt className="text-gray-600">Timestamp:</dt>
|
||||||
|
<dd className="text-gray-900">
|
||||||
|
{photo.timestamp.toLocaleString()}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,3 +3,4 @@ export { MapPage } from './MapPage';
|
|||||||
export { ReportFormPage } from './ReportFormPage';
|
export { ReportFormPage } from './ReportFormPage';
|
||||||
export { EventsPage } from './EventsPage';
|
export { EventsPage } from './EventsPage';
|
||||||
export { AnalyticsPage } from './AnalyticsPage';
|
export { AnalyticsPage } from './AnalyticsPage';
|
||||||
|
export { PhotoUploadDemo } from './PhotoUploadDemo';
|
||||||
|
|||||||
Reference in New Issue
Block a user