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:
parent
9d8314096b
commit
4302f2d4f8
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 "Saved" under Maps</li>
|
||||
<li>Export and download the ZIP</li>
|
||||
<li>Extract and find "Saved Places.json"</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
Loading…
Reference in New Issue