feat(atlas): mark sub-national regions as visited with cascade behavior
- Add visited_regions table migration - Mark/unmark region endpoints with auto-mark parent country - Unmark country cascades to its regions; unmark last region cascades to country - Region modal with mark/unmark flow and bucket list shortcut - Viewport-based lazy loading of region GeoJSON at zoom >= 6 - i18n: add atlas.markRegionVisitedHint and atlas.confirmUnmarkRegion across all 13 locales
This commit is contained in:
@@ -690,8 +690,10 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.unmark': 'إزالة',
|
||||
'atlas.confirmMark': 'تعيين هذا البلد كمُزار؟',
|
||||
'atlas.confirmUnmark': 'إزالة هذا البلد من قائمة المُزارة؟',
|
||||
'atlas.confirmUnmarkRegion': 'إزالة هذه المنطقة من قائمة المُزارة؟',
|
||||
'atlas.markVisited': 'تعيين كمُزار',
|
||||
'atlas.markVisitedHint': 'إضافة هذا البلد إلى قائمة المُزارة',
|
||||
'atlas.markRegionVisitedHint': 'إضافة هذه المنطقة إلى قائمة المُزارة',
|
||||
'atlas.addToBucket': 'إضافة إلى قائمة الأمنيات',
|
||||
'atlas.addPoi': 'إضافة مكان',
|
||||
'atlas.searchCountry': 'ابحث عن دولة...',
|
||||
|
||||
@@ -672,8 +672,10 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.unmark': 'Remover',
|
||||
'atlas.confirmMark': 'Marcar este país como visitado?',
|
||||
'atlas.confirmUnmark': 'Remover este país da lista de visitados?',
|
||||
'atlas.confirmUnmarkRegion': 'Remover esta região da lista de visitados?',
|
||||
'atlas.markVisited': 'Marcar como visitado',
|
||||
'atlas.markVisitedHint': 'Adicionar este país à lista de visitados',
|
||||
'atlas.markRegionVisitedHint': 'Adicionar esta região à lista de visitados',
|
||||
'atlas.addToBucket': 'Adicionar à lista de desejos',
|
||||
'atlas.addPoi': 'Adicionar lugar',
|
||||
'atlas.searchCountry': 'Buscar um país...',
|
||||
|
||||
@@ -689,8 +689,10 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.unmark': 'Odebrat',
|
||||
'atlas.confirmMark': 'Označit tuto zemi jako navštívenou?',
|
||||
'atlas.confirmUnmark': 'Odebrat tuto zemi ze seznamu navštívených?',
|
||||
'atlas.confirmUnmarkRegion': 'Odebrat tento region ze seznamu navštívených?',
|
||||
'atlas.markVisited': 'Označit jako navštívené',
|
||||
'atlas.markVisitedHint': 'Přidat tuto zemi do seznamu navštívených',
|
||||
'atlas.markRegionVisitedHint': 'Přidat tento region do seznamu navštívených',
|
||||
'atlas.addToBucket': 'Přidat do seznamu přání (Bucket list)',
|
||||
'atlas.addPoi': 'Přidat místo',
|
||||
'atlas.bucketNamePlaceholder': 'Název (země, město, místo...)',
|
||||
|
||||
@@ -688,8 +688,10 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.unmark': 'Entfernen',
|
||||
'atlas.confirmMark': 'Dieses Land als besucht markieren?',
|
||||
'atlas.confirmUnmark': 'Dieses Land von der Liste entfernen?',
|
||||
'atlas.confirmUnmarkRegion': 'Diese Region von der Liste entfernen?',
|
||||
'atlas.markVisited': 'Als besucht markieren',
|
||||
'atlas.markVisitedHint': 'Dieses Land zur besuchten Liste hinzufügen',
|
||||
'atlas.markRegionVisitedHint': 'Diese Region zur besuchten Liste hinzufügen',
|
||||
'atlas.addToBucket': 'Zur Bucket List',
|
||||
'atlas.addPoi': 'Ort hinzufügen',
|
||||
'atlas.searchCountry': 'Land suchen...',
|
||||
|
||||
@@ -685,8 +685,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.unmark': 'Remove',
|
||||
'atlas.confirmMark': 'Mark this country as visited?',
|
||||
'atlas.confirmUnmark': 'Remove this country from your visited list?',
|
||||
'atlas.confirmUnmarkRegion': 'Remove this region from your visited list?',
|
||||
'atlas.markVisited': 'Mark as visited',
|
||||
'atlas.markVisitedHint': 'Add this country to your visited list',
|
||||
'atlas.markRegionVisitedHint': 'Add this region to your visited list',
|
||||
'atlas.addToBucket': 'Add to bucket list',
|
||||
'atlas.addPoi': 'Add place',
|
||||
'atlas.searchCountry': 'Search a country...',
|
||||
|
||||
@@ -700,8 +700,10 @@ const es: Record<string, string> = {
|
||||
'atlas.unmark': 'Eliminar',
|
||||
'atlas.confirmMark': '¿Marcar este país como visitado?',
|
||||
'atlas.confirmUnmark': '¿Eliminar este país de tu lista de visitados?',
|
||||
'atlas.confirmUnmarkRegion': '¿Eliminar esta región de tu lista de visitados?',
|
||||
'atlas.markVisited': 'Marcar como visitado',
|
||||
'atlas.markVisitedHint': 'Añadir este país a tu lista de visitados',
|
||||
'atlas.markRegionVisitedHint': 'Añadir esta región a tu lista de visitados',
|
||||
'atlas.addToBucket': 'Añadir a lista de deseos',
|
||||
'atlas.addPoi': 'Añadir lugar',
|
||||
'atlas.searchCountry': 'Buscar un país...',
|
||||
|
||||
@@ -723,8 +723,10 @@ const fr: Record<string, string> = {
|
||||
'atlas.unmark': 'Retirer',
|
||||
'atlas.confirmMark': 'Marquer ce pays comme visité ?',
|
||||
'atlas.confirmUnmark': 'Retirer ce pays de votre liste ?',
|
||||
'atlas.confirmUnmarkRegion': 'Retirer cette région de votre liste ?',
|
||||
'atlas.markVisited': 'Marquer comme visité',
|
||||
'atlas.markVisitedHint': 'Ajouter ce pays à votre liste de visités',
|
||||
'atlas.markRegionVisitedHint': 'Ajouter cette région à votre liste de visités',
|
||||
'atlas.addToBucket': 'Ajouter à la bucket list',
|
||||
'atlas.addPoi': 'Ajouter un lieu',
|
||||
'atlas.searchCountry': 'Rechercher un pays…',
|
||||
|
||||
@@ -688,8 +688,10 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.unmark': 'Eltávolítás',
|
||||
'atlas.confirmMark': 'Megjelölöd ezt az országot meglátogatottként?',
|
||||
'atlas.confirmUnmark': 'Eltávolítod ezt az országot a meglátogatottak listájáról?',
|
||||
'atlas.confirmUnmarkRegion': 'Eltávolítod ezt a régiót a meglátogatottak listájáról?',
|
||||
'atlas.markVisited': 'Megjelölés meglátogatottként',
|
||||
'atlas.markVisitedHint': 'Ország hozzáadása a meglátogatottak listájához',
|
||||
'atlas.markRegionVisitedHint': 'Régió hozzáadása a meglátogatottak listájához',
|
||||
'atlas.addToBucket': 'Hozzáadás a bakancslistához',
|
||||
'atlas.addPoi': 'Hely hozzáadása',
|
||||
'atlas.searchCountry': 'Ország keresése...',
|
||||
|
||||
@@ -688,8 +688,10 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.unmark': 'Rimuovi',
|
||||
'atlas.confirmMark': 'Segnare questo paese come visitato?',
|
||||
'atlas.confirmUnmark': 'Rimuovere questo paese dalla tua lista dei visitati?',
|
||||
'atlas.confirmUnmarkRegion': 'Rimuovere questa regione dalla tua lista dei visitati?',
|
||||
'atlas.markVisited': 'Segna come visitato',
|
||||
'atlas.markVisitedHint': 'Aggiungi questo paese alla tua lista dei visitati',
|
||||
'atlas.markRegionVisitedHint': 'Aggiungi questa regione alla tua lista dei visitati',
|
||||
'atlas.addToBucket': 'Aggiungi alla lista desideri',
|
||||
'atlas.addPoi': 'Aggiungi luogo',
|
||||
'atlas.bucketNamePlaceholder': 'Nome (paese, città, luogo...)',
|
||||
|
||||
@@ -723,8 +723,10 @@ const nl: Record<string, string> = {
|
||||
'atlas.unmark': 'Verwijderen',
|
||||
'atlas.confirmMark': 'Dit land als bezocht markeren?',
|
||||
'atlas.confirmUnmark': 'Dit land van je bezochte lijst verwijderen?',
|
||||
'atlas.confirmUnmarkRegion': 'Deze regio van je bezochte lijst verwijderen?',
|
||||
'atlas.markVisited': 'Markeren als bezocht',
|
||||
'atlas.markVisitedHint': 'Dit land toevoegen aan je bezochte lijst',
|
||||
'atlas.markRegionVisitedHint': 'Deze regio toevoegen aan je bezochte lijst',
|
||||
'atlas.addToBucket': 'Aan bucket list toevoegen',
|
||||
'atlas.addPoi': 'Plaats toevoegen',
|
||||
'atlas.searchCountry': 'Zoek een land...',
|
||||
|
||||
@@ -651,8 +651,10 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'atlas.unmark': 'Usuń',
|
||||
'atlas.confirmMark': 'Oznaczyć ten kraj jako odwiedzony?',
|
||||
'atlas.confirmUnmark': 'Usunąć ten kraj z listy odwiedzonych?',
|
||||
'atlas.confirmUnmarkRegion': 'Usunąć ten region z listy odwiedzonych?',
|
||||
'atlas.markVisited': 'Oznacz jako odwiedzony',
|
||||
'atlas.markVisitedHint': 'Dodaj ten kraj do listy odwiedzonych',
|
||||
'atlas.markRegionVisitedHint': 'Dodaj ten region do listy odwiedzonych',
|
||||
'atlas.addToBucket': 'Dodaj do listy marzeń',
|
||||
'atlas.addPoi': 'Dodaj miejsce',
|
||||
'atlas.bucketNamePlaceholder': 'Nazwa (kraj, miasto, miejsce...)',
|
||||
|
||||
@@ -723,8 +723,10 @@ const ru: Record<string, string> = {
|
||||
'atlas.unmark': 'Удалить',
|
||||
'atlas.confirmMark': 'Отметить эту страну как посещённую?',
|
||||
'atlas.confirmUnmark': 'Удалить эту страну из списка посещённых?',
|
||||
'atlas.confirmUnmarkRegion': 'Удалить этот регион из списка посещённых?',
|
||||
'atlas.markVisited': 'Отметить как посещённую',
|
||||
'atlas.markVisitedHint': 'Добавить эту страну в список посещённых',
|
||||
'atlas.markRegionVisitedHint': 'Добавить этот регион в список посещённых',
|
||||
'atlas.addToBucket': 'В список желаний',
|
||||
'atlas.addPoi': 'Добавить место',
|
||||
'atlas.searchCountry': 'Поиск страны...',
|
||||
|
||||
@@ -723,8 +723,10 @@ const zh: Record<string, string> = {
|
||||
'atlas.unmark': '移除',
|
||||
'atlas.confirmMark': '将此国家标记为已访问?',
|
||||
'atlas.confirmUnmark': '从已访问列表中移除此国家?',
|
||||
'atlas.confirmUnmarkRegion': '从已访问列表中移除此地区?',
|
||||
'atlas.markVisited': '标记为已访问',
|
||||
'atlas.markVisitedHint': '将此国家添加到已访问列表',
|
||||
'atlas.markRegionVisitedHint': '将此地区添加到已访问列表',
|
||||
'atlas.addToBucket': '添加到心愿单',
|
||||
'atlas.addPoi': '添加地点',
|
||||
'atlas.searchCountry': '搜索国家...',
|
||||
|
||||
@@ -154,13 +154,16 @@ export default function AtlasPage(): React.ReactElement {
|
||||
const [selectedCountry, setSelectedCountry] = useState<string | null>(null)
|
||||
const [countryDetail, setCountryDetail] = useState<CountryDetail | null>(null)
|
||||
const [geoData, setGeoData] = useState<GeoJsonFeatureCollection | null>(null)
|
||||
const [visitedRegions, setVisitedRegions] = useState<Record<string, { code: string; name: string; placeCount: number }[]>>({})
|
||||
const [visitedRegions, setVisitedRegions] = useState<Record<string, { code: string; name: string; placeCount: number; manuallyMarked?: boolean }[]>>({})
|
||||
const regionLayerRef = useRef<L.GeoJSON | null>(null)
|
||||
const regionGeoCache = useRef<Record<string, GeoJsonFeatureCollection>>({})
|
||||
const [showRegions, setShowRegions] = useState(false)
|
||||
const [regionGeoLoaded, setRegionGeoLoaded] = useState(0)
|
||||
const regionTooltipRef = useRef<HTMLDivElement>(null)
|
||||
const [confirmAction, setConfirmAction] = useState<{ type: 'mark' | 'unmark' | 'choose' | 'bucket'; code: string; name: string } | null>(null)
|
||||
const loadCountryDetailRef = useRef<(code: string) => void>(() => {})
|
||||
const handleMarkCountryRef = useRef<(code: string, name: string) => void>(() => {})
|
||||
const setConfirmActionRef = useRef<typeof setConfirmAction>(() => {})
|
||||
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)
|
||||
|
||||
@@ -227,33 +230,41 @@ export default function AtlasPage(): React.ReactElement {
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Load visited regions from server + per-country GeoJSON (once on mount)
|
||||
const regionsLoadedRef = useRef(false)
|
||||
// Load visited regions (geocoded from places/trips) — once on mount
|
||||
useEffect(() => {
|
||||
if (regionsLoadedRef.current) return
|
||||
regionsLoadedRef.current = true
|
||||
apiClient.get(`/addons/atlas/regions?_t=${Date.now()}`)
|
||||
.then(r => {
|
||||
const regions = r.data?.regions || {}
|
||||
setVisitedRegions(regions)
|
||||
const countries = Object.keys(regions)
|
||||
if (countries.length === 0) return
|
||||
apiClient.get(`/addons/atlas/regions/geo?countries=${countries.join(',')}`)
|
||||
.then(geoRes => {
|
||||
const geo = geoRes.data
|
||||
if (geo?.features) {
|
||||
for (const c of countries) {
|
||||
const features = geo.features.filter((f: any) => f.properties?.iso_a2?.toUpperCase() === c)
|
||||
regionGeoCache.current[c] = { type: 'FeatureCollection', features }
|
||||
}
|
||||
setRegionGeoLoaded(v => v + 1)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
})
|
||||
.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
|
||||
@@ -263,7 +274,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
center: [25, 0],
|
||||
zoom: 3,
|
||||
minZoom: 3,
|
||||
maxZoom: 7,
|
||||
maxZoom: 10,
|
||||
zoomControl: false,
|
||||
attributionControl: false,
|
||||
maxBounds: [[-90, -220], [90, 220]],
|
||||
@@ -279,7 +290,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
: 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png'
|
||||
|
||||
L.tileLayer(tileUrl, {
|
||||
maxZoom: 8,
|
||||
maxZoom: 10,
|
||||
keepBuffer: 25,
|
||||
updateWhenZooming: true,
|
||||
updateWhenIdle: false,
|
||||
@@ -290,31 +301,47 @@ export default function AtlasPage(): React.ReactElement {
|
||||
|
||||
// Preload adjacent zoom level tiles
|
||||
L.tileLayer(tileUrl, {
|
||||
maxZoom: 8,
|
||||
maxZoom: 10,
|
||||
keepBuffer: 10,
|
||||
opacity: 0,
|
||||
tileSize: 256,
|
||||
crossOrigin: true,
|
||||
}).addTo(map)
|
||||
|
||||
// Custom pane for region layer — below overlay (z-index 400) but above tiles
|
||||
// Custom pane for region layer — above overlay (z-index 400)
|
||||
map.createPane('regionPane')
|
||||
map.getPane('regionPane')!.style.zIndex = '399'
|
||||
map.getPane('regionPane')!.style.zIndex = '401'
|
||||
|
||||
mapInstance.current = map
|
||||
|
||||
// Zoom-based region switching — dim country layer directly
|
||||
// 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'
|
||||
const regionPane = map.getPane('regionPane')
|
||||
if (regionPane) {
|
||||
regionPane.style.opacity = shouldShow ? '1' : '0'
|
||||
regionPane.style.pointerEvents = shouldShow ? 'auto' : 'none'
|
||||
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 }
|
||||
@@ -465,13 +492,11 @@ export default function AtlasPage(): React.ReactElement {
|
||||
return false
|
||||
}
|
||||
|
||||
// Only include visited region features (non-visited pass through to country layer)
|
||||
// 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) {
|
||||
if (isVisitedFeature(f)) {
|
||||
allFeatures.push(f)
|
||||
}
|
||||
allFeatures.push(f)
|
||||
}
|
||||
}
|
||||
if (allFeatures.length === 0) return
|
||||
@@ -495,26 +520,64 @@ export default function AtlasPage(): React.ReactElement {
|
||||
pane: 'regionPane',
|
||||
style: (feature) => {
|
||||
const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase()
|
||||
return {
|
||||
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({ fillOpacity: 0.95, weight: 2, color: dark ? '#818cf8' : '#4f46e5' })
|
||||
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 = `<div style="font-weight:600;margin-bottom:3px">${regionName}</div><div style="opacity:0.5;font-size:10px">${countryName}</div><div style="margin-top:5px;font-size:11px"><b>${count}</b> ${count === 1 ? 'place' : 'places'}</div>`
|
||||
tt.innerHTML = visited
|
||||
? `<div style="font-weight:600;margin-bottom:3px">${regionName}</div><div style="opacity:0.5;font-size:10px">${countryName}</div><div style="margin-top:5px;font-size:11px"><b>${count}</b> ${count === 1 ? 'place' : 'places'}</div>`
|
||||
: `<div style="font-weight:600;margin-bottom:3px">${regionName}</div><div style="opacity:0.5;font-size:10px">${countryName}</div>`
|
||||
}
|
||||
})
|
||||
layer.on('mousemove', (e: any) => {
|
||||
@@ -527,12 +590,18 @@ export default function AtlasPage(): React.ReactElement {
|
||||
if (tt) tt.style.display = 'none'
|
||||
})
|
||||
},
|
||||
}).addTo(mapInstance.current)
|
||||
})
|
||||
// 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)
|
||||
@@ -586,6 +655,12 @@ export default function AtlasPage(): React.ReactElement {
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,6 +738,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
setCountryDetail(r.data)
|
||||
} catch { /* */ }
|
||||
}
|
||||
loadCountryDetailRef.current = loadCountryDetail
|
||||
|
||||
const stats = data?.stats || { totalTrips: 0, totalPlaces: 0, totalCountries: 0, totalDays: 0 }
|
||||
const countries = data?.countries || []
|
||||
@@ -932,6 +1008,50 @@ export default function AtlasPage(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmAction.type === 'choose-region' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{confirmAction.countryName && (
|
||||
<p style={{ margin: '-8px 0 8px', fontSize: 12, color: 'var(--text-muted)' }}>{confirmAction.countryName}</p>
|
||||
)}
|
||||
<button onClick={async () => {
|
||||
const { code: countryCode, name: rName, regionCode: rCode } = confirmAction
|
||||
if (!rCode) return
|
||||
try {
|
||||
await apiClient.post(`/addons/atlas/region/${rCode}/mark`, { name: rName, country_code: countryCode })
|
||||
setVisitedRegions(prev => {
|
||||
const existing = prev[countryCode] || []
|
||||
if (existing.find(r => r.code === rCode)) return prev
|
||||
return { ...prev, [countryCode]: [...existing, { code: rCode, name: rName, placeCount: 0, manuallyMarked: true }] }
|
||||
})
|
||||
setData(prev => {
|
||||
if (!prev || prev.countries.find(c => c.code === countryCode)) return prev
|
||||
return { ...prev, countries: [...prev.countries, { code: countryCode, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } }
|
||||
})
|
||||
} catch {}
|
||||
setConfirmAction(null)
|
||||
}}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||
<MapPin size={18} style={{ color: 'var(--text-primary)', flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('atlas.markVisited')}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{t('atlas.markRegionVisitedHint')}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button onClick={() => setConfirmAction({ ...confirmAction, type: 'bucket' })}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||
<Star size={18} style={{ color: '#fbbf24', flexShrink: 0 }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('atlas.addToBucket')}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{t('atlas.addToBucketHint')}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmAction.type === 'unmark' && (
|
||||
<>
|
||||
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.confirmUnmark')}</p>
|
||||
@@ -948,6 +1068,51 @@ export default function AtlasPage(): React.ReactElement {
|
||||
</>
|
||||
)}
|
||||
|
||||
{confirmAction.type === 'unmark-region' && (
|
||||
<>
|
||||
{confirmAction.countryName && (
|
||||
<p style={{ margin: '-8px 0 8px', fontSize: 12, color: 'var(--text-muted)' }}>{confirmAction.countryName}</p>
|
||||
)}
|
||||
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.confirmUnmarkRegion')}</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||
<button onClick={() => setConfirmAction(null)}
|
||||
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={async () => {
|
||||
const { code: countryCode, regionCode: rCode } = confirmAction
|
||||
if (!rCode) return
|
||||
try {
|
||||
await apiClient.delete(`/addons/atlas/region/${rCode}/mark`)
|
||||
setVisitedRegions(prev => {
|
||||
const remaining = (prev[countryCode] || []).filter(r => r.code !== rCode)
|
||||
const next = { ...prev, [countryCode]: remaining }
|
||||
if (remaining.length === 0) delete next[countryCode]
|
||||
return next
|
||||
})
|
||||
// If no manually-marked regions remain, also remove country if it has no trips/places
|
||||
setData(prev => {
|
||||
if (!prev) return prev
|
||||
const c = prev.countries.find(c => c.code === countryCode)
|
||||
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
|
||||
const remainingRegions = (visitedRegions[countryCode] || []).filter(r => r.code !== rCode && r.manuallyMarked)
|
||||
if (remainingRegions.length > 0) return prev
|
||||
return {
|
||||
...prev,
|
||||
countries: prev.countries.filter(c => c.code !== countryCode),
|
||||
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
|
||||
}
|
||||
})
|
||||
} catch {}
|
||||
setConfirmAction(null)
|
||||
}}
|
||||
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: '#ef4444', color: 'white' }}>
|
||||
{t('atlas.unmark')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{confirmAction.type === 'bucket' && (
|
||||
<>
|
||||
<p style={{ margin: '0 0 14px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.bucketWhen')}</p>
|
||||
@@ -978,7 +1143,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<button onClick={() => setConfirmAction({ ...confirmAction, type: 'choose' })}
|
||||
<button onClick={() => setConfirmAction({ ...confirmAction, type: confirmAction.regionCode ? 'choose-region' : 'choose' })}
|
||||
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
|
||||
@@ -563,6 +563,20 @@ function runMigrations(db: Database.Database): void {
|
||||
CREATE INDEX IF NOT EXISTS idx_place_regions_region ON place_regions(region_code);
|
||||
`);
|
||||
},
|
||||
() => {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS visited_regions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
region_code TEXT NOT NULL,
|
||||
region_name TEXT NOT NULL,
|
||||
country_code TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, region_code)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_visited_regions_country ON visited_regions(country_code);
|
||||
`);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
getCountryPlaces,
|
||||
markCountryVisited,
|
||||
unmarkCountryVisited,
|
||||
markRegionVisited,
|
||||
unmarkRegionVisited,
|
||||
getVisitedRegions,
|
||||
getRegionGeo,
|
||||
listBucketList,
|
||||
@@ -56,6 +58,20 @@ router.delete('/country/:code/mark', (req: Request, res: Response) => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.post('/region/:code/mark', (req: Request, res: Response) => {
|
||||
const userId = (req as AuthRequest).user.id;
|
||||
const { name, country_code } = req.body;
|
||||
if (!name || !country_code) return res.status(400).json({ error: 'name and country_code are required' });
|
||||
markRegionVisited(userId, req.params.code.toUpperCase(), name, country_code.toUpperCase());
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.delete('/region/:code/mark', (req: Request, res: Response) => {
|
||||
const userId = (req as AuthRequest).user.id;
|
||||
unmarkRegionVisited(userId, req.params.code.toUpperCase());
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Bucket List ─────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/bucket-list', (req: Request, res: Response) => {
|
||||
|
||||
@@ -371,6 +371,32 @@ export function markCountryVisited(userId: number, code: string): void {
|
||||
|
||||
export function unmarkCountryVisited(userId: number, code: string): void {
|
||||
db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(userId, code);
|
||||
db.prepare('DELETE FROM visited_regions WHERE user_id = ? AND country_code = ?').run(userId, code);
|
||||
}
|
||||
|
||||
// ── Mark / unmark region ────────────────────────────────────────────────────
|
||||
|
||||
export function listManuallyVisitedRegions(userId: number): { region_code: string; region_name: string; country_code: string }[] {
|
||||
return db.prepare(
|
||||
'SELECT region_code, region_name, country_code FROM visited_regions WHERE user_id = ? ORDER BY created_at DESC'
|
||||
).all(userId) as { region_code: string; region_name: string; country_code: string }[];
|
||||
}
|
||||
|
||||
export function markRegionVisited(userId: number, regionCode: string, regionName: string, countryCode: string): void {
|
||||
db.prepare('INSERT OR IGNORE INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)').run(userId, regionCode, regionName, countryCode);
|
||||
// Auto-mark parent country if not already visited
|
||||
db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, countryCode);
|
||||
}
|
||||
|
||||
export function unmarkRegionVisited(userId: number, regionCode: string): void {
|
||||
const region = db.prepare('SELECT country_code FROM visited_regions WHERE user_id = ? AND region_code = ?').get(userId, regionCode) as { country_code: string } | undefined;
|
||||
db.prepare('DELETE FROM visited_regions WHERE user_id = ? AND region_code = ?').run(userId, regionCode);
|
||||
if (region) {
|
||||
const remaining = db.prepare('SELECT COUNT(*) as count FROM visited_regions WHERE user_id = ? AND country_code = ?').get(userId, region.country_code) as { count: number };
|
||||
if (remaining.count === 0) {
|
||||
db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(userId, region.country_code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sub-national region resolution ────────────────────────────────────────
|
||||
@@ -450,11 +476,20 @@ export async function getVisitedRegions(userId: number): Promise<{ regions: Reco
|
||||
}
|
||||
}
|
||||
|
||||
const result: Record<string, { code: string; name: string; placeCount: number }[]> = {};
|
||||
const result: Record<string, { code: string; name: string; placeCount: number; manuallyMarked?: boolean }[]> = {};
|
||||
for (const [country, regions] of Object.entries(regionMap)) {
|
||||
result[country] = [...regions.values()];
|
||||
}
|
||||
|
||||
// Merge manually marked regions
|
||||
const manualRegions = listManuallyVisitedRegions(userId);
|
||||
for (const r of manualRegions) {
|
||||
if (!result[r.country_code]) result[r.country_code] = [];
|
||||
if (!result[r.country_code].find(x => x.code === r.region_code)) {
|
||||
result[r.country_code].push({ code: r.region_code, name: r.region_name, placeCount: 0, manuallyMarked: true });
|
||||
}
|
||||
}
|
||||
|
||||
return { regions: result };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user