diff --git a/client/src/components/shared/CustomSelect.tsx b/client/src/components/shared/CustomSelect.tsx index da4d193..2df9c5e 100644 --- a/client/src/components/shared/CustomSelect.tsx +++ b/client/src/components/shared/CustomSelect.tsx @@ -107,9 +107,15 @@ export default function CustomSelect({ {open && ReactDOM.createPortal(
{ const r = ref.current?.getBoundingClientRect(); return r ? r.bottom + 4 : 0 })(), - left: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.left : 0 })(), - width: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.width : 200 })(), + ...(() => { + const r = ref.current?.getBoundingClientRect() + if (!r) return { top: 0, left: 0, width: 200 } + const spaceBelow = window.innerHeight - r.bottom + const openUp = spaceBelow < 220 && r.top > spaceBelow + return openUp + ? { bottom: window.innerHeight - r.top + 4, left: r.left, width: r.width } + : { top: r.bottom + 4, left: r.left, width: r.width } + })(), zIndex: 99999, background: 'var(--bg-card)', backdropFilter: 'blur(24px) saturate(180%)', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index ab7401f..3bb9785 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -545,6 +545,10 @@ const de: Record = { 'atlas.markVisited': 'Als besucht markieren', 'atlas.markVisitedHint': 'Dieses Land zur besuchten Liste hinzufügen', 'atlas.addToBucket': 'Zur Bucket List', + 'atlas.addPoi': 'Ort hinzufügen', + 'atlas.bucketNamePlaceholder': 'Name (Land, Stadt, Ort...)', + 'atlas.month': 'Monat', + 'atlas.year': 'Jahr', 'atlas.addToBucketHint': 'Als Wunschziel speichern', 'atlas.bucketWhen': 'Wann möchtest du dorthin reisen?', 'atlas.statsTab': 'Statistik', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index f198997..9463693 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -545,6 +545,10 @@ const en: Record = { 'atlas.markVisited': 'Mark as visited', 'atlas.markVisitedHint': 'Add this country to your visited list', 'atlas.addToBucket': 'Add to bucket list', + 'atlas.addPoi': 'Add place', + 'atlas.bucketNamePlaceholder': 'Name (country, city, place...)', + 'atlas.month': 'Month', + 'atlas.year': 'Year', 'atlas.addToBucketHint': 'Save as a place you want to visit', 'atlas.bucketWhen': 'When do you plan to visit?', 'atlas.statsTab': 'Stats', diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx index 1687dfc..1129667 100644 --- a/client/src/pages/AtlasPage.tsx +++ b/client/src/pages/AtlasPage.tsx @@ -3,9 +3,9 @@ import { useNavigate } from 'react-router-dom' import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../i18n' import { useSettingsStore } from '../store/settingsStore' import Navbar from '../components/Layout/Navbar' -import apiClient from '../api/client' +import apiClient, { mapsApi } from '../api/client' import CustomSelect from '../components/shared/CustomSelect' -import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2 } from 'lucide-react' +import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react' import L from 'leaflet' import type { AtlasPlace, GeoJsonFeatureCollection, TranslationFn } from '../types' @@ -154,14 +154,19 @@ export default function AtlasPage(): React.ReactElement { 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()) + const [bucketMonth, setBucketMonth] = useState(0) + const [bucketYear, setBucketYear] = useState(0) // Bucket list - interface BucketItem { id: number; name: string; lat: number | null; lng: number | null; country_code: string | null; notes: string | null } + interface BucketItem { id: number; name: string; lat: number | null; lng: number | null; country_code: string | null; notes: string | null; target_date: string | null } const [bucketList, setBucketList] = useState([]) const [showBucketAdd, setShowBucketAdd] = useState(false) - const [bucketForm, setBucketForm] = useState({ name: '', notes: '' }) + const [bucketForm, setBucketForm] = useState({ name: '', notes: '', lat: '', lng: '', target_date: '' }) + const [bucketSearch, setBucketSearch] = useState('') + const [bucketSearchResults, setBucketSearchResults] = useState([]) + const [bucketSearching, setBucketSearching] = useState(false) + const [bucketPoiMonth, setBucketPoiMonth] = useState(0) + const [bucketPoiYear, setBucketPoiYear] = useState(0) const [bucketTab, setBucketTab] = useState<'stats' | 'bucket'>('stats') const bucketMarkersRef = useRef(null) @@ -397,9 +402,15 @@ export default function AtlasPage(): React.ReactElement { 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 }) + const data: Record = { name: bucketForm.name.trim() } + if (bucketForm.notes.trim()) data.notes = bucketForm.notes.trim() + if (bucketForm.lat && bucketForm.lng) { data.lat = parseFloat(bucketForm.lat); data.lng = parseFloat(bucketForm.lng) } + const targetDate = bucketForm.target_date || (bucketPoiMonth > 0 && bucketPoiYear > 0 ? `${bucketPoiYear}-${String(bucketPoiMonth).padStart(2, '0')}` : null) + if (targetDate) data.target_date = targetDate + const r = await apiClient.post('/addons/atlas/bucket-list', data) setBucketList(prev => [r.data.item, ...prev]) - setBucketForm({ name: '', notes: '' }) + setBucketForm({ name: '', notes: '', lat: '', lng: '', target_date: '' }) + setBucketSearch(''); setBucketSearchResults([]); setBucketPoiMonth(0); setBucketPoiYear(0) setShowBucketAdd(false) } catch { /* */ } } @@ -411,6 +422,28 @@ export default function AtlasPage(): React.ReactElement { } catch { /* */ } } + const handleBucketPoiSearch = async () => { + if (!bucketSearch.trim()) return + setBucketSearching(true) + try { + const result = await mapsApi.search(bucketSearch, language) + setBucketSearchResults(result.places || []) + } catch {} finally { setBucketSearching(false) } + } + + const handleSelectBucketPoi = (result: any) => { + const targetDate = bucketPoiMonth > 0 && bucketPoiYear > 0 ? `${bucketPoiYear}-${String(bucketPoiMonth).padStart(2, '0')}` : null + setBucketForm({ + name: result.name || bucketSearch, + notes: '', + lat: String(result.lat || ''), + lng: String(result.lng || ''), + target_date: targetDate || '', + }) + setBucketSearchResults([]) + setBucketSearch('') + } + // Render bucket list markers on map useEffect(() => { if (!mapInstance.current) return @@ -517,6 +550,10 @@ export default function AtlasPage(): React.ReactElement { showBucketAdd={showBucketAdd} setShowBucketAdd={setShowBucketAdd} bucketForm={bucketForm} setBucketForm={setBucketForm} onAddBucket={handleAddBucketItem} onDeleteBucket={handleDeleteBucketItem} + onSearchBucket={handleBucketPoiSearch} onSelectBucketPoi={handleSelectBucketPoi} + bucketSearchResults={bucketSearchResults} bucketPoiMonth={bucketPoiMonth} setBucketPoiMonth={setBucketPoiMonth} + bucketPoiYear={bucketPoiYear} setBucketPoiYear={setBucketPoiYear} bucketSearching={bucketSearching} + bucketSearch={bucketSearch} setBucketSearch={setBucketSearch} t={t} dark={dark} />
@@ -594,7 +631,11 @@ export default function AtlasPage(): React.ReactElement { setBucketMonth(Number(v))} - options={Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleString(language, { month: 'long' }) }))} + placeholder={t('atlas.month')} + options={[ + { value: 0, label: '—' }, + ...Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleString(language, { month: 'long' }) })), + ]} size="sm" /> @@ -602,22 +643,27 @@ export default function AtlasPage(): React.ReactElement { setBucketYear(Number(v))} - options={Array.from({ length: 20 }, (_, i) => ({ value: new Date().getFullYear() + i, label: String(new Date().getFullYear() + i) }))} + placeholder={t('atlas.year')} + options={[ + { value: 0, label: '—' }, + ...Array.from({ length: 20 }, (_, i) => ({ value: new Date().getFullYear() + i, label: String(new Date().getFullYear() + i) })), + ]} size="sm" /> -
+
))} - {bucketList.length === 0 && ( + {bucketList.length === 0 && !showBucketAdd && (
{t('atlas.bucketEmptyHint')}
)}
+ {showBucketAdd ? ( +
+ {/* Search or manual name */} +
+
+ { 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 && ( + + )} + {bucketForm.name && ( + + )} +
+ {bucketSearchResults.length > 0 && ( +
+ {bucketSearchResults.slice(0, 6).map((r, i) => ( + + ))} +
+ )} +
+ {/* Selected place indicator */} + {bucketForm.lat && bucketForm.lng && ( +
+ {Number(bucketForm.lat).toFixed(4)}, {Number(bucketForm.lng).toFixed(4)} +
+ )} + {/* Month / Year with CustomSelect */} +
+
+ setBucketPoiMonth(Number(v))} placeholder={t('atlas.month')} size="sm" + options={[{ value: 0, label: '—' }, ...Array.from({ length: 12 }, (_, i) => ({ value: i + 1, label: new Date(2000, i).toLocaleString(language, { month: 'short' }) }))]} /> +
+
+ setBucketPoiYear(Number(v))} placeholder={t('atlas.year')} size="sm" + options={[{ value: 0, label: '—' }, ...Array.from({ length: 20 }, (_, i) => ({ value: new Date().getFullYear() + i, label: String(new Date().getFullYear() + i) }))]} /> +
+
+
+ + +
+
+ ) : ( +
+ +
+ )} + ) return ( diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 2254b21..015cf69 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -329,6 +329,10 @@ function runMigrations(db: Database.Database): void { // Add paid_by_user_id to budget_items for expense tracking / settlement try { db.exec('ALTER TABLE budget_items ADD COLUMN paid_by_user_id INTEGER REFERENCES users(id)'); } catch {} }, + () => { + // Add target_date to bucket_list for optional visit planning + try { db.exec('ALTER TABLE bucket_list ADD COLUMN target_date TEXT DEFAULT NULL'); } catch {} + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/atlas.ts b/server/src/routes/atlas.ts index e692e5f..a34c0fa 100644 --- a/server/src/routes/atlas.ts +++ b/server/src/routes/atlas.ts @@ -277,10 +277,10 @@ router.get('/bucket-list', (req: Request, res: Response) => { router.post('/bucket-list', (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { name, lat, lng, country_code, notes } = req.body; + const { name, lat, lng, country_code, notes, target_date } = 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 result = db.prepare('INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes, target_date) VALUES (?, ?, ?, ?, ?, ?, ?)').run( + authReq.user.id, name.trim(), lat ?? null, lng ?? null, country_code ?? null, notes ?? null, target_date ?? null ); const item = db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(result.lastInsertRowid); res.status(201).json({ item }); @@ -288,10 +288,25 @@ router.post('/bucket-list', (req: Request, res: Response) => { router.put('/bucket-list/:id', (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { name, notes } = req.body; + const { name, notes, lat, lng, country_code, target_date } = 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); + db.prepare(`UPDATE bucket_list SET + name = COALESCE(?, name), + notes = CASE WHEN ? THEN ? ELSE notes END, + lat = CASE WHEN ? THEN ? ELSE lat END, + lng = CASE WHEN ? THEN ? ELSE lng END, + country_code = CASE WHEN ? THEN ? ELSE country_code END, + target_date = CASE WHEN ? THEN ? ELSE target_date END + WHERE id = ?`).run( + name?.trim() || null, + notes !== undefined ? 1 : 0, notes !== undefined ? (notes || null) : null, + lat !== undefined ? 1 : 0, lat !== undefined ? (lat || null) : null, + lng !== undefined ? 1 : 0, lng !== undefined ? (lng || null) : null, + country_code !== undefined ? 1 : 0, country_code !== undefined ? (country_code || null) : null, + target_date !== undefined ? 1 : 0, target_date !== undefined ? (target_date || null) : null, + req.params.id + ); res.json({ item: db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(req.params.id) }); });