Files
TREK/client/src/pages/AtlasPage.tsx
T
Maurice fc7d8b5d12 Migrate TREK 3 to NestJS + React 19 with a shared Zod contract layer
Brownfield strangler migration of the backend onto NestJS modules
(auth, trips, days, places, assignments, packing, todo, budget,
reservations, collab, files, photos, journey, share, settings, backup,
oidc, oauth, admin, atlas, vacay, weather, airports, maps, categories,
tags, notifications, system-notices) served through a per-prefix
dispatcher, keeping the existing SQLite/better-sqlite3 DB and JWT
httpOnly cookie auth, with behavioural parity for every route.

Client: React 19 upgrade, "page = wiring container + data hook"
pattern across all pages, per-domain Zustand stores bound to
@trek/shared contracts, and decomposition of the large components
(DayPlanSidebar, PackingListPanel, CollabNotes, FileManager,
MemoriesPanel, PlacesSidebar, CollabChat, SystemNoticeModal,
BudgetPanel, PlaceFormModal, ...) into focused render units backed by
in-file hooks.

Apply the shared global request pipeline (helmet/CSP, CORS, HSTS,
forced HTTPS, the global MFA policy and request logging) to the NestJS
instance as well, so a migrated route is protected identically to the
legacy fallback rather than bypassing it.
2026-05-30 02:39:26 +02:00

