diff --git a/src/app/[slug]/page.tsx b/src/app/[slug]/page.tsx index a99c801..776141e 100644 --- a/src/app/[slug]/page.tsx +++ b/src/app/[slug]/page.tsx @@ -12,6 +12,8 @@ import RoomHeader from '@/components/room/RoomHeader'; import ShareModal from '@/components/room/ShareModal'; import MeetingPointModal from '@/components/room/MeetingPointModal'; import WaypointModal from '@/components/room/WaypointModal'; +import ImportModal from '@/components/room/ImportModal'; +import type { ImportedPlace } from '@/components/room/ImportModal'; import InstallBanner from '@/components/room/InstallBanner'; import JoinForm from '@/components/room/JoinForm'; import type { Participant, ParticipantLocation, Waypoint } from '@/types'; @@ -34,6 +36,7 @@ export default function RoomPage() { const [showShare, setShowShare] = useState(false); const [showParticipants, setShowParticipants] = useState(true); const [showMeetingPoint, setShowMeetingPoint] = useState(false); + const [showImport, setShowImport] = useState(false); const [currentUser, setCurrentUser] = useState<{ name: string; emoji: string } | null>(null); const [selectedParticipant, setSelectedParticipant] = useState(null); const [selectedWaypoint, setSelectedWaypoint] = useState(null); @@ -375,6 +378,7 @@ export default function RoomPage() { onClose={() => setShowParticipants(false)} onNavigateTo={handleNavigateTo} onSetMeetingPoint={() => setShowMeetingPoint(true)} + onImportPlaces={() => setShowImport(true)} /> )} @@ -421,6 +425,27 @@ export default function RoomPage() { }} /> )} + + {/* Import Modal */} + {showImport && ( + setShowImport(false)} + onImport={(places: ImportedPlace[]) => { + // Add each imported place as a waypoint + places.forEach((place) => { + addWaypoint({ + name: place.name, + emoji: place.emoji, + location: { + latitude: place.latitude, + longitude: place.longitude, + }, + type: place.type, + }); + }); + }} + /> + )} ); } diff --git a/src/components/room/ImportModal.tsx b/src/components/room/ImportModal.tsx new file mode 100644 index 0000000..f948f52 --- /dev/null +++ b/src/components/room/ImportModal.tsx @@ -0,0 +1,327 @@ +'use client'; + +import { useState, useCallback, useRef } from 'react'; +import type { WaypointType } from '@/types'; +import { + parseGoogleMapsGeoJSON, + validateFileSize, + formatFileSize, + MAX_FILE_SIZE, + type ParsedPlace, +} from '@/lib/googleMapsParser'; + +interface ImportModalProps { + onClose: () => void; + onImport: (places: ImportedPlace[]) => void; +} + +export interface ImportedPlace { + name: string; + emoji: string; + latitude: number; + longitude: number; + type: WaypointType; + note?: string; +} + +type ImportStep = 'upload' | 'preview' | 'success'; + +export default function ImportModal({ onClose, onImport }: ImportModalProps) { + const [step, setStep] = useState('upload'); + const [error, setError] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const [parsedPlaces, setParsedPlaces] = useState([]); + const [selectedPlaces, setSelectedPlaces] = useState>(new Set()); + const [importedCount, setImportedCount] = useState(0); + const fileInputRef = useRef(null); + + // Process the uploaded file + const processFile = useCallback(async (file: File) => { + setError(null); + + // Validate file size + if (!validateFileSize(file)) { + setError(`File too large (${formatFileSize(file.size)}). Maximum size is ${formatFileSize(MAX_FILE_SIZE)}.`); + return; + } + + // Validate file type + if (!file.name.endsWith('.json') && !file.name.endsWith('.geojson')) { + setError('Please upload a .json or .geojson file.'); + return; + } + + try { + const text = await file.text(); + const result = parseGoogleMapsGeoJSON(text); + + if (!result.success) { + setError(result.error || 'Failed to parse file.'); + return; + } + + setParsedPlaces(result.places); + // Select all places by default + setSelectedPlaces(new Set(result.places.map((_, i) => i))); + setStep('preview'); + } catch { + setError('Failed to read file. Please try again.'); + } + }, []); + + // Handle file drop + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + + const files = e.dataTransfer.files; + if (files.length > 0) { + processFile(files[0]); + } + }, + [processFile] + ); + + // Handle drag events + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; + + // Handle file input change + const handleFileChange = (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + processFile(files[0]); + } + }; + + // Toggle individual place selection + const togglePlace = (index: number) => { + const newSelected = new Set(selectedPlaces); + if (newSelected.has(index)) { + newSelected.delete(index); + } else { + newSelected.add(index); + } + setSelectedPlaces(newSelected); + }; + + // Select/deselect all + const toggleAll = () => { + if (selectedPlaces.size === parsedPlaces.length) { + setSelectedPlaces(new Set()); + } else { + setSelectedPlaces(new Set(parsedPlaces.map((_, i) => i))); + } + }; + + // Import selected places + const handleImport = () => { + const placesToImport: ImportedPlace[] = []; + + parsedPlaces.forEach((place, index) => { + if (selectedPlaces.has(index)) { + placesToImport.push({ + name: place.name, + emoji: '📍', + latitude: place.latitude, + longitude: place.longitude, + type: 'poi', + note: place.note, + }); + } + }); + + if (placesToImport.length > 0) { + onImport(placesToImport); + setImportedCount(placesToImport.length); + setStep('success'); + } + }; + + // Go back to upload step + const handleBack = () => { + setStep('upload'); + setParsedPlaces([]); + setSelectedPlaces(new Set()); + setError(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + return ( +
+
+ {/* Header */} +
+

Import Places

+ +
+ + {/* Upload Step */} + {step === 'upload' && ( +
+ {/* Instructions */} +
+

How to export from Google Maps:

+
    +
  1. + Go to{' '} + + Google Takeout + +
  2. +
  3. Select only "Saved" under Maps
  4. +
  5. Export and download the ZIP
  6. +
  7. Extract and find "Saved Places.json"
  8. +
  9. Upload that file here
  10. +
+
+ + {/* Drop zone */} +
fileInputRef.current?.click()} + className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${ + isDragging + ? 'border-rmaps-primary bg-rmaps-primary/10' + : 'border-white/20 hover:border-white/40' + }`} + > + +
📁
+

+ {isDragging ? 'Drop file here' : 'Drag & drop or click to upload'} +

+

+ .json or .geojson files (max {formatFileSize(MAX_FILE_SIZE)}) +

+
+ + {/* Error message */} + {error && ( +
+ {error} +
+ )} + + {/* Cancel button */} + +
+ )} + + {/* Preview Step */} + {step === 'preview' && ( +
+ {/* Summary */} +
+ + Found {parsedPlaces.length} place{parsedPlaces.length !== 1 ? 's' : ''} + + +
+ + {/* Places list */} +
+ {parsedPlaces.map((place, index) => ( + + ))} +
+ + {/* Selected count */} +
+ {selectedPlaces.size} of {parsedPlaces.length} selected +
+ + {/* Actions */} +
+ + +
+
+ )} + + {/* Success Step */} + {step === 'success' && ( +
+
🎉
+
+

Import Complete!

+

+ Added {importedCount} waypoint{importedCount !== 1 ? 's' : ''} to the map +

+
+ +
+ )} +
+
+ ); +} diff --git a/src/components/room/ParticipantList.tsx b/src/components/room/ParticipantList.tsx index b9aff0c..ea9ce8f 100644 --- a/src/components/room/ParticipantList.tsx +++ b/src/components/room/ParticipantList.tsx @@ -11,6 +11,7 @@ interface ParticipantListProps { onClose: () => void; onNavigateTo: (participant: Participant) => void; onSetMeetingPoint?: () => void; + onImportPlaces?: () => void; } export default function ParticipantList({ @@ -21,6 +22,7 @@ export default function ParticipantList({ onClose, onNavigateTo, onSetMeetingPoint, + onImportPlaces, }: ParticipantListProps) { const [isRefreshing, setIsRefreshing] = useState(false); const [pingingUser, setPingingUser] = useState(null); @@ -304,13 +306,27 @@ export default function ParticipantList({ {/* Footer actions */} -
+
+
); diff --git a/src/lib/googleMapsParser.ts b/src/lib/googleMapsParser.ts new file mode 100644 index 0000000..825b41d --- /dev/null +++ b/src/lib/googleMapsParser.ts @@ -0,0 +1,213 @@ +/** + * Google Maps GeoJSON Parser + * Parses saved places exported from Google Takeout + */ + +// Google Maps export format from Takeout +export interface GoogleMapsFeature { + type: 'Feature'; + geometry: { + type: 'Point'; + coordinates: [number, number]; // [longitude, latitude] - GeoJSON order + }; + properties: { + Title?: string; + Note?: string; + Updated?: string; + URL?: string; + // Google Maps sometimes uses different property names + title?: string; + name?: string; + description?: string; + }; +} + +export interface GoogleMapsGeoJSON { + type: 'FeatureCollection'; + features: GoogleMapsFeature[]; +} + +export interface ParsedPlace { + name: string; + latitude: number; + longitude: number; + note?: string; + url?: string; + updatedAt?: Date; +} + +export interface ParseResult { + success: boolean; + places: ParsedPlace[]; + error?: string; +} + +/** + * Validates that the data is a valid GeoJSON FeatureCollection + */ +export function validateGeoJSON(data: unknown): data is GoogleMapsGeoJSON { + if (!data || typeof data !== 'object') { + return false; + } + + const obj = data as Record; + + // Check for FeatureCollection type + if (obj.type !== 'FeatureCollection') { + return false; + } + + // Check for features array + if (!Array.isArray(obj.features)) { + return false; + } + + return true; +} + +/** + * Validates a single feature from the GeoJSON + */ +function isValidFeature(feature: unknown): feature is GoogleMapsFeature { + if (!feature || typeof feature !== 'object') { + return false; + } + + const f = feature as Record; + + // Must be a Feature + if (f.type !== 'Feature') { + return false; + } + + // Must have geometry + if (!f.geometry || typeof f.geometry !== 'object') { + return false; + } + + const geometry = f.geometry as Record; + + // Must be a Point + if (geometry.type !== 'Point') { + return false; + } + + // Must have coordinates array with at least 2 numbers + if (!Array.isArray(geometry.coordinates) || geometry.coordinates.length < 2) { + return false; + } + + const [lng, lat] = geometry.coordinates; + if (typeof lng !== 'number' || typeof lat !== 'number') { + return false; + } + + // Validate coordinate ranges + if (lat < -90 || lat > 90 || lng < -180 || lng > 180) { + return false; + } + + return true; +} + +/** + * Extracts the name from a feature's properties + */ +function extractName(properties: GoogleMapsFeature['properties']): string { + return ( + properties.Title || + properties.title || + properties.name || + 'Unnamed Place' + ); +} + +/** + * Extracts the note/description from a feature's properties + */ +function extractNote(properties: GoogleMapsFeature['properties']): string | undefined { + return properties.Note || properties.description || undefined; +} + +/** + * Parses Google Maps GeoJSON data and extracts places + */ +export function parseGoogleMapsGeoJSON(jsonString: string): ParseResult { + let data: unknown; + + // Parse JSON + try { + data = JSON.parse(jsonString); + } catch { + return { + success: false, + places: [], + error: 'Invalid JSON format. Please ensure the file is valid JSON.', + }; + } + + // Validate GeoJSON structure + if (!validateGeoJSON(data)) { + return { + success: false, + places: [], + error: 'Invalid GeoJSON format. Please upload a GeoJSON file from Google Takeout.', + }; + } + + // Extract places from features + const places: ParsedPlace[] = []; + + for (const feature of data.features) { + if (!isValidFeature(feature)) { + // Skip invalid features but continue processing + continue; + } + + const [longitude, latitude] = feature.geometry.coordinates; + const properties = feature.properties || {}; + + places.push({ + name: extractName(properties), + latitude, + longitude, + note: extractNote(properties), + url: properties.URL, + updatedAt: properties.Updated ? new Date(properties.Updated) : undefined, + }); + } + + if (places.length === 0) { + return { + success: false, + places: [], + error: 'No valid places found in this file. Make sure the file contains Point features.', + }; + } + + return { + success: true, + places, + }; +} + +/** + * Maximum file size for import (5MB) + */ +export const MAX_FILE_SIZE = 5 * 1024 * 1024; + +/** + * Validates file size + */ +export function validateFileSize(file: File): boolean { + return file.size <= MAX_FILE_SIZE; +} + +/** + * Formats file size for display + */ +export function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +}