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:
jubnl
2026-04-05 03:17:33 +02:00
parent 16cadeb09e
commit 71c1683bb3
17 changed files with 301 additions and 45 deletions

View File

@@ -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': 'ابحث عن دولة...',

View File

@@ -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...',

View File

@@ -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...)',

View File

@@ -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...',

View File

@@ -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...',

View File

@@ -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...',

View File

@@ -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…',

View File

@@ -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...',

View File

@@ -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...)',

View File

@@ -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...',

View File

@@ -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...)',

View File

@@ -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': 'Поиск страны...',

View File

@@ -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': '搜索国家...',

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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) => {

View File

@@ -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 };
}