diff --git a/package-lock.json b/package-lock.json index bef5f47..dca1dbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "rmaps-online", "version": "0.1.0", "dependencies": { + "jszip": "^3.10.1", "maplibre-gl": "^5.0.0", "nanoid": "^5.0.9", "next": "^14.2.28", @@ -1899,6 +1900,11 @@ "dev": true, "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": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3338,6 +3344,11 @@ "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": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3381,7 +3392,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/internal-slot": { @@ -3970,6 +3980,17 @@ "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": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", @@ -4020,6 +4041,14 @@ "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": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -4576,6 +4605,11 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4902,6 +4936,11 @@ "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": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -4999,6 +5038,25 @@ "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": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -5206,6 +5264,11 @@ "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -5312,6 +5375,11 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5462,6 +5530,14 @@ "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": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -6174,7 +6250,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/which": { diff --git a/package.json b/package.json index 56ef54f..f0523c1 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "type-check": "tsc --noEmit" }, "dependencies": { + "jszip": "^3.10.1", "maplibre-gl": "^5.0.0", "nanoid": "^5.0.9", "next": "^14.2.28", diff --git a/src/components/room/ImportModal.tsx b/src/components/room/ImportModal.tsx index f948f52..84b7f87 100644 --- a/src/components/room/ImportModal.tsx +++ b/src/components/room/ImportModal.tsx @@ -1,12 +1,11 @@ 'use client'; import { useState, useCallback, useRef } from 'react'; +import JSZip from 'jszip'; import type { WaypointType } from '@/types'; import { parseGoogleMapsGeoJSON, - validateFileSize, formatFileSize, - MAX_FILE_SIZE, type ParsedPlace, } from '@/lib/googleMapsParser'; @@ -26,46 +25,117 @@ export interface ImportedPlace { type ImportStep = 'upload' | 'preview' | 'success'; +const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB for ZIP files + export default function ImportModal({ onClose, onImport }: ImportModalProps) { const [step, setStep] = useState('upload'); const [error, setError] = useState(null); const [isDragging, setIsDragging] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); const [parsedPlaces, setParsedPlaces] = useState([]); const [selectedPlaces, setSelectedPlaces] = useState>(new Set()); const [importedCount, setImportedCount] = useState(0); const fileInputRef = useRef(null); + // Find JSON file with saved places in a ZIP + const findSavedPlacesInZip = async (zip: JSZip): Promise => { + 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 const processFile = useCallback(async (file: File) => { setError(null); + setIsProcessing(true); // 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)}.`); - return; - } - - // Validate file type - if (!file.name.endsWith('.json') && !file.name.endsWith('.geojson')) { - setError('Please upload a .json or .geojson file.'); + setIsProcessing(false); return; } try { - const text = await file.text(); - const result = parseGoogleMapsGeoJSON(text); + let jsonContent: string; + + 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) { setError(result.error || 'Failed to parse file.'); + setIsProcessing(false); return; } setParsedPlaces(result.places); - // Select all places by default setSelectedPlaces(new Set(result.places.map((_, i) => i))); setStep('preview'); - } catch { + } catch (err) { + console.error('File processing error:', err); setError('Failed to read file. Please try again.'); + } finally { + setIsProcessing(false); } }, []); @@ -83,7 +153,6 @@ export default function ImportModal({ onClose, onImport }: ImportModalProps) { [processFile] ); - // Handle drag events const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); @@ -94,7 +163,6 @@ export default function ImportModal({ onClose, onImport }: ImportModalProps) { setIsDragging(false); }; - // Handle file input change const handleFileChange = (e: React.ChangeEvent) => { const files = e.target.files; 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 newSelected = new Set(selectedPlaces); if (newSelected.has(index)) { @@ -113,7 +180,6 @@ export default function ImportModal({ onClose, onImport }: ImportModalProps) { setSelectedPlaces(newSelected); }; - // Select/deselect all const toggleAll = () => { if (selectedPlaces.size === parsedPlaces.length) { setSelectedPlaces(new Set()); @@ -122,7 +188,6 @@ export default function ImportModal({ onClose, onImport }: ImportModalProps) { } }; - // Import selected places const handleImport = () => { const placesToImport: ImportedPlace[] = []; @@ -146,7 +211,6 @@ export default function ImportModal({ onClose, onImport }: ImportModalProps) { } }; - // Go back to upload step const handleBack = () => { setStep('upload'); setParsedPlaces([]); @@ -162,18 +226,13 @@ export default function ImportModal({ onClose, onImport }: ImportModalProps) {
{/* Header */}
-

Import Places

+

Import from Google Maps

@@ -181,26 +240,44 @@ export default function ImportModal({ onClose, onImport }: ImportModalProps) { {/* Upload Step */} {step === 'upload' && (
- {/* Instructions */} -
-

How to export from Google Maps:

-
    -
  1. - Go to{' '} + {/* Simple Instructions */} +
  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. -
+

Select "Saved Places" and export

+
+
+ +
+
+ 2 +
+
+

Download the ZIP file

+

Google will email you when ready

+
+
+ +
+
+ 3 +
+
+

Upload the ZIP here

+

No need to extract it!

+
+
{/* Drop zone */} @@ -208,27 +285,39 @@ export default function ImportModal({ onClose, onImport }: ImportModalProps) { 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 + onClick={() => !isProcessing && fileInputRef.current?.click()} + className={`border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all ${ + isProcessing + ? 'border-white/10 bg-white/5 cursor-wait' + : isDragging ? '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' }`} > -
📁
-

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

-

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

+ {isProcessing ? ( + <> +
+

Processing...

+ + ) : ( + <> +
📦
+

+ {isDragging ? 'Drop file here' : 'Drop ZIP or click to upload'} +

+

+ Accepts .zip, .json, or .geojson +

+ + )}
{/* Error message */} @@ -248,20 +337,15 @@ export default function ImportModal({ onClose, onImport }: ImportModalProps) { {/* Preview Step */} {step === 'preview' && (
- {/* Summary */}
Found {parsedPlaces.length} place{parsedPlaces.length !== 1 ? 's' : ''} -
- {/* Places list */}
{parsedPlaces.map((place, index) => (