'use client'; /** * MapView — client component that initializes Leaflet, fetches /api/map, * and renders location-level markers with themed popups. */ import { useEffect, useRef, useState } from 'react'; import { useDictionary } from './I18nProvider'; /** Escapes special HTML characters to prevent XSS in Leaflet popup templates. */ function escapeHtml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } interface MapLocationPublic { city: string; country: string; lat: number; lng: number; photoCount: number; coverUrl: string; albums: { name: string; url: string }[]; } export function MapView() { const t = useDictionary(); const containerRef = useRef(null); const mapRef = useRef(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; async function init() { // Dynamically import Leaflet (client-only) const L = (await import('leaflet')).default; if (cancelled || !containerRef.current || mapRef.current) return; // Fetch map data const res = await fetch('/api/map'); if (!res.ok) { let detail = ''; try { detail = await res.text(); } catch { /* ignore */ } console.error('[Map] API error', res.status, detail); setError(t.map.loadFailed(res.status)); setLoading(false); return; } const locations: MapLocationPublic[] = await res.json(); if (cancelled) return; // Initialize map const map = L.map(containerRef.current, { zoomControl: true, attributionControl: true, }); mapRef.current = map; // Dark-themed tiles (CartoDB Dark Matter) L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { attribution: '© OSM © CARTO', subdomains: 'abcd', maxZoom: 19, }).addTo(map); if (locations.length === 0) { // No geotagged photos — show world view map.setView([20, 0], 2); setLoading(false); return; } // Create markers with custom HTML icons const markers: L.Marker[] = []; for (const loc of locations) { const icon = L.divIcon({ className: '', html: `
${loc.photoCount}
`, iconSize: [32, 32], iconAnchor: [16, 16], popupAnchor: [0, -20], }); const albumLinks = loc.albums .map( (a) => `${escapeHtml(a.name)} →`, ) .join(''); const popupHtml = `
${escapeHtml(loc.city)}

${escapeHtml(loc.city)}

${escapeHtml(loc.country)}

${escapeHtml(t.common.photos(loc.photoCount))}

${albumLinks}
`; const marker = L.marker([loc.lat, loc.lng], { icon }) .addTo(map) .bindPopup(popupHtml, { maxWidth: 260, minWidth: 200 }); markers.push(marker); } // Fit bounds to show all markers if (markers.length > 0) { const group = L.featureGroup(markers); map.fitBounds(group.getBounds().pad(0.15)); } // Force Leaflet to recalculate tile grid setTimeout(() => map.invalidateSize(), 100); setLoading(false); } init().catch((err) => { console.error('[Map] Init error:', err); if (!cancelled) { setError(t.map.initFailed); setLoading(false); } }); return () => { cancelled = true; if (mapRef.current) { (mapRef.current as L.Map).remove(); mapRef.current = null; } }; }, [t]); if (error) { return (

{error}

); } return (
{loading && (

{t.map.loading}

)}
); }