diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 08820dd..8519020 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -605,6 +605,7 @@ const ar: Record = { 'atlas.markVisitedHint': 'إضافة هذا البلد إلى قائمة المُزارة', 'atlas.addToBucket': 'إضافة إلى قائمة الأمنيات', 'atlas.addPoi': 'إضافة مكان', + 'atlas.searchCountry': 'ابحث عن دولة...', 'atlas.bucketNamePlaceholder': 'الاسم (بلد، مدينة، مكان…)', 'atlas.month': 'الشهر', 'atlas.year': 'السنة', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index dab6913..b5eb490 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -601,6 +601,7 @@ const br: Record = { 'atlas.markVisitedHint': 'Adicionar este país à lista de visitados', 'atlas.addToBucket': 'Adicionar à lista de desejos', 'atlas.addPoi': 'Adicionar lugar', + 'atlas.searchCountry': 'Buscar um país...', 'atlas.bucketNamePlaceholder': 'Nome (país, cidade, lugar…)', 'atlas.month': 'Mês', 'atlas.year': 'Ano', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index daefce0..5ae0624 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -605,6 +605,7 @@ const cs: Record = { 'atlas.markVisitedHint': 'Přidat tuto zemi do seznamu navštívených', 'atlas.addToBucket': 'Přidat do seznamu přání (Bucket list)', 'atlas.addPoi': 'Přidat místo', + 'atlas.searchCountry': 'Hledat zemi...', 'atlas.bucketNamePlaceholder': 'Název (země, město, místo...)', 'atlas.month': 'Měsíc', 'atlas.addToBucketHint': 'Uložit jako místo, které chcete navštívit', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 1e5422d..9adbd26 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -601,6 +601,7 @@ const de: Record = { 'atlas.markVisitedHint': 'Dieses Land zur besuchten Liste hinzufügen', 'atlas.addToBucket': 'Zur Bucket List', 'atlas.addPoi': 'Ort hinzufügen', + 'atlas.searchCountry': 'Land suchen...', 'atlas.bucketNamePlaceholder': 'Name (Land, Stadt, Ort...)', 'atlas.month': 'Monat', 'atlas.year': 'Jahr', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index b4f5a05..c75cd2e 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -600,6 +600,7 @@ const en: Record = { 'atlas.markVisitedHint': 'Add this country to your visited list', 'atlas.addToBucket': 'Add to bucket list', 'atlas.addPoi': 'Add place', + 'atlas.searchCountry': 'Search a country...', 'atlas.bucketNamePlaceholder': 'Name (country, city, place...)', 'atlas.month': 'Month', 'atlas.year': 'Year', @@ -608,7 +609,6 @@ const en: Record = { 'atlas.statsTab': 'Stats', 'atlas.bucketTab': 'Bucket List', 'atlas.addBucket': 'Add to bucket list', - 'atlas.bucketNamePlaceholder': 'Place or destination...', 'atlas.bucketNotesPlaceholder': 'Notes (optional)', 'atlas.bucketEmpty': 'Your bucket list is empty', 'atlas.bucketEmptyHint': 'Add places you dream of visiting', @@ -621,7 +621,6 @@ const en: Record = { 'atlas.nextTrip': 'Next trip', 'atlas.daysLeft': 'days left', 'atlas.streak': 'Streak', - 'atlas.year': 'year', 'atlas.years': 'years', 'atlas.yearInRow': 'year in a row', 'atlas.yearsInRow': 'years in a row', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 778a98c..4c9bf1a 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -615,6 +615,7 @@ const es: Record = { 'atlas.markVisitedHint': 'Añadir este país a tu lista de visitados', 'atlas.addToBucket': 'Añadir a lista de deseos', 'atlas.addPoi': 'Añadir lugar', + 'atlas.searchCountry': 'Buscar un país...', 'atlas.bucketNamePlaceholder': 'Nombre (país, ciudad, lugar…)', 'atlas.month': 'Mes', 'atlas.year': 'Año', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 079f0c1..0b28e62 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -636,6 +636,7 @@ const fr: Record = { 'atlas.markVisitedHint': 'Ajouter ce pays à votre liste de visités', 'atlas.addToBucket': 'Ajouter à la bucket list', 'atlas.addPoi': 'Ajouter un lieu', + 'atlas.searchCountry': 'Rechercher un pays…', 'atlas.bucketNamePlaceholder': 'Nom (pays, ville, lieu…)', 'atlas.month': 'Mois', 'atlas.year': 'Année', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 212287c..2e77cd3 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -601,6 +601,7 @@ const hu: Record = { 'atlas.markVisitedHint': 'Ország 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...', 'atlas.bucketNamePlaceholder': 'Név (ország, város, hely...)', 'atlas.month': 'Hónap', 'atlas.addToBucketHint': 'Mentés meglátogatni kívánt helyként', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 8727080..2db2e2f 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -601,6 +601,7 @@ const it: Record = { 'atlas.markVisitedHint': 'Aggiungi questo paese alla tua lista dei visitati', 'atlas.addToBucket': 'Aggiungi alla lista desideri', 'atlas.addPoi': 'Aggiungi luogo', + 'atlas.searchCountry': 'Cerca un paese...', 'atlas.bucketNamePlaceholder': 'Nome (paese, città, luogo...)', 'atlas.month': 'Mese', 'atlas.addToBucketHint': 'Salvalo come luogo che vuoi visitare', @@ -608,7 +609,6 @@ const it: Record = { 'atlas.statsTab': 'Statistiche', 'atlas.bucketTab': 'Lista desideri', 'atlas.addBucket': 'Aggiungi alla lista desideri', - 'atlas.bucketNamePlaceholder': 'Luogo o destinazione...', 'atlas.bucketNotesPlaceholder': 'Note (opzionale)', 'atlas.bucketEmpty': 'La tua lista desideri è vuota', 'atlas.bucketEmptyHint': 'Aggiungi luoghi che sogni di visitare', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index cfe0255..d8cd878 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -636,6 +636,7 @@ const nl: Record = { 'atlas.markVisitedHint': 'Dit land toevoegen aan je bezochte lijst', 'atlas.addToBucket': 'Aan bucket list toevoegen', 'atlas.addPoi': 'Plaats toevoegen', + 'atlas.searchCountry': 'Zoek een land...', 'atlas.bucketNamePlaceholder': 'Naam (land, stad, plek…)', 'atlas.month': 'Maand', 'atlas.year': 'Jaar', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 5698ebe..0e5be25 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -636,6 +636,7 @@ const ru: Record = { 'atlas.markVisitedHint': 'Добавить эту страну в список посещённых', 'atlas.addToBucket': 'В список желаний', 'atlas.addPoi': 'Добавить место', + 'atlas.searchCountry': 'Поиск страны...', 'atlas.bucketNamePlaceholder': 'Название (страна, город, место…)', 'atlas.month': 'Месяц', 'atlas.year': 'Год', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 02cd7d1..161c477 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -636,6 +636,7 @@ const zh: Record = { 'atlas.markVisitedHint': '将此国家添加到已访问列表', 'atlas.addToBucket': '添加到心愿单', 'atlas.addPoi': '添加地点', + 'atlas.searchCountry': '搜索国家...', 'atlas.bucketNamePlaceholder': '名称(国家、城市、地点…)', 'atlas.month': '月份', 'atlas.year': '年份', diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx index 1129667..7c77eb6 100644 --- a/client/src/pages/AtlasPage.tsx +++ b/client/src/pages/AtlasPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useRef } from 'react' +import React, { useEffect, useMemo, useState, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../i18n' import { useSettingsStore } from '../store/settingsStore' @@ -127,6 +127,7 @@ export default function AtlasPage(): React.ReactElement { const glareRef = useRef(null) const borderGlareRef = useRef(null) const panelRef = useRef(null) + const country_layer_by_a2_ref = useRef>({}) const handlePanelMouseMove = (e: React.MouseEvent): void => { if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return @@ -139,7 +140,7 @@ export default function AtlasPage(): React.ReactElement { // Border glow that follows cursor borderGlareRef.current.style.opacity = '1' borderGlareRef.current.style.maskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)` - borderGlareRef.current.style.WebkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)` + borderGlareRef.current.style.webkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)` } const handlePanelMouseLeave = () => { if (glareRef.current) glareRef.current.style.opacity = '0' @@ -170,6 +171,26 @@ export default function AtlasPage(): React.ReactElement { const [bucketTab, setBucketTab] = useState<'stats' | 'bucket'>('stats') const bucketMarkersRef = useRef(null) + const [atlas_country_search, set_atlas_country_search] = useState('') + const [atlas_country_results, set_atlas_country_results] = useState<{ code: string; label: string }[]>([]) + const [atlas_country_open, set_atlas_country_open] = useState(false) + + const atlas_country_options = useMemo(() => { + if (!geoData) return [] + const opts: { code: string; label: string }[] = [] + const seen = new Set() + for (const f of (geoData as any).features || []) { + const a2 = f?.properties?.ISO_A2 + if (!a2 || a2 === '-99' || typeof a2 !== 'string' || a2.length !== 2) continue + if (seen.has(a2)) continue + seen.add(a2) + const label = String(f?.properties?.NAME || f?.properties?.ADMIN || resolveName(a2) || a2) + opts.push({ code: a2, label }) + } + opts.sort((a, b) => a.label.localeCompare(b.label)) + return opts + }, [geoData, resolveName]) + // Load atlas data + bucket list useEffect(() => { Promise.all([ @@ -231,8 +252,7 @@ export default function AtlasPage(): React.ReactElement { updateWhenIdle: false, tileSize: 256, zoomOffset: 0, - crossOrigin: true, - loading: true, + crossOrigin: true }).addTo(map) // Preload adjacent zoom level tiles @@ -292,6 +312,7 @@ export default function AtlasPage(): React.ReactElement { const a3 = feature.properties?.ADM0_A3 || feature.properties?.ISO_A3 || feature.properties?.['ISO3166-1-Alpha-3'] || feature.id const c = countryMap[a3] if (c) { + country_layer_by_a2_ref.current[c.code] = layer const name = resolveName(c.code) const formatDate = (d) => { if (!d) return '—'; const dt = new Date(d); return dt.toLocaleDateString(getLocaleForLanguage(language), { month: 'short', year: 'numeric' }) } const tooltipHtml = ` @@ -337,6 +358,7 @@ export default function AtlasPage(): React.ReactElement { const isoA2 = feature.properties?.ISO_A2 const countryCode = a3ToA2Entry ? a3ToA2Entry[0] : (isoA2 && isoA2 !== '-99' ? isoA2 : null) if (countryCode && countryCode !== '-99') { + country_layer_by_a2_ref.current[countryCode] = layer const name = feature.properties?.NAME || feature.properties?.ADMIN || resolveName(countryCode) layer.bindTooltip(`
${name}
`, { sticky: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1 @@ -366,6 +388,23 @@ export default function AtlasPage(): React.ReactElement { setConfirmAction({ type: 'unmark', code, name: resolveName(code) }) } + const select_country_from_search = (country_code: string): void => { + const country_label = resolveName(country_code) + set_atlas_country_search(country_label) + set_atlas_country_open(false) + set_atlas_country_results([]) + + const layer = country_layer_by_a2_ref.current[country_code] + try { + if (layer?.getBounds && mapInstance.current) { + mapInstance.current.fitBounds(layer.getBounds(), { padding: [24, 24], animate: true, maxZoom: 6 }) + } + } catch (e ) { + console.error('Error fitting bounds', e) + } + setConfirmAction({ type: 'choose', code: country_code, name: country_label }) + } + const executeConfirmAction = async (): Promise => { if (!confirmAction) return const { type, code } = confirmAction @@ -494,6 +533,129 @@ export default function AtlasPage(): React.ReactElement {
{/* Map */}
+
+
+
+ + { + const raw = e.target.value + set_atlas_country_search(raw) + const q = raw.trim().toLowerCase() + if (!q) { + set_atlas_country_results([]) + set_atlas_country_open(false) + return + } + const results = atlas_country_options + .filter(o => o.label.toLowerCase().includes(q) || o.code.toLowerCase() === q) + .slice(0, 8) + set_atlas_country_results(results) + set_atlas_country_open(true) + }} + onFocus={() => { + if (atlas_country_results.length > 0) set_atlas_country_open(true) + }} + onKeyDown={(e) => { + if (e.key === 'Escape') { + set_atlas_country_open(false) + return + } + if (e.key === 'Enter') { + const first = atlas_country_results[0] + if (first) select_country_from_search(first.code) + } + }} + placeholder={t('atlas.searchCountry')} + autoComplete="off" + spellCheck={false} + style={{ + flex: 1, + border: 'none', + outline: 'none', + background: 'transparent', + fontSize: 13, + fontFamily: 'inherit', + color: 'var(--text-primary)', + }} + /> + {atlas_country_search.trim() && ( + + )} +
+ + {atlas_country_open && atlas_country_results.length > 0 && ( +
set_atlas_country_open(false)} + > + {atlas_country_results.map((r) => ( + + ))} +
+ )} +
+
{/* Mobile: Bottom bar */}
@@ -551,7 +713,7 @@ export default function AtlasPage(): React.ReactElement { bucketForm={bucketForm} setBucketForm={setBucketForm} onAddBucket={handleAddBucketItem} onDeleteBucket={handleDeleteBucketItem} onSearchBucket={handleBucketPoiSearch} onSelectBucketPoi={handleSelectBucketPoi} - bucketSearchResults={bucketSearchResults} bucketPoiMonth={bucketPoiMonth} setBucketPoiMonth={setBucketPoiMonth} + bucketSearchResults={bucketSearchResults} setBucketSearchResults={setBucketSearchResults} bucketPoiMonth={bucketPoiMonth} setBucketPoiMonth={setBucketPoiMonth} bucketPoiYear={bucketPoiYear} setBucketPoiYear={setBucketPoiYear} bucketSearching={bucketSearching} bucketSearch={bucketSearch} setBucketSearch={setBucketSearch} t={t} dark={dark} @@ -629,24 +791,24 @@ export default function AtlasPage(): React.ReactElement {
setBucketMonth(Number(v))} placeholder={t('atlas.month')} options={[ - { value: 0, label: '—' }, - ...Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleString(language, { month: 'long' }) })), + { value: '0', label: '—' }, + ...Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: new Date(2000, i).toLocaleString(language, { month: 'long' }) })), ]} size="sm" />
setBucketYear(Number(v))} placeholder={t('atlas.year')} options={[ - { value: 0, label: '—' }, - ...Array.from({ length: 20 }, (_, i) => ({ value: new Date().getFullYear() + i, label: String(new Date().getFullYear() + i) })), + { value: '0', label: '—' }, + ...Array.from({ length: 20 }, (_, i) => ({ value: String(new Date().getFullYear() + i), label: String(new Date().getFullYear() + i) })), ]} size="sm" /> @@ -717,6 +879,7 @@ interface SidebarContentProps { onSearchBucket: () => Promise onSelectBucketPoi: (result: any) => void bucketSearchResults: any[] + setBucketSearchResults: (v: string[]) => void bucketPoiMonth: number setBucketPoiMonth: (v: number) => void bucketPoiYear: number @@ -728,7 +891,7 @@ interface SidebarContentProps { dark: boolean } -function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onCountryClick, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement { +function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement { const { language } = useTranslation() const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})` const tp = dark ? '#f1f5f9' : '#0f172a' @@ -854,12 +1017,12 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail {/* Month / Year with CustomSelect */}
- setBucketPoiMonth(Number(v))} placeholder={t('atlas.month')} size="sm" - options={[{ value: 0, label: '—' }, ...Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleString(language, { month: 'short' }) }))]} /> + setBucketPoiMonth(Number(v))} placeholder={t('atlas.month')} size="sm" + options={[{ value: '0', label: '—' }, ...Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: new Date(2000, i).toLocaleString(language, { month: 'short' }) }))]} />
- setBucketPoiYear(Number(v))} placeholder={t('atlas.year')} size="sm" - options={[{ value: 0, label: '—' }, ...Array.from({ length: 20 }, (_, i) => ({ value: new Date().getFullYear() + i, label: String(new Date().getFullYear() + i) }))]} /> + setBucketPoiYear(Number(v))} placeholder={t('atlas.year')} size="sm" + options={[{ value: '0', label: '—' }, ...Array.from({ length: 20 }, (_, i) => ({ value: String(new Date().getFullYear() + i), label: String(new Date().getFullYear() + i) }))]} />