feat: Add Google Maps import feature

- Add googleMapsParser.ts utility for parsing Google Takeout GeoJSON
- Add ImportModal component with drag-and-drop file upload
- Three-step wizard: Upload → Preview → Success
- Preview list with checkboxes and select/deselect all
- Add "Import Places" button to ParticipantList footer
- Imported places become waypoints with type "poi"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-01-23 16:33:53 +01:00
parent 9d8314096b
commit 4302f2d4f8
4 changed files with 582 additions and 1 deletions

View File

@ -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<Participant | null>(null);
const [selectedWaypoint, setSelectedWaypoint] = useState<Waypoint | null>(null);
@ -375,6 +378,7 @@ export default function RoomPage() {
onClose={() => setShowParticipants(false)}
onNavigateTo={handleNavigateTo}
onSetMeetingPoint={() => setShowMeetingPoint(true)}
onImportPlaces={() => setShowImport(true)}
/>
</div>
)}
@ -421,6 +425,27 @@ export default function RoomPage() {
}}
/>
)}
{/* Import Modal */}
{showImport && (
<ImportModal
onClose={() => 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,
});
});
}}
/>
)}
</div>
);
}

View File

@ -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<ImportStep>('upload');
const [error, setError] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [parsedPlaces, setParsedPlaces] = useState<ParsedPlace[]>([]);
const [selectedPlaces, setSelectedPlaces] = useState<Set<number>>(new Set());
const [importedCount, setImportedCount] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4 z-50">
<div className="room-panel rounded-2xl p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold">Import Places</h2>
<button
onClick={onClose}
className="p-1 rounded hover:bg-white/10 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Upload Step */}
{step === 'upload' && (
<div className="space-y-4">
{/* Instructions */}
<div className="bg-white/5 rounded-lg p-4 text-sm">
<p className="font-medium mb-2">How to export from Google Maps:</p>
<ol className="list-decimal list-inside space-y-1 text-white/70">
<li>
Go to{' '}
<a
href="https://takeout.google.com"
target="_blank"
rel="noopener noreferrer"
className="text-rmaps-primary hover:underline"
>
Google Takeout
</a>
</li>
<li>Select only &quot;Saved&quot; under Maps</li>
<li>Export and download the ZIP</li>
<li>Extract and find &quot;Saved Places.json&quot;</li>
<li>Upload that file here</li>
</ol>
</div>
{/* Drop zone */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => 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'
}`}
>
<input
ref={fileInputRef}
type="file"
accept=".json,.geojson"
onChange={handleFileChange}
className="hidden"
/>
<div className="text-4xl mb-2">📁</div>
<p className="text-white/80">
{isDragging ? 'Drop file here' : 'Drag & drop or click to upload'}
</p>
<p className="text-white/40 text-sm mt-1">
.json or .geojson files (max {formatFileSize(MAX_FILE_SIZE)})
</p>
</div>
{/* Error message */}
{error && (
<div className="bg-red-500/20 border border-red-500/40 rounded-lg p-3 text-sm text-red-300">
{error}
</div>
)}
{/* Cancel button */}
<button onClick={onClose} className="btn-ghost w-full">
Cancel
</button>
</div>
)}
{/* Preview Step */}
{step === 'preview' && (
<div className="space-y-4">
{/* Summary */}
<div className="flex items-center justify-between text-sm">
<span className="text-white/60">
Found {parsedPlaces.length} place{parsedPlaces.length !== 1 ? 's' : ''}
</span>
<button
onClick={toggleAll}
className="text-rmaps-primary hover:underline"
>
{selectedPlaces.size === parsedPlaces.length ? 'Deselect all' : 'Select all'}
</button>
</div>
{/* Places list */}
<div className="bg-white/5 rounded-lg border border-white/10 max-h-64 overflow-y-auto">
{parsedPlaces.map((place, index) => (
<label
key={index}
className="flex items-center gap-3 p-3 hover:bg-white/5 cursor-pointer border-b border-white/5 last:border-0"
>
<input
type="checkbox"
checked={selectedPlaces.has(index)}
onChange={() => togglePlace(index)}
className="w-4 h-4 rounded border-white/20 bg-white/10 text-rmaps-primary focus:ring-rmaps-primary focus:ring-offset-0"
/>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{place.name}</div>
<div className="text-xs text-white/40">
{place.latitude.toFixed(4)}, {place.longitude.toFixed(4)}
</div>
</div>
<span className="text-xl">📍</span>
</label>
))}
</div>
{/* Selected count */}
<div className="text-sm text-white/60 text-center">
{selectedPlaces.size} of {parsedPlaces.length} selected
</div>
{/* Actions */}
<div className="flex gap-3">
<button onClick={handleBack} className="btn-ghost flex-1">
Back
</button>
<button
onClick={handleImport}
disabled={selectedPlaces.size === 0}
className="btn-primary flex-1 disabled:opacity-50 disabled:cursor-not-allowed"
>
Import {selectedPlaces.size > 0 ? `(${selectedPlaces.size})` : ''}
</button>
</div>
</div>
)}
{/* Success Step */}
{step === 'success' && (
<div className="space-y-4 text-center">
<div className="text-6xl">🎉</div>
<div>
<p className="text-lg font-medium">Import Complete!</p>
<p className="text-white/60">
Added {importedCount} waypoint{importedCount !== 1 ? 's' : ''} to the map
</p>
</div>
<button onClick={onClose} className="btn-primary w-full">
Done
</button>
</div>
)}
</div>
</div>
);
}

View File

@ -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<string | null>(null);
@ -304,13 +306,27 @@ export default function ParticipantList({
</div>
{/* Footer actions */}
<div className="p-4 border-t border-white/10">
<div className="p-4 border-t border-white/10 space-y-2">
<button
className="btn-secondary w-full text-sm"
onClick={onSetMeetingPoint}
>
Set Meeting Point
</button>
<button
className="btn-ghost w-full text-sm flex items-center justify-center gap-2"
onClick={onImportPlaces}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
</svg>
Import Places
</button>
</div>
</div>
);

213
src/lib/googleMapsParser.ts Normal file
View File

@ -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<string, unknown>;
// 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<string, unknown>;
// 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<string, unknown>;
// 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`;
}