mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
1ad2e92142
The bulk of what SonarQube still had open on the reliability side, read site by
site rather than swept. Two clusters are worth calling out because they are
defects, not lint.
**Four regexes could be made to hang the process.** The worst is
`/.+@.+\..+/` in the forgot-password path: three unbounded `.+` that all
overlap, unanchored, on an endpoint that needs no login and whose schema is a
bare z.string() under a 100kb body cap. A thousand '@' characters had not
finished after 100 seconds — one request pins the event loop, and Node has one.
EMAIL_REGEX in auth.helpers is the same shape on register and profile update
(310ms for 8kb). The two tag strippers over an uploaded KML description and over
Commons metadata took 0.52s on a 16k run of '<'. Each rewrite was proven
equivalent by brute force against the original — millions of strings for the
coordinate patterns, 671,876 for the tag stripper — because a subtly different
regex on a parsing path is worse than the finding.
`<[^>]+>` could not be fixed in the pattern: `[^>]` matches `<` too, so the only
unambiguous form changes what happens to malformed markup. It is now a scan in
common/stripHtmlTags.ts, quirks included — `<>` is left alone, `<a<b>` is one
tag — with the equivalence pinned by a test that compares against the old regex
over every string up to length six.
**And roughly 500 places could only be reached with a mouse.** Clickable divs
became real buttons where the markup allowed (52 of them), took
role + tabIndex + Enter/Space where they had to stay divs, and modal backdrops
took role="presentation" — the honest role, since a backdrop is a mouse shortcut
for a Cancel control that is already in the tab order, and making it a button
plants a nameless full-screen tab stop around its own contents.
Alongside those: `.sort()` calls that now say the order they always had, `{n &&
<X/>}` guards that rendered a literal "0" for a zero-byte file and for a place at
latitude 0, an optimistic budget write that stored a boolean where the contract
and both transports use 0/1, and the Number statics.
sonar-project.properties documents the six that were deliberately left: two wiki
patterns with no overlapping quantifier pair whose "fix" would stop parsing
`[see [1]](url)`, four tar header fields bounded at 12-155 bytes by the format,
two djb2 hashes where the 32-bit wrap is the algorithm, two `| 0` that are also
coercing undefined to 0 on a path where the API sends no user_id, and the MCP
session maps Sonar reads as empty because they are filled from another module.
787 lines
47 KiB
TypeScript
787 lines
47 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { useTranslation } from '../i18n'
|
|
import Navbar from '../components/Layout/Navbar'
|
|
import apiClient from '../api/client'
|
|
import CustomSelect from '../components/shared/CustomSelect'
|
|
import EmptyState from '../components/shared/EmptyState'
|
|
import { Globe, MapPin, Briefcase, Calendar, Flag, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react'
|
|
import type { TranslationFn } from '../types'
|
|
import { A2_TO_A3, countryCodeToFlag, findBucketDuplicate, isBucketDuplicateError, withCountryMarkedVisited, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel'
|
|
import { continentForCountry } from '@trek/shared'
|
|
import { useAtlas } from './atlas/useAtlas'
|
|
import AtlasCountrySearch from './atlas/AtlasCountrySearch'
|
|
import AtlasLayerToggle from './atlas/AtlasLayerToggle'
|
|
import { useToast } from '../components/shared/Toast'
|
|
import { getApiErrorMessage } from '../types'
|
|
|
|
export default function AtlasPage(): React.ReactElement {
|
|
// ViewportRoute in App.tsx picks the branch now, so the phone screen is a
|
|
// chunk of its own instead of a dead limb in this one.
|
|
return <AtlasPageDesktop />
|
|
}
|
|
|
|
function AtlasPageDesktop(): 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,
|
|
showPlanned, togglePlanned,
|
|
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,
|
|
atlas_place_results, atlas_places_loading, search_places, select_place_from_search,
|
|
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()
|
|
const toast = useToast()
|
|
// Solid surfaces when the user disabled transparency (read at render — the
|
|
// attribute is already set by applyAppearance before navigating here).
|
|
const noTransparency = typeof document !== 'undefined' && document.documentElement.hasAttribute('data-no-transparency')
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen bg-surface">
|
|
<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 border-edge border-t-content" />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="h-screen overflow-hidden bg-surface">
|
|
<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: noTransparency ? (dark ? '#0f0f14' : '#ffffff') : (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: 'calc(12px * var(--fs-scale-body, 1))', minWidth: 120,
|
|
}} />
|
|
<AtlasCountrySearch
|
|
dark={dark}
|
|
t={t}
|
|
search={atlas_country_search}
|
|
setSearch={set_atlas_country_search}
|
|
results={atlas_country_results}
|
|
setResults={set_atlas_country_results}
|
|
open={atlas_country_open}
|
|
setOpen={set_atlas_country_open}
|
|
options={atlas_country_options}
|
|
onSelect={select_country_from_search}
|
|
placeResults={atlas_place_results}
|
|
placesLoading={atlas_places_loading}
|
|
onQueryChange={search_places}
|
|
onSelectPlace={select_place_from_search}
|
|
/>
|
|
<AtlasLayerToggle
|
|
t={t}
|
|
showPlanned={showPlanned}
|
|
onToggle={togglePlanned}
|
|
plannedCount={stats.totalCountriesPlanned || 0}
|
|
/>
|
|
|
|
{/* 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 text-content">{stats.totalCountries}</p>
|
|
<p className="text-[9px] font-semibold uppercase tracking-wide mt-1 text-content-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 text-content">{v}</p>
|
|
<p className="text-[9px] font-semibold uppercase tracking-wide mt-1 text-content-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: noTransparency ? (dark ? '#15151c' : '#ffffff') : (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 role="presentation" className="bg-[rgba(0,0,0,0.4)]" style={{ position: 'fixed', inset: 0, zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}
|
|
onClick={() => setConfirmAction(null)}>
|
|
<div role="presentation" className="bg-surface-card" style={{ 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: 'calc(36px * var(--fs-scale-title, 1))', marginBottom: 12 }}>{countryCodeToFlag(confirmAction.code)}</div>
|
|
)}
|
|
<h3 className="text-content" style={{ margin: '0 0 16px', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700 }}>{confirmAction.name}</h3>
|
|
|
|
{confirmAction.type === 'choose' && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
<button type="button" onClick={async () => {
|
|
try {
|
|
await apiClient.post(`/addons/atlas/country/${confirmAction.code}/mark`)
|
|
setData(prev => (prev ? withCountryMarkedVisited(prev, confirmAction.code) : prev))
|
|
} catch (err) {
|
|
toast.error(getApiErrorMessage(err, t('common.error')))
|
|
}
|
|
setConfirmAction(null)
|
|
}}
|
|
className="border border-edge"
|
|
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, 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} className="text-content" style={{ flexShrink: 0 }} />
|
|
<div>
|
|
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('atlas.markVisited')}</div>
|
|
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 1 }}>{t('atlas.markVisitedHint')}</div>
|
|
</div>
|
|
</button>
|
|
<button type="button" onClick={() => setConfirmAction({ ...confirmAction, type: 'bucket' as any })}
|
|
className="border border-edge"
|
|
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, 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} className="text-[#fbbf24]" style={{ flexShrink: 0 }} />
|
|
<div>
|
|
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('atlas.addToBucket')}</div>
|
|
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 1 }}>{t('atlas.addToBucketHint')}</div>
|
|
</div>
|
|
</button>
|
|
{(() => {
|
|
const wishlistItems = bucketList.filter(b => b.country_code === confirmAction.code)
|
|
if (wishlistItems.length === 0) return null
|
|
return (
|
|
<button type="button" onClick={async () => {
|
|
await Promise.all(wishlistItems.map(item => handleDeleteBucketItem(item.id)))
|
|
setConfirmAction(null)
|
|
}}
|
|
className="border border-edge"
|
|
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, 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'}>
|
|
<Trash2 size={18} className="text-[#ef4444]" style={{ flexShrink: 0 }} />
|
|
<div>
|
|
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('atlas.removeFromBucket')}</div>
|
|
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 1 }}>{t('atlas.removeFromBucketHint')}</div>
|
|
</div>
|
|
</button>
|
|
)
|
|
})()}
|
|
</div>
|
|
)}
|
|
|
|
{confirmAction.type === 'choose-region' && (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
{confirmAction.countryName && (
|
|
<p className="text-content-muted" style={{ margin: '-8px 0 8px', fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{confirmAction.countryName}</p>
|
|
)}
|
|
<button type="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 => (prev ? withCountryMarkedVisited(prev, countryCode) : prev))
|
|
} catch (err) {
|
|
toast.error(getApiErrorMessage(err, t('common.error')))
|
|
}
|
|
setConfirmAction(null)
|
|
}}
|
|
className="border border-edge"
|
|
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, 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} className="text-content" style={{ flexShrink: 0 }} />
|
|
<div>
|
|
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('atlas.markVisited')}</div>
|
|
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 1 }}>{t('atlas.markRegionVisitedHint')}</div>
|
|
</div>
|
|
</button>
|
|
<button type="button" onClick={() => setConfirmAction({ ...confirmAction, type: 'bucket' })}
|
|
className="border border-edge"
|
|
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '12px 16px', borderRadius: 12, 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} className="text-[#fbbf24]" style={{ flexShrink: 0 }} />
|
|
<div>
|
|
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('atlas.addToBucket')}</div>
|
|
<div className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', marginTop: 1 }}>{t('atlas.addToBucketHint')}</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{confirmAction.type === 'unmark' && (
|
|
<>
|
|
<p className="text-content-muted" style={{ margin: '0 0 20px', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{t('atlas.confirmUnmark')}</p>
|
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
|
<button type="button" onClick={() => setConfirmAction(null)}
|
|
className="border border-edge text-content-muted"
|
|
style={{ padding: '8px 20px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button type="button" onClick={executeConfirmAction}
|
|
className="bg-[#ef4444] text-white"
|
|
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
|
{t('atlas.unmark')}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{confirmAction.type === 'unmark-region' && (
|
|
<>
|
|
{confirmAction.countryName && (
|
|
<p className="text-content-muted" style={{ margin: '-8px 0 8px', fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{confirmAction.countryName}</p>
|
|
)}
|
|
<p className="text-content-muted" style={{ margin: '0 0 20px', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{t('atlas.confirmUnmarkRegion')}</p>
|
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
|
<button type="button" onClick={() => setConfirmAction(null)}
|
|
className="border border-edge text-content-muted"
|
|
style={{ padding: '8px 20px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button type="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 visible regions remain at all (not just manually-marked ones —
|
|
// the server now hides a region regardless of how it was derived, and
|
|
// cascades to the country the same way), remove the country too, but
|
|
// only when it has no real place/trip data of its own: a country with
|
|
// real places is never actually hidden server-side (#1490), so
|
|
// optimistically removing it here would just flash and reappear on
|
|
// the next reload.
|
|
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)
|
|
if (remainingRegions.length > 0) return prev
|
|
const cont = continentForCountry(countryCode)
|
|
return {
|
|
...prev,
|
|
countries: prev.countries.filter(c => c.code !== countryCode),
|
|
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
|
|
continents: { ...prev.continents, [cont]: Math.max(0, (prev.continents?.[cont] || 0) - 1) },
|
|
}
|
|
})
|
|
} catch (err) {
|
|
toast.error(getApiErrorMessage(err, t('common.error')))
|
|
}
|
|
setConfirmAction(null)
|
|
}}
|
|
className="bg-[#ef4444] text-white"
|
|
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
|
{t('atlas.unmark')}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{confirmAction.type === 'bucket' && (
|
|
<>
|
|
<p className="text-content-muted" style={{ margin: '0 0 14px', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{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 type="button" onClick={() => setConfirmAction({ ...confirmAction, type: confirmAction.regionCode ? 'choose-region' : 'choose' })}
|
|
className="border border-edge text-content-muted"
|
|
style={{ padding: '8px 20px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>
|
|
{t('common.back')}
|
|
</button>
|
|
<button type="button" onClick={async () => {
|
|
const targetDate = bucketMonth > 0 && bucketYear > 0 ? `${bucketYear}-${String(bucketMonth).padStart(2, '0')}` : null
|
|
// #1898: one entry per target date. The dialog stays open on a
|
|
// duplicate so another month can be picked right away.
|
|
if (findBucketDuplicate(bucketList, { name: confirmAction.name, country_code: confirmAction.code, target_date: targetDate, lat: null, lng: null })) {
|
|
toast.error(t('atlas.bucketDuplicate'))
|
|
return
|
|
}
|
|
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 (err) {
|
|
if (isBucketDuplicateError(err)) {
|
|
toast.error(t('atlas.bucketDuplicate'))
|
|
return
|
|
}
|
|
toast.error(getApiErrorMessage(err, t('common.error')))
|
|
}
|
|
setBucketMonth(0); setBucketYear(0)
|
|
setConfirmAction(null)
|
|
}}
|
|
className="bg-[#fbbf24] text-[#1a1a1a]"
|
|
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
|
{t('atlas.addToBucket')}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{confirmAction.type === 'mark' && (
|
|
<>
|
|
<p className="text-content-muted" style={{ margin: '0 0 20px', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{t('atlas.confirmMark')}</p>
|
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
|
<button type="button" onClick={() => setConfirmAction(null)}
|
|
className="border border-edge text-content-muted"
|
|
style={{ padding: '8px 20px', borderRadius: 10, background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button type="button" onClick={executeConfirmAction}
|
|
className="bg-content text-white"
|
|
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
|
{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 bucketSearchRowRef = 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'), 'Antarctica': t('atlas.antarctica') }
|
|
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 type="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: 'calc(12px * var(--fs-scale-body, 1))', 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}
|
|
<EmptyState scene="atlas" title={t('atlas.noData')} layout="row" size={52} />
|
|
</>
|
|
)
|
|
}
|
|
|
|
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} className="text-[#fbbf24]" style={{ 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 type="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: 'calc(12px * var(--fs-scale-body, 1))' }}>
|
|
{t('atlas.bucketEmptyHint')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{showBucketAdd ? (
|
|
<div style={{ padding: '8px 16px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
{/* Search or manual name */}
|
|
<div>
|
|
<div ref={bucketSearchRowRef} 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
|
|
className="border border-edge text-content bg-surface-input"
|
|
style={{ flex: 1, padding: '6px 10px', borderRadius: 8, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }}
|
|
/>
|
|
{!bucketForm.name && (
|
|
<button type="button" onClick={onSearchBucket} disabled={bucketSearching}
|
|
className="bg-accent text-accent-text"
|
|
style={{ padding: '6px 10px', borderRadius: 8, border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
|
|
<Search size={12} />
|
|
</button>
|
|
)}
|
|
{bucketForm.name && (
|
|
<button type="button" onClick={() => { setBucketForm({ ...bucketForm, name: '', lat: '', lng: '' }); setBucketSearch('') }}
|
|
className="border border-edge text-content-faint"
|
|
style={{ padding: '6px 8px', borderRadius: 8, background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
|
|
<X size={12} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{/* Portalled to the body: the panel that hosts this form clips its
|
|
overflow, which used to swallow the top of the list (#1899). */}
|
|
{bucketSearchResults.length > 0 && createPortal(
|
|
<div className="bg-surface-card border border-edge" style={{
|
|
position: 'fixed',
|
|
...(() => {
|
|
const rect = bucketSearchRowRef.current?.getBoundingClientRect()
|
|
if (!rect) return { left: 0, top: 0, width: 240, maxHeight: 160 }
|
|
const above = rect.top - 12
|
|
const below = window.innerHeight - rect.bottom - 12
|
|
const openUp = above >= below
|
|
return {
|
|
left: rect.left,
|
|
width: rect.width,
|
|
// Never taller than the room on that side, so the list scrolls
|
|
// instead of running off the edge of the viewport.
|
|
maxHeight: Math.min(160, openUp ? above : below),
|
|
...(openUp ? { bottom: window.innerHeight - rect.top + 4 } : { top: rect.bottom + 4 }),
|
|
}
|
|
})(),
|
|
zIndex: 99999, borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.12)', overflowY: 'auto',
|
|
}}>
|
|
{bucketSearchResults.slice(0, 6).map((r, i) => (
|
|
<button type="button" key={i} onClick={() => onSelectBucketPoi(r)} className="border-b border-edge-faint" style={{ display: 'flex', flexDirection: 'column', gap: 1, width: '100%', padding: '6px 10px', borderTop: 'none', borderLeft: 'none', borderRight: 'none', background: 'none', cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit' }}>
|
|
<span className="text-content" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500 }}>{r.name}</span>
|
|
{r.address && <span className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))' }}>{r.address}</span>}
|
|
</button>
|
|
))}
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</div>
|
|
{/* Selected place indicator */}
|
|
{bucketForm.lat && bucketForm.lng && (
|
|
<div className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', 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 type="button" onClick={() => { setShowBucketAdd(false); setBucketForm({ name: '', notes: '', lat: '', lng: '', target_date: '' }); setBucketSearch(''); setBucketSearchResults([]); setBucketPoiMonth(0); setBucketPoiYear(0) }}
|
|
className="border border-edge text-content-muted"
|
|
style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', padding: '4px 10px', borderRadius: 6, background: 'none', cursor: 'pointer', fontFamily: 'inherit' }}>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button type="button" onClick={onAddBucket} disabled={!bucketForm.name.trim()}
|
|
className="bg-[#fbbf24] text-[#1a1a1a]"
|
|
style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', padding: '4px 12px', borderRadius: 6, border: 'none', 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 type="button" onClick={() => setShowBucketAdd(true)}
|
|
className="border border-dashed border-edge"
|
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4, width: '100%', padding: '5px 0', borderRadius: 8, background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', 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 flex-col justify-center px-5 py-4 mx-2 my-2 rounded-xl" style={{ background: bg(0.08) }}>
|
|
<div className="flex items-baseline gap-1.5">
|
|
<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>
|
|
{(stats.totalCountriesPlanned || 0) > 0 && (
|
|
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide whitespace-nowrap" style={{ color: tf }}>
|
|
+{stats.totalCountriesPlanned} {t('atlas.planned')}
|
|
</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">
|
|
{/* Antarctica only joins the row once someone has actually been — CONTINENT_MAP has
|
|
always known AQ, but a permanent zero column would be dead space for everyone else. */}
|
|
{['Europe', 'Asia', 'North America', 'South America', 'Africa', 'Oceania', ...((continents?.['Antarctica'] || 0) > 0 ? ['Antarctica'] : [])].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">
|
|
{/* 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)}
|
|
{countryDetail.status && countryDetail.status !== 'visited' && (
|
|
<span className="ml-2 text-[9px] font-semibold uppercase tracking-wide" style={{ color: tf }}>{t('atlas.planned')}</span>
|
|
)}
|
|
</p>
|
|
<p className="text-[10px] mb-1" style={{ color: tf }}>{countryDetail.places.length} {t('atlas.places')} · {countryDetail.trips.length} {t('atlas.tripPlural')}</p>
|
|
<div className="flex flex-wrap gap-1">
|
|
{countryDetail.trips.slice(0, 3).map(trip => (
|
|
<button type="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 type="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 bg-[rgba(239,68,68,0.1)] text-[#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>
|
|
</>
|
|
)
|
|
}
|