v2.1.0 — Real-time collaboration, performance & security overhaul

Real-Time Collaboration (WebSocket):
- WebSocket server with JWT auth and trip-based rooms
- Live sync for all CRUD operations (places, assignments, days, notes, budget, packing, reservations, files)
- Socket-based exclusion to prevent duplicate updates
- Auto-reconnect with exponential backoff
- Assignment move sync between days

Performance:
- 16 database indexes on all foreign key columns
- N+1 query fix in places, assignments and days endpoints
- Marker clustering (react-leaflet-cluster) with configurable radius
- List virtualization (react-window) for places sidebar
- useMemo for filtered places
- SQLite WAL mode + busy_timeout for concurrent writes
- Weather API: server-side cache (1h forecast, 15min current) + client sessionStorage
- Google Places photos: persisted to DB after first fetch
- Google Details: 3-tier cache (memory → sessionStorage → API)

Security:
- CORS auto-configuration (production: same-origin, dev: open)
- API keys removed from /auth/me response
- Admin-only endpoint for reading API keys
- Path traversal prevention in cover image deletion
- JWT secret persisted to file (survives restarts)
- Avatar upload file extension whitelist
- API key fallback: normal users use admin's key without exposure
- Case-insensitive email login

Dark Mode:
- Fixed hardcoded colors across PackingList, Budget, ReservationModal, ReservationsPanel
- Mobile map buttons and sidebar sheets respect dark mode
- Cluster markers always dark

