fix: map black screen — move Leaflet CSS to main.jsx, add ErrorBoundary
Leaflet CSS inside a lazy component has a load-order race with the map initialization. Moving it to main.jsx ensures it's always ready. Also adds a MapErrorBoundary so any Leaflet crash shows an error message instead of unmounting the whole app (black screen), and filters out any non-finite GPS coordinates before passing them to the map. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
26ef65a27e
commit
e6e19ebaea
2 files changed
+26
-7
No files matched your search
@@ -1,10 +1,22 @@
|
|||||||
import { useEffect, useState, useMemo } from 'react';
|
import { useEffect, useState, useMemo, Component } from 'react';
|
||||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||||
import MarkerClusterGroup from 'react-leaflet-cluster';
|
import MarkerClusterGroup from 'react-leaflet-cluster';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
|
||||||
import { fetchMapPhotos } from '../api/photos.js';
|
import { fetchMapPhotos } from '../api/photos.js';
|
||||||
|
|
||||||
|
class MapErrorBoundary extends Component {
|
||||||
|
state = { error: null };
|
||||||
|
static getDerivedStateFromError(e) { return { error: e }; }
|
||||||
|
render() {
|
||||||
|
if (this.state.error) return (
|
||||||
|
<div style={{ padding: 24, color: '#f87171', fontSize: 14 }}>
|
||||||
|
Map failed to load: {this.state.error.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Custom dot icon — avoids Leaflet's broken default asset URLs in Vite
|
// Custom dot icon — avoids Leaflet's broken default asset URLs in Vite
|
||||||
const dotIcon = (color = '#4d9eff') =>
|
const dotIcon = (color = '#4d9eff') =>
|
||||||
L.divIcon({
|
L.divIcon({
|
||||||
@@ -37,10 +49,14 @@ export default function MapView({ onOpenPhoto }) {
|
|||||||
.catch(e => setError(e.message));
|
.catch(e => setError(e.message));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const validPoints = useMemo(() =>
|
||||||
|
(points ?? []).filter(p => Number.isFinite(p.lat) && Number.isFinite(p.lon)),
|
||||||
|
[points]);
|
||||||
|
|
||||||
const center = useMemo(() => {
|
const center = useMemo(() => {
|
||||||
if (!points?.length) return [20, 0];
|
if (!validPoints.length) return [20, 0];
|
||||||
return centroid(points);
|
return centroid(validPoints);
|
||||||
}, [points]);
|
}, [validPoints]);
|
||||||
|
|
||||||
if (error) return (
|
if (error) return (
|
||||||
<p style={{ color: '#f87171', padding: 24, fontSize: 13 }}>{error}</p>
|
<p style={{ color: '#f87171', padding: 24, fontSize: 13 }}>{error}</p>
|
||||||
@@ -50,7 +66,7 @@ export default function MapView({ onOpenPhoto }) {
|
|||||||
<p style={{ color: '#777', padding: 24, fontSize: 13 }}>Loading map…</p>
|
<p style={{ color: '#777', padding: 24, fontSize: 13 }}>Loading map…</p>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (points.length === 0) return (
|
if (validPoints.length === 0) return (
|
||||||
<div style={{ textAlign: 'center', paddingTop: 60, color: '#777' }}>
|
<div style={{ textAlign: 'center', paddingTop: 60, color: '#777' }}>
|
||||||
<p style={{ fontSize: 15, marginBottom: 8 }}>No geotagged photos.</p>
|
<p style={{ fontSize: 15, marginBottom: 8 }}>No geotagged photos.</p>
|
||||||
<p style={{ fontSize: 13 }}>Photos with GPS metadata will appear here once indexed.</p>
|
<p style={{ fontSize: 13 }}>Photos with GPS metadata will appear here once indexed.</p>
|
||||||
@@ -58,6 +74,7 @@ export default function MapView({ onOpenPhoto }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<MapErrorBoundary>
|
||||||
<div style={{ height: 'calc(100vh - 52px)' }}>
|
<div style={{ height: 'calc(100vh - 52px)' }}>
|
||||||
<MapContainer
|
<MapContainer
|
||||||
center={center}
|
center={center}
|
||||||
@@ -71,7 +88,7 @@ export default function MapView({ onOpenPhoto }) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<MarkerClusterGroup chunkedLoading>
|
<MarkerClusterGroup chunkedLoading>
|
||||||
{points.map(p => (
|
{validPoints.map(p => (
|
||||||
<Marker key={p.id} position={[p.lat, p.lon]} icon={ICON}>
|
<Marker key={p.id} position={[p.lat, p.lon]} icon={ICON}>
|
||||||
<Popup maxWidth={220} className="luma-popup">
|
<Popup maxWidth={220} className="luma-popup">
|
||||||
<div style={{ margin: 0, padding: 0 }}>
|
<div style={{ margin: 0, padding: 0 }}>
|
||||||
@@ -101,5 +118,6 @@ export default function MapView({ onOpenPhoto }) {
|
|||||||
</MarkerClusterGroup>
|
</MarkerClusterGroup>
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
</div>
|
</div>
|
||||||
|
</MapErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
import App from './App.jsx';
|
import App from './App.jsx';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
|
|||||||
Reference in new issue
Block a user