mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 14:51:45 +00:00
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.unmark': 'إزالة',
|
||||||
'atlas.confirmMark': 'تعيين هذا البلد كمُزار؟',
|
'atlas.confirmMark': 'تعيين هذا البلد كمُزار؟',
|
||||||
'atlas.confirmUnmark': 'إزالة هذا البلد من قائمة المُزارة؟',
|
'atlas.confirmUnmark': 'إزالة هذا البلد من قائمة المُزارة؟',
|
||||||
|
'atlas.confirmUnmarkRegion': 'إزالة هذه المنطقة من قائمة المُزارة؟',
|
||||||
'atlas.markVisited': 'تعيين كمُزار',
|
'atlas.markVisited': 'تعيين كمُزار',
|
||||||
'atlas.markVisitedHint': 'إضافة هذا البلد إلى قائمة المُزارة',
|
'atlas.markVisitedHint': 'إضافة هذا البلد إلى قائمة المُزارة',
|
||||||
|
'atlas.markRegionVisitedHint': 'إضافة هذه المنطقة إلى قائمة المُزارة',
|
||||||
'atlas.addToBucket': 'إضافة إلى قائمة الأمنيات',
|
'atlas.addToBucket': 'إضافة إلى قائمة الأمنيات',
|
||||||
'atlas.addPoi': 'إضافة مكان',
|
'atlas.addPoi': 'إضافة مكان',
|
||||||
'atlas.searchCountry': 'ابحث عن دولة...',
|
'atlas.searchCountry': 'ابحث عن دولة...',
|
||||||
|
|||||||
@@ -672,8 +672,10 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.unmark': 'Remover',
|
'atlas.unmark': 'Remover',
|
||||||
'atlas.confirmMark': 'Marcar este país como visitado?',
|
'atlas.confirmMark': 'Marcar este país como visitado?',
|
||||||
'atlas.confirmUnmark': 'Remover este país da lista de visitados?',
|
'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.markVisited': 'Marcar como visitado',
|
||||||
'atlas.markVisitedHint': 'Adicionar este país à lista de visitados',
|
'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.addToBucket': 'Adicionar à lista de desejos',
|
||||||
'atlas.addPoi': 'Adicionar lugar',
|
'atlas.addPoi': 'Adicionar lugar',
|
||||||
'atlas.searchCountry': 'Buscar um país...',
|
'atlas.searchCountry': 'Buscar um país...',
|
||||||
|
|||||||
@@ -689,8 +689,10 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.unmark': 'Odebrat',
|
'atlas.unmark': 'Odebrat',
|
||||||
'atlas.confirmMark': 'Označit tuto zemi jako navštívenou?',
|
'atlas.confirmMark': 'Označit tuto zemi jako navštívenou?',
|
||||||
'atlas.confirmUnmark': 'Odebrat tuto zemi ze seznamu navštívených?',
|
'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.markVisited': 'Označit jako navštívené',
|
||||||
'atlas.markVisitedHint': 'Přidat tuto zemi do seznamu navštívených',
|
'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.addToBucket': 'Přidat do seznamu přání (Bucket list)',
|
||||||
'atlas.addPoi': 'Přidat místo',
|
'atlas.addPoi': 'Přidat místo',
|
||||||
'atlas.bucketNamePlaceholder': 'Název (země, město, 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.unmark': 'Entfernen',
|
||||||
'atlas.confirmMark': 'Dieses Land als besucht markieren?',
|
'atlas.confirmMark': 'Dieses Land als besucht markieren?',
|
||||||
'atlas.confirmUnmark': 'Dieses Land von der Liste entfernen?',
|
'atlas.confirmUnmark': 'Dieses Land von der Liste entfernen?',
|
||||||
|
'atlas.confirmUnmarkRegion': 'Diese Region von der Liste entfernen?',
|
||||||
'atlas.markVisited': 'Als besucht markieren',
|
'atlas.markVisited': 'Als besucht markieren',
|
||||||
'atlas.markVisitedHint': 'Dieses Land zur besuchten Liste hinzufügen',
|
'atlas.markVisitedHint': 'Dieses Land zur besuchten Liste hinzufügen',
|
||||||
|
'atlas.markRegionVisitedHint': 'Diese Region zur besuchten Liste hinzufügen',
|
||||||
'atlas.addToBucket': 'Zur Bucket List',
|
'atlas.addToBucket': 'Zur Bucket List',
|
||||||
'atlas.addPoi': 'Ort hinzufügen',
|
'atlas.addPoi': 'Ort hinzufügen',
|
||||||
'atlas.searchCountry': 'Land suchen...',
|
'atlas.searchCountry': 'Land suchen...',
|
||||||
|
|||||||
@@ -685,8 +685,10 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.unmark': 'Remove',
|
'atlas.unmark': 'Remove',
|
||||||
'atlas.confirmMark': 'Mark this country as visited?',
|
'atlas.confirmMark': 'Mark this country as visited?',
|
||||||
'atlas.confirmUnmark': 'Remove this country from your visited list?',
|
'atlas.confirmUnmark': 'Remove this country from your visited list?',
|
||||||
|
'atlas.confirmUnmarkRegion': 'Remove this region from your visited list?',
|
||||||
'atlas.markVisited': 'Mark as visited',
|
'atlas.markVisited': 'Mark as visited',
|
||||||
'atlas.markVisitedHint': 'Add this country to your visited list',
|
'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.addToBucket': 'Add to bucket list',
|
||||||
'atlas.addPoi': 'Add place',
|
'atlas.addPoi': 'Add place',
|
||||||
'atlas.searchCountry': 'Search a country...',
|
'atlas.searchCountry': 'Search a country...',
|
||||||
|
|||||||
@@ -700,8 +700,10 @@ const es: Record<string, string> = {
|
|||||||
'atlas.unmark': 'Eliminar',
|
'atlas.unmark': 'Eliminar',
|
||||||
'atlas.confirmMark': '¿Marcar este país como visitado?',
|
'atlas.confirmMark': '¿Marcar este país como visitado?',
|
||||||
'atlas.confirmUnmark': '¿Eliminar este país de tu lista de visitados?',
|
'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.markVisited': 'Marcar como visitado',
|
||||||
'atlas.markVisitedHint': 'Añadir este país a tu lista de visitados',
|
'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.addToBucket': 'Añadir a lista de deseos',
|
||||||
'atlas.addPoi': 'Añadir lugar',
|
'atlas.addPoi': 'Añadir lugar',
|
||||||
'atlas.searchCountry': 'Buscar un país...',
|
'atlas.searchCountry': 'Buscar un país...',
|
||||||
|
|||||||
@@ -723,8 +723,10 @@ const fr: Record<string, string> = {
|
|||||||
'atlas.unmark': 'Retirer',
|
'atlas.unmark': 'Retirer',
|
||||||
'atlas.confirmMark': 'Marquer ce pays comme visité ?',
|
'atlas.confirmMark': 'Marquer ce pays comme visité ?',
|
||||||
'atlas.confirmUnmark': 'Retirer ce pays de votre liste ?',
|
'atlas.confirmUnmark': 'Retirer ce pays de votre liste ?',
|
||||||
|
'atlas.confirmUnmarkRegion': 'Retirer cette région de votre liste ?',
|
||||||
'atlas.markVisited': 'Marquer comme visité',
|
'atlas.markVisited': 'Marquer comme visité',
|
||||||
'atlas.markVisitedHint': 'Ajouter ce pays à votre liste de visités',
|
'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.addToBucket': 'Ajouter à la bucket list',
|
||||||
'atlas.addPoi': 'Ajouter un lieu',
|
'atlas.addPoi': 'Ajouter un lieu',
|
||||||
'atlas.searchCountry': 'Rechercher un pays…',
|
'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.unmark': 'Eltávolítás',
|
||||||
'atlas.confirmMark': 'Megjelölöd ezt az országot meglátogatottként?',
|
'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.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.markVisited': 'Megjelölés meglátogatottként',
|
||||||
'atlas.markVisitedHint': 'Ország hozzáadása a meglátogatottak listájához',
|
'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.addToBucket': 'Hozzáadás a bakancslistához',
|
||||||
'atlas.addPoi': 'Hely hozzáadása',
|
'atlas.addPoi': 'Hely hozzáadása',
|
||||||
'atlas.searchCountry': 'Ország keresése...',
|
'atlas.searchCountry': 'Ország keresése...',
|
||||||
|
|||||||
@@ -688,8 +688,10 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.unmark': 'Rimuovi',
|
'atlas.unmark': 'Rimuovi',
|
||||||
'atlas.confirmMark': 'Segnare questo paese come visitato?',
|
'atlas.confirmMark': 'Segnare questo paese come visitato?',
|
||||||
'atlas.confirmUnmark': 'Rimuovere questo paese dalla tua lista dei visitati?',
|
'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.markVisited': 'Segna come visitato',
|
||||||
'atlas.markVisitedHint': 'Aggiungi questo paese alla tua lista dei visitati',
|
'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.addToBucket': 'Aggiungi alla lista desideri',
|
||||||
'atlas.addPoi': 'Aggiungi luogo',
|
'atlas.addPoi': 'Aggiungi luogo',
|
||||||
'atlas.bucketNamePlaceholder': 'Nome (paese, città, luogo...)',
|
'atlas.bucketNamePlaceholder': 'Nome (paese, città, luogo...)',
|
||||||
|
|||||||
@@ -723,8 +723,10 @@ const nl: Record<string, string> = {
|
|||||||
'atlas.unmark': 'Verwijderen',
|
'atlas.unmark': 'Verwijderen',
|
||||||
'atlas.confirmMark': 'Dit land als bezocht markeren?',
|
'atlas.confirmMark': 'Dit land als bezocht markeren?',
|
||||||
'atlas.confirmUnmark': 'Dit land van je bezochte lijst verwijderen?',
|
'atlas.confirmUnmark': 'Dit land van je bezochte lijst verwijderen?',
|
||||||
|
'atlas.confirmUnmarkRegion': 'Deze regio van je bezochte lijst verwijderen?',
|
||||||
'atlas.markVisited': 'Markeren als bezocht',
|
'atlas.markVisited': 'Markeren als bezocht',
|
||||||
'atlas.markVisitedHint': 'Dit land toevoegen aan je bezochte lijst',
|
'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.addToBucket': 'Aan bucket list toevoegen',
|
||||||
'atlas.addPoi': 'Plaats toevoegen',
|
'atlas.addPoi': 'Plaats toevoegen',
|
||||||
'atlas.searchCountry': 'Zoek een land...',
|
'atlas.searchCountry': 'Zoek een land...',
|
||||||
|
|||||||
@@ -651,8 +651,10 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.unmark': 'Usuń',
|
'atlas.unmark': 'Usuń',
|
||||||
'atlas.confirmMark': 'Oznaczyć ten kraj jako odwiedzony?',
|
'atlas.confirmMark': 'Oznaczyć ten kraj jako odwiedzony?',
|
||||||
'atlas.confirmUnmark': 'Usunąć ten kraj z listy odwiedzonych?',
|
'atlas.confirmUnmark': 'Usunąć ten kraj z listy odwiedzonych?',
|
||||||
|
'atlas.confirmUnmarkRegion': 'Usunąć ten region z listy odwiedzonych?',
|
||||||
'atlas.markVisited': 'Oznacz jako odwiedzony',
|
'atlas.markVisited': 'Oznacz jako odwiedzony',
|
||||||
'atlas.markVisitedHint': 'Dodaj ten kraj do listy odwiedzonych',
|
'atlas.markVisitedHint': 'Dodaj ten kraj do listy odwiedzonych',
|
||||||
|
'atlas.markRegionVisitedHint': 'Dodaj ten region do listy odwiedzonych',
|
||||||
'atlas.addToBucket': 'Dodaj do listy marzeń',
|
'atlas.addToBucket': 'Dodaj do listy marzeń',
|
||||||
'atlas.addPoi': 'Dodaj miejsce',
|
'atlas.addPoi': 'Dodaj miejsce',
|
||||||
'atlas.bucketNamePlaceholder': 'Nazwa (kraj, miasto, miejsce...)',
|
'atlas.bucketNamePlaceholder': 'Nazwa (kraj, miasto, miejsce...)',
|
||||||
|
|||||||
@@ -723,8 +723,10 @@ const ru: Record<string, string> = {
|
|||||||
'atlas.unmark': 'Удалить',
|
'atlas.unmark': 'Удалить',
|
||||||
'atlas.confirmMark': 'Отметить эту страну как посещённую?',
|
'atlas.confirmMark': 'Отметить эту страну как посещённую?',
|
||||||
'atlas.confirmUnmark': 'Удалить эту страну из списка посещённых?',
|
'atlas.confirmUnmark': 'Удалить эту страну из списка посещённых?',
|
||||||
|
'atlas.confirmUnmarkRegion': 'Удалить этот регион из списка посещённых?',
|
||||||
'atlas.markVisited': 'Отметить как посещённую',
|
'atlas.markVisited': 'Отметить как посещённую',
|
||||||
'atlas.markVisitedHint': 'Добавить эту страну в список посещённых',
|
'atlas.markVisitedHint': 'Добавить эту страну в список посещённых',
|
||||||
|
'atlas.markRegionVisitedHint': 'Добавить этот регион в список посещённых',
|
||||||
'atlas.addToBucket': 'В список желаний',
|
'atlas.addToBucket': 'В список желаний',
|
||||||
'atlas.addPoi': 'Добавить место',
|
'atlas.addPoi': 'Добавить место',
|
||||||
'atlas.searchCountry': 'Поиск страны...',
|
'atlas.searchCountry': 'Поиск страны...',
|
||||||
|
|||||||
@@ -723,8 +723,10 @@ const zh: Record<string, string> = {
|
|||||||
'atlas.unmark': '移除',
|
'atlas.unmark': '移除',
|
||||||
'atlas.confirmMark': '将此国家标记为已访问?',
|
'atlas.confirmMark': '将此国家标记为已访问?',
|
||||||
'atlas.confirmUnmark': '从已访问列表中移除此国家?',
|
'atlas.confirmUnmark': '从已访问列表中移除此国家?',
|
||||||
|
'atlas.confirmUnmarkRegion': '从已访问列表中移除此地区?',
|
||||||
'atlas.markVisited': '标记为已访问',
|
'atlas.markVisited': '标记为已访问',
|
||||||
'atlas.markVisitedHint': '将此国家添加到已访问列表',
|
'atlas.markVisitedHint': '将此国家添加到已访问列表',
|
||||||
|
'atlas.markRegionVisitedHint': '将此地区添加到已访问列表',
|
||||||
'atlas.addToBucket': '添加到心愿单',
|
'atlas.addToBucket': '添加到心愿单',
|
||||||
'atlas.addPoi': '添加地点',
|
'atlas.addPoi': '添加地点',
|
||||||
'atlas.searchCountry': '搜索国家...',
|
'atlas.searchCountry': '搜索国家...',
|
||||||
|
|||||||
+209
-44
@@ -154,13 +154,16 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
const [selectedCountry, setSelectedCountry] = useState<string | null>(null)
|
const [selectedCountry, setSelectedCountry] = useState<string | null>(null)
|
||||||
const [countryDetail, setCountryDetail] = useState<CountryDetail | null>(null)
|
const [countryDetail, setCountryDetail] = useState<CountryDetail | null>(null)
|
||||||
const [geoData, setGeoData] = useState<GeoJsonFeatureCollection | 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 regionLayerRef = useRef<L.GeoJSON | null>(null)
|
||||||
const regionGeoCache = useRef<Record<string, GeoJsonFeatureCollection>>({})
|
const regionGeoCache = useRef<Record<string, GeoJsonFeatureCollection>>({})
|
||||||
const [showRegions, setShowRegions] = useState(false)
|
const [showRegions, setShowRegions] = useState(false)
|
||||||
const [regionGeoLoaded, setRegionGeoLoaded] = useState(0)
|
const [regionGeoLoaded, setRegionGeoLoaded] = useState(0)
|
||||||
const regionTooltipRef = useRef<HTMLDivElement>(null)
|
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 [bucketMonth, setBucketMonth] = useState(0)
|
||||||
const [bucketYear, setBucketYear] = useState(0)
|
const [bucketYear, setBucketYear] = useState(0)
|
||||||
|
|
||||||
@@ -227,33 +230,41 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Load visited regions from server + per-country GeoJSON (once on mount)
|
// Load visited regions (geocoded from places/trips) — once on mount
|
||||||
const regionsLoadedRef = useRef(false)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (regionsLoadedRef.current) return
|
|
||||||
regionsLoadedRef.current = true
|
|
||||||
apiClient.get(`/addons/atlas/regions?_t=${Date.now()}`)
|
apiClient.get(`/addons/atlas/regions?_t=${Date.now()}`)
|
||||||
.then(r => {
|
.then(r => setVisitedRegions(r.data?.regions || {}))
|
||||||
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(() => {})
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
.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
|
// Initialize map — runs after loading is done and mapRef is available
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading || !mapRef.current) return
|
if (loading || !mapRef.current) return
|
||||||
@@ -263,7 +274,7 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
center: [25, 0],
|
center: [25, 0],
|
||||||
zoom: 3,
|
zoom: 3,
|
||||||
minZoom: 3,
|
minZoom: 3,
|
||||||
maxZoom: 7,
|
maxZoom: 10,
|
||||||
zoomControl: false,
|
zoomControl: false,
|
||||||
attributionControl: false,
|
attributionControl: false,
|
||||||
maxBounds: [[-90, -220], [90, 220]],
|
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'
|
: 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png'
|
||||||
|
|
||||||
L.tileLayer(tileUrl, {
|
L.tileLayer(tileUrl, {
|
||||||
maxZoom: 8,
|
maxZoom: 10,
|
||||||
keepBuffer: 25,
|
keepBuffer: 25,
|
||||||
updateWhenZooming: true,
|
updateWhenZooming: true,
|
||||||
updateWhenIdle: false,
|
updateWhenIdle: false,
|
||||||
@@ -290,31 +301,47 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
|
|
||||||
// Preload adjacent zoom level tiles
|
// Preload adjacent zoom level tiles
|
||||||
L.tileLayer(tileUrl, {
|
L.tileLayer(tileUrl, {
|
||||||
maxZoom: 8,
|
maxZoom: 10,
|
||||||
keepBuffer: 10,
|
keepBuffer: 10,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
tileSize: 256,
|
tileSize: 256,
|
||||||
crossOrigin: true,
|
crossOrigin: true,
|
||||||
}).addTo(map)
|
}).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.createPane('regionPane')
|
||||||
map.getPane('regionPane')!.style.zIndex = '399'
|
map.getPane('regionPane')!.style.zIndex = '401'
|
||||||
|
|
||||||
mapInstance.current = map
|
mapInstance.current = map
|
||||||
|
|
||||||
// Zoom-based region switching — dim country layer directly
|
// Zoom-based region switching
|
||||||
map.on('zoomend', () => {
|
map.on('zoomend', () => {
|
||||||
const z = map.getZoom()
|
const z = map.getZoom()
|
||||||
const shouldShow = z >= 5
|
const shouldShow = z >= 5
|
||||||
setShowRegions(shouldShow)
|
setShowRegions(shouldShow)
|
||||||
const overlayPane = map.getPane('overlayPane')
|
const overlayPane = map.getPane('overlayPane')
|
||||||
if (overlayPane) overlayPane.style.opacity = shouldShow ? '0.35' : '1'
|
if (overlayPane) {
|
||||||
const regionPane = map.getPane('regionPane')
|
overlayPane.style.opacity = shouldShow ? '0.35' : '1'
|
||||||
if (regionPane) {
|
overlayPane.style.pointerEvents = shouldShow ? 'none' : 'auto'
|
||||||
regionPane.style.opacity = shouldShow ? '1' : '0'
|
|
||||||
regionPane.style.pointerEvents = shouldShow ? 'auto' : 'none'
|
|
||||||
}
|
}
|
||||||
|
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 }
|
return () => { map.remove(); mapInstance.current = null }
|
||||||
@@ -465,13 +492,11 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
return false
|
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[] = []
|
const allFeatures: any[] = []
|
||||||
for (const geo of Object.values(regionGeoCache.current)) {
|
for (const geo of Object.values(regionGeoCache.current)) {
|
||||||
for (const f of geo.features) {
|
for (const f of geo.features) {
|
||||||
if (isVisitedFeature(f)) {
|
allFeatures.push(f)
|
||||||
allFeatures.push(f)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (allFeatures.length === 0) return
|
if (allFeatures.length === 0) return
|
||||||
@@ -495,26 +520,64 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
pane: 'regionPane',
|
pane: 'regionPane',
|
||||||
style: (feature) => {
|
style: (feature) => {
|
||||||
const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase()
|
const countryA2 = (feature?.properties?.iso_a2 || '').toUpperCase()
|
||||||
return {
|
const visited = isVisitedFeature(feature)
|
||||||
|
return visited ? {
|
||||||
fillColor: a2ColorMap[countryA2] || '#6366f1',
|
fillColor: a2ColorMap[countryA2] || '#6366f1',
|
||||||
fillOpacity: 0.85,
|
fillOpacity: 0.85,
|
||||||
color: dark ? '#888' : '#64748b',
|
color: dark ? '#888' : '#64748b',
|
||||||
weight: 1.2,
|
weight: 1.2,
|
||||||
|
} : {
|
||||||
|
fillColor: dark ? '#ffffff' : '#000000',
|
||||||
|
fillOpacity: 0.03,
|
||||||
|
color: dark ? '#555' : '#94a3b8',
|
||||||
|
weight: 1,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onEachFeature: (feature, layer) => {
|
onEachFeature: (feature, layer) => {
|
||||||
const regionName = feature?.properties?.name || ''
|
const regionName = feature?.properties?.name || ''
|
||||||
const countryName = feature?.properties?.admin || ''
|
const countryName = feature?.properties?.admin || ''
|
||||||
const regionCode = feature?.properties?.iso_3166_2 || ''
|
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
|
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) => {
|
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
|
const tt = regionTooltipRef.current
|
||||||
if (tt) {
|
if (tt) {
|
||||||
tt.style.display = 'block'
|
tt.style.display = 'block'
|
||||||
tt.style.left = e.originalEvent.clientX + 12 + 'px'
|
tt.style.left = e.originalEvent.clientX + 12 + 'px'
|
||||||
tt.style.top = e.originalEvent.clientY - 10 + '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) => {
|
layer.on('mousemove', (e: any) => {
|
||||||
@@ -527,12 +590,18 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
if (tt) tt.style.display = 'none'
|
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])
|
}, [regionGeoLoaded, visitedRegions, dark, t])
|
||||||
|
|
||||||
const handleMarkCountry = (code: string, name: string): void => {
|
const handleMarkCountry = (code: string, name: string): void => {
|
||||||
setConfirmAction({ type: 'choose', code, name })
|
setConfirmAction({ type: 'choose', code, name })
|
||||||
}
|
}
|
||||||
|
handleMarkCountryRef.current = handleMarkCountry
|
||||||
|
setConfirmActionRef.current = setConfirmAction
|
||||||
|
|
||||||
const handleUnmarkCountry = (code: string): void => {
|
const handleUnmarkCountry = (code: string): void => {
|
||||||
const country = data?.countries.find(c => c.code === code)
|
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) },
|
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)
|
setCountryDetail(r.data)
|
||||||
} catch { /* */ }
|
} catch { /* */ }
|
||||||
}
|
}
|
||||||
|
loadCountryDetailRef.current = loadCountryDetail
|
||||||
|
|
||||||
const stats = data?.stats || { totalTrips: 0, totalPlaces: 0, totalCountries: 0, totalDays: 0 }
|
const stats = data?.stats || { totalTrips: 0, totalPlaces: 0, totalCountries: 0, totalDays: 0 }
|
||||||
const countries = data?.countries || []
|
const countries = data?.countries || []
|
||||||
@@ -932,6 +1008,50 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
</div>
|
</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' && (
|
{confirmAction.type === 'unmark' && (
|
||||||
<>
|
<>
|
||||||
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.confirmUnmark')}</p>
|
<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' && (
|
{confirmAction.type === 'bucket' && (
|
||||||
<>
|
<>
|
||||||
<p style={{ margin: '0 0 14px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.bucketWhen')}</p>
|
<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>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
|
<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)' }}>
|
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')}
|
{t('common.back')}
|
||||||
</button>
|
</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);
|
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) {
|
if (currentVersion < migrations.length) {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
getCountryPlaces,
|
getCountryPlaces,
|
||||||
markCountryVisited,
|
markCountryVisited,
|
||||||
unmarkCountryVisited,
|
unmarkCountryVisited,
|
||||||
|
markRegionVisited,
|
||||||
|
unmarkRegionVisited,
|
||||||
getVisitedRegions,
|
getVisitedRegions,
|
||||||
getRegionGeo,
|
getRegionGeo,
|
||||||
listBucketList,
|
listBucketList,
|
||||||
@@ -56,6 +58,20 @@ router.delete('/country/:code/mark', (req: Request, res: Response) => {
|
|||||||
res.json({ success: true });
|
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 ─────────────────────────────────────────────────────────────
|
// ── Bucket List ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
router.get('/bucket-list', (req: Request, res: Response) => {
|
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 {
|
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_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 ────────────────────────────────────────
|
// ── 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)) {
|
for (const [country, regions] of Object.entries(regionMap)) {
|
||||||
result[country] = [...regions.values()];
|
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 };
|
return { regions: result };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user