From 8458481950241fabf8bffddf34c05a40b12293e2 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 29 Mar 2026 16:51:35 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20atlas=20country=20marking,=20bucket=20l?= =?UTF-8?q?ist,=20trip=20creation=20UX=20=E2=80=94=20closes=20#49?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atlas: - Click any country to mark as visited or add to bucket list - Bucket list with country flags, planned month/year, horizontal layout - Confirm popup with two options (mark visited / bucket list) - Full A2/A3 country code mapping for all countries Trip creation: - Drag & drop cover image support - Add travel buddies via CustomSelect dropdown when creating a trip - Manual date entry via double-click on date picker (supports DD.MM.YYYY, ISO, etc.) --- client/src/components/Trips/TripFormModal.tsx | 68 +++- .../shared/CustomDateTimePicker.tsx | 37 +- client/src/i18n/translations/de.ts | 19 +- client/src/i18n/translations/en.ts | 19 +- client/src/pages/AtlasPage.tsx | 381 +++++++++++++++++- server/src/db/migrations.ts | 21 + server/src/routes/atlas.ts | 60 ++- 7 files changed, 582 insertions(+), 23 deletions(-) diff --git a/client/src/components/Trips/TripFormModal.tsx b/client/src/components/Trips/TripFormModal.tsx index fc434878..e8c78087 100644 --- a/client/src/components/Trips/TripFormModal.tsx +++ b/client/src/components/Trips/TripFormModal.tsx @@ -1,7 +1,9 @@ import { useState, useEffect, useRef } from 'react' import Modal from '../shared/Modal' -import { Calendar, Camera, X, Clipboard } from 'lucide-react' -import { tripsApi } from '../../api/client' +import { Calendar, Camera, X, Clipboard, UserPlus } from 'lucide-react' +import { tripsApi, authApi } from '../../api/client' +import CustomSelect from '../shared/CustomSelect' +import { useAuthStore } from '../../store/authStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { CustomDatePicker } from '../shared/CustomDateTimePicker' @@ -20,6 +22,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp const fileRef = useRef(null) const toast = useToast() const { t } = useTranslation() + const currentUser = useAuthStore(s => s.user) const [formData, setFormData] = useState({ title: '', @@ -32,6 +35,9 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp const [coverPreview, setCoverPreview] = useState(null) const [pendingCoverFile, setPendingCoverFile] = useState(null) const [uploadingCover, setUploadingCover] = useState(false) + const [allUsers, setAllUsers] = useState<{ id: number; username: string }[]>([]) + const [selectedMembers, setSelectedMembers] = useState([]) + const [memberSelectValue, setMemberSelectValue] = useState('') useEffect(() => { if (trip) { @@ -47,7 +53,11 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp setCoverPreview(null) } setPendingCoverFile(null) + setSelectedMembers([]) setError('') + if (!trip) { + authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {}) + } }, [trip, isOpen]) const handleSubmit = async (e) => { @@ -65,6 +75,15 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp start_date: formData.start_date || null, end_date: formData.end_date || null, }) + // Add selected members for newly created trips + if (selectedMembers.length > 0 && result?.trip?.id) { + for (const userId of selectedMembers) { + const user = allUsers.find(u => u.id === userId) + if (user) { + try { await tripsApi.addMember(result.trip.id, user.username) } catch {} + } + } + } // Upload pending cover for newly created trips if (pendingCoverFile && result?.trip?.id) { try { @@ -212,7 +231,10 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp ) : ( + )} {open && ReactDOM.createPortal(
= { 'dashboard.endDate': 'Enddatum', 'dashboard.noDateHint': 'Kein Datum gesetzt — es werden 7 Standardtage erstellt. Du kannst das jederzeit ändern.', 'dashboard.coverImage': 'Titelbild', - 'dashboard.addCoverImage': 'Titelbild hinzufügen', + 'dashboard.addCoverImage': 'Titelbild hinzufügen (oder per Drag & Drop)', + 'dashboard.addMembers': 'Reisebegleiter', + 'dashboard.addMember': 'Mitglied hinzufügen', 'dashboard.coverSaved': 'Titelbild gespeichert', 'dashboard.coverUploadError': 'Fehler beim Hochladen', 'dashboard.coverRemoveError': 'Fehler beim Entfernen', @@ -526,6 +528,21 @@ const de: Record = { 'atlas.countries': 'Länder', 'atlas.trips': 'Reisen', 'atlas.places': 'Orte', + 'atlas.unmark': 'Entfernen', + 'atlas.confirmMark': 'Dieses Land als besucht markieren?', + 'atlas.confirmUnmark': 'Dieses Land von der Liste entfernen?', + 'atlas.markVisited': 'Als besucht markieren', + 'atlas.markVisitedHint': 'Dieses Land zur besuchten Liste hinzufügen', + 'atlas.addToBucket': 'Zur Bucket List', + 'atlas.addToBucketHint': 'Als Wunschziel speichern', + 'atlas.bucketWhen': 'Wann möchtest du dorthin reisen?', + 'atlas.statsTab': 'Statistik', + 'atlas.bucketTab': 'Bucket List', + 'atlas.addBucket': 'Zur Bucket List hinzufügen', + 'atlas.bucketNamePlaceholder': 'Ort oder Reiseziel...', + 'atlas.bucketNotesPlaceholder': 'Notizen (optional)', + 'atlas.bucketEmpty': 'Deine Bucket List ist leer', + 'atlas.bucketEmptyHint': 'Füge Orte hinzu, die du besuchen möchtest', 'atlas.days': 'Tage', 'atlas.visitedCountries': 'Besuchte Länder', 'atlas.cities': 'Städte', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 10b8e6d6..5f4a9778 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -101,7 +101,9 @@ const en: Record = { 'dashboard.endDate': 'End Date', 'dashboard.noDateHint': 'No date set — 7 default days will be created. You can change this anytime.', 'dashboard.coverImage': 'Cover Image', - 'dashboard.addCoverImage': 'Add cover image', + 'dashboard.addCoverImage': 'Add cover image (or drag & drop)', + 'dashboard.addMembers': 'Travel buddies', + 'dashboard.addMember': 'Add member', 'dashboard.coverSaved': 'Cover image saved', 'dashboard.coverUploadError': 'Failed to upload', 'dashboard.coverRemoveError': 'Failed to remove', @@ -526,6 +528,21 @@ const en: Record = { 'atlas.countries': 'Countries', 'atlas.trips': 'Trips', 'atlas.places': 'Places', + 'atlas.unmark': 'Remove', + 'atlas.confirmMark': 'Mark this country as visited?', + 'atlas.confirmUnmark': 'Remove this country from your visited list?', + 'atlas.markVisited': 'Mark as visited', + 'atlas.markVisitedHint': 'Add this country to your visited list', + 'atlas.addToBucket': 'Add to bucket list', + 'atlas.addToBucketHint': 'Save as a place you want to visit', + 'atlas.bucketWhen': 'When do you plan to visit?', + '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', 'atlas.days': 'Days', 'atlas.visitedCountries': 'Visited Countries', 'atlas.cities': 'Cities', diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx index ef81d8da..59723d29 100644 --- a/client/src/pages/AtlasPage.tsx +++ b/client/src/pages/AtlasPage.tsx @@ -4,7 +4,8 @@ import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../i18n' import { useSettingsStore } from '../store/settingsStore' import Navbar from '../components/Layout/Navbar' import apiClient from '../api/client' -import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X } from 'lucide-react' +import CustomSelect from '../components/shared/CustomSelect' +import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2 } from 'lucide-react' import L from 'leaflet' import type { AtlasPlace, GeoJsonFeatureCollection, TranslationFn } from '../types' @@ -40,6 +41,7 @@ interface AtlasData { interface CountryDetail { places: AtlasPlace[] trips: { id: number; title: string }[] + manually_marked?: boolean } function MobileStats({ data, stats, countries, resolveName, t, dark }: { data: AtlasData | null; stats: AtlasStats; countries: AtlasCountry[]; resolveName: (code: string) => string; t: TranslationFn; dark: boolean }): React.ReactElement { @@ -108,7 +110,9 @@ function useCountryNames(language: string): (code: string) => string { } // Map visited country codes to ISO-3166 alpha3 (GeoJSON uses alpha3) -const A2_TO_A3: Record = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BR":"BRA","BE":"BEL","BG":"BGR","CA":"CAN","CL":"CHL","CN":"CHN","CO":"COL","HR":"HRV","CZ":"CZE","DK":"DNK","EG":"EGY","EE":"EST","FI":"FIN","FR":"FRA","DE":"DEU","GR":"GRC","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JP":"JPN","KE":"KEN","KR":"KOR","LV":"LVA","LT":"LTU","LU":"LUX","MY":"MYS","MX":"MEX","MA":"MAR","NL":"NLD","NZ":"NZL","NO":"NOR","PK":"PAK","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","RO":"ROU","RU":"RUS","SA":"SAU","RS":"SRB","SK":"SVK","SI":"SVN","ZA":"ZAF","ES":"ESP","SE":"SWE","CH":"CHE","TH":"THA","TR":"TUR","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","VN":"VNM","NG":"NGA"} +// Built dynamically from GeoJSON + hardcoded fallbacks +const A2_TO_A3_BASE: Record = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AG":"ATG","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BS":"BHS","BH":"BHR","BD":"BGD","BB":"BRB","BY":"BLR","BE":"BEL","BZ":"BLZ","BJ":"BEN","BT":"BTN","BO":"BOL","BA":"BIH","BW":"BWA","BR":"BRA","BN":"BRN","BG":"BGR","BF":"BFA","BI":"BDI","CV":"CPV","KH":"KHM","CM":"CMR","CA":"CAN","CF":"CAF","TD":"TCD","CL":"CHL","CN":"CHN","CO":"COL","KM":"COM","CG":"COG","CD":"COD","CR":"CRI","CI":"CIV","HR":"HRV","CU":"CUB","CY":"CYP","CZ":"CZE","DK":"DNK","DJ":"DJI","DM":"DMA","DO":"DOM","EC":"ECU","EG":"EGY","SV":"SLV","GQ":"GNQ","ER":"ERI","EE":"EST","SZ":"SWZ","ET":"ETH","FJ":"FJI","FI":"FIN","FR":"FRA","GA":"GAB","GM":"GMB","GE":"GEO","DE":"DEU","GH":"GHA","GR":"GRC","GD":"GRD","GT":"GTM","GN":"GIN","GW":"GNB","GY":"GUY","HT":"HTI","HN":"HND","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JM":"JAM","JP":"JPN","JO":"JOR","KZ":"KAZ","KE":"KEN","KI":"KIR","KP":"PRK","KR":"KOR","KW":"KWT","KG":"KGZ","LA":"LAO","LV":"LVA","LB":"LBN","LS":"LSO","LR":"LBR","LY":"LBY","LI":"LIE","LT":"LTU","LU":"LUX","MG":"MDG","MW":"MWI","MY":"MYS","MV":"MDV","ML":"MLI","MT":"MLT","MR":"MRT","MU":"MUS","MX":"MEX","MD":"MDA","MN":"MNG","ME":"MNE","MA":"MAR","MZ":"MOZ","MM":"MMR","NA":"NAM","NP":"NPL","NL":"NLD","NZ":"NZL","NI":"NIC","NE":"NER","NG":"NGA","MK":"MKD","NO":"NOR","OM":"OMN","PK":"PAK","PA":"PAN","PG":"PNG","PY":"PRY","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","QA":"QAT","RO":"ROU","RU":"RUS","RW":"RWA","SA":"SAU","SN":"SEN","RS":"SRB","SL":"SLE","SG":"SGP","SK":"SVK","SI":"SVN","SB":"SLB","SO":"SOM","ZA":"ZAF","SS":"SSD","ES":"ESP","LK":"LKA","SD":"SDN","SR":"SUR","SE":"SWE","CH":"CHE","SY":"SYR","TW":"TWN","TJ":"TJK","TZ":"TZA","TH":"THA","TL":"TLS","TG":"TGO","TT":"TTO","TN":"TUN","TR":"TUR","TM":"TKM","UG":"UGA","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","UY":"URY","UZ":"UZB","VU":"VUT","VE":"VEN","VN":"VNM","YE":"YEM","ZM":"ZMB","ZW":"ZWE"} +let A2_TO_A3: Record = { ...A2_TO_A3_BASE } export default function AtlasPage(): React.ReactElement { const { t, language } = useTranslation() @@ -149,11 +153,26 @@ export default function AtlasPage(): React.ReactElement { const [selectedCountry, setSelectedCountry] = useState(null) const [countryDetail, setCountryDetail] = useState(null) const [geoData, setGeoData] = useState(null) + const [confirmAction, setConfirmAction] = useState<{ type: 'mark' | 'unmark' | 'choose' | 'bucket'; code: string; name: string } | null>(null) + const [bucketMonth, setBucketMonth] = useState(new Date().getMonth() + 1) + const [bucketYear, setBucketYear] = useState(new Date().getFullYear()) - // Load atlas data + // Bucket list + interface BucketItem { id: number; name: string; lat: number | null; lng: number | null; country_code: string | null; notes: string | null } + const [bucketList, setBucketList] = useState([]) + const [showBucketAdd, setShowBucketAdd] = useState(false) + const [bucketForm, setBucketForm] = useState({ name: '', notes: '' }) + const [bucketTab, setBucketTab] = useState<'stats' | 'bucket'>('stats') + const bucketMarkersRef = useRef(null) + + // Load atlas data + bucket list useEffect(() => { - apiClient.get('/addons/atlas/stats').then(r => { - setData(r.data) + Promise.all([ + apiClient.get('/addons/atlas/stats'), + apiClient.get('/addons/atlas/bucket-list'), + ]).then(([statsRes, bucketRes]) => { + setData(statsRes.data) + setBucketList(bucketRes.data.items || []) setLoading(false) }).catch(() => setLoading(false)) }, []) @@ -162,7 +181,17 @@ export default function AtlasPage(): React.ReactElement { useEffect(() => { fetch('https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_admin_0_countries.geojson') .then(r => r.json()) - .then(geo => setGeoData(geo)) + .then(geo => { + // Dynamically build A2→A3 mapping from GeoJSON + for (const f of geo.features) { + const a2 = f.properties?.ISO_A2 + const a3 = f.properties?.ADM0_A3 || f.properties?.ISO_A3 + if (a2 && a3 && a2 !== '-99' && a3 !== '-99' && !A2_TO_A3[a2]) { + A2_TO_A3[a2] = a3 + } + } + setGeoData(geo) + }) .catch(() => {}) }, []) @@ -222,6 +251,10 @@ export default function AtlasPage(): React.ReactElement { const countryMap = {} data.countries.forEach(c => { if (A2_TO_A3[c.code]) countryMap[A2_TO_A3[c.code]] = c }) + // Preserve current map view + const currentCenter = mapInstance.current.getCenter() + const currentZoom = mapInstance.current.getZoom() + if (geoLayerRef.current) { mapInstance.current.removeLayer(geoLayerRef.current) } @@ -278,18 +311,128 @@ export default function AtlasPage(): React.ReactElement { layer.bindTooltip(tooltipHtml, { sticky: false, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1 }) - layer.on('click', () => loadCountryDetail(c.code)) + layer.on('click', () => { + if (c.placeCount === 0 && c.tripCount === 0) { + // Manually marked only — show unmark popup + handleUnmarkCountry(c.code) + } else { + loadCountryDetail(c.code) + } + }) layer.on('mouseover', (e) => { e.target.setStyle({ fillOpacity: 0.9, weight: 2, color: dark ? '#818cf8' : '#4f46e5' }) }) layer.on('mouseout', (e) => { geoLayerRef.current.resetStyle(e.target) }) + } else { + // Unvisited country — allow clicking to mark as visited + // Reverse lookup: find A2 code from A3, or use A3 directly + const a3ToA2Entry = Object.entries(A2_TO_A3).find(([, v]) => v === a3) + const isoA2 = feature.properties?.ISO_A2 + const countryCode = a3ToA2Entry ? a3ToA2Entry[0] : (isoA2 && isoA2 !== '-99' ? isoA2 : null) + if (countryCode && countryCode !== '-99') { + const name = feature.properties?.NAME || feature.properties?.ADMIN || resolveName(countryCode) + layer.bindTooltip(`
${name}
`, { + sticky: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1 + }) + layer.on('click', () => handleMarkCountry(countryCode, name)) + layer.on('mouseover', (e) => { + e.target.setStyle({ fillOpacity: 0.5, weight: 1.5, color: dark ? '#555' : '#94a3b8' }) + }) + layer.on('mouseout', (e) => { + geoLayerRef.current.resetStyle(e.target) + }) + } } } }).addTo(mapInstance.current) + + // Restore map view after re-render + mapInstance.current.setView(currentCenter, currentZoom, { animate: false }) }, [geoData, data, dark]) + const handleMarkCountry = (code: string, name: string): void => { + setConfirmAction({ type: 'choose', code, name }) + } + + const handleUnmarkCountry = (code: string): void => { + const country = data?.countries.find(c => c.code === code) + setConfirmAction({ type: 'unmark', code, name: resolveName(code) }) + } + + const executeConfirmAction = async (): Promise => { + if (!confirmAction) return + const { type, code } = confirmAction + setConfirmAction(null) + + // Update local state immediately (no API reload = no map re-render flash) + if (type === 'mark') { + apiClient.post(`/addons/atlas/country/${code}/mark`).catch(() => {}) + setData(prev => { + if (!prev || prev.countries.find(c => c.code === code)) return prev + return { + ...prev, + countries: [...prev.countries, { code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], + stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 }, + } + }) + } else { + apiClient.delete(`/addons/atlas/country/${code}/mark`).catch(() => {}) + setSelectedCountry(null) + setCountryDetail(null) + setData(prev => { + if (!prev) return prev + const c = prev.countries.find(c => c.code === code) + if (!c || c.placeCount > 0 || c.tripCount > 0) return prev + return { + ...prev, + countries: prev.countries.filter(c => c.code !== code), + stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) }, + } + }) + } + } + + const handleAddBucketItem = async (): Promise => { + if (!bucketForm.name.trim()) return + try { + const r = await apiClient.post('/addons/atlas/bucket-list', { name: bucketForm.name.trim(), notes: bucketForm.notes.trim() || null }) + setBucketList(prev => [r.data.item, ...prev]) + setBucketForm({ name: '', notes: '' }) + setShowBucketAdd(false) + } catch { /* */ } + } + + const handleDeleteBucketItem = async (id: number): Promise => { + try { + await apiClient.delete(`/addons/atlas/bucket-list/${id}`) + setBucketList(prev => prev.filter(i => i.id !== id)) + } catch { /* */ } + } + + // Render bucket list markers on map + useEffect(() => { + if (!mapInstance.current) return + if (bucketMarkersRef.current) { + mapInstance.current.removeLayer(bucketMarkersRef.current) + } + if (bucketList.length === 0) return + const markers = bucketList.filter(b => b.lat && b.lng).map(b => { + const icon = L.divIcon({ + className: '', + html: `
`, + iconSize: [28, 28], + iconAnchor: [14, 14], + }) + return L.marker([b.lat!, b.lng!], { icon }).bindTooltip( + `
${b.name}
${b.notes ? `
${b.notes}
` : ''}`, + { className: 'atlas-tooltip', direction: 'top', offset: [0, -14] } + ) + }) + bucketMarkersRef.current = L.layerGroup(markers).addTo(mapInstance.current) + }, [bucketList]) + const loadCountryDetail = async (code: string): Promise => { setSelectedCountry(code) try { @@ -348,6 +491,7 @@ export default function AtlasPage(): React.ReactElement { left: '50%', transform: 'translateX(-50%)', width: 'fit-content', + maxWidth: 'calc(100vw - 40px)', background: dark ? 'rgba(10,10,15,0.55)' : 'rgba(255,255,255,0.2)', backdropFilter: 'blur(24px) saturate(180%)', WebkitBackdropFilter: 'blur(24px) saturate(180%)', @@ -368,13 +512,139 @@ export default function AtlasPage(): React.ReactElement { navigate(`/trips/${id}`)} + onCountryClick={loadCountryDetail} onTripClick={(id) => navigate(`/trips/${id}`)} onUnmarkCountry={handleUnmarkCountry} + bucketList={bucketList} bucketTab={bucketTab} setBucketTab={setBucketTab} + showBucketAdd={showBucketAdd} setShowBucketAdd={setShowBucketAdd} + bucketForm={bucketForm} setBucketForm={setBucketForm} + onAddBucket={handleAddBucketItem} onDeleteBucket={handleDeleteBucketItem} t={t} dark={dark} />
- + + {/* Country action popup */} + {confirmAction && ( +
setConfirmAction(null)}> +
e.stopPropagation()}> + {confirmAction.code.length === 2 ? ( + {confirmAction.code} + ) : ( +
{countryCodeToFlag(confirmAction.code)}
+ )} +

{confirmAction.name}

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

{t('atlas.confirmUnmark')}

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

{t('atlas.bucketWhen')}

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

{t('atlas.confirmMark')}

+
+ + +
+ + )} +
+
+ )} ) } @@ -388,11 +658,21 @@ interface SidebarContentProps { resolveName: (code: string) => string onCountryClick: (code: string) => void onTripClick: (id: number) => void + onUnmarkCountry?: (code: string) => void + bucketList: any[] + bucketTab: 'stats' | 'bucket' + setBucketTab: (tab: 'stats' | 'bucket') => void + showBucketAdd: boolean + setShowBucketAdd: (v: boolean) => void + bucketForm: { name: string; notes: string } + setBucketForm: (f: { name: string; notes: string }) => void + onAddBucket: () => Promise + onDeleteBucket: (id: number) => Promise t: TranslationFn dark: boolean } -function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onCountryClick, onTripClick, t, dark }: SidebarContentProps): React.ReactElement { +function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onCountryClick, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, t, dark }: SidebarContentProps): React.ReactElement { const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})` const tp = dark ? '#f1f5f9' : '#0f172a' const tm = dark ? '#94a3b8' : '#64748b' @@ -405,20 +685,75 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail const CL = { 'Europe': t('atlas.europe'), 'Asia': t('atlas.asia'), 'North America': t('atlas.northAmerica'), 'South America': t('atlas.southAmerica'), 'Africa': t('atlas.africa'), 'Oceania': t('atlas.oceania') } const contColors = ['#818cf8', '#f472b6', '#34d399', '#fbbf24', '#fb923c', '#22d3ee'] - if (countries.length === 0 && !lastTrip) { + // Tab switcher + const tabBar = ( +
+ {[{ id: 'stats', label: t('atlas.statsTab'), icon: Globe }, { id: 'bucket', label: t('atlas.bucketTab'), icon: Star }].map(tab => ( + + ))} +
+ ) + + if (countries.length === 0 && !lastTrip && bucketTab !== 'bucket') { return ( -
- -

{t('atlas.noData')}

-

{t('atlas.noDataHint')}

-
+ <> + {tabBar} +
+ +

{t('atlas.noData')}

+

{t('atlas.noDataHint')}

+
+ ) } const thisYear = new Date().getFullYear() const divider = `2px solid ${bg(0.08)}` + // Bucket list content + const bucketContent = ( +
+ {bucketList.map(item => ( +
+ {(() => { + const code = item.country_code?.length === 2 ? item.country_code : (Object.entries(A2_TO_A3).find(([, v]) => v === item.country_code)?.[0] || '') + return code ? ( + {code} + ) : + })()} + {item.name} + {item.notes && {item.notes}} + +
+ ))} + {bucketList.length === 0 && ( +
+ {t('atlas.bucketEmptyHint')} +
+ )} +
+ ) + return ( + <> + {tabBar} + {/* Both tabs always rendered so the wider one sets the panel width */} +
+
{/* ═══ SECTION 1: Numbers ═══ */} @@ -507,11 +842,25 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail {trip.title} ))} + {countryDetail.manually_marked && onUnmarkCountry && ( + + )}
)} + +
+ {bucketContent} +
+ + ) } diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index f9ad8e51..2ce013cf 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -264,6 +264,27 @@ function runMigrations(db: Database.Database): void { try { db.exec('ALTER TABLE packing_items ADD COLUMN weight_grams INTEGER'); } catch {} try { db.exec('ALTER TABLE packing_items ADD COLUMN bag_id INTEGER REFERENCES packing_bags(id) ON DELETE SET NULL'); } catch {} }, + () => { + db.exec(`CREATE TABLE IF NOT EXISTS visited_countries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + country_code TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, country_code) + )`); + }, + () => { + db.exec(`CREATE TABLE IF NOT EXISTS bucket_list ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + lat REAL, + lng REAL, + country_code TEXT, + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/atlas.ts b/server/src/routes/atlas.ts index d5a7227c..c998b355 100644 --- a/server/src/routes/atlas.ts +++ b/server/src/routes/atlas.ts @@ -153,6 +153,14 @@ router.get('/stats', (req: Request, res: Response) => { } const totalCities = citySet.size; + // Merge manually marked countries + const manualCountries = db.prepare('SELECT country_code FROM visited_countries WHERE user_id = ?').all(userId) as { country_code: string }[]; + for (const mc of manualCountries) { + if (!countries.find(c => c.code === mc.country_code)) { + countries.push({ code: mc.country_code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }); + } + } + const mostVisited = countries.length > 0 ? countries.reduce((a, b) => a.placeCount > b.placeCount ? a : b) : null; const continents: Record = {}; @@ -239,7 +247,57 @@ router.get('/country/:code', (req: Request, res: Response) => { const matchingTrips = trips.filter(t => matchingTripIds.has(t.id)).map(t => ({ id: t.id, title: t.title, start_date: t.start_date, end_date: t.end_date })); - res.json({ places: matchingPlaces, trips: matchingTrips }); + const isManuallyMarked = !!(db.prepare('SELECT 1 FROM visited_countries WHERE user_id = ? AND country_code = ?').get(userId, code)); + res.json({ places: matchingPlaces, trips: matchingTrips, manually_marked: isManuallyMarked }); +}); + +// Mark/unmark country as visited +router.post('/country/:code/mark', (req: Request, res: Response) => { + const authReq = req as AuthRequest; + db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(authReq.user.id, req.params.code.toUpperCase()); + res.json({ success: true }); +}); + +router.delete('/country/:code/mark', (req: Request, res: Response) => { + const authReq = req as AuthRequest; + db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(authReq.user.id, req.params.code.toUpperCase()); + res.json({ success: true }); +}); + +// ── Bucket List ───────────────────────────────────────────────────────────── + +router.get('/bucket-list', (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const items = db.prepare('SELECT * FROM bucket_list WHERE user_id = ? ORDER BY created_at DESC').all(authReq.user.id); + res.json({ items }); +}); + +router.post('/bucket-list', (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { name, lat, lng, country_code, notes } = req.body; + if (!name?.trim()) return res.status(400).json({ error: 'Name is required' }); + const result = db.prepare('INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes) VALUES (?, ?, ?, ?, ?, ?)').run( + authReq.user.id, name.trim(), lat ?? null, lng ?? null, country_code ?? null, notes ?? null + ); + const item = db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json({ item }); +}); + +router.put('/bucket-list/:id', (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { name, notes } = req.body; + const item = db.prepare('SELECT * FROM bucket_list WHERE id = ? AND user_id = ?').get(req.params.id, authReq.user.id); + if (!item) return res.status(404).json({ error: 'Item not found' }); + db.prepare('UPDATE bucket_list SET name = COALESCE(?, name), notes = COALESCE(?, notes) WHERE id = ?').run(name?.trim() || null, notes ?? null, req.params.id); + res.json({ item: db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(req.params.id) }); +}); + +router.delete('/bucket-list/:id', (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const item = db.prepare('SELECT * FROM bucket_list WHERE id = ? AND user_id = ?').get(req.params.id, authReq.user.id); + if (!item) return res.status(404).json({ error: 'Item not found' }); + db.prepare('DELETE FROM bucket_list WHERE id = ?').run(req.params.id); + res.json({ success: true }); }); export default router;