UI/UX:
- Redesigned login page with animated planes, stars and feature cards
- Admin: create user functionality with CustomSelect
- Mobile: day-picker popup for assigning places to days
- Mobile: touch-friendly reorder buttons (32px targets)
- Mobile: responsive text (shorter labels on small screens)
- Packing list: index-based category colors
- i18n: translated date picker placeholder, fixed German labels
- Default map tile: CartoDB Light
This commit is contained in:
Maurice
2026-03-19 12:44:22 +01:00
parent f000943489
commit 74f19f3312
44 changed files with 1714 additions and 363 deletions
+103 -9
View File
@@ -8,7 +8,8 @@ import Modal from '../components/shared/Modal'
import { useToast } from '../components/shared/Toast'
import CategoryManager from '../components/Admin/CategoryManager'
import BackupPanel from '../components/Admin/BackupPanel'
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2 } from 'lucide-react'
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus } from 'lucide-react'
import CustomSelect from '../components/shared/CustomSelect'
export default function AdminPage() {
const { t } = useTranslation()
@@ -25,6 +26,8 @@ export default function AdminPage() {
const [isLoading, setIsLoading] = useState(true)
const [editingUser, setEditingUser] = useState(null)
const [editForm, setEditForm] = useState({ username: '', email: '', role: 'user', password: '' })
const [showCreateUser, setShowCreateUser] = useState(false)
const [createForm, setCreateForm] = useState({ username: '', email: '', password: '', role: 'user' })
// Registration toggle
const [allowRegistration, setAllowRegistration] = useState(true)
@@ -135,6 +138,22 @@ export default function AdminPage() {
}
}
const handleCreateUser = async () => {
if (!createForm.username.trim() || !createForm.email.trim() || !createForm.password.trim()) {
toast.error(t('admin.toast.fieldsRequired'))
return
}
try {
const data = await adminApi.createUser(createForm)
setUsers(prev => [data.user, ...prev])
setShowCreateUser(false)
setCreateForm({ username: '', email: '', password: '', role: 'user' })
toast.success(t('admin.toast.userCreated'))
} catch (err) {
toast.error(err.response?.data?.error || t('admin.toast.createError'))
}
}
const handleEditUser = (user) => {
setEditingUser(user)
setEditForm({ username: user.username, email: user.email, role: user.role, password: '' })
@@ -232,8 +251,15 @@ export default function AdminPage() {
{/* Tab content */}
{activeTab === 'users' && (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="p-5 border-b border-slate-100">
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
<h2 className="font-semibold text-slate-900">{t('admin.tabs.users')} ({users.length})</h2>
<button
onClick={() => setShowCreateUser(true)}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors"
>
<UserPlus className="w-4 h-4" />
{t('admin.createUser')}
</button>
</div>
{isLoading ? (
@@ -464,6 +490,74 @@ export default function AdminPage() {
</div>
</div>
{/* Create user modal */}
<Modal
isOpen={showCreateUser}
onClose={() => setShowCreateUser(false)}
title={t('admin.createUser')}
size="sm"
footer={
<div className="flex gap-3 justify-end">
<button
onClick={() => setShowCreateUser(false)}
className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50"
>
{t('common.cancel')}
</button>
<button
onClick={handleCreateUser}
className="px-4 py-2 text-sm bg-slate-900 hover:bg-slate-700 text-white rounded-lg"
>
{t('admin.createUser')}
</button>
</div>
}
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')} *</label>
<input
type="text"
value={createForm.username}
onChange={e => setCreateForm(f => ({ ...f, username: e.target.value }))}
placeholder={t('settings.username')}
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.email')} *</label>
<input
type="email"
value={createForm.email}
onChange={e => setCreateForm(f => ({ ...f, email: e.target.value }))}
placeholder={t('common.email')}
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.password')} *</label>
<input
type="password"
value={createForm.password}
onChange={e => setCreateForm(f => ({ ...f, password: e.target.value }))}
placeholder={t('common.password')}
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
<CustomSelect
value={createForm.role}
onChange={value => setCreateForm(f => ({ ...f, role: value }))}
options={[
{ value: 'user', label: t('settings.roleUser') },
{ value: 'admin', label: t('settings.roleAdmin') },
]}
/>
</div>
</div>
</Modal>
{/* Edit user modal */}
<Modal
isOpen={!!editingUser}
@@ -519,14 +613,14 @@ export default function AdminPage() {
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
<select
<CustomSelect
value={editForm.role}
onChange={e => setEditForm(f => ({ ...f, role: e.target.value }))}
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white text-sm"
>
<option value="user">{t('settings.roleUser')}</option>
<option value="admin">{t('settings.roleAdmin')}</option>
</select>
onChange={value => setEditForm(f => ({ ...f, role: value }))}
options={[
{ value: 'user', label: t('settings.roleUser') },
{ value: 'admin', label: t('settings.roleAdmin') },
]}
/>
</div>
</div>
)}
+178 -21
View File
@@ -4,7 +4,7 @@ import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { useTranslation } from '../i18n'
import { authApi } from '../api/client'
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe } from 'lucide-react'
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route } from 'lucide-react'
export default function LoginPage() {
const { t, language } = useTranslation()
@@ -81,38 +81,141 @@ export default function LoginPage() {
</button>
{/* Left — branding */}
<div style={{ display: 'none', width: '45%', background: '#111827', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', padding: '60px 48px' }}
<div style={{ display: 'none', width: '55%', background: 'linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%)', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', padding: '60px 48px', position: 'relative', overflow: 'hidden' }}
className="lg-panel">
<style>{`@media(min-width:1024px){.lg-panel{display:flex!important}}`}</style>
{/* Logo */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 48 }}>
<div style={{ width: 44, height: 44, background: 'white', borderRadius: 12, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Plane size={22} style={{ color: '#111827' }} />
</div>
<span style={{ fontSize: 26, fontWeight: 800, color: 'white', letterSpacing: '-0.02em' }}>NOMAD</span>
{/* Stars */}
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
{Array.from({ length: 40 }, (_, i) => (
<div key={i} className="login-star" style={{
position: 'absolute',
width: Math.random() > 0.7 ? 2 : 1,
height: Math.random() > 0.7 ? 2 : 1,
borderRadius: '50%',
background: 'white',
opacity: 0.15 + Math.random() * 0.25,
top: `${Math.random() * 70}%`,
left: `${Math.random() * 100}%`,
animationDelay: `${Math.random() * 4}s`,
}} />
))}
</div>
<div style={{ maxWidth: 320, textAlign: 'center' }}>
<h2 style={{ margin: '0 0 16px', fontSize: 32, fontWeight: 800, color: 'white', lineHeight: 1.2 }}>
{/* Animated glow orbs */}
<div className="login-orb1" style={{ position: 'absolute', width: 500, height: 500, borderRadius: '50%', background: 'radial-gradient(circle, rgba(99,102,241,0.1) 0%, transparent 70%)', filter: 'blur(80px)' }} />
<div className="login-orb2" style={{ position: 'absolute', width: 350, height: 350, borderRadius: '50%', background: 'radial-gradient(circle, rgba(14,165,233,0.08) 0%, transparent 70%)', filter: 'blur(60px)' }} />
{/* Animated planes — realistic silhouettes at different sizes/speeds */}
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none' }}>
{/* Plane 1 — large, slow, foreground */}
<svg className="login-plane1" viewBox="0 0 480 120" style={{ position: 'absolute', width: 48, opacity: 0.12 }}>
<g fill="white" transform="translate(240,60) rotate(-12)">
<ellipse cx="0" cy="0" rx="120" ry="12" />
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
<path d="M-100,-5 L-120,-30 L-108,-30 L-90,-8 Z" />
<path d="M-100,5 L-120,30 L-108,30 L-90,8 Z" />
<ellipse cx="60" cy="0" rx="18" ry="8" />
</g>
</svg>
{/* Plane 2 — small, faster, higher */}
<svg className="login-plane2" viewBox="0 0 480 120" style={{ position: 'absolute', width: 24, opacity: 0.08 }}>
<g fill="white" transform="translate(240,60) rotate(-12)">
<ellipse cx="0" cy="0" rx="120" ry="12" />
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
<ellipse cx="60" cy="0" rx="18" ry="8" />
</g>
</svg>
{/* Plane 3 — medium, mid-speed */}
<svg className="login-plane3" viewBox="0 0 480 120" style={{ position: 'absolute', width: 32, opacity: 0.06 }}>
<g fill="white" transform="translate(240,60) rotate(-5)">
<ellipse cx="0" cy="0" rx="120" ry="12" />
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
<path d="M-100,-5 L-120,-30 L-108,-30 L-90,-8 Z" />
<path d="M-100,5 L-120,30 L-108,30 L-90,8 Z" />
<ellipse cx="60" cy="0" rx="18" ry="8" />
</g>
</svg>
{/* Plane 4 — tiny, fast, high */}
<svg className="login-plane4" viewBox="0 0 480 120" style={{ position: 'absolute', width: 16, opacity: 0.07 }}>
<g fill="white" transform="translate(240,60) rotate(-10)">
<ellipse cx="0" cy="0" rx="120" ry="12" />
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
<ellipse cx="60" cy="0" rx="18" ry="8" />
</g>
</svg>
{/* Plane 5 — medium, right to left, lower */}
<svg className="login-plane5" viewBox="0 0 480 120" style={{ position: 'absolute', width: 28, opacity: 0.05 }}>
<g fill="white" transform="translate(240,60) rotate(8) scale(-1,1)">
<ellipse cx="0" cy="0" rx="120" ry="12" />
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
<path d="M-100,-5 L-120,-30 L-108,-30 L-90,-8 Z" />
<path d="M-100,5 L-120,30 L-108,30 L-90,8 Z" />
<ellipse cx="60" cy="0" rx="18" ry="8" />
</g>
</svg>
{/* Plane 6 — tiny distant */}
<svg className="login-plane6" viewBox="0 0 480 120" style={{ position: 'absolute', width: 12, opacity: 0.04 }}>
<g fill="white" transform="translate(240,60) rotate(-15)">
<ellipse cx="0" cy="0" rx="120" ry="12" />
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
<ellipse cx="60" cy="0" rx="18" ry="8" />
</g>
</svg>
</div>
<div style={{ position: 'relative', zIndex: 1, maxWidth: 560, textAlign: 'center' }}>
{/* Logo */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 48, justifyContent: 'center' }}>
<div style={{ width: 48, height: 48, background: 'white', borderRadius: 14, display: 'flex', alignItems: 'center', justifyContent: 'center', boxShadow: '0 0 30px rgba(255,255,255,0.1)' }}>
<Plane size={24} style={{ color: '#0f172a' }} />
</div>
<span style={{ fontSize: 28, fontWeight: 800, color: 'white', letterSpacing: '-0.02em' }}>NOMAD</span>
</div>
<h2 style={{ margin: '0 0 12px', fontSize: 36, fontWeight: 800, color: 'white', lineHeight: 1.15, letterSpacing: '-0.02em' }}>
{t('login.tagline')}
</h2>
<p style={{ margin: 0, fontSize: 15, color: 'rgba(255,255,255,0.55)', lineHeight: 1.65 }}>
<p style={{ margin: '0 0 44px', fontSize: 15, color: 'rgba(255,255,255,0.5)', lineHeight: 1.7 }}>
{t('login.description')}
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, marginTop: 40 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
{[
{ Icon: MapPin, label: t('login.features.places') },
{ Icon: Calendar, label: t('login.features.schedule') },
{ Icon: Package, label: t('login.features.packing') },
].map(({ Icon, label }) => (
<div key={label} style={{ background: 'rgba(255,255,255,0.07)', borderRadius: 14, padding: '18px 12px', border: '1px solid rgba(255,255,255,0.1)', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
<Icon size={20} style={{ color: 'rgba(255,255,255,0.6)' }} />
<div style={{ fontSize: 12, color: 'rgba(255,255,255,0.45)', fontWeight: 500 }}>{label}</div>
{ Icon: Map, label: t('login.features.maps'), desc: t('login.features.mapsDesc') },
{ Icon: Zap, label: t('login.features.realtime'), desc: t('login.features.realtimeDesc') },
{ Icon: Wallet, label: t('login.features.budget'), desc: t('login.features.budgetDesc') },
{ Icon: Users, label: t('login.features.collab'), desc: t('login.features.collabDesc') },
{ Icon: CheckSquare, label: t('login.features.packing'), desc: t('login.features.packingDesc') },
{ Icon: BookMarked, label: t('login.features.bookings'), desc: t('login.features.bookingsDesc') },
{ Icon: FolderOpen, label: t('login.features.files'), desc: t('login.features.filesDesc') },
{ Icon: Route, label: t('login.features.routes'), desc: t('login.features.routesDesc') },
].map(({ Icon, label, desc }) => (
<div key={label} style={{ background: 'rgba(255,255,255,0.04)', borderRadius: 14, padding: '14px 12px', border: '1px solid rgba(255,255,255,0.06)', textAlign: 'left', transition: 'all 0.2s' }}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.08)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.12)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.06)' }}>
<Icon size={17} style={{ color: 'rgba(255,255,255,0.7)', marginBottom: 7 }} />
<div style={{ fontSize: 12.5, color: 'white', fontWeight: 600, marginBottom: 2 }}>{label}</div>
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.35)', lineHeight: 1.4 }}>{desc}</div>
</div>
))}
</div>
<p style={{ marginTop: 36, fontSize: 11.5, color: 'rgba(255,255,255,0.25)', letterSpacing: '0.03em' }}>
{t('login.selfHosted')}
</p>
</div>
</div>
@@ -168,7 +271,7 @@ export default function LoginPage() {
<Mail size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
<input
type="email" value={email} onChange={e => setEmail(e.target.value)} required
placeholder="deine@email.de" style={inputBase}
placeholder={t('login.emailPlaceholder')} style={inputBase}
onFocus={e => e.target.style.borderColor = '#111827'}
onBlur={e => e.target.style.borderColor = '#e5e7eb'}
/>
@@ -225,7 +328,61 @@ export default function LoginPage() {
</div>
</div>
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
<style>{`
@keyframes spin { to { transform: rotate(360deg) } }
@keyframes orbFloat1 {
0%, 100% { top: 15%; left: 30%; }
25% { top: 25%; left: 55%; }
50% { top: 45%; left: 40%; }
75% { top: 20%; left: 20%; }
}
@keyframes orbFloat2 {
0%, 100% { bottom: 20%; right: 15%; }
25% { bottom: 35%; right: 35%; }
50% { bottom: 15%; right: 45%; }
75% { bottom: 40%; right: 20%; }
}
.login-orb1 { animation: orbFloat1 20s ease-in-out infinite; }
.login-orb2 { animation: orbFloat2 25s ease-in-out infinite; }
@keyframes twinkle {
0%, 100% { opacity: 0.15; }
50% { opacity: 0.5; }
}
.login-star { animation: twinkle 3s ease-in-out infinite; }
@keyframes plane1Move {
0% { left: -8%; top: 30%; transform: rotate(-8deg); }
100% { left: 108%; top: 10%; transform: rotate(-12deg); }
}
@keyframes plane2Move {
0% { right: -5%; top: 18%; transform: rotate(5deg); }
100% { right: 110%; top: 8%; transform: rotate(3deg); }
}
@keyframes plane3Move {
0% { left: -6%; top: 55%; transform: rotate(-10deg); }
100% { left: 110%; top: 35%; transform: rotate(-6deg); }
}
@keyframes plane4Move {
0% { left: -4%; top: 8%; transform: rotate(-3deg); }
100% { left: 110%; top: 5%; transform: rotate(-5deg); }
}
@keyframes plane5Move {
0% { right: -6%; top: 65%; transform: rotate(3deg); }
100% { right: 110%; top: 50%; transform: rotate(-2deg); }
}
@keyframes plane6Move {
0% { left: -3%; top: 75%; transform: rotate(-7deg); }
100% { left: 110%; top: 58%; transform: rotate(-5deg); }
}
.login-plane1 { animation: plane1Move 24s ease-in-out infinite; }
.login-plane2 { animation: plane2Move 18s ease-in-out infinite; animation-delay: 6s; }
.login-plane3 { animation: plane3Move 30s ease-in-out infinite; animation-delay: 12s; }
.login-plane4 { animation: plane4Move 14s ease-in-out infinite; animation-delay: 3s; }
.login-plane5 { animation: plane5Move 22s ease-in-out infinite; animation-delay: 9s; }
.login-plane6 { animation: plane6Move 32s ease-in-out infinite; animation-delay: 16s; }
`}</style>
</div>
)
}
+22 -30
View File
@@ -18,6 +18,7 @@ import Navbar from '../components/Layout/Navbar'
import { useToast } from '../components/shared/Toast'
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react'
import { useTranslation } from '../i18n'
import { joinTrip, leaveTrip, addListener, removeListener } from '../api/websocket'
const MIN_SIDEBAR = 200
const MAX_SIDEBAR = 520
@@ -39,7 +40,6 @@ export default function TripPlannerPage() {
{ id: 'dateien', label: t('trip.tabs.files') },
]
// Layout state
const [activeTab, setActiveTab] = useState('plan')
const handleTabChange = (tabId) => {
@@ -54,7 +54,6 @@ export default function TripPlannerPage() {
const isResizingLeft = useRef(false)
const isResizingRight = useRef(false)
// Content state
const [selectedPlaceId, setSelectedPlaceId] = useState(null)
const [showPlaceForm, setShowPlaceForm] = useState(false)
const [editingPlace, setEditingPlace] = useState(null)
@@ -79,7 +78,18 @@ export default function TripPlannerPage() {
if (tripId) tripStore.loadReservations(tripId)
}, [tripId])
// Resize handlers
// WebSocket: join trip and listen for remote events
useEffect(() => {
if (!tripId) return
const handler = useTripStore.getState().handleRemoteEvent
joinTrip(tripId)
addListener(handler)
return () => {
leaveTrip(tripId)
removeListener(handler)
}
}, [tripId])
useEffect(() => {
const onMove = (e) => {
if (isResizingLeft.current) {
@@ -107,7 +117,6 @@ export default function TripPlannerPage() {
}
}, [])
// Map places — always show all places with coordinates
const mapPlaces = useCallback(() => {
return places.filter(p => p.lat && p.lng)
}, [places])
@@ -219,7 +228,7 @@ export default function TripPlannerPage() {
return map
}, [selectedDayId, assignments])
const mapTileUrl = settings.map_tile_url || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
const mapTileUrl = settings.map_tile_url || 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
const defaultCenter = [settings.default_lat || 48.8566, settings.default_lng || 2.3522]
const defaultZoom = settings.default_zoom || 10
@@ -241,7 +250,6 @@ export default function TripPlannerPage() {
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', ...fontStyle }}>
<Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onShare={() => setShowMembersModal(true)} />
{/* Tab bar */}
<div style={{
position: 'fixed', top: 56, left: 0, right: 0, zIndex: 40,
display: 'flex', alignItems: 'center', justifyContent: 'center',
@@ -278,13 +286,11 @@ export default function TripPlannerPage() {
})}
</div>
{/* Content — offset by navbar (56px) + tab bar (44px) */}
{/* Offset by navbar (56px) + tab bar (44px) */}
<div style={{ flex: 1, overflow: 'hidden', marginTop: 100, position: 'relative' }}>
{/* PLAN MODE */}
{activeTab === 'plan' && (
<div style={{ position: 'absolute', inset: 0 }}>
{/* Map fills entire space */}
<MapView
places={mapPlaces()}
route={route}
@@ -298,7 +304,6 @@ export default function TripPlannerPage() {
dayOrderMap={dayOrderMap}
/>
{/* Route info overlay */}
{routeInfo && (
<div style={{
position: 'absolute', bottom: selectedPlace ? 180 : 20, left: '50%', transform: 'translateX(-50%)',
@@ -313,9 +318,7 @@ export default function TripPlannerPage() {
</div>
)}
{/* LEFT SIDEBAR — glass, absolute, floating rounded */}
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
{/* Collapse toggle — am rechten Rand der Sidebar, halb herausstehend */}
<button onClick={() => setLeftCollapsed(c => !c)}
style={{
position: leftCollapsed ? 'fixed' : 'absolute', top: leftCollapsed ? 'calc(56px + 44px + 14px)' : 14, left: leftCollapsed ? 10 : undefined, right: leftCollapsed ? undefined : -28, zIndex: 25,
@@ -359,7 +362,6 @@ export default function TripPlannerPage() {
reservations={reservations}
onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true) }}
/>
{/* Resize handle — right edge */}
{!leftCollapsed && (
<div
onMouseDown={() => { isResizingLeft.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none' }}
@@ -371,9 +373,7 @@ export default function TripPlannerPage() {
</div>
</div>
{/* RIGHT SIDEBAR — glass, absolute, floating rounded */}
<div className="hidden md:block" style={{ position: 'absolute', right: 10, top: 10, bottom: 10, zIndex: 20 }}>
{/* Collapse toggle — am linken Rand der Sidebar, halb herausstehend */}
<button onClick={() => setRightCollapsed(c => !c)}
style={{
position: rightCollapsed ? 'fixed' : 'absolute', top: rightCollapsed ? 'calc(56px + 44px + 14px)' : 14, right: rightCollapsed ? 10 : undefined, left: rightCollapsed ? undefined : -28, zIndex: 25,
@@ -399,7 +399,6 @@ export default function TripPlannerPage() {
transition: 'width 0.25s ease',
opacity: rightCollapsed ? 0 : 1,
}}>
{/* Resize handle — left edge */}
{!rightCollapsed && (
<div
onMouseDown={() => { isResizingRight.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none' }}
@@ -423,19 +422,17 @@ export default function TripPlannerPage() {
</div>
</div>
{/* Mobile controls */}
<div className="flex md:hidden" style={{ position: 'absolute', top: 12, left: 12, right: 12, justifyContent: 'space-between', zIndex: 30 }}>
<button onClick={() => setMobileSidebarOpen('left')}
style={{ background: 'rgba(255,255,255,0.95)', backdropFilter: 'blur(12px)', border: 'none', borderRadius: 24, padding: '11px 24px', fontSize: 15, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 12px rgba(0,0,0,0.15)', minHeight: 44, fontFamily: 'inherit' }}>
style={{ background: 'var(--bg-card)', color: 'var(--text-primary)', backdropFilter: 'blur(12px)', border: '1px solid var(--border-primary)', borderRadius: 24, padding: '11px 24px', fontSize: 15, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 12px rgba(0,0,0,0.15)', minHeight: 44, fontFamily: 'inherit' }}>
{t('trip.mobilePlan')}
</button>
<button onClick={() => setMobileSidebarOpen('right')}
style={{ background: 'rgba(255,255,255,0.95)', backdropFilter: 'blur(12px)', border: 'none', borderRadius: 24, padding: '11px 24px', fontSize: 15, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 12px rgba(0,0,0,0.15)', minHeight: 44, fontFamily: 'inherit' }}>
style={{ background: 'var(--bg-card)', color: 'var(--text-primary)', backdropFilter: 'blur(12px)', border: '1px solid var(--border-primary)', borderRadius: 24, padding: '11px 24px', fontSize: 15, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 12px rgba(0,0,0,0.15)', minHeight: 44, fontFamily: 'inherit' }}>
{t('trip.mobilePlaces')}
</button>
</div>
{/* Bottom inspector */}
{selectedPlace && (
<PlaceInspector
place={selectedPlace}
@@ -453,20 +450,19 @@ export default function TripPlannerPage() {
/>
)}
{/* Mobile bottom sheet */}
{mobileSidebarOpen && (
<div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.3)', zIndex: 50 }} onClick={() => setMobileSidebarOpen(null)}>
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'white', borderRadius: '20px 20px 0 0', maxHeight: '80vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)' }}>
<span style={{ fontWeight: 600, fontSize: 14, color: '#111827' }}>{mobileSidebarOpen === 'left' ? t('trip.mobilePlan') : t('trip.mobilePlaces')}</span>
<button onClick={() => setMobileSidebarOpen(null)} style={{ background: 'rgba(0,0,0,0.07)', border: 'none', borderRadius: '50%', width: 28, height: 28, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'var(--bg-card)', borderRadius: '20px 20px 0 0', maxHeight: '80vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', borderBottom: '1px solid var(--border-secondary)' }}>
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>{mobileSidebarOpen === 'left' ? t('trip.mobilePlan') : t('trip.mobilePlaces')}</span>
<button onClick={() => setMobileSidebarOpen(null)} style={{ background: 'var(--bg-tertiary)', border: 'none', borderRadius: '50%', width: 28, height: 28, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--text-primary)' }}>
<X size={14} />
</button>
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left'
? <DayPlanSidebar trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} />
: <PlacesSidebar places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={handlePlaceClick} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} />
: <PlacesSidebar places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={handlePlaceClick} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile />
}
</div>
</div>
@@ -475,7 +471,6 @@ export default function TripPlannerPage() {
</div>
)}
{/* BUCHUNGEN */}
{activeTab === 'buchungen' && (
<div style={{ height: '100%', maxWidth: 1200, margin: '0 auto', width: '100%', display: 'flex', flexDirection: 'column' }}>
<ReservationsPanel
@@ -492,21 +487,18 @@ export default function TripPlannerPage() {
</div>
)}
{/* PACKLISTE */}
{activeTab === 'packliste' && (
<div style={{ height: '100%', overflowY: 'auto', maxWidth: 1200, margin: '0 auto', width: '100%', padding: '8px 0' }}>
<PackingListPanel tripId={tripId} items={packingItems} />
</div>
)}
{/* FINANZPLAN */}
{activeTab === 'finanzplan' && (
<div style={{ height: '100%', overflowY: 'auto', maxWidth: 1400, margin: '0 auto', width: '100%', padding: '8px 0' }}>
<BudgetPanel tripId={tripId} />
</div>
)}
{/* DATEIEN */}
{activeTab === 'dateien' && (
<div style={{ height: '100%', overflow: 'hidden' }}>
<FileManager