841 lines
48 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from '../i18n'
import Navbar from '../components/Layout/Navbar'
import apiClient from '../api/client'
import CustomSelect from '../components/shared/CustomSelect'
import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react'
import type { TranslationFn } from '../types'
import { A2_TO_A3, countryCodeToFlag, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel'
import { useAtlas } from './atlas/useAtlas'
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 {
const tp = dark ? '#f1f5f9' : '#0f172a'
const tf = dark ? '#475569' : '#94a3b8'
const { continents, lastTrip, nextTrip, streak, firstYear, tripsThisYear } = data || {}
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 thisYear = new Date().getFullYear()
return (
<div className="space-y-4">
{/* Stats grid */}
<div className="grid grid-cols-5 gap-2">
{[[stats.totalCountries, t('atlas.countries')], [stats.totalTrips, t('atlas.trips')], [stats.totalPlaces, t('atlas.places')], [stats.totalCities || 0, t('atlas.cities')], [stats.totalDays, t('atlas.days')]].map(([v, l], i) => (
<div key={i} className="text-center py-2">
<p className="text-xl font-black tabular-nums" style={{ color: tp }}>{v}</p>
<p className="text-[9px] font-semibold uppercase tracking-wide" style={{ color: tf }}>{l}</p>
</div>
))}
</div>
{/* Continents */}
<div className="grid grid-cols-6 gap-1">
{['Europe', 'Asia', 'North America', 'South America', 'Africa', 'Oceania'].map(cont => {
const count = continents?.[cont] || 0
return (
<div key={cont} className="text-center py-1">
<p className="text-base font-bold tabular-nums" style={{ color: count > 0 ? tp : (dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.12)') }}>{count}</p>
<p className="text-[8px] font-semibold uppercase" style={{ color: count > 0 ? tf : (dark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)') }}>{CL[cont]}</p>
</div>
)
})}
</div>
{/* Highlights */}
<div className="flex gap-3">
{streak > 0 && (
<div className="text-center flex-1 py-2">
<p className="text-xl font-black tabular-nums" style={{ color: tp }}>{streak}</p>
<p className="text-[9px] font-semibold uppercase tracking-wide" style={{ color: tf }}>{streak === 1 ? t('atlas.yearInRow') : t('atlas.yearsInRow')}</p>
</div>
)}
{tripsThisYear > 0 && (
<div className="text-center flex-1 py-2">
<p className="text-xl font-black tabular-nums" style={{ color: tp }}>{tripsThisYear}</p>
<p className="text-[9px] font-semibold uppercase tracking-wide" style={{ color: tf }}>{tripsThisYear === 1 ? t('atlas.tripIn') : t('atlas.tripsIn')} {thisYear}</p>
</div>
)}
</div>
</div>
)
}
export default function AtlasPage(): React.ReactElement {
// Page = wiring container: the whole interactive globe (map lifecycle, atlas +
// bucket data, mark/unmark flows, country search) lives in useAtlas. The page
// only wires that state into JSX and its presentational SidebarContent helper.
const {
t, language, navigate, resolveName, dark, loading,
mapRef, regionTooltipRef, panelRef, glareRef, borderGlareRef,
handlePanelMouseMove, handlePanelMouseLeave,
data, setData, stats, countries, selectedCountry, countryDetail,
loadCountryDetail, handleUnmarkCountry, select_country_from_search,
visitedRegions, setVisitedRegions,
atlas_country_search, set_atlas_country_search,
atlas_country_results, set_atlas_country_results,
atlas_country_open, set_atlas_country_open, atlas_country_options,
confirmAction, setConfirmAction, executeConfirmAction,
bucketMonth, setBucketMonth, bucketYear, setBucketYear,
bucketList, setBucketList, bucketTab, setBucketTab,
showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm,
handleAddBucketItem, handleDeleteBucketItem, handleBucketPoiSearch, handleSelectBucketPoi,
bucketSearchResults, setBucketSearchResults,
bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear,
bucketSearching, bucketSearch, setBucketSearch,
} = useAtlas()
if (loading) {
return (
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
<Navbar />
<div className="flex items-center justify-center" style={{ paddingTop: 'var(--nav-h)', minHeight: 'calc(100vh - var(--nav-h))' }}>
<div className="w-8 h-8 border-2 rounded-full animate-spin" style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
</div>
</div>
)
}
return (
<div className="h-screen overflow-hidden" style={{ background: 'var(--bg-primary)' }}>
<Navbar />
<div style={{ position: 'fixed', top: 'var(--nav-h)', left: 0, right: 0, bottom: 'env(safe-area-inset-bottom, 0px)' }}>
{/* Map */}
<div ref={mapRef} style={{ position: 'absolute', inset: 0, zIndex: 1, background: dark ? '#1a1a2e' : '#f0f0f0' }} />
{/* Region tooltip (custom, always on top, ref-controlled to avoid re-renders) */}
<div ref={regionTooltipRef} style={{
position: 'fixed', display: 'none',
zIndex: 9999, pointerEvents: 'none',
background: dark ? 'rgba(15,15,20,0.92)' : 'rgba(255,255,255,0.96)',
color: dark ? '#fff' : '#111',
borderRadius: 10, padding: '10px 14px',
boxShadow: '0 4px 16px rgba(0,0,0,0.18)',
border: `1px solid ${dark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)'}`,
fontSize: 12, minWidth: 120,
}} />
<div
className="absolute z-20 flex justify-center"
style={{ top: 'calc(env(safe-area-inset-top, 0px) + 14px)', left: 0, right: 0, pointerEvents: 'none' }}
>
<div style={{ width: 'min(520px, calc(100vw - 28px))', pointerEvents: 'auto' }}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '10px 12px',
borderRadius: 16,
border: '1px solid ' + (dark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.08)'),
background: dark ? 'rgba(10,10,15,0.55)' : 'rgba(255,255,255,0.55)',
backdropFilter: 'blur(18px) saturate(180%)',
WebkitBackdropFilter: 'blur(18px) saturate(180%)',
boxShadow: dark ? '0 8px 26px rgba(0,0,0,0.25)' : '0 8px 26px rgba(0,0,0,0.10)',
}}>
<Search size={16} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<input
value={atlas_country_search}
onChange={(e) => {
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() && (
<button
onClick={() => {
set_atlas_country_search('')
set_atlas_country_results([])
set_atlas_country_open(false)
}}
style={{ border: 'none', background: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}
aria-label="Clear"
>
<X size={14} />
</button>
)}
</div>
{atlas_country_open && atlas_country_results.length > 0 && (
<div
style={{
marginTop: 8,
borderRadius: 14,
overflow: 'hidden',
border: '1px solid ' + (dark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.08)'),
background: dark ? 'rgba(10,10,15,0.75)' : 'rgba(255,255,255,0.75)',
backdropFilter: 'blur(18px) saturate(180%)',
WebkitBackdropFilter: 'blur(18px) saturate(180%)',
boxShadow: dark ? '0 12px 30px rgba(0,0,0,0.35)' : '0 12px 30px rgba(0,0,0,0.12)',
}}
onMouseLeave={() => set_atlas_country_open(false)}
>
{atlas_country_results.map((r) => (
<button
key={r.code}
onClick={() => select_country_from_search(r.code)}
style={{
width: '100%',
border: 'none',
background: 'transparent',
cursor: 'pointer',
padding: '10px 12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontFamily: 'inherit',
textAlign: 'left',
borderBottom: '1px solid ' + (dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)'),
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.background = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)' }}
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
<span style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<img src={`https://flagcdn.com/w40/${r.code.toLowerCase()}.png`} alt={r.code} style={{ width: 28, height: 20, borderRadius: 4, objectFit: 'cover' }} />
<span style={{ fontSize: 13, fontWeight: 650, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{r.label}
</span>
</span>
<ChevronRight size={16} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
</button>
))}
</div>
)}
</div>
</div>
{/* Mobile: Bottom bar */}
<div className="md:hidden absolute left-0 right-0 z-10 flex justify-center" style={{ bottom: 'calc(84px + env(safe-area-inset-bottom, 0px) + 8px)', touchAction: 'manipulation' }}>
<div className="flex items-center gap-4 px-5 py-4 rounded-2xl"
style={{ background: dark ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.5)', backdropFilter: 'blur(16px)' }}>
{/* Countries highlighted */}
<div className="text-center px-3 py-1.5 rounded-xl" style={{ background: dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)' }}>
<p className="text-3xl font-black tabular-nums leading-none" style={{ color: 'var(--text-primary)' }}>{stats.totalCountries}</p>
<p className="text-[9px] font-semibold uppercase tracking-wide mt-1" style={{ color: 'var(--text-faint)' }}>{t('atlas.countries')}</p>
</div>
{[[stats.totalTrips, t('atlas.trips')], [stats.totalPlaces, t('atlas.places')], [stats.totalCities || 0, t('atlas.cities')], [stats.totalDays, t('atlas.days')]].map(([v, l], i) => (
<div key={i} className="text-center px-1">
<p className="text-xl font-black tabular-nums leading-none" style={{ color: 'var(--text-primary)' }}>{v}</p>
<p className="text-[9px] font-semibold uppercase tracking-wide mt-1" style={{ color: 'var(--text-faint)' }}>{l}</p>
</div>
))}
</div>
</div>
{/* Desktop Panel — bottom center, glass effect */}
<div
ref={panelRef}
onMouseMove={handlePanelMouseMove}
onMouseLeave={handlePanelMouseLeave}
className="hidden md:flex flex-col absolute z-10 overflow-hidden transition-[width,height,transform,box-shadow] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{
bottom: 16,
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%)',
border: '1px solid ' + (dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'),
borderRadius: 20,
boxShadow: dark
? '0 8px 32px rgba(0,0,0,0.3)'
: '0 8px 32px rgba(0,0,0,0.08)',
}}
>
{/* Liquid glass glare effect */}
<div ref={glareRef} className="absolute inset-0 pointer-events-none" style={{ opacity: 0, transition: 'opacity 0.3s ease', borderRadius: 20 }} />
{/* Border glow that follows cursor */}
<div ref={borderGlareRef} className="absolute inset-0 pointer-events-none" style={{
opacity: 0, transition: 'opacity 0.3s ease', borderRadius: 20,
border: dark ? '1.5px solid rgba(255,255,255,0.5)' : '2px solid rgba(0,0,0,0.15)',
}} />
<SidebarContent
data={data} stats={stats} countries={countries} selectedCountry={selectedCountry}
countryDetail={countryDetail} resolveName={resolveName}
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}
onSearchBucket={handleBucketPoiSearch} onSelectBucketPoi={handleSelectBucketPoi}
bucketSearchResults={bucketSearchResults} setBucketSearchResults={setBucketSearchResults} bucketPoiMonth={bucketPoiMonth} setBucketPoiMonth={setBucketPoiMonth}
bucketPoiYear={bucketPoiYear} setBucketPoiYear={setBucketPoiYear} bucketSearching={bucketSearching}
bucketSearch={bucketSearch} setBucketSearch={setBucketSearch}
t={t} dark={dark}
/>
</div>
</div>
{/* Country action popup */}
{confirmAction && (
<div style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}
onClick={() => setConfirmAction(null)}>
<div style={{ background: 'var(--bg-card)', borderRadius: 16, padding: 24, maxWidth: 340, width: '100%', boxShadow: '0 16px 48px rgba(0,0,0,0.2)', textAlign: 'center' }}
onClick={e => e.stopPropagation()}>
{confirmAction.code.length === 2 ? (
<img src={`https://flagcdn.com/w80/${confirmAction.code.toLowerCase()}.png`} alt={confirmAction.code} style={{ width: 48, height: 34, borderRadius: 6, objectFit: 'cover', marginBottom: 12, display: 'inline-block' }} />
) : (
<div style={{ fontSize: 36, marginBottom: 12 }}>{countryCodeToFlag(confirmAction.code)}</div>
)}
<h3 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{confirmAction.name}</h3>
{confirmAction.type === 'choose' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<button onClick={async () => {
try {
await apiClient.post(`/addons/atlas/country/${confirmAction.code}/mark`)
setData(prev => {
if (!prev || prev.countries.find(c => c.code === confirmAction.code)) return prev
return { ...prev, countries: [...prev.countries, { code: confirmAction.code, 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.markVisitedHint')}</div>
</div>
</button>
<button onClick={() => setConfirmAction({ ...confirmAction, type: 'bucket' as any })}
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 === 'choose-region' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{confirmAction.countryName && (
<p style={{ margin: '-8px 0 8px', fontSize: 12, color: 'var(--text-muted)' }}>{confirmAction.countryName}</p>
)}
<button onClick={async () => {
const { code: countryCode, name: rName, regionCode: rCode } = confirmAction
if (!rCode) return
try {
await apiClient.post(`/addons/atlas/region/${rCode}/mark`, { name: rName, country_code: countryCode })
setVisitedRegions(prev => {
const existing = prev[countryCode] || []
if (existing.find(r => r.code === rCode)) return prev
return { ...prev, [countryCode]: [...existing, { code: rCode, name: rName, placeCount: 0, manuallyMarked: true }] }
})
setData(prev => {
if (!prev || prev.countries.find(c => c.code === countryCode)) return prev
return { ...prev, countries: [...prev.countries, { code: countryCode, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } }
})
} catch {}
setConfirmAction(null)
}}
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<MapPin size={18} style={{ color: 'var(--text-primary)', flexShrink: 0 }} />
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('atlas.markVisited')}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{t('atlas.markRegionVisitedHint')}</div>
</div>
</button>
<button onClick={() => setConfirmAction({ ...confirmAction, type: 'bucket' })}
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left', transition: 'background 0.12s' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<Star size={18} style={{ color: '#fbbf24', flexShrink: 0 }} />
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('atlas.addToBucket')}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 1 }}>{t('atlas.addToBucketHint')}</div>
</div>
</button>
</div>
)}
{confirmAction.type === 'unmark' && (
<>
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.confirmUnmark')}</p>
<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={executeConfirmAction}
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 === 'unmark-region' && (
<>
{confirmAction.countryName && (
<p style={{ margin: '-8px 0 8px', fontSize: 12, color: 'var(--text-muted)' }}>{confirmAction.countryName}</p>
)}
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.confirmUnmarkRegion')}</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
<button onClick={() => setConfirmAction(null)}
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button onClick={async () => {
const { code: countryCode, regionCode: rCode } = confirmAction
if (!rCode) return
try {
await apiClient.delete(`/addons/atlas/region/${rCode}/mark`)
setVisitedRegions(prev => {
const remaining = (prev[countryCode] || []).filter(r => r.code !== rCode)
const next = { ...prev, [countryCode]: remaining }
if (remaining.length === 0) delete next[countryCode]
return next
})
// If no manually-marked regions remain, also remove country if it has no trips/places
setData(prev => {
if (!prev) return prev
const c = prev.countries.find(c => c.code === countryCode)
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
const remainingRegions = (visitedRegions[countryCode] || []).filter(r => r.code !== rCode && r.manuallyMarked)
if (remainingRegions.length > 0) return prev
return {
...prev,
countries: prev.countries.filter(c => c.code !== countryCode),
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
}
})
} catch {}
setConfirmAction(null)
}}
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: '#ef4444', color: 'white' }}>
{t('atlas.unmark')}
</button>
</div>
</>
)}
{confirmAction.type === 'bucket' && (
<>
<p style={{ margin: '0 0 14px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.bucketWhen')}</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', marginBottom: 16 }}>
<div style={{ flex: 1 }}>
<CustomSelect
value={String(bucketMonth)}
onChange={v => setBucketMonth(Number(v))}
placeholder={t('atlas.month')}
options={[
{ value: '0', label: '—' },
...Array.from({ length: 12 }, (_, i) => ({ value: String(i + 1), label: new Date(2000, i).toLocaleString(language, { month: 'long' }) })),
]}
size="sm"
/>
</div>
<div style={{ flex: 1 }}>
<CustomSelect
value={String(bucketYear)}
onChange={v => setBucketYear(Number(v))}
placeholder={t('atlas.year')}
options={[
{ value: '0', label: '—' },
...Array.from({ length: 20 }, (_, i) => ({ value: String(new Date().getFullYear() + i), label: String(new Date().getFullYear() + i) })),
]}
size="sm"
/>
</div>
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
<button onClick={() => setConfirmAction({ ...confirmAction, type: confirmAction.regionCode ? 'choose-region' : 'choose' })}
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.back')}
</button>
<button onClick={async () => {
const targetDate = bucketMonth > 0 && bucketYear > 0 ? `${bucketYear}-${String(bucketMonth).padStart(2, '0')}` : null
try {
const r = await apiClient.post('/addons/atlas/bucket-list', { name: confirmAction.name, country_code: confirmAction.code, target_date: targetDate })
setBucketList(prev => [r.data.item, ...prev])
} catch {}
setBucketMonth(0); setBucketYear(0)
setConfirmAction(null)
}}
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: '#fbbf24', color: '#1a1a1a' }}>
{t('atlas.addToBucket')}
</button>
</div>
</>
)}
{confirmAction.type === 'mark' && (
<>
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)' }}>{t('atlas.confirmMark')}</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={executeConfirmAction}
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: 'var(--text-primary)', color: 'white' }}>
{t('atlas.markVisited')}
</button>
</div>
</>
)}
</div>
</div>
)}
</div>
)
}
interface SidebarContentProps {
data: AtlasData | null
stats: AtlasStats
countries: AtlasCountry[]
selectedCountry: string | null
countryDetail: CountryDetail | null
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; lat: string; lng: string; target_date: string }
setBucketForm: (f: { name: string; notes: string; lat: string; lng: string; target_date: string }) => void
onAddBucket: () => Promise<void>
onDeleteBucket: (id: number) => Promise<void>
onSearchBucket: () => Promise<void>
onSelectBucketPoi: (result: any) => void
bucketSearchResults: any[]
setBucketSearchResults: (v: string[]) => void
bucketPoiMonth: number
setBucketPoiMonth: (v: number) => void
bucketPoiYear: number
setBucketPoiYear: (v: number) => void
bucketSearching: boolean
bucketSearch: string
setBucketSearch: (v: string) => void
t: TranslationFn
dark: boolean
}
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 statsContentRef = useRef<HTMLDivElement>(null)
const [statsWidth, setStatsWidth] = useState<number | undefined>(undefined)
useEffect(() => {
const el = statsContentRef.current
if (!el || typeof ResizeObserver === 'undefined') return
const ro = new ResizeObserver(() => setStatsWidth(el.offsetWidth))
ro.observe(el)
return () => ro.disconnect()
}, [])
const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
const tp = dark ? '#f1f5f9' : '#0f172a'
const tm = dark ? '#94a3b8' : '#64748b'
const tf = dark ? '#475569' : '#94a3b8'
const accent = '#818cf8'
const { mostVisited, continents, lastTrip, nextTrip, streak, firstYear, tripsThisYear } = data || {}
const contEntries = continents ? Object.entries(continents).sort((a, b) => b[1] - a[1]) : []
const maxCont = contEntries.length > 0 ? contEntries[0][1] : 1
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']
// Tab switcher
const tabBar = (
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', marginBottom: 4 }}>
{[{ id: 'stats', label: t('atlas.statsTab'), icon: Globe }, { id: 'bucket', label: t('atlas.bucketTab'), icon: Star }].map(tab => (
<button key={tab.id} onClick={() => setBucketTab(tab.id as any)}
style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '7px 0', borderRadius: 10, border: 'none', cursor: 'pointer', fontFamily: 'inherit',
fontSize: 12, fontWeight: 600, transition: 'all 0.15s',
background: bucketTab === tab.id ? bg(0.1) : 'transparent',
color: bucketTab === tab.id ? tp : tf,
}}>
<tab.icon size={13} />
{tab.label}
</button>
))}
</div>
)
if (countries.length === 0 && !lastTrip && bucketTab !== 'bucket') {
return (
<>
{tabBar}
<div className="p-8 text-center">
<Globe size={28} className="mx-auto mb-2" style={{ color: tf, opacity: 0.4 }} />
<p className="text-sm font-medium" style={{ color: tm }}>{t('atlas.noData')}</p>
<p className="text-xs mt-1" style={{ color: tf }}>{t('atlas.noDataHint')}</p>
</div>
</>
)
}
const thisYear = new Date().getFullYear()
const divider = `2px solid ${bg(0.08)}`
// Bucket list content
const bucketContent = (
<>
<div className="flex items-stretch" style={{ overflowX: 'auto', padding: '0 8px', maxWidth: statsWidth, width: '100%' }}>
{bucketList.map(item => (
<div key={item.id} className="group flex flex-col items-center justify-center shrink-0" style={{ padding: '8px 14px', position: 'relative', minWidth: 80 }}>
{(() => {
const code = item.country_code?.length === 2 ? item.country_code : (Object.entries(A2_TO_A3).find(([, v]) => v === item.country_code)?.[0] || '')
return code ? (
<img src={`https://flagcdn.com/w40/${code.toLowerCase()}.png`} alt={code} style={{ width: 28, height: 20, borderRadius: 4, objectFit: 'cover', marginBottom: 4 }} />
) : <Star size={16} style={{ color: '#fbbf24', marginBottom: 4 }} fill="#fbbf24" />
})()}
<span className="text-xs font-semibold text-center leading-tight" style={{ color: tp, maxWidth: 90, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.name}</span>
{item.target_date && (() => {
const [y, m] = item.target_date.split('-')
const label = m ? new Date(Number(y), Number(m) - 1).toLocaleString(language, { month: 'short', year: 'numeric' }) : y
return <span className="text-[9px] mt-0.5 text-center" style={{ color: tf }}>{label}</span>
})()}
{!item.target_date && item.notes && <span className="text-[9px] mt-0.5 text-center" style={{ color: tf, maxWidth: 90, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.notes}</span>}
<button onClick={() => onDeleteBucket(item.id)}
className="opacity-0 group-hover:opacity-100"
style={{ position: 'absolute', top: 4, right: 4, background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: tf, display: 'flex', transition: 'opacity 0.15s' }}>
<X size={10} />
</button>
</div>
))}
{bucketList.length === 0 && !showBucketAdd && (
<div className="flex items-center justify-center py-4 px-6" style={{ color: tf, fontSize: 12 }}>
{t('atlas.bucketEmptyHint')}
</div>
)}
</div>
{showBucketAdd ? (
<div style={{ padding: '8px 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
{/* Search or manual name */}
<div style={{ position: 'relative' }}>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={bucketForm.name || bucketSearch}
onChange={e => { const v = e.target.value; if (bucketForm.name) setBucketForm({ ...bucketForm, name: v }); else setBucketSearch(v) }}
onKeyDown={e => { if (e.key === 'Enter' && !bucketForm.name) onSearchBucket(); else if (e.key === 'Enter') onAddBucket(); if (e.key === 'Escape') setShowBucketAdd(false) }}
placeholder={t('atlas.bucketNamePlaceholder')}
autoFocus
style={{ flex: 1, padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)', fontSize: 12, fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)', background: 'var(--bg-input)' }}
/>
{!bucketForm.name && (
<button onClick={onSearchBucket} disabled={bucketSearching}
style={{ padding: '6px 10px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
<Search size={12} />
</button>
)}
{bucketForm.name && (
<button onClick={() => { setBucketForm({ ...bucketForm, name: '', lat: '', lng: '' }); setBucketSearch('') }}
style={{ padding: '6px 8px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}>
<X size={12} />
</button>
)}
</div>
{bucketSearchResults.length > 0 && (
<div style={{ position: 'absolute', bottom: '100%', left: 0, right: 0, zIndex: 50, marginBottom: 4, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.12)', maxHeight: 160, overflowY: 'auto' }}>
{bucketSearchResults.slice(0, 6).map((r, i) => (
<button key={i} onClick={() => onSelectBucketPoi(r)} style={{ display: 'flex', flexDirection: 'column', gap: 1, width: '100%', padding: '6px 10px', border: 'none', background: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit', borderBottom: '1px solid var(--border-faint)' }}>
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-primary)' }}>{r.name}</span>
{r.address && <span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{r.address}</span>}
</button>
))}
</div>
)}
</div>
{/* Selected place indicator */}
{bucketForm.lat && bucketForm.lng && (
<div style={{ fontSize: 10, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', gap: 4 }}>
<MapPin size={10} /> {Number(bucketForm.lat).toFixed(4)}, {Number(bucketForm.lng).toFixed(4)}
</div>
)}
{/* Month / Year with CustomSelect */}
<div style={{ display: 'flex', gap: 6 }}>
<div style={{ flex: 1 }}>
<CustomSelect value={String(bucketPoiMonth)} onChange={v => 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' }) }))]} />
</div>
<div style={{ flex: 1 }}>
<CustomSelect value={String(bucketPoiYear)} onChange={v => 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) }))]} />
</div>
</div>
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<button onClick={() => { setShowBucketAdd(false); setBucketForm({ name: '', notes: '', lat: '', lng: '', target_date: '' }); setBucketSearch(''); setBucketSearchResults([]); setBucketPoiMonth(0); setBucketPoiYear(0) }}
style={{ fontSize: 11, padding: '4px 10px', borderRadius: 6, border: '1px solid var(--border-primary)', background: 'none', cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
{t('common.cancel')}
</button>
<button onClick={onAddBucket} disabled={!bucketForm.name.trim()}
style={{ fontSize: 11, padding: '4px 12px', borderRadius: 6, border: 'none', background: '#fbbf24', color: '#1a1a1a', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: bucketForm.name.trim() ? 1 : 0.5 }}>
{t('common.add')}
</button>
</div>
</div>
) : (
<div style={{ padding: '4px 16px 8px' }}>
<button onClick={() => setShowBucketAdd(true)}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4, width: '100%', padding: '5px 0', borderRadius: 8, border: '1px dashed var(--border-primary)', background: 'none', fontSize: 11, color: tf, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={11} /> {t('atlas.addPoi')}
</button>
</div>
)}
</>
)
return (
<>
{tabBar}
{/* Both tabs always rendered so the wider one sets the panel width */}
<div style={{ display: 'grid' }}>
<div style={bucketTab === 'bucket' ? { visibility: 'hidden' as const, gridArea: '1/1' } : { gridArea: '1/1' }}>
<div ref={statsContentRef} className="flex items-stretch justify-center">
{/* ═══ SECTION 1: Numbers ═══ */}
{/* Countries hero */}
<div className="flex items-baseline gap-1.5 px-5 py-4 mx-2 my-2 rounded-xl" style={{ background: bg(0.08) }}>
<span className="text-5xl font-black tabular-nums leading-none" style={{ color: tp }}>{stats.totalCountries}</span>
<span className="text-sm font-medium" style={{ color: tm }}>{t('atlas.countries')}</span>
</div>
{/* Other stats */}
{[[stats.totalTrips, t('atlas.trips')], [stats.totalPlaces, t('atlas.places')], [stats.totalCities || 0, t('atlas.cities')], [stats.totalDays, t('atlas.days')]].map(([v, l], i) => (
<div key={i} className="flex flex-col items-center justify-center px-3 py-5 shrink-0">
<span className="text-2xl font-black tabular-nums leading-none" style={{ color: tp }}>{v}</span>
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide whitespace-nowrap" style={{ color: tf }}>{l}</span>
</div>
))}
{/* ═══ DIVIDER ═══ */}
<div style={{ width: 2, background: bg(0.08), margin: '12px 14px' }} />
{/* ═══ SECTION 2: Continents ═══ */}
<div className="flex items-center gap-4 px-3 py-4 shrink-0">
{['Europe', 'Asia', 'North America', 'South America', 'Africa', 'Oceania'].map((cont) => {
const count = continents?.[cont] || 0
const active = count > 0
return (
<div key={cont} className="flex flex-col items-center shrink-0">
<span className="text-2xl font-black tabular-nums leading-none" style={{ color: active ? tp : bg(0.15) }}>{count}</span>
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide whitespace-nowrap" style={{ color: active ? tf : bg(0.1) }}>{CL[cont]}</span>
</div>
)
})}
</div>
{/* ═══ DIVIDER ═══ */}
<div style={{ width: 2, background: bg(0.08), margin: '12px 14px' }} />
{/* ═══ SECTION 3: Highlights & Streaks ═══ */}
<div className="flex items-center gap-5 px-3 py-4">
{/* Last trip */}
{lastTrip && (
<button onClick={() => onTripClick(lastTrip.id)} className="flex items-center gap-2.5 text-left transition-opacity hover:opacity-75">
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-lg shrink-0" style={{ background: bg(0.06) }}>
{lastTrip.countryCode ? countryCodeToFlag(lastTrip.countryCode) : <MapPin size={16} style={{ color: tm }} />}
</div>
<div className="min-w-0">
<p className="text-[9px] uppercase tracking-wider font-semibold" style={{ color: tf }}>{t('atlas.lastTrip')}</p>
<p className="text-[13px] font-bold truncate" style={{ color: tp }}>{lastTrip.title}</p>
</div>
</button>
)}
{/* Streak */}
{streak > 0 && (
<div className="flex flex-col items-center justify-center px-3">
<span className="text-2xl font-black tabular-nums leading-none" style={{ color: tp }}>{streak}</span>
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide text-center leading-tight whitespace-nowrap" style={{ color: tf }}>
{streak === 1 ? t('atlas.yearInRow') : t('atlas.yearsInRow')}
</span>
</div>
)}
{/* This year */}
{tripsThisYear > 0 && (
<div className="flex flex-col items-center justify-center px-3">
<span className="text-2xl font-black tabular-nums leading-none" style={{ color: tp }}>{tripsThisYear}</span>
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide text-center leading-tight whitespace-nowrap" style={{ color: tf }}>
{tripsThisYear === 1 ? t('atlas.tripIn') : t('atlas.tripsIn')} {thisYear}
</span>
</div>
)}
</div>
{/* ═══ Country detail overlay ═══ */}
{selectedCountry && countryDetail && (
<>
<div style={{ width: 2, background: bg(0.08), margin: '12px 0' }} />
<div className="flex items-center gap-3 px-6 py-4">
<span className="text-3xl">{countryCodeToFlag(selectedCountry)}</span>
<div>
<p className="text-sm font-bold" style={{ color: tp }}>{resolveName(selectedCountry)}</p>
<p className="text-[10px] mb-1" style={{ color: tf }}>{countryDetail.places.length} {t('atlas.places')} · {countryDetail.trips.length} Trips</p>
<div className="flex flex-wrap gap-1">
{countryDetail.trips.slice(0, 3).map(trip => (
<button key={trip.id} onClick={() => onTripClick(trip.id)}
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-semibold transition-opacity hover:opacity-75"
style={{ background: bg(0.08), color: tp }}>
<Briefcase size={9} style={{ color: tm }} />
{trip.title}
</button>
))}
{countryDetail.manually_marked && onUnmarkCountry && (
<button onClick={() => onUnmarkCountry(selectedCountry!)}
className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-semibold transition-opacity hover:opacity-75"
style={{ background: 'rgba(239,68,68,0.1)', color: '#ef4444' }}>
<X size={9} />
{t('atlas.unmark')}
</button>
)}
</div>
</div>
</div>
</>
)}
</div>
</div>
<div style={bucketTab === 'stats' ? { visibility: 'hidden' as const, gridArea: '1/1' } : { gridArea: '1/1' }}>
{bucketContent}
</div>
</div>
</>
)
}