mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 06:41:46 +00:00
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:
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user