import React, { useEffect, useMemo, useState, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../i18n' import { useSettingsStore } from '../store/settingsStore' import Navbar from '../components/Layout/Navbar' import apiClient, { mapsApi } from '../api/client' import CustomSelect from '../components/shared/CustomSelect' import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react' import L from 'leaflet' import type { AtlasPlace, GeoJsonFeatureCollection, TranslationFn } from '../types' // Convert country code to flag emoji interface AtlasCountry { code: string tripCount: number placeCount: number firstVisit?: string | null lastVisit?: string | null } interface AtlasStats { totalTrips: number totalPlaces: number totalCountries: number totalDays: number totalCities?: number } interface AtlasData { countries: AtlasCountry[] stats: AtlasStats mostVisited?: AtlasCountry | null continents?: Record lastTrip?: { id: number; title: string; countryCode?: string } | null nextTrip?: { id: number; title: string; countryCode?: string } | null streak?: number firstYear?: number tripsThisYear?: number } interface CountryDetail { places: AtlasPlace[] trips: { id: number; title: string }[] manually_marked?: boolean } function MobileStats({ data, stats, countries, resolveName, t, dark }: { data: AtlasData | null; stats: AtlasStats; countries: AtlasCountry[]; resolveName: (code: string) => string; t: TranslationFn; dark: boolean }): React.ReactElement { const tp = dark ? '#f1f5f9' : '#0f172a' const tf = dark ? '#475569' : '#94a3b8' const { continents, lastTrip, nextTrip, streak, firstYear, tripsThisYear } = data || {} const CL = { 'Europe': t('atlas.europe'), 'Asia': t('atlas.asia'), 'North America': t('atlas.northAmerica'), 'South America': t('atlas.southAmerica'), 'Africa': t('atlas.africa'), 'Oceania': t('atlas.oceania') } const thisYear = new Date().getFullYear() return (
{/* Stats grid */}
{[[stats.totalCountries, t('atlas.countries')], [stats.totalTrips, t('atlas.trips')], [stats.totalPlaces, t('atlas.places')], [stats.totalCities || 0, t('atlas.cities')], [stats.totalDays, t('atlas.days')]].map(([v, l], i) => (

{v}

{l}

))}
{/* Continents */}
{['Europe', 'Asia', 'North America', 'South America', 'Africa', 'Oceania'].map(cont => { const count = continents?.[cont] || 0 return (

0 ? tp : (dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.12)') }}>{count}

0 ? tf : (dark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)') }}>{CL[cont]}

) })}
{/* Highlights */}
{streak > 0 && (

{streak}

{streak === 1 ? t('atlas.yearInRow') : t('atlas.yearsInRow')}

)} {tripsThisYear > 0 && (

{tripsThisYear}

{tripsThisYear === 1 ? t('atlas.tripIn') : t('atlas.tripsIn')} {thisYear}

)}
) } function countryCodeToFlag(code: string): string { if (!code || code.length !== 2) return '' return String.fromCodePoint(...[...code.toUpperCase()].map(c => 0x1F1E6 + c.charCodeAt(0) - 65)) } function useCountryNames(language: string): (code: string) => string { const [resolver, setResolver] = useState<(code: string) => string>(() => (code: string) => code) useEffect(() => { try { const dn = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' }) setResolver(() => (code: string) => { try { return dn.of(code) || code } catch { return code } }) } catch { /* */ } }, [language]) return resolver } // Map visited country codes to ISO-3166 alpha3 (GeoJSON uses alpha3) // Built dynamically from GeoJSON + hardcoded fallbacks const A2_TO_A3_BASE: Record = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AG":"ATG","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BS":"BHS","BH":"BHR","BD":"BGD","BB":"BRB","BY":"BLR","BE":"BEL","BZ":"BLZ","BJ":"BEN","BT":"BTN","BO":"BOL","BA":"BIH","BW":"BWA","BR":"BRA","BN":"BRN","BG":"BGR","BF":"BFA","BI":"BDI","CV":"CPV","KH":"KHM","CM":"CMR","CA":"CAN","CF":"CAF","TD":"TCD","CL":"CHL","CN":"CHN","CO":"COL","KM":"COM","CG":"COG","CD":"COD","CR":"CRI","CI":"CIV","HR":"HRV","CU":"CUB","CY":"CYP","CZ":"CZE","DK":"DNK","DJ":"DJI","DM":"DMA","DO":"DOM","EC":"ECU","EG":"EGY","SV":"SLV","GQ":"GNQ","ER":"ERI","EE":"EST","SZ":"SWZ","ET":"ETH","FJ":"FJI","FI":"FIN","FR":"FRA","GA":"GAB","GM":"GMB","GE":"GEO","DE":"DEU","GH":"GHA","GR":"GRC","GD":"GRD","GT":"GTM","GN":"GIN","GW":"GNB","GY":"GUY","HT":"HTI","HN":"HND","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JM":"JAM","JP":"JPN","JO":"JOR","KZ":"KAZ","KE":"KEN","KI":"KIR","KP":"PRK","KR":"KOR","KW":"KWT","KG":"KGZ","LA":"LAO","LV":"LVA","LB":"LBN","LS":"LSO","LR":"LBR","LY":"LBY","LI":"LIE","LT":"LTU","LU":"LUX","MG":"MDG","MW":"MWI","MY":"MYS","MV":"MDV","ML":"MLI","MT":"MLT","MR":"MRT","MU":"MUS","MX":"MEX","MD":"MDA","MN":"MNG","ME":"MNE","MA":"MAR","MZ":"MOZ","MM":"MMR","NA":"NAM","NP":"NPL","NL":"NLD","NZ":"NZL","NI":"NIC","NE":"NER","NG":"NGA","MK":"MKD","NO":"NOR","OM":"OMN","PK":"PAK","PA":"PAN","PG":"PNG","PY":"PRY","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","QA":"QAT","RO":"ROU","RU":"RUS","RW":"RWA","SA":"SAU","SN":"SEN","RS":"SRB","SL":"SLE","SG":"SGP","SK":"SVK","SI":"SVN","SB":"SLB","SO":"SOM","ZA":"ZAF","SS":"SSD","ES":"ESP","LK":"LKA","SD":"SDN","SR":"SUR","SE":"SWE","CH":"CHE","SY":"SYR","TW":"TWN","TJ":"TJK","TZ":"TZA","TH":"THA","TL":"TLS","TG":"TGO","TT":"TTO","TN":"TUN","TR":"TUR","TM":"TKM","UG":"UGA","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","UY":"URY","UZ":"UZB","VU":"VUT","VE":"VEN","VN":"VNM","YE":"YEM","ZM":"ZMB","ZW":"ZWE"} let A2_TO_A3: Record = { ...A2_TO_A3_BASE } export default function AtlasPage(): React.ReactElement { const { t, language } = useTranslation() const { settings } = useSettingsStore() const navigate = useNavigate() const resolveName = useCountryNames(language) const dm = settings.dark_mode const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) const mapRef = useRef(null) const mapInstance = useRef(null) const geoLayerRef = useRef(null) const glareRef = useRef(null) const borderGlareRef = useRef(null) const panelRef = useRef(null) const country_layer_by_a2_ref = useRef>({}) const handlePanelMouseMove = (e: React.MouseEvent): void => { if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return const rect = panelRef.current.getBoundingClientRect() const x = e.clientX - rect.left const y = e.clientY - rect.top // Subtle inner glow glareRef.current.style.background = `radial-gradient(circle 300px at ${x}px ${y}px, ${dark ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.25)'} 0%, transparent 70%)` glareRef.current.style.opacity = '1' // Border glow that follows cursor borderGlareRef.current.style.opacity = '1' borderGlareRef.current.style.maskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)` borderGlareRef.current.style.webkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)` } const handlePanelMouseLeave = () => { if (glareRef.current) glareRef.current.style.opacity = '0' if (borderGlareRef.current) borderGlareRef.current.style.opacity = '0' } const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [sidebarOpen, setSidebarOpen] = useState(true) const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false) const [selectedCountry, setSelectedCountry] = useState(null) const [countryDetail, setCountryDetail] = useState(null) const [geoData, setGeoData] = useState(null) const [visitedRegions, setVisitedRegions] = useState>({}) const regionLayerRef = useRef(null) const regionGeoCache = useRef>({}) const [showRegions, setShowRegions] = useState(false) const [regionGeoLoaded, setRegionGeoLoaded] = useState(0) const regionTooltipRef = useRef(null) const loadCountryDetailRef = useRef<(code: string) => void>(() => {}) const handleMarkCountryRef = useRef<(code: string, name: string) => void>(() => {}) const setConfirmActionRef = useRef(() => {}) const [confirmAction, setConfirmAction] = useState<{ type: 'mark' | 'unmark' | 'choose' | 'bucket' | 'choose-region' | 'unmark-region'; code: string; name: string; regionCode?: string; countryName?: string } | null>(null) const [bucketMonth, setBucketMonth] = useState(0) const [bucketYear, setBucketYear] = useState(0) // Bucket list interface BucketItem { id: number; name: string; lat: number | null; lng: number | null; country_code: string | null; notes: string | null; target_date: string | null } const [bucketList, setBucketList] = useState([]) const [showBucketAdd, setShowBucketAdd] = useState(false) const [bucketForm, setBucketForm] = useState({ name: '', notes: '', lat: '', lng: '', target_date: '' }) const [bucketSearch, setBucketSearch] = useState('') const [bucketSearchResults, setBucketSearchResults] = useState([]) const [bucketSearching, setBucketSearching] = useState(false) const [bucketPoiMonth, setBucketPoiMonth] = useState(0) const [bucketPoiYear, setBucketPoiYear] = useState(0) const [bucketTab, setBucketTab] = useState<'stats' | 'bucket'>('stats') const bucketMarkersRef = useRef(null) const [atlas_country_search, set_atlas_country_search] = useState('') const [atlas_country_results, set_atlas_country_results] = useState<{ code: string; label: string }[]>([]) const [atlas_country_open, set_atlas_country_open] = useState(false) const atlas_country_options = useMemo(() => { if (!geoData) return [] const opts: { code: string; label: string }[] = [] const seen = new Set() for (const f of (geoData as any).features || []) { const a2 = f?.properties?.ISO_A2 if (!a2 || a2 === '-99' || typeof a2 !== 'string' || a2.length !== 2) continue if (seen.has(a2)) continue seen.add(a2) const label = String(resolveName(a2) || f?.properties?.NAME || f?.properties?.ADMIN || a2) opts.push({ code: a2, label }) } opts.sort((a, b) => a.label.localeCompare(b.label)) return opts }, [geoData, resolveName]) // Load atlas data + bucket list useEffect(() => { Promise.all([ apiClient.get('/addons/atlas/stats'), apiClient.get('/addons/atlas/bucket-list'), ]).then(([statsRes, bucketRes]) => { setData(statsRes.data) setBucketList(bucketRes.data.items || []) setLoading(false) }).catch(() => setLoading(false)) }, []) // Load GeoJSON world data (direct GeoJSON, no conversion needed) useEffect(() => { fetch('https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson') .then(r => r.json()) .then(geo => { // Dynamically build A2→A3 mapping from GeoJSON for (const f of geo.features) { const a2 = f.properties?.ISO_A2 const a3 = f.properties?.ADM0_A3 || f.properties?.ISO_A3 if (a2 && a3 && a2 !== '-99' && a3 !== '-99' && !A2_TO_A3[a2]) { A2_TO_A3[a2] = a3 } } setGeoData(geo) }) .catch(() => {}) }, []) // Load visited regions (geocoded from places/trips) — once on mount useEffect(() => { apiClient.get(`/addons/atlas/regions?_t=${Date.now()}`) .then(r => setVisitedRegions(r.data?.regions || {})) .catch(() => {}) }, []) // Load admin-1 GeoJSON for countries visible in the current viewport const loadRegionsForViewportRef = useRef<() => void>(() => {}) const loadRegionsForViewport = (): void => { if (!mapInstance.current) return const bounds = mapInstance.current.getBounds() const toLoad: string[] = [] for (const [code, layer] of Object.entries(country_layer_by_a2_ref.current)) { if (regionGeoCache.current[code]) continue try { if (bounds.intersects((layer as any).getBounds())) toLoad.push(code) } catch {} } if (!toLoad.length) return apiClient.get(`/addons/atlas/regions/geo?countries=${toLoad.join(',')}`) .then(geoRes => { const geo = geoRes.data if (!geo?.features) return let added = false for (const c of toLoad) { const features = geo.features.filter((f: any) => f.properties?.iso_a2?.toUpperCase() === c) if (features.length > 0) { regionGeoCache.current[c] = { type: 'FeatureCollection', features }; added = true } } if (added) setRegionGeoLoaded(v => v + 1) }) .catch(() => {}) } loadRegionsForViewportRef.current = loadRegionsForViewport // Initialize map — runs after loading is done and mapRef is available useEffect(() => { if (loading || !mapRef.current) return if (mapInstance.current) { mapInstance.current.remove(); mapInstance.current = null } const map = L.map(mapRef.current, { center: [25, 0], zoom: 3, minZoom: 3, maxZoom: 10, zoomControl: false, attributionControl: false, maxBounds: [[-90, -220], [90, 220]], maxBoundsViscosity: 1.0, fadeAnimation: false, preferCanvas: true, }) L.control.zoom({ position: 'bottomright' }).addTo(map) const tileUrl = dark ? 'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png' : 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png' L.tileLayer(tileUrl, { maxZoom: 10, keepBuffer: 25, updateWhenZooming: true, updateWhenIdle: false, tileSize: 256, zoomOffset: 0, crossOrigin: true }).addTo(map) // Preload adjacent zoom level tiles L.tileLayer(tileUrl, { maxZoom: 10, keepBuffer: 10, opacity: 0, tileSize: 256, crossOrigin: true, }).addTo(map) // Custom pane for region layer — above overlay (z-index 400) map.createPane('regionPane') map.getPane('regionPane')!.style.zIndex = '401' mapInstance.current = map // Zoom-based region switching map.on('zoomend', () => { const z = map.getZoom() const shouldShow = z >= 5 setShowRegions(shouldShow) const overlayPane = map.getPane('overlayPane') if (overlayPane) { overlayPane.style.opacity = shouldShow ? '0.35' : '1' overlayPane.style.pointerEvents = shouldShow ? 'none' : 'auto' } if (shouldShow) { // Re-add region layer if it was removed while zoomed out if (regionLayerRef.current && !map.hasLayer(regionLayerRef.current)) { regionLayerRef.current.addTo(map) } loadRegionsForViewportRef.current() } else { // Physically remove region layer so its SVG paths can't intercept events if (regionTooltipRef.current) regionTooltipRef.current.style.display = 'none' if (regionLayerRef.current && map.hasLayer(regionLayerRef.current)) { regionLayerRef.current.resetStyle() regionLayerRef.current.removeFrom(map) } } }) map.on('moveend', () => { if (map.getZoom() >= 6) loadRegionsForViewportRef.current() }) return () => { map.remove(); mapInstance.current = null } }, [dark, loading]) // Render GeoJSON countries useEffect(() => { if (!mapInstance.current || !geoData || !data) return const visitedA3 = new Set(data.countries.map(c => A2_TO_A3[c.code]).filter(Boolean)) const countryMap = {} data.countries.forEach(c => { if (A2_TO_A3[c.code]) countryMap[A2_TO_A3[c.code]] = c }) // Preserve current map view const currentCenter = mapInstance.current.getCenter() const currentZoom = mapInstance.current.getZoom() if (geoLayerRef.current) { mapInstance.current.removeLayer(geoLayerRef.current) } // Generate deterministic color per country code const VISITED_COLORS = ['#6366f1','#ec4899','#14b8a6','#f97316','#8b5cf6','#ef4444','#3b82f6','#22c55e','#06b6d4','#f43f5e','#a855f7','#10b981','#0ea5e9','#e11d48','#0d9488','#7c3aed','#2563eb','#dc2626','#059669','#d946ef'] // Assign colors in order of visit (by index in countries array) so no two neighbors share a color easily const visitedA3List = [...visitedA3] const colorMap = {} visitedA3List.forEach((a3, i) => { colorMap[a3] = VISITED_COLORS[i % VISITED_COLORS.length] }) const colorForCode = (a3) => colorMap[a3] || VISITED_COLORS[0] const canvasRenderer = L.canvas({ padding: 0.5, tolerance: 5 }) geoLayerRef.current = L.geoJSON(geoData, { renderer: canvasRenderer, interactive: true, bubblingMouseEvents: false, style: (feature) => { const a3 = feature.properties?.ADM0_A3 || feature.properties?.ISO_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id const visited = visitedA3.has(a3) return { fillColor: visited ? colorForCode(a3) : (dark ? '#1e1e2e' : '#e2e8f0'), fillOpacity: visited ? 0.7 : 0.3, color: dark ? '#333' : '#cbd5e1', weight: 0.5, } }, onEachFeature: (feature, layer) => { const a3 = feature.properties?.ADM0_A3 || feature.properties?.ISO_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id const c = countryMap[a3] if (c) { country_layer_by_a2_ref.current[c.code] = layer const name = resolveName(c.code) const formatDate = (d) => { if (!d) return '—'; const dt = new Date(d); return dt.toLocaleDateString(getLocaleForLanguage(language), { month: 'short', year: 'numeric' }) } const tooltipHtml = `
${name}
${c.tripCount} ${c.tripCount === 1 ? t('atlas.tripSingular') : t('atlas.tripPlural')}
${c.placeCount} ${c.placeCount === 1 ? t('atlas.placeVisited') : t('atlas.placesVisited')}
${t('atlas.firstVisit')} ${formatDate(c.firstVisit)}
${t('atlas.lastVisitLabel')} ${formatDate(c.lastVisit)}
` layer.bindTooltip(tooltipHtml, { sticky: false, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1 }) layer.on('click', () => { if (c.placeCount === 0 && c.tripCount === 0) { handleUnmarkCountry(c.code) } }) layer.on('mouseover', (e) => { e.target.setStyle({ fillOpacity: 0.9, weight: 2, color: dark ? '#818cf8' : '#4f46e5' }) }) layer.on('mouseout', (e) => { geoLayerRef.current.resetStyle(e.target) }) } else { // Unvisited country — allow clicking to mark as visited // Reverse lookup: find A2 code from A3, or use A3 directly const a3ToA2Entry = Object.entries(A2_TO_A3).find(([, v]) => v === a3) const isoA2 = feature.properties?.ISO_A2 const countryCode = a3ToA2Entry ? a3ToA2Entry[0] : (isoA2 && isoA2 !== '-99' ? isoA2 : null) if (countryCode && countryCode !== '-99') { country_layer_by_a2_ref.current[countryCode] = layer const name = feature.properties?.NAME || feature.properties?.ADMIN || resolveName(countryCode) layer.bindTooltip(`
${name}
`, { sticky: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1 }) layer.on('click', () => handleMarkCountry(countryCode, name)) layer.on('mouseover', (e) => { e.target.setStyle({ fillOpacity: 0.5, weight: 1.5, color: dark ? '#555' : '#94a3b8' }) }) layer.on('mouseout', (e) => { geoLayerRef.current.resetStyle(e.target) }) } } } }).addTo(mapInstance.current) // Restore map view after re-render mapInstance.current.setView(currentCenter, currentZoom, { animate: false }) }, [geoData, data, dark]) // Render sub-national region layer (zoom >= 5) useEffect(() => { if (!mapInstance.current) return // Remove existing region layer if (regionLayerRef.current) { mapInstance.current.removeLayer(regionLayerRef.current) regionLayerRef.current = null } if (Object.keys(regionGeoCache.current).length === 0) return // Build set of visited region codes first const visitedRegionCodes = new Set() const visitedRegionNames = new Set() const regionPlaceCounts: Record = {} for (const [, regions] of Object.entries(visitedRegions)) { for (const r of regions) { visitedRegionCodes.add(r.code) visitedRegionNames.add(r.name.toLowerCase()) regionPlaceCounts[r.code] = r.placeCount regionPlaceCounts[r.name.toLowerCase()] = r.placeCount } } // Match feature by ISO code OR region name const isVisitedFeature = (f: any) => { if (visitedRegionCodes.has(f.properties?.iso_3166_2)) return true const name = (f.properties?.name || '').toLowerCase() if (visitedRegionNames.has(name)) return true // Fuzzy: check if any visited name is contained in feature name or vice versa for (const vn of visitedRegionNames) { if (name.includes(vn) || vn.includes(name)) return true } return false } // Include ALL region features — visited ones get colored fill, unvisited get outline only const allFeatures: any[] = [] for (const geo of Object.values(regionGeoCache.current)) { for (const f of geo.features) { allFeatures.push(f) } } if (allFeatures.length === 0) return // Use same colors as country layer const VISITED_COLORS = ['#6366f1','#ec4899','#14b8a6','#f97316','#8b5cf6','#ef4444','#3b82f6','#22c55e','#06b6d4','#f43f5e','#a855f7','#10b981','#0ea5e9','#e11d48','#0d9488','#7c3aed','#2563eb','#dc2626','#059669','#d946ef'] const countryA3Set = data ? data.countries.map(c => A2_TO_A3[c.code]).filter(Boolean) : [] const countryColorMap: Record = {} countryA3Set.forEach((a3, i) => { countryColorMap[a3] = VISITED_COLORS[i % VISITED_COLORS.length] }) // Map country A2 code to country color const a2ColorMap: Record = {} if (data) data.countries.forEach(c => { if (A2_TO_A3[c.code] && countryColorMap[A2_TO_A3[c.code]]) a2ColorMap[c.code] = countryColorMap[A2_TO_A3[c.code]] }) const mergedGeo = { type: 'FeatureCollection', features: allFeatures } const svgRenderer = L.svg({ pane: 'regionPane' }) regionLayerRef.current = L.geoJSON(mergedGeo as any, { renderer: svgRenderer, interactive: true, pane: 'regionPane', style: (feature) => { const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase() const visited = isVisitedFeature(feature) return visited ? { fillColor: a2ColorMap[countryA2] || '#6366f1', fillOpacity: 0.85, color: dark ? '#888' : '#64748b', weight: 1.2, } : { fillColor: dark ? '#ffffff' : '#000000', fillOpacity: 0.03, color: dark ? '#555' : '#94a3b8', weight: 1, } }, onEachFeature: (feature, layer) => { const regionName = feature?.properties?.name || '' const countryName = feature?.properties?.admin || '' const regionCode = feature?.properties?.iso_3166_2 || '' const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase() const visited = isVisitedFeature(feature) const count = regionPlaceCounts[regionCode] || regionPlaceCounts[regionName.toLowerCase()] || 0 layer.on('click', () => { if (!countryA2) return if (visited) { const regionEntry = visitedRegions[countryA2]?.find(r => r.code === regionCode) if (regionEntry?.manuallyMarked) { setConfirmActionRef.current({ type: 'unmark-region', code: countryA2, name: regionName, regionCode, countryName, }) } else { loadCountryDetailRef.current(countryA2) } } else { setConfirmActionRef.current({ type: 'choose-region', code: countryA2, // country A2 code — used for flag display name: regionName, // region name — shown as heading regionCode, countryName, }) } }) layer.on('mouseover', (e: any) => { e.target.setStyle(visited ? { fillOpacity: 0.95, weight: 2, color: dark ? '#818cf8' : '#4f46e5' } : { fillOpacity: 0.15, fillColor: dark ? '#818cf8' : '#4f46e5', weight: 1.5, color: dark ? '#818cf8' : '#4f46e5' } ) const tt = regionTooltipRef.current if (tt) { tt.style.display = 'block' tt.style.left = e.originalEvent.clientX + 12 + 'px' tt.style.top = e.originalEvent.clientY - 10 + 'px' tt.innerHTML = visited ? `
${regionName}
${countryName}
${count} ${count === 1 ? 'place' : 'places'}
` : `
${regionName}
${countryName}
` } }) layer.on('mousemove', (e: any) => { const tt = regionTooltipRef.current if (tt) { tt.style.left = e.originalEvent.clientX + 12 + 'px'; tt.style.top = e.originalEvent.clientY - 10 + 'px' } }) layer.on('mouseout', (e: any) => { regionLayerRef.current?.resetStyle(e.target) const tt = regionTooltipRef.current if (tt) tt.style.display = 'none' }) }, }) // Only add to map if currently in region mode — otherwise hold it ready for when user zooms in if (mapInstance.current.getZoom() >= 6) { regionLayerRef.current.addTo(mapInstance.current) } }, [regionGeoLoaded, visitedRegions, dark, t]) const handleMarkCountry = (code: string, name: string): void => { setConfirmAction({ type: 'choose', code, name }) } handleMarkCountryRef.current = handleMarkCountry setConfirmActionRef.current = setConfirmAction const handleUnmarkCountry = (code: string): void => { const country = data?.countries.find(c => c.code === code) setConfirmAction({ type: 'unmark', code, name: resolveName(code) }) } const select_country_from_search = (country_code: string): void => { const country_label = resolveName(country_code) set_atlas_country_search(country_label) set_atlas_country_open(false) set_atlas_country_results([]) const layer = country_layer_by_a2_ref.current[country_code] try { if (layer?.getBounds && mapInstance.current) { mapInstance.current.fitBounds(layer.getBounds(), { padding: [24, 24], animate: true, maxZoom: 6 }) } } catch (e ) { console.error('Error fitting bounds', e) } setConfirmAction({ type: 'choose', code: country_code, name: country_label }) } const executeConfirmAction = async (): Promise => { if (!confirmAction) return const { type, code } = confirmAction setConfirmAction(null) // Update local state immediately (no API reload = no map re-render flash) if (type === 'mark') { apiClient.post(`/addons/atlas/country/${code}/mark`).catch(() => {}) setData(prev => { if (!prev || prev.countries.find(c => c.code === code)) return prev return { ...prev, countries: [...prev.countries, { code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 }, } }) } else { apiClient.delete(`/addons/atlas/country/${code}/mark`).catch(() => {}) setSelectedCountry(null) setCountryDetail(null) setData(prev => { if (!prev) return prev const c = prev.countries.find(c => c.code === code) if (!c || c.placeCount > 0 || c.tripCount > 0) return prev return { ...prev, countries: prev.countries.filter(c => c.code !== code), stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) }, } }) setVisitedRegions(prev => { if (!prev[code]) return prev const next = { ...prev } delete next[code] return next }) } } const handleAddBucketItem = async (): Promise => { if (!bucketForm.name.trim()) return try { const data: Record = { name: bucketForm.name.trim() } if (bucketForm.notes.trim()) data.notes = bucketForm.notes.trim() if (bucketForm.lat && bucketForm.lng) { data.lat = parseFloat(bucketForm.lat); data.lng = parseFloat(bucketForm.lng) } const targetDate = bucketForm.target_date || (bucketPoiMonth > 0 && bucketPoiYear > 0 ? `${bucketPoiYear}-${String(bucketPoiMonth).padStart(2, '0')}` : null) if (targetDate) data.target_date = targetDate const r = await apiClient.post('/addons/atlas/bucket-list', data) setBucketList(prev => [r.data.item, ...prev]) setBucketForm({ name: '', notes: '', lat: '', lng: '', target_date: '' }) setBucketSearch(''); setBucketSearchResults([]); setBucketPoiMonth(0); setBucketPoiYear(0) setShowBucketAdd(false) } catch { /* */ } } const handleDeleteBucketItem = async (id: number): Promise => { try { await apiClient.delete(`/addons/atlas/bucket-list/${id}`) setBucketList(prev => prev.filter(i => i.id !== id)) } catch { /* */ } } const handleBucketPoiSearch = async () => { if (!bucketSearch.trim()) return setBucketSearching(true) try { const result = await mapsApi.search(bucketSearch, language) setBucketSearchResults(result.places || []) } catch {} finally { setBucketSearching(false) } } const handleSelectBucketPoi = (result: any) => { const targetDate = bucketPoiMonth > 0 && bucketPoiYear > 0 ? `${bucketPoiYear}-${String(bucketPoiMonth).padStart(2, '0')}` : null setBucketForm({ name: result.name || bucketSearch, notes: '', lat: String(result.lat || ''), lng: String(result.lng || ''), target_date: targetDate || '', }) setBucketSearchResults([]) setBucketSearch('') } // Render bucket list markers on map useEffect(() => { if (!mapInstance.current) return if (bucketMarkersRef.current) { mapInstance.current.removeLayer(bucketMarkersRef.current) } if (bucketList.length === 0) return const markers = bucketList.filter(b => b.lat && b.lng).map(b => { const icon = L.divIcon({ className: '', html: `
`, iconSize: [28, 28], iconAnchor: [14, 14], }) return L.marker([b.lat!, b.lng!], { icon }).bindTooltip( `
${b.name}
${b.notes ? `
${b.notes}
` : ''}`, { className: 'atlas-tooltip', direction: 'top', offset: [0, -14] } ) }) bucketMarkersRef.current = L.layerGroup(markers).addTo(mapInstance.current) }, [bucketList]) const loadCountryDetail = async (code: string): Promise => { setSelectedCountry(code) try { const r = await apiClient.get(`/addons/atlas/country/${code}`) setCountryDetail(r.data) } catch { /* */ } } loadCountryDetailRef.current = loadCountryDetail const stats = data?.stats || { totalTrips: 0, totalPlaces: 0, totalCountries: 0, totalDays: 0 } const countries = data?.countries || [] if (loading) { return (
) } return (
{/* Map */}
{/* Region tooltip (custom, always on top, ref-controlled to avoid re-renders) */}
{ const raw = e.target.value set_atlas_country_search(raw) const q = raw.trim().toLowerCase() if (!q) { set_atlas_country_results([]) set_atlas_country_open(false) return } const results = atlas_country_options .filter(o => o.label.toLowerCase().includes(q) || o.code.toLowerCase() === q) .slice(0, 8) set_atlas_country_results(results) set_atlas_country_open(true) }} onFocus={() => { if (atlas_country_results.length > 0) set_atlas_country_open(true) }} onKeyDown={(e) => { if (e.key === 'Escape') { set_atlas_country_open(false) return } if (e.key === 'Enter') { const first = atlas_country_results[0] if (first) select_country_from_search(first.code) } }} placeholder={t('atlas.searchCountry')} autoComplete="off" spellCheck={false} style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 13, fontFamily: 'inherit', color: 'var(--text-primary)', }} /> {atlas_country_search.trim() && ( )}
{atlas_country_open && atlas_country_results.length > 0 && (
set_atlas_country_open(false)} > {atlas_country_results.map((r) => ( ))}
)}
{/* Mobile: Bottom bar */}
{/* Countries highlighted */}

{stats.totalCountries}

{t('atlas.countries')}

{[[stats.totalTrips, t('atlas.trips')], [stats.totalPlaces, t('atlas.places')], [stats.totalCities || 0, t('atlas.cities')], [stats.totalDays, t('atlas.days')]].map(([v, l], i) => (

{v}

{l}

))}
{/* Desktop Panel — bottom center, glass effect */}
{/* Liquid glass glare effect */}
{/* Border glow that follows cursor */}
navigate(`/trips/${id}`)} onUnmarkCountry={handleUnmarkCountry} bucketList={bucketList} bucketTab={bucketTab} setBucketTab={setBucketTab} showBucketAdd={showBucketAdd} setShowBucketAdd={setShowBucketAdd} bucketForm={bucketForm} setBucketForm={setBucketForm} onAddBucket={handleAddBucketItem} onDeleteBucket={handleDeleteBucketItem} onSearchBucket={handleBucketPoiSearch} onSelectBucketPoi={handleSelectBucketPoi} bucketSearchResults={bucketSearchResults} setBucketSearchResults={setBucketSearchResults} bucketPoiMonth={bucketPoiMonth} setBucketPoiMonth={setBucketPoiMonth} bucketPoiYear={bucketPoiYear} setBucketPoiYear={setBucketPoiYear} bucketSearching={bucketSearching} bucketSearch={bucketSearch} setBucketSearch={setBucketSearch} t={t} dark={dark} />
{/* Country action popup */} {confirmAction && (
setConfirmAction(null)}>
e.stopPropagation()}> {confirmAction.code.length === 2 ? ( {confirmAction.code} ) : (
{countryCodeToFlag(confirmAction.code)}
)}

{confirmAction.name}

{confirmAction.type === 'choose' && (
)} {confirmAction.type === 'choose-region' && (
{confirmAction.countryName && (

{confirmAction.countryName}

)}
)} {confirmAction.type === 'unmark' && ( <>

{t('atlas.confirmUnmark')}

)} {confirmAction.type === 'unmark-region' && ( <> {confirmAction.countryName && (

{confirmAction.countryName}

)}

{t('atlas.confirmUnmarkRegion')}

)} {confirmAction.type === 'bucket' && ( <>

{t('atlas.bucketWhen')}

setBucketMonth(Number(v))} placeholder={t('atlas.month')} options={[ { value: '0', label: '—' }, ...Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: new Date(2000, i).toLocaleString(language, { month: 'long' }) })), ]} size="sm" />
setBucketYear(Number(v))} placeholder={t('atlas.year')} options={[ { value: '0', label: '—' }, ...Array.from({ length: 20 }, (_, i) => ({ value: String(new Date().getFullYear() + i), label: String(new Date().getFullYear() + i) })), ]} size="sm" />
)} {confirmAction.type === 'mark' && ( <>

{t('atlas.confirmMark')}

)}
)}
) } interface SidebarContentProps { data: AtlasData | null stats: AtlasStats countries: AtlasCountry[] selectedCountry: string | null countryDetail: CountryDetail | null resolveName: (code: string) => string onCountryClick: (code: string) => void onTripClick: (id: number) => void onUnmarkCountry?: (code: string) => void bucketList: any[] bucketTab: 'stats' | 'bucket' setBucketTab: (tab: 'stats' | 'bucket') => void showBucketAdd: boolean setShowBucketAdd: (v: boolean) => void bucketForm: { name: string; notes: string; lat: string; lng: string; target_date: string } setBucketForm: (f: { name: string; notes: string; lat: string; lng: string; target_date: string }) => void onAddBucket: () => Promise onDeleteBucket: (id: number) => Promise onSearchBucket: () => Promise onSelectBucketPoi: (result: any) => void bucketSearchResults: any[] setBucketSearchResults: (v: string[]) => void bucketPoiMonth: number setBucketPoiMonth: (v: number) => void bucketPoiYear: number setBucketPoiYear: (v: number) => void bucketSearching: boolean bucketSearch: string setBucketSearch: (v: string) => void t: TranslationFn dark: boolean } function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement { const { language } = useTranslation() const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})` const tp = dark ? '#f1f5f9' : '#0f172a' const tm = dark ? '#94a3b8' : '#64748b' const tf = dark ? '#475569' : '#94a3b8' const accent = '#818cf8' const { mostVisited, continents, lastTrip, nextTrip, streak, firstYear, tripsThisYear } = data || {} const contEntries = continents ? Object.entries(continents).sort((a, b) => b[1] - a[1]) : [] const maxCont = contEntries.length > 0 ? contEntries[0][1] : 1 const CL = { 'Europe': t('atlas.europe'), 'Asia': t('atlas.asia'), 'North America': t('atlas.northAmerica'), 'South America': t('atlas.southAmerica'), 'Africa': t('atlas.africa'), 'Oceania': t('atlas.oceania') } const contColors = ['#818cf8', '#f472b6', '#34d399', '#fbbf24', '#fb923c', '#22d3ee'] // Tab switcher const tabBar = (
{[{ id: 'stats', label: t('atlas.statsTab'), icon: Globe }, { id: 'bucket', label: t('atlas.bucketTab'), icon: Star }].map(tab => ( ))}
) if (countries.length === 0 && !lastTrip && bucketTab !== 'bucket') { return ( <> {tabBar}

{t('atlas.noData')}

{t('atlas.noDataHint')}

) } const thisYear = new Date().getFullYear() const divider = `2px solid ${bg(0.08)}` // Bucket list content const bucketContent = ( <>
{bucketList.map(item => (
{(() => { const code = item.country_code?.length === 2 ? item.country_code : (Object.entries(A2_TO_A3).find(([, v]) => v === item.country_code)?.[0] || '') return code ? ( {code} ) : })()} {item.name} {item.target_date && (() => { const [y, m] = item.target_date.split('-') const label = m ? new Date(Number(y), Number(m) - 1).toLocaleString(language, { month: 'short', year: 'numeric' }) : y return {label} })()} {!item.target_date && item.notes && {item.notes}}
))} {bucketList.length === 0 && !showBucketAdd && (
{t('atlas.bucketEmptyHint')}
)}
{showBucketAdd ? (
{/* Search or manual name */}
{ const v = e.target.value; if (bucketForm.name) setBucketForm({ ...bucketForm, name: v }); else setBucketSearch(v) }} onKeyDown={e => { if (e.key === 'Enter' && !bucketForm.name) onSearchBucket(); else if (e.key === 'Enter') onAddBucket(); if (e.key === 'Escape') setShowBucketAdd(false) }} placeholder={t('atlas.bucketNamePlaceholder')} autoFocus style={{ flex: 1, padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 12, fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)', background: 'var(--bg-input)' }} /> {!bucketForm.name && ( )} {bucketForm.name && ( )}
{bucketSearchResults.length > 0 && (
{bucketSearchResults.slice(0, 6).map((r, i) => ( ))}
)}
{/* Selected place indicator */} {bucketForm.lat && bucketForm.lng && (
{Number(bucketForm.lat).toFixed(4)}, {Number(bucketForm.lng).toFixed(4)}
)} {/* Month / Year with CustomSelect */}
setBucketPoiMonth(Number(v))} placeholder={t('atlas.month')} size="sm" options={[{ value: '0', label: '—' }, ...Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: new Date(2000, i).toLocaleString(language, { month: 'short' }) }))]} />
setBucketPoiYear(Number(v))} placeholder={t('atlas.year')} size="sm" options={[{ value: '0', label: '—' }, ...Array.from({ length: 20 }, (_, i) => ({ value: String(new Date().getFullYear() + i), label: String(new Date().getFullYear() + i) }))]} />
) : (
)} ) return ( <> {tabBar} {/* Both tabs always rendered so the wider one sets the panel width */}
{/* ═══ SECTION 1: Numbers ═══ */} {/* Countries hero */}
{stats.totalCountries} {t('atlas.countries')}
{/* Other stats */} {[[stats.totalTrips, t('atlas.trips')], [stats.totalPlaces, t('atlas.places')], [stats.totalCities || 0, t('atlas.cities')], [stats.totalDays, t('atlas.days')]].map(([v, l], i) => (
{v} {l}
))} {/* ═══ DIVIDER ═══ */}
{/* ═══ SECTION 2: Continents ═══ */}
{['Europe', 'Asia', 'North America', 'South America', 'Africa', 'Oceania'].map((cont) => { const count = continents?.[cont] || 0 const active = count > 0 return (
{count} {CL[cont]}
) })}
{/* ═══ DIVIDER ═══ */}
{/* ═══ SECTION 3: Highlights & Streaks ═══ */}
{/* Last trip */} {lastTrip && ( )} {/* Streak */} {streak > 0 && (
{streak} {streak === 1 ? t('atlas.yearInRow') : t('atlas.yearsInRow')}
)} {/* This year */} {tripsThisYear > 0 && (
{tripsThisYear} {tripsThisYear === 1 ? t('atlas.tripIn') : t('atlas.tripsIn')} {thisYear}
)}
{/* ═══ Country detail overlay ═══ */} {selectedCountry && countryDetail && ( <>
{countryCodeToFlag(selectedCountry)}

{resolveName(selectedCountry)}

{countryDetail.places.length} {t('atlas.places')} · {countryDetail.trips.length} Trips

{countryDetail.trips.slice(0, 3).map(trip => ( ))} {countryDetail.manually_marked && onUnmarkCountry && ( )}
)}
{bucketContent}
) }