mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 23:51:17 +00:00
cleanup
This commit is contained in:
1 parent
d854614a87
commit
48c3e15a38
295 files changed
+178378
-1036
No files matched your search
@@ -0,0 +1,412 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
|
||||
// Dynamically load Leaflet
|
||||
let L: any = null;
|
||||
|
||||
interface ContactMapProps {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
zoom?: number;
|
||||
address?: string;
|
||||
clubName?: string;
|
||||
mapStyle?: string;
|
||||
height?: number;
|
||||
clubPrimaryColor?: string;
|
||||
clubSecondaryColor?: string;
|
||||
}
|
||||
|
||||
// Available map styles
|
||||
export const MAP_STYLES = {
|
||||
// Clean & Minimal
|
||||
'positron': {
|
||||
name: 'Positron (Light)',
|
||||
url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
description: 'Clean light map, perfect for overlays'
|
||||
},
|
||||
'positron-no-labels': {
|
||||
name: 'Positron No Labels',
|
||||
url: 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
description: 'Minimal light map without labels'
|
||||
},
|
||||
|
||||
// Dark Themes
|
||||
'dark': {
|
||||
name: 'Dark Matter',
|
||||
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
description: 'Dark theme, great for night mode'
|
||||
},
|
||||
'dark-no-labels': {
|
||||
name: 'Dark No Labels',
|
||||
url: 'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
description: 'Dark map without labels'
|
||||
},
|
||||
|
||||
// Black & White
|
||||
'toner': {
|
||||
name: 'Toner (B&W)',
|
||||
url: 'https://tiles.stadiamaps.com/tiles/stamen_toner/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© Stamen Design © OpenStreetMap',
|
||||
description: 'High contrast black and white'
|
||||
},
|
||||
'toner-lite': {
|
||||
name: 'Toner Lite (B&W)',
|
||||
url: 'https://tiles.stadiamaps.com/tiles/stamen_toner_lite/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© Stamen Design © OpenStreetMap',
|
||||
description: 'Subtle black and white'
|
||||
},
|
||||
|
||||
// Colorful Options
|
||||
'voyager': {
|
||||
name: 'Voyager',
|
||||
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© OpenStreetMap © CartoDB',
|
||||
description: 'Balanced colors, good readability'
|
||||
},
|
||||
'terrain': {
|
||||
name: 'Terrain',
|
||||
url: 'https://tiles.stadiamaps.com/tiles/stamen_terrain/{z}/{x}/{y}{r}.jpg',
|
||||
attribution: '© Stamen Design © OpenStreetMap',
|
||||
description: 'Natural terrain visualization'
|
||||
},
|
||||
'watercolor': {
|
||||
name: 'Watercolor',
|
||||
url: 'https://tiles.stadiamaps.com/tiles/stamen_watercolor/{z}/{x}/{y}.jpg',
|
||||
attribution: '© Stamen Design © OpenStreetMap',
|
||||
description: 'Artistic watercolor style'
|
||||
},
|
||||
|
||||
// Default
|
||||
'default': {
|
||||
name: 'OpenStreetMap',
|
||||
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
description: 'Standard OpenStreetMap'
|
||||
},
|
||||
|
||||
// Satellite
|
||||
'satellite': {
|
||||
name: 'Satellite',
|
||||
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
attribution: '© Esri',
|
||||
description: 'Satellite imagery'
|
||||
},
|
||||
};
|
||||
|
||||
const ContactMap: React.FC<ContactMapProps> = ({
|
||||
latitude,
|
||||
longitude,
|
||||
zoom = 15,
|
||||
address,
|
||||
clubName,
|
||||
mapStyle = 'default',
|
||||
height = 400,
|
||||
clubPrimaryColor,
|
||||
clubSecondaryColor,
|
||||
}) => {
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<any>(null);
|
||||
const tileLayerRef = useRef<any>(null);
|
||||
const markerRef = useRef<any>(null);
|
||||
const [isLoaded, setIsLoaded] = React.useState(false);
|
||||
const [loadError, setLoadError] = React.useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Load Leaflet CSS and JS dynamically
|
||||
const loadLeaflet = async () => {
|
||||
try {
|
||||
// Check if already loaded
|
||||
if ((window as any).L) {
|
||||
L = (window as any).L;
|
||||
setIsLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load CSS
|
||||
if (!document.getElementById('leaflet-css')) {
|
||||
const link = document.createElement('link');
|
||||
link.id = 'leaflet-css';
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'https://unpkg.com/[email protected]/dist/leaflet.css';
|
||||
link.integrity = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
|
||||
link.crossOrigin = '';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
// Load JS
|
||||
if (!document.getElementById('leaflet-js')) {
|
||||
const script = document.createElement('script');
|
||||
script.id = 'leaflet-js';
|
||||
script.src = 'https://unpkg.com/[email protected]/dist/leaflet.js';
|
||||
script.integrity = 'sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=';
|
||||
script.crossOrigin = '';
|
||||
|
||||
script.onload = () => {
|
||||
L = (window as any).L;
|
||||
setIsLoaded(true);
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
setLoadError('Failed to load map library');
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
} catch (error) {
|
||||
setLoadError('Error loading map');
|
||||
}
|
||||
};
|
||||
|
||||
loadLeaflet();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoaded || !L || !mapRef.current || mapInstanceRef.current) return;
|
||||
|
||||
try {
|
||||
// Initialize map
|
||||
const map = L.map(mapRef.current, {
|
||||
center: [latitude, longitude],
|
||||
zoom: zoom,
|
||||
scrollWheelZoom: false, // Disable scroll zoom for better UX
|
||||
});
|
||||
|
||||
mapInstanceRef.current = map;
|
||||
|
||||
// Initial tile layer
|
||||
const { tileUrl, attribution } = (() => {
|
||||
let url = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
let attr = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
|
||||
if (mapStyle && MAP_STYLES[mapStyle as keyof typeof MAP_STYLES]) {
|
||||
const style = MAP_STYLES[mapStyle as keyof typeof MAP_STYLES];
|
||||
url = style.url;
|
||||
attr = style.attribution;
|
||||
} else if (mapStyle && mapStyle.startsWith('http')) {
|
||||
url = mapStyle;
|
||||
}
|
||||
return { tileUrl: url, attribution: attr };
|
||||
})();
|
||||
|
||||
tileLayerRef.current = L.tileLayer(tileUrl, {
|
||||
attribution,
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
// Create custom marker icon with club colors
|
||||
const markerColor = clubPrimaryColor || '#3388ff';
|
||||
const customIcon = createCustomMarkerIcon(markerColor, L);
|
||||
|
||||
// Add marker
|
||||
markerRef.current = L.marker([latitude, longitude], { icon: customIcon }).addTo(map);
|
||||
|
||||
// Add popup if address is provided
|
||||
if (clubName || address) {
|
||||
let popupContent = '';
|
||||
if (clubName) popupContent += `<b>${clubName}</b><br>`;
|
||||
if (address) popupContent += address;
|
||||
markerRef.current.bindPopup(popupContent);
|
||||
}
|
||||
|
||||
// Enable scroll zoom on click
|
||||
map.on('click', () => {
|
||||
map.scrollWheelZoom.enable();
|
||||
});
|
||||
|
||||
// Disable scroll zoom on mouseout
|
||||
map.on('mouseout', () => {
|
||||
map.scrollWheelZoom.disable();
|
||||
});
|
||||
|
||||
// Invalidate size after initial mount to ensure tiles render fully
|
||||
// (use a short delay to let layout settle)
|
||||
try {
|
||||
setTimeout(() => {
|
||||
try { map.invalidateSize(); } catch {}
|
||||
}, 150);
|
||||
} catch {}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing map:', error);
|
||||
setLoadError('Failed to initialize map');
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
try {
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.remove();
|
||||
}
|
||||
} finally {
|
||||
mapInstanceRef.current = null;
|
||||
tileLayerRef.current = null;
|
||||
markerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isLoaded]);
|
||||
|
||||
// Observe container visibility to invalidate size when it becomes visible (e.g., tabs, accordions)
|
||||
useEffect(() => {
|
||||
if (!mapRef.current) return;
|
||||
const el = mapRef.current;
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
const e = entries[0];
|
||||
if (e && e.isIntersecting && mapInstanceRef.current) {
|
||||
try {
|
||||
// Next frame ensures correct layout measurement
|
||||
requestAnimationFrame(() => {
|
||||
try { mapInstanceRef.current!.invalidateSize(); } catch {}
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
}, { root: null, threshold: 0.1 });
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [isLoaded]);
|
||||
|
||||
// Observe container resize to keep Leaflet in sync with layout changes
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || !('ResizeObserver' in window)) return;
|
||||
const el = mapRef.current;
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (!mapInstanceRef.current) return;
|
||||
try { mapInstanceRef.current.invalidateSize(); } catch {}
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [isLoaded]);
|
||||
|
||||
// Update center/zoom and marker when coords/zoom change
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current || !L) return;
|
||||
try {
|
||||
const map = mapInstanceRef.current;
|
||||
if (typeof latitude === 'number' && typeof longitude === 'number') {
|
||||
map.setView([latitude, longitude], typeof zoom === 'number' ? zoom : map.getZoom());
|
||||
if (markerRef.current) {
|
||||
markerRef.current.setLatLng([latitude, longitude]);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}, [latitude, longitude, zoom]);
|
||||
|
||||
// Update map style (tile layer) when mapStyle changes
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current || !L) return;
|
||||
try {
|
||||
const map = mapInstanceRef.current;
|
||||
// Compute URL and attribution
|
||||
let url = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
let attr = '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
|
||||
if (mapStyle && MAP_STYLES[mapStyle as keyof typeof MAP_STYLES]) {
|
||||
const style = MAP_STYLES[mapStyle as keyof typeof MAP_STYLES];
|
||||
url = style.url;
|
||||
attr = style.attribution;
|
||||
} else if (mapStyle && mapStyle.startsWith('http')) {
|
||||
url = mapStyle;
|
||||
}
|
||||
if (tileLayerRef.current) {
|
||||
map.removeLayer(tileLayerRef.current);
|
||||
}
|
||||
tileLayerRef.current = L.tileLayer(url, { attribution: attr, maxZoom: 19 }).addTo(map);
|
||||
} catch {}
|
||||
}, [mapStyle]);
|
||||
|
||||
// Update popup content when clubName/address change
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (markerRef.current) {
|
||||
if (clubName || address) {
|
||||
let popupContent = '';
|
||||
if (clubName) popupContent += `<b>${clubName}</b><br>`;
|
||||
if (address) popupContent += address;
|
||||
markerRef.current.bindPopup(popupContent);
|
||||
} else {
|
||||
markerRef.current.unbindPopup();
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}, [clubName, address]);
|
||||
|
||||
// Helper function to create color filter
|
||||
function createColorFilter(color: string): string | null {
|
||||
try {
|
||||
// Validate and normalize color
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.style.color = color;
|
||||
document.body.appendChild(tempDiv);
|
||||
const computedColor = window.getComputedStyle(tempDiv).color;
|
||||
document.body.removeChild(tempDiv);
|
||||
return computedColor;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create custom marker with club colors
|
||||
function createCustomMarkerIcon(color: string, leaflet: any) {
|
||||
// Create SVG marker with custom color
|
||||
const svgIcon = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 36" width="36" height="54">
|
||||
<defs>
|
||||
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
|
||||
<feOffset dx="0" dy="2" result="offsetblur"/>
|
||||
<feComponentTransfer>
|
||||
<feFuncA type="linear" slope="0.3"/>
|
||||
</feComponentTransfer>
|
||||
<feMerge>
|
||||
<feMergeNode/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<path fill="${color}" stroke="#fff" stroke-width="1.5" filter="url(#shadow)"
|
||||
d="M12 0C7.03 0 3 4.03 3 9c0 7.5 9 18 9 18s9-10.5 9-18c0-4.97-4.03-9-9-9z"/>
|
||||
<circle cx="12" cy="9" r="3" fill="#fff"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const iconUrl = 'data:image/svg+xml;base64,' + btoa(svgIcon);
|
||||
|
||||
return leaflet.icon({
|
||||
iconUrl: iconUrl,
|
||||
iconSize: [36, 54],
|
||||
iconAnchor: [18, 54],
|
||||
popupAnchor: [0, -54],
|
||||
});
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<Box
|
||||
ref={mapRef}
|
||||
w="100%"
|
||||
h={`${height}px`}
|
||||
bg="gray.100"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
borderRadius="md"
|
||||
>
|
||||
{loadError}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={mapRef}
|
||||
w="100%"
|
||||
h={`${height}px`}
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
boxShadow="md"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactMap;
|
||||
Reference in new issue
Block a user