feat: Simplify Google Maps import - accept ZIP files directly
- Users can now upload the ZIP file directly from Google Takeout - No need to extract the ZIP first - Simplified 3-step instructions with direct link to Takeout - Added JSZip dependency for ZIP processing - Auto-detects saved places JSON in various ZIP structures - Shows loading spinner while processing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
fb38a07e37
commit
3f37d92aa0
|
|
@ -8,6 +8,7 @@
|
||||||
"name": "rmaps-online",
|
"name": "rmaps-online",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"maplibre-gl": "^5.0.0",
|
"maplibre-gl": "^5.0.0",
|
||||||
"nanoid": "^5.0.9",
|
"nanoid": "^5.0.9",
|
||||||
"next": "^14.2.28",
|
"next": "^14.2.28",
|
||||||
|
|
@ -1899,6 +1900,11 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/core-util-is": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
|
@ -3338,6 +3344,11 @@
|
||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immediate": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
|
|
@ -3381,7 +3392,6 @@
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/internal-slot": {
|
"node_modules/internal-slot": {
|
||||||
|
|
@ -3970,6 +3980,17 @@
|
||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jszip": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||||
|
"dependencies": {
|
||||||
|
"lie": "~3.3.0",
|
||||||
|
"pako": "~1.0.2",
|
||||||
|
"readable-stream": "~2.3.6",
|
||||||
|
"setimmediate": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/kdbush": {
|
"node_modules/kdbush": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
|
||||||
|
|
@ -4020,6 +4041,14 @@
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lie": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"immediate": "~3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lilconfig": {
|
"node_modules/lilconfig": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||||
|
|
@ -4576,6 +4605,11 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
|
|
@ -4902,6 +4936,11 @@
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/process-nextick-args": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
|
||||||
|
},
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
|
|
@ -4999,6 +5038,25 @@
|
||||||
"pify": "^2.3.0"
|
"pify": "^2.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/readable-stream/node_modules/isarray": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
|
|
@ -5206,6 +5264,11 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||||
|
},
|
||||||
"node_modules/safe-push-apply": {
|
"node_modules/safe-push-apply": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||||
|
|
@ -5312,6 +5375,11 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/setimmediate": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|
@ -5462,6 +5530,14 @@
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||||
|
|
@ -6174,7 +6250,6 @@
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"maplibre-gl": "^5.0.0",
|
"maplibre-gl": "^5.0.0",
|
||||||
"nanoid": "^5.0.9",
|
"nanoid": "^5.0.9",
|
||||||
"next": "^14.2.28",
|
"next": "^14.2.28",
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback, useRef } from 'react';
|
import { useState, useCallback, useRef } from 'react';
|
||||||
|
import JSZip from 'jszip';
|
||||||
import type { WaypointType } from '@/types';
|
import type { WaypointType } from '@/types';
|
||||||
import {
|
import {
|
||||||
parseGoogleMapsGeoJSON,
|
parseGoogleMapsGeoJSON,
|
||||||
validateFileSize,
|
|
||||||
formatFileSize,
|
formatFileSize,
|
||||||
MAX_FILE_SIZE,
|
|
||||||
type ParsedPlace,
|
type ParsedPlace,
|
||||||
} from '@/lib/googleMapsParser';
|
} from '@/lib/googleMapsParser';
|
||||||
|
|
||||||
|
|
@ -26,46 +25,117 @@ export interface ImportedPlace {
|
||||||
|
|
||||||
type ImportStep = 'upload' | 'preview' | 'success';
|
type ImportStep = 'upload' | 'preview' | 'success';
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB for ZIP files
|
||||||
|
|
||||||
export default function ImportModal({ onClose, onImport }: ImportModalProps) {
|
export default function ImportModal({ onClose, onImport }: ImportModalProps) {
|
||||||
const [step, setStep] = useState<ImportStep>('upload');
|
const [step, setStep] = useState<ImportStep>('upload');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [parsedPlaces, setParsedPlaces] = useState<ParsedPlace[]>([]);
|
const [parsedPlaces, setParsedPlaces] = useState<ParsedPlace[]>([]);
|
||||||
const [selectedPlaces, setSelectedPlaces] = useState<Set<number>>(new Set());
|
const [selectedPlaces, setSelectedPlaces] = useState<Set<number>>(new Set());
|
||||||
const [importedCount, setImportedCount] = useState(0);
|
const [importedCount, setImportedCount] = useState(0);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Find JSON file with saved places in a ZIP
|
||||||
|
const findSavedPlacesInZip = async (zip: JSZip): Promise<string | null> => {
|
||||||
|
const possibleNames = [
|
||||||
|
'Saved Places.json',
|
||||||
|
'saved_places.json',
|
||||||
|
'SavedPlaces.json',
|
||||||
|
];
|
||||||
|
|
||||||
|
// First, try exact matches in common locations
|
||||||
|
for (const name of possibleNames) {
|
||||||
|
// Try root level
|
||||||
|
if (zip.files[name]) {
|
||||||
|
return await zip.files[name].async('string');
|
||||||
|
}
|
||||||
|
// Try in Takeout/Maps folder
|
||||||
|
const takeoutPath = `Takeout/Maps (your places)/${name}`;
|
||||||
|
if (zip.files[takeoutPath]) {
|
||||||
|
return await zip.files[takeoutPath].async('string');
|
||||||
|
}
|
||||||
|
const takeoutPath2 = `Takeout/Maps/${name}`;
|
||||||
|
if (zip.files[takeoutPath2]) {
|
||||||
|
return await zip.files[takeoutPath2].async('string');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for any .json file that looks like saved places
|
||||||
|
for (const [path, file] of Object.entries(zip.files)) {
|
||||||
|
if (file.dir) continue;
|
||||||
|
if (path.toLowerCase().includes('saved') && path.endsWith('.json')) {
|
||||||
|
return await file.async('string');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: find any GeoJSON file
|
||||||
|
for (const [path, file] of Object.entries(zip.files)) {
|
||||||
|
if (file.dir) continue;
|
||||||
|
if (path.endsWith('.json') || path.endsWith('.geojson')) {
|
||||||
|
const content = await file.async('string');
|
||||||
|
// Check if it's a FeatureCollection (GeoJSON)
|
||||||
|
if (content.includes('"FeatureCollection"')) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
// Process the uploaded file
|
// Process the uploaded file
|
||||||
const processFile = useCallback(async (file: File) => {
|
const processFile = useCallback(async (file: File) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setIsProcessing(true);
|
||||||
|
|
||||||
// Validate file size
|
// Validate file size
|
||||||
if (!validateFileSize(file)) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
setError(`File too large (${formatFileSize(file.size)}). Maximum size is ${formatFileSize(MAX_FILE_SIZE)}.`);
|
setError(`File too large (${formatFileSize(file.size)}). Maximum size is ${formatFileSize(MAX_FILE_SIZE)}.`);
|
||||||
return;
|
setIsProcessing(false);
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file type
|
|
||||||
if (!file.name.endsWith('.json') && !file.name.endsWith('.geojson')) {
|
|
||||||
setError('Please upload a .json or .geojson file.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const text = await file.text();
|
let jsonContent: string;
|
||||||
const result = parseGoogleMapsGeoJSON(text);
|
|
||||||
|
if (file.name.endsWith('.zip')) {
|
||||||
|
// Handle ZIP file
|
||||||
|
const zip = await JSZip.loadAsync(file);
|
||||||
|
const found = await findSavedPlacesInZip(zip);
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
setError('Could not find saved places in this ZIP. Make sure you exported "Saved" from Google Maps.');
|
||||||
|
setIsProcessing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
jsonContent = found;
|
||||||
|
} else if (file.name.endsWith('.json') || file.name.endsWith('.geojson')) {
|
||||||
|
// Handle direct JSON file
|
||||||
|
jsonContent = await file.text();
|
||||||
|
} else {
|
||||||
|
setError('Please upload a .zip, .json, or .geojson file.');
|
||||||
|
setIsProcessing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = parseGoogleMapsGeoJSON(jsonContent);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setError(result.error || 'Failed to parse file.');
|
setError(result.error || 'Failed to parse file.');
|
||||||
|
setIsProcessing(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setParsedPlaces(result.places);
|
setParsedPlaces(result.places);
|
||||||
// Select all places by default
|
|
||||||
setSelectedPlaces(new Set(result.places.map((_, i) => i)));
|
setSelectedPlaces(new Set(result.places.map((_, i) => i)));
|
||||||
setStep('preview');
|
setStep('preview');
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('File processing error:', err);
|
||||||
setError('Failed to read file. Please try again.');
|
setError('Failed to read file. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -83,7 +153,6 @@ export default function ImportModal({ onClose, onImport }: ImportModalProps) {
|
||||||
[processFile]
|
[processFile]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle drag events
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
|
|
@ -94,7 +163,6 @@ export default function ImportModal({ onClose, onImport }: ImportModalProps) {
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle file input change
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
|
|
@ -102,7 +170,6 @@ export default function ImportModal({ onClose, onImport }: ImportModalProps) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Toggle individual place selection
|
|
||||||
const togglePlace = (index: number) => {
|
const togglePlace = (index: number) => {
|
||||||
const newSelected = new Set(selectedPlaces);
|
const newSelected = new Set(selectedPlaces);
|
||||||
if (newSelected.has(index)) {
|
if (newSelected.has(index)) {
|
||||||
|
|
@ -113,7 +180,6 @@ export default function ImportModal({ onClose, onImport }: ImportModalProps) {
|
||||||
setSelectedPlaces(newSelected);
|
setSelectedPlaces(newSelected);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Select/deselect all
|
|
||||||
const toggleAll = () => {
|
const toggleAll = () => {
|
||||||
if (selectedPlaces.size === parsedPlaces.length) {
|
if (selectedPlaces.size === parsedPlaces.length) {
|
||||||
setSelectedPlaces(new Set());
|
setSelectedPlaces(new Set());
|
||||||
|
|
@ -122,7 +188,6 @@ export default function ImportModal({ onClose, onImport }: ImportModalProps) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Import selected places
|
|
||||||
const handleImport = () => {
|
const handleImport = () => {
|
||||||
const placesToImport: ImportedPlace[] = [];
|
const placesToImport: ImportedPlace[] = [];
|
||||||
|
|
||||||
|
|
@ -146,7 +211,6 @@ export default function ImportModal({ onClose, onImport }: ImportModalProps) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Go back to upload step
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
setStep('upload');
|
setStep('upload');
|
||||||
setParsedPlaces([]);
|
setParsedPlaces([]);
|
||||||
|
|
@ -162,18 +226,13 @@ export default function ImportModal({ onClose, onImport }: ImportModalProps) {
|
||||||
<div className="room-panel rounded-2xl p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
|
<div className="room-panel rounded-2xl p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-xl font-bold">Import Places</h2>
|
<h2 className="text-xl font-bold">Import from Google Maps</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-1 rounded hover:bg-white/10 transition-colors"
|
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">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -181,26 +240,44 @@ export default function ImportModal({ onClose, onImport }: ImportModalProps) {
|
||||||
{/* Upload Step */}
|
{/* Upload Step */}
|
||||||
{step === 'upload' && (
|
{step === 'upload' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Instructions */}
|
{/* Simple Instructions */}
|
||||||
<div className="bg-white/5 rounded-lg p-4 text-sm">
|
<div className="space-y-3">
|
||||||
<p className="font-medium mb-2">How to export from Google Maps:</p>
|
<div className="flex items-start gap-3">
|
||||||
<ol className="list-decimal list-inside space-y-1 text-white/70">
|
<div className="w-6 h-6 rounded-full bg-rmaps-primary/20 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
<li>
|
<span className="text-xs font-bold text-rmaps-primary">1</span>
|
||||||
Go to{' '}
|
</div>
|
||||||
|
<div>
|
||||||
<a
|
<a
|
||||||
href="https://takeout.google.com"
|
href="https://takeout.google.com/takeout/custom/maps"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-rmaps-primary hover:underline"
|
className="text-rmaps-primary hover:underline font-medium"
|
||||||
>
|
>
|
||||||
Google Takeout
|
Open Google Takeout →
|
||||||
</a>
|
</a>
|
||||||
</li>
|
<p className="text-white/50 text-sm">Select "Saved Places" and export</p>
|
||||||
<li>Select only "Saved" under Maps</li>
|
</div>
|
||||||
<li>Export and download the ZIP</li>
|
</div>
|
||||||
<li>Extract and find "Saved Places.json"</li>
|
|
||||||
<li>Upload that file here</li>
|
<div className="flex items-start gap-3">
|
||||||
</ol>
|
<div className="w-6 h-6 rounded-full bg-rmaps-primary/20 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
<span className="text-xs font-bold text-rmaps-primary">2</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Download the ZIP file</p>
|
||||||
|
<p className="text-white/50 text-sm">Google will email you when ready</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-rmaps-primary/20 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
|
<span className="text-xs font-bold text-rmaps-primary">3</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Upload the ZIP here</p>
|
||||||
|
<p className="text-white/50 text-sm">No need to extract it!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Drop zone */}
|
{/* Drop zone */}
|
||||||
|
|
@ -208,27 +285,39 @@ export default function ImportModal({ onClose, onImport }: ImportModalProps) {
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => !isProcessing && fileInputRef.current?.click()}
|
||||||
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${
|
className={`border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all ${
|
||||||
isDragging
|
isProcessing
|
||||||
|
? 'border-white/10 bg-white/5 cursor-wait'
|
||||||
|
: isDragging
|
||||||
? 'border-rmaps-primary bg-rmaps-primary/10'
|
? 'border-rmaps-primary bg-rmaps-primary/10'
|
||||||
: 'border-white/20 hover:border-white/40'
|
: 'border-white/20 hover:border-white/40 hover:bg-white/5'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".json,.geojson"
|
accept=".zip,.json,.geojson"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
|
disabled={isProcessing}
|
||||||
/>
|
/>
|
||||||
<div className="text-4xl mb-2">📁</div>
|
{isProcessing ? (
|
||||||
<p className="text-white/80">
|
<>
|
||||||
{isDragging ? 'Drop file here' : 'Drag & drop or click to upload'}
|
<div className="w-8 h-8 border-2 border-rmaps-primary border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
||||||
</p>
|
<p className="text-white/60">Processing...</p>
|
||||||
<p className="text-white/40 text-sm mt-1">
|
</>
|
||||||
.json or .geojson files (max {formatFileSize(MAX_FILE_SIZE)})
|
) : (
|
||||||
</p>
|
<>
|
||||||
|
<div className="text-4xl mb-2">📦</div>
|
||||||
|
<p className="text-white/80 font-medium">
|
||||||
|
{isDragging ? 'Drop file here' : 'Drop ZIP or click to upload'}
|
||||||
|
</p>
|
||||||
|
<p className="text-white/40 text-xs mt-1">
|
||||||
|
Accepts .zip, .json, or .geojson
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error message */}
|
{/* Error message */}
|
||||||
|
|
@ -248,20 +337,15 @@ export default function ImportModal({ onClose, onImport }: ImportModalProps) {
|
||||||
{/* Preview Step */}
|
{/* Preview Step */}
|
||||||
{step === 'preview' && (
|
{step === 'preview' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Summary */}
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-white/60">
|
<span className="text-white/60">
|
||||||
Found {parsedPlaces.length} place{parsedPlaces.length !== 1 ? 's' : ''}
|
Found {parsedPlaces.length} place{parsedPlaces.length !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button onClick={toggleAll} className="text-rmaps-primary hover:underline">
|
||||||
onClick={toggleAll}
|
|
||||||
className="text-rmaps-primary hover:underline"
|
|
||||||
>
|
|
||||||
{selectedPlaces.size === parsedPlaces.length ? 'Deselect all' : 'Select all'}
|
{selectedPlaces.size === parsedPlaces.length ? 'Deselect all' : 'Select all'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Places list */}
|
|
||||||
<div className="bg-white/5 rounded-lg border border-white/10 max-h-64 overflow-y-auto">
|
<div className="bg-white/5 rounded-lg border border-white/10 max-h-64 overflow-y-auto">
|
||||||
{parsedPlaces.map((place, index) => (
|
{parsedPlaces.map((place, index) => (
|
||||||
<label
|
<label
|
||||||
|
|
@ -285,12 +369,10 @@ export default function ImportModal({ onClose, onImport }: ImportModalProps) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selected count */}
|
|
||||||
<div className="text-sm text-white/60 text-center">
|
<div className="text-sm text-white/60 text-center">
|
||||||
{selectedPlaces.size} of {parsedPlaces.length} selected
|
{selectedPlaces.size} of {parsedPlaces.length} selected
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button onClick={handleBack} className="btn-ghost flex-1">
|
<button onClick={handleBack} className="btn-ghost flex-1">
|
||||||
Back
|
Back
|
||||||
|
|
@ -313,7 +395,7 @@ export default function ImportModal({ onClose, onImport }: ImportModalProps) {
|
||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-medium">Import Complete!</p>
|
<p className="text-lg font-medium">Import Complete!</p>
|
||||||
<p className="text-white/60">
|
<p className="text-white/60">
|
||||||
Added {importedCount} waypoint{importedCount !== 1 ? 's' : ''} to the map
|
Added {importedCount} place{importedCount !== 1 ? 's' : ''} to the map
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="btn-primary w-full">
|
<button onClick={onClose} className="btn-primary w-full">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue