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:
42
client/package-lock.json
generated
42
client/package-lock.json
generated
@@ -16,7 +16,9 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.4.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-leaflet-cluster": "^2.1.0",
|
||||
"react-router-dom": "^6.22.2",
|
||||
"react-window": "^2.2.7",
|
||||
"topojson-client": "^3.1.0",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
@@ -1447,14 +1449,14 @@
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.28",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
@@ -1868,7 +1870,7 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
@@ -2479,6 +2481,15 @@
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/leaflet.markercluster": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz",
|
||||
"integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -3022,6 +3033,21 @@
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-leaflet-cluster": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet-cluster/-/react-leaflet-cluster-2.1.0.tgz",
|
||||
"integrity": "sha512-16X7XQpRThQFC4PH4OpXHimGg19ouWmjxjtpxOeBKpvERSvIRqTx7fvhTwkEPNMFTQ8zTfddz6fRTUmUEQul7g==",
|
||||
"license": "SEE LICENSE IN <LICENSE>",
|
||||
"dependencies": {
|
||||
"leaflet.markercluster": "^1.5.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.8.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"react-leaflet": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -3064,6 +3090,16 @@
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-window": {
|
||||
"version": "2.2.7",
|
||||
"resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.7.tgz",
|
||||
"integrity": "sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.4.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-leaflet-cluster": "^2.1.0",
|
||||
"react-router-dom": "^6.22.2",
|
||||
"react-window": "^2.2.7",
|
||||
"topojson-client": "^3.1.0",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from 'axios'
|
||||
import { getSocketId } from './websocket'
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: '/api',
|
||||
@@ -7,13 +8,17 @@ const apiClient = axios.create({
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor - add auth token
|
||||
// Request interceptor - add auth token and socket ID
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
const sid = getSocketId()
|
||||
if (sid) {
|
||||
config.headers['X-Socket-Id'] = sid
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
@@ -112,6 +117,7 @@ export const categoriesApi = {
|
||||
|
||||
export const adminApi = {
|
||||
users: () => apiClient.get('/admin/users').then(r => r.data),
|
||||
createUser: (data) => apiClient.post('/admin/users', data).then(r => r.data),
|
||||
updateUser: (id, data) => apiClient.put(`/admin/users/${id}`, data).then(r => r.data),
|
||||
deleteUser: (id) => apiClient.delete(`/admin/users/${id}`).then(r => r.data),
|
||||
stats: () => apiClient.get('/admin/stats').then(r => r.data),
|
||||
|
||||
143
client/src/api/websocket.js
Normal file
143
client/src/api/websocket.js
Normal file
@@ -0,0 +1,143 @@
|
||||
// Singleton WebSocket manager for real-time collaboration
|
||||
|
||||
let socket = null
|
||||
let reconnectTimer = null
|
||||
let reconnectDelay = 1000
|
||||
const MAX_RECONNECT_DELAY = 30000
|
||||
const listeners = new Set()
|
||||
const activeTrips = new Set()
|
||||
let currentToken = null
|
||||
let refetchCallback = null
|
||||
let mySocketId = null
|
||||
|
||||
export function getSocketId() {
|
||||
return mySocketId
|
||||
}
|
||||
|
||||
export function setRefetchCallback(fn) {
|
||||
refetchCallback = fn
|
||||
}
|
||||
|
||||
function getWsUrl(token) {
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
return `${protocol}://${location.host}/ws?token=${token}`
|
||||
}
|
||||
|
||||
function handleMessage(event) {
|
||||
try {
|
||||
const parsed = JSON.parse(event.data)
|
||||
// Store our socket ID from welcome message
|
||||
if (parsed.type === 'welcome') {
|
||||
mySocketId = parsed.socketId
|
||||
console.log('[WS] Got socketId:', mySocketId)
|
||||
return
|
||||
}
|
||||
console.log('[WS] Received:', parsed.type, parsed)
|
||||
listeners.forEach(fn => {
|
||||
try { fn(parsed) } catch (err) { console.error('WebSocket listener error:', err) }
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('WebSocket message parse error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer) return
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
if (currentToken) {
|
||||
connectInternal(currentToken, true)
|
||||
}
|
||||
}, reconnectDelay)
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
|
||||
}
|
||||
|
||||
function connectInternal(token, isReconnect = false) {
|
||||
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
||||
return
|
||||
}
|
||||
|
||||
const url = getWsUrl(token)
|
||||
socket = new WebSocket(url)
|
||||
|
||||
socket.onopen = () => {
|
||||
console.log('[WS] Connected', isReconnect ? '(reconnect)' : '(initial)')
|
||||
reconnectDelay = 1000
|
||||
// Join active trips on any connect (initial or reconnect)
|
||||
if (activeTrips.size > 0) {
|
||||
activeTrips.forEach(tripId => {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'join', tripId }))
|
||||
console.log('[WS] Joined trip', tripId)
|
||||
}
|
||||
})
|
||||
// Refetch trip data for active trips
|
||||
if (refetchCallback) {
|
||||
activeTrips.forEach(tripId => {
|
||||
try { refetchCallback(tripId) } catch (err) {
|
||||
console.error('Failed to refetch trip data on reconnect:', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
socket.onmessage = handleMessage
|
||||
|
||||
socket.onclose = () => {
|
||||
socket = null
|
||||
if (currentToken) {
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
// onclose will fire after onerror, reconnect handled there
|
||||
}
|
||||
}
|
||||
|
||||
export function connect(token) {
|
||||
currentToken = token
|
||||
reconnectDelay = 1000
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
connectInternal(token, false)
|
||||
}
|
||||
|
||||
export function disconnect() {
|
||||
currentToken = null
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
activeTrips.clear()
|
||||
if (socket) {
|
||||
socket.onclose = null // prevent reconnect
|
||||
socket.close()
|
||||
socket = null
|
||||
}
|
||||
}
|
||||
|
||||
export function joinTrip(tripId) {
|
||||
activeTrips.add(String(tripId))
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'join', tripId: String(tripId) }))
|
||||
}
|
||||
}
|
||||
|
||||
export function leaveTrip(tripId) {
|
||||
activeTrips.delete(String(tripId))
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'leave', tripId: String(tripId) }))
|
||||
}
|
||||
}
|
||||
|
||||
export function addListener(fn) {
|
||||
listeners.add(fn)
|
||||
}
|
||||
|
||||
export function removeListener(fn) {
|
||||
listeners.delete(fn)
|
||||
}
|
||||
@@ -37,7 +37,7 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
|
||||
return <input ref={inputRef} type="text" inputMode={type === 'number' ? 'decimal' : 'text'} value={editValue}
|
||||
onChange={e => setEditValue(e.target.value)} onBlur={save}
|
||||
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setEditValue(value ?? ''); setEditing(false) } }}
|
||||
style={{ width: '100%', border: '1px solid #6366f1', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }}
|
||||
style={{ width: '100%', border: '1px solid var(--accent)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }}
|
||||
placeholder={placeholder} />
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
|
||||
style={{ cursor: 'pointer', padding: '4px 6px', borderRadius: 4, minHeight: 28, display: 'flex', alignItems: 'center',
|
||||
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
|
||||
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'rgba(99,102,241,0.06)'}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
{display || placeholder || '-'}
|
||||
</div>
|
||||
@@ -101,7 +101,7 @@ function AddItemRow({ onAdd, t }) {
|
||||
</td>
|
||||
<td style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
<button onClick={handleAdd} disabled={!name.trim()} title={t('reservations.add')}
|
||||
style={{ background: name.trim() ? '#6366f1' : 'var(--border-primary)', border: 'none', borderRadius: 4, color: '#fff',
|
||||
style={{ background: name.trim() ? 'var(--text-primary)' : 'var(--border-primary)', border: 'none', borderRadius: 4, color: 'var(--bg-primary)',
|
||||
cursor: name.trim() ? 'pointer' : 'default', padding: '4px 8px', display: 'inline-flex', alignItems: 'center' }}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
@@ -132,7 +132,6 @@ function PieChart({ segments, size = 200, totalLabel }) {
|
||||
background: `conic-gradient(${stops})`,
|
||||
boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
|
||||
}} />
|
||||
{/* Center hole */}
|
||||
<div style={{
|
||||
position: 'absolute', top: '50%', left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
@@ -206,14 +205,14 @@ export default function BudgetPanel({ tripId }) {
|
||||
</div>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', margin: '0 0 8px' }}>{t('budget.emptyTitle')}</h2>
|
||||
<p style={{ fontSize: 14, color: 'var(--text-muted)', margin: '0 0 24px', lineHeight: 1.5 }}>{t('budget.emptyText')}</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', alignItems: 'stretch', maxWidth: 320, margin: '0 auto' }}>
|
||||
<input value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
|
||||
placeholder={t('budget.emptyPlaceholder')}
|
||||
style={{ padding: '10px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 14, fontFamily: 'inherit', width: 260, outline: 'none' }} />
|
||||
style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
|
||||
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
|
||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '10px 20px', fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', display: 'inline-flex', alignItems: 'center', gap: 6, opacity: newCategoryName.trim() ? 1 : 0.5 }}>
|
||||
<Plus size={16} /> {t('budget.createCategory')}
|
||||
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '0 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.5, flexShrink: 0 }}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -223,7 +222,6 @@ export default function BudgetPanel({ tripId }) {
|
||||
// ── Main Layout ──────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div style={{ fontFamily: "'Poppins', -apple-system, BlinkMacSystemFont, system-ui, sans-serif" }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 16px 12px', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Calculator size={20} color="var(--text-primary)" />
|
||||
@@ -231,10 +229,7 @@ export default function BudgetPanel({ tripId }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main: table + sidebar */}
|
||||
<div style={{ display: 'flex', gap: 20, padding: '0 16px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
|
||||
{/* Left: Tables */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{categoryNames.map((cat, ci) => {
|
||||
const items = grouped[cat]
|
||||
@@ -243,7 +238,6 @@ export default function BudgetPanel({ tripId }) {
|
||||
|
||||
return (
|
||||
<div key={cat} style={{ marginBottom: 16 }}>
|
||||
{/* Category header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff', borderRadius: '10px 10px 0 0', padding: '9px 14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
|
||||
@@ -259,7 +253,6 @@ export default function BudgetPanel({ tripId }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div style={{ overflowX: 'auto', border: '1px solid var(--border-primary)', borderTop: 'none', borderRadius: '0 0 10px 10px' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
@@ -317,9 +310,7 @@ export default function BudgetPanel({ tripId }) {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right: Sidebar */}
|
||||
<div className="w-full md:w-[280px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
|
||||
{/* Currency selector */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<CustomSelect
|
||||
value={currency}
|
||||
@@ -329,7 +320,6 @@ export default function BudgetPanel({ tripId }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Add category */}
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
|
||||
<input
|
||||
value={newCategoryName}
|
||||
@@ -344,7 +334,6 @@ export default function BudgetPanel({ tripId }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Grand total card */}
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #000000 0%, #18181b 100%)',
|
||||
borderRadius: 16, padding: '24px 20px', color: '#fff', marginBottom: 16,
|
||||
@@ -364,7 +353,6 @@ export default function BudgetPanel({ tripId }) {
|
||||
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div>
|
||||
</div>
|
||||
|
||||
{/* Pie chart card */}
|
||||
{pieSegments.length > 0 && (
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, padding: '20px 16px',
|
||||
@@ -375,7 +363,6 @@ export default function BudgetPanel({ tripId }) {
|
||||
|
||||
<PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
|
||||
|
||||
{/* Legend */}
|
||||
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{pieSegments.map(seg => {
|
||||
const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0'
|
||||
@@ -389,7 +376,6 @@ export default function BudgetPanel({ tripId }) {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Category amounts */}
|
||||
<div style={{ marginTop: 12, borderTop: '1px solid var(--border-secondary)', paddingTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{pieSegments.map(seg => (
|
||||
<div key={seg.name} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, useMap } from 'react-leaflet'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
|
||||
@@ -81,7 +84,6 @@ function createPlaceIcon(place, orderNumber, isSelected) {
|
||||
})
|
||||
}
|
||||
|
||||
// Pan/zoom to selected place
|
||||
function SelectionController({ places, selectedPlaceId }) {
|
||||
const map = useMap()
|
||||
const prev = useRef(null)
|
||||
@@ -99,7 +101,6 @@ function SelectionController({ places, selectedPlaceId }) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Recenter map when default center changes
|
||||
function MapController({ center, zoom }) {
|
||||
const map = useMap()
|
||||
const prevCenter = useRef(center)
|
||||
@@ -198,51 +199,72 @@ export function MapView({
|
||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} />
|
||||
<MapClickHandler onClick={onMapClick} />
|
||||
|
||||
{places.map((place) => {
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
const resolvedPhotoUrl = place.image_url || (place.google_place_id && photoUrls[place.google_place_id]) || null
|
||||
const orderNumber = dayOrderMap[place.id] ?? null
|
||||
const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumber, isSelected)
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
maxClusterRadius={30}
|
||||
disableClusteringAtZoom={11}
|
||||
spiderfyOnMaxZoom
|
||||
showCoverageOnHover={false}
|
||||
zoomToBoundsOnClick
|
||||
iconCreateFunction={(cluster) => {
|
||||
const count = cluster.getChildCount()
|
||||
const size = count < 10 ? 36 : count < 50 ? 42 : 48
|
||||
return L.divIcon({
|
||||
html: `<div class="marker-cluster-custom"
|
||||
style="width:${size}px;height:${size}px;">
|
||||
<span>${count}</span>
|
||||
</div>`,
|
||||
className: 'marker-cluster-wrapper',
|
||||
iconSize: L.point(size, size),
|
||||
})
|
||||
}}
|
||||
>
|
||||
{places.map((place) => {
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
const resolvedPhotoUrl = place.image_url || (place.google_place_id && photoUrls[place.google_place_id]) || null
|
||||
const orderNumber = dayOrderMap[place.id] ?? null
|
||||
const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumber, isSelected)
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={place.id}
|
||||
position={[place.lat, place.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{
|
||||
click: () => onMarkerClick && onMarkerClick(place.id),
|
||||
}}
|
||||
zIndexOffset={isSelected ? 1000 : 0}
|
||||
>
|
||||
<Tooltip
|
||||
direction="right"
|
||||
offset={[0, 0]}
|
||||
opacity={1}
|
||||
className="map-tooltip"
|
||||
return (
|
||||
<Marker
|
||||
key={place.id}
|
||||
position={[place.lat, place.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{
|
||||
click: () => onMarkerClick && onMarkerClick(place.id),
|
||||
}}
|
||||
zIndexOffset={isSelected ? 1000 : 0}
|
||||
>
|
||||
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
|
||||
{place.name}
|
||||
</div>
|
||||
{place.category_name && (() => {
|
||||
const CatIcon = getCategoryIcon(place.category_icon)
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
||||
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{place.address && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{place.address}
|
||||
<Tooltip
|
||||
direction="right"
|
||||
offset={[0, 0]}
|
||||
opacity={1}
|
||||
className="map-tooltip"
|
||||
>
|
||||
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
|
||||
{place.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
)
|
||||
})}
|
||||
{place.category_name && (() => {
|
||||
const CatIcon = getCategoryIcon(place.category_icon)
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
||||
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{place.address && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{place.address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
)
|
||||
})}
|
||||
</MarkerClusterGroup>
|
||||
|
||||
{route && route.length > 1 && (
|
||||
<Polyline
|
||||
|
||||
@@ -40,15 +40,28 @@ const VORSCHLAEGE = [
|
||||
{ name: 'Kreditkarte', kategorie: 'Finanzen' },
|
||||
]
|
||||
|
||||
const KAT_DOTS = {
|
||||
'Dokumente': '#3b82f6',
|
||||
'Kleidung': '#a855f7',
|
||||
'Körperpflege': '#ec4899',
|
||||
'Elektronik': '#22c55e',
|
||||
'Gesundheit': '#f97316',
|
||||
'Finanzen': '#16a34a',
|
||||
// Cycling color palette — works in light & dark mode
|
||||
const KAT_COLORS = [
|
||||
'#3b82f6', // blue
|
||||
'#a855f7', // purple
|
||||
'#ec4899', // pink
|
||||
'#22c55e', // green
|
||||
'#f97316', // orange
|
||||
'#06b6d4', // cyan
|
||||
'#ef4444', // red
|
||||
'#eab308', // yellow
|
||||
'#8b5cf6', // violet
|
||||
'#14b8a6', // teal
|
||||
]
|
||||
// Stable color assignment: category name → index via simple hash
|
||||
function katColor(kat, allCategories) {
|
||||
const idx = allCategories ? allCategories.indexOf(kat) : -1
|
||||
if (idx >= 0) return KAT_COLORS[idx % KAT_COLORS.length]
|
||||
// Fallback: hash-based
|
||||
let h = 0
|
||||
for (let i = 0; i < kat.length; i++) h = ((h << 5) - h + kat.charCodeAt(i)) | 0
|
||||
return KAT_COLORS[Math.abs(h) % KAT_COLORS.length]
|
||||
}
|
||||
function katDot(kat) { return KAT_DOTS[kat] || '#9ca3af' }
|
||||
|
||||
// ── Artikel-Zeile ──────────────────────────────────────────────────────────
|
||||
function ArtikelZeile({ item, tripId, categories, onCategoryChange }) {
|
||||
@@ -91,7 +104,6 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange }) {
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<button onClick={handleToggle} style={{
|
||||
flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex',
|
||||
color: item.checked ? '#10b981' : 'var(--text-faint)', transition: 'color 0.15s',
|
||||
@@ -99,7 +111,6 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange }) {
|
||||
{item.checked ? <CheckSquare size={18} /> : <Square size={18} />}
|
||||
</button>
|
||||
|
||||
{/* Name */}
|
||||
{editing ? (
|
||||
<input
|
||||
type="text" value={editName} autoFocus
|
||||
@@ -122,16 +133,14 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange }) {
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Actions — always in DOM, visible on hover */}
|
||||
<div style={{ display: 'flex', gap: 2, alignItems: 'center', opacity: hovered ? 1 : 0, transition: 'opacity 0.12s', flexShrink: 0 }}>
|
||||
{/* Category change */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setShowCatPicker(p => !p)}
|
||||
title={t('packing.changeCategory')}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 5px', borderRadius: 6, display: 'flex', alignItems: 'center', color: 'var(--text-faint)', fontSize: 10, gap: 2 }}
|
||||
>
|
||||
<span style={{ width: 7, height: 7, borderRadius: '50%', background: katDot(item.category || t('packing.defaultCategory')), display: 'inline-block' }} />
|
||||
<span style={{ width: 7, height: 7, borderRadius: '50%', background: katColor(item.category || t('packing.defaultCategory'), categories), display: 'inline-block' }} />
|
||||
</button>
|
||||
{showCatPicker && (
|
||||
<div style={{
|
||||
@@ -146,7 +155,7 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange }) {
|
||||
border: 'none', cursor: 'pointer', fontSize: 12.5, fontFamily: 'inherit',
|
||||
color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
|
||||
}}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: katDot(cat), flexShrink: 0 }} />
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(cat, categories), flexShrink: 0 }} />
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
@@ -154,13 +163,11 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit */}
|
||||
<button onClick={() => setEditing(true)} title={t('common.rename')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
|
||||
{/* Delete */}
|
||||
<button onClick={handleDelete} title={t('common.delete')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={13} />
|
||||
@@ -181,7 +188,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
const { t } = useTranslation()
|
||||
const abgehakt = items.filter(i => i.checked).length
|
||||
const alleAbgehakt = abgehakt === items.length
|
||||
const dot = katDot(kategorie)
|
||||
const dot = katColor(kategorie, allCategories)
|
||||
|
||||
const handleSaveKatName = async () => {
|
||||
const neu = editKatName.trim()
|
||||
@@ -207,7 +214,6 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 6, background: 'var(--bg-card)', borderRadius: 14, border: '1px solid var(--border-secondary)', overflow: 'visible' }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px', borderBottom: offen ? '1px solid var(--border-secondary)' : 'none' }}>
|
||||
<button onClick={() => setOffen(o => !o)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex', color: 'var(--text-faint)', flexShrink: 0 }}>
|
||||
{offen ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
|
||||
@@ -229,16 +235,14 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Progress pill */}
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 600, padding: '1px 8px', borderRadius: 99,
|
||||
background: alleAbgehakt ? '#dcfce7' : 'var(--bg-tertiary)',
|
||||
background: alleAbgehakt ? 'rgba(22,163,74,0.12)' : 'var(--bg-tertiary)',
|
||||
color: alleAbgehakt ? '#16a34a' : 'var(--text-muted)',
|
||||
}}>
|
||||
{abgehakt}/{items.length}
|
||||
</span>
|
||||
|
||||
{/* Kategorie-Menü */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button onClick={() => setShowMenu(m => !m)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
@@ -257,7 +261,6 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
{offen && (
|
||||
<div style={{ padding: '4px 4px 6px' }}>
|
||||
{items.map(item => (
|
||||
@@ -337,7 +340,6 @@ export default function PackingListPanel({ tripId, items }) {
|
||||
catch { toast.error(t('packing.toast.addError')) }
|
||||
}
|
||||
|
||||
// Rename all items in a category
|
||||
const handleRenameCategory = async (oldName, newName) => {
|
||||
const toUpdate = items.filter(i => (i.category || t('packing.defaultCategory')) === oldName)
|
||||
for (const item of toUpdate) {
|
||||
@@ -345,14 +347,12 @@ export default function PackingListPanel({ tripId, items }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all items in a category
|
||||
const handleDeleteCategory = async (catItems) => {
|
||||
for (const item of catItems) {
|
||||
try { await deletePackingItem(tripId, item.id) } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all checked items
|
||||
const handleClearChecked = async () => {
|
||||
if (!confirm(t('packing.confirm.clearChecked', { count: abgehakt }))) return
|
||||
for (const item of items.filter(i => i.checked)) {
|
||||
@@ -383,23 +383,23 @@ export default function PackingListPanel({ tripId, items }) {
|
||||
fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)',
|
||||
background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
{t('packing.clearChecked', { count: abgehakt })}
|
||||
<span className="hidden sm:inline">{t('packing.clearChecked', { count: abgehakt })}</span>
|
||||
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setZeigeVorschlaege(v => !v)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||
border: '1px solid', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||
background: zeigeVorschlaege ? '#111827' : 'var(--bg-card)',
|
||||
borderColor: zeigeVorschlaege ? '#111827' : 'var(--border-primary)',
|
||||
color: zeigeVorschlaege ? 'white' : 'var(--text-muted)',
|
||||
background: zeigeVorschlaege ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: zeigeVorschlaege ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
color: zeigeVorschlaege ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
}}>
|
||||
<Sparkles size={12} /> {t('packing.suggestions')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fortschrittsbalken */}
|
||||
{items.length > 0 && (
|
||||
{items.length > 0 && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ height: 5, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
@@ -414,14 +414,12 @@ export default function PackingListPanel({ tripId, items }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Artikel hinzufügen */}
|
||||
<form onSubmit={handleAdd} style={{ display: 'flex', gap: 6 }}>
|
||||
<input
|
||||
type="text" value={neuerName} onChange={e => setNeuerName(e.target.value)}
|
||||
placeholder={t('packing.addPlaceholder')}
|
||||
style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13.5, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
{/* Kategorie-Auswahl */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
ref={katInputRef}
|
||||
@@ -443,14 +441,14 @@ export default function PackingListPanel({ tripId, items }) {
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: katDot(cat), flexShrink: 0 }} />
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(cat, allCategories), flexShrink: 0 }} />
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button type="submit" style={{ padding: '8px 12px', borderRadius: 10, border: 'none', background: '#111827', color: 'white', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
|
||||
<button type="submit" style={{ padding: '8px 12px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</form>
|
||||
@@ -489,8 +487,8 @@ export default function PackingListPanel({ tripId, items }) {
|
||||
<button key={id} onClick={() => setFilter(id)} style={{
|
||||
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer',
|
||||
fontSize: 12, fontFamily: 'inherit', fontWeight: filter === id ? 600 : 400,
|
||||
background: filter === id ? '#111827' : 'transparent',
|
||||
color: filter === id ? 'white' : 'var(--text-muted)',
|
||||
background: filter === id ? 'var(--text-primary)' : 'transparent',
|
||||
color: filter === id ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
}}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -661,7 +661,7 @@ export default function DayPlanSidebar({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 1, opacity: isHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
|
||||
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={moveUp} disabled={placeIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: placeIdx === 0 ? 'default' : 'pointer', color: placeIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||
<ChevronUp size={12} strokeWidth={2} />
|
||||
</button>
|
||||
@@ -736,14 +736,14 @@ export default function DayPlanSidebar({
|
||||
<div style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.2', marginTop: 2 }}>{note.time}</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 1, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', color: noteIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
|
||||
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', color: noteIdx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
|
||||
<div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={e => openEditNote(day.id, note, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Pencil size={10} /></button>
|
||||
<button onClick={e => deleteNote(day.id, note.id, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Trash2 size={10} /></button>
|
||||
</div>
|
||||
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isNoteHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', color: noteIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
|
||||
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', color: noteIdx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
|
||||
@@ -8,14 +8,31 @@ import { useTranslation } from '../../i18n'
|
||||
|
||||
const detailsCache = new Map()
|
||||
|
||||
function getSessionCache(key) {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : undefined
|
||||
} catch { return undefined }
|
||||
}
|
||||
|
||||
function setSessionCache(key, value) {
|
||||
try { sessionStorage.setItem(key, JSON.stringify(value)) } catch {}
|
||||
}
|
||||
|
||||
function useGoogleDetails(googlePlaceId, language) {
|
||||
const [details, setDetails] = useState(null)
|
||||
const cacheKey = `${googlePlaceId}_${language}`
|
||||
const cacheKey = `gdetails_${googlePlaceId}_${language}`
|
||||
useEffect(() => {
|
||||
if (!googlePlaceId) { setDetails(null); return }
|
||||
// In-memory cache (fastest)
|
||||
if (detailsCache.has(cacheKey)) { setDetails(detailsCache.get(cacheKey)); return }
|
||||
// sessionStorage cache (survives reload)
|
||||
const cached = getSessionCache(cacheKey)
|
||||
if (cached) { detailsCache.set(cacheKey, cached); setDetails(cached); return }
|
||||
// Fetch from API
|
||||
mapsApi.details(googlePlaceId, language).then(data => {
|
||||
detailsCache.set(cacheKey, data.place)
|
||||
setSessionCache(cacheKey, data.place)
|
||||
setDetails(data.place)
|
||||
}).catch(() => {})
|
||||
}, [googlePlaceId, language])
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Search, Plus, X } from 'lucide-react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { Search, Plus, X, CalendarDays } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -7,12 +8,13 @@ import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
export default function PlacesSidebar({
|
||||
places, categories, assignments, selectedDayId, selectedPlaceId,
|
||||
onPlaceClick, onAddPlace, onAssignToDay,
|
||||
onPlaceClick, onAddPlace, onAssignToDay, days, isMobile,
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [search, setSearch] = useState('')
|
||||
const [filter, setFilter] = useState('all') // 'all' | 'ungeplant'
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [categoryFilter, setCategoryFilter] = useState('')
|
||||
const [dayPickerPlace, setDayPickerPlace] = useState(null)
|
||||
|
||||
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
|
||||
const plannedIds = new Set(
|
||||
@@ -129,7 +131,13 @@ export default function PlacesSidebar({
|
||||
// Backup in window für Cross-Component Drag (dataTransfer geht bei Re-Render verloren)
|
||||
window.__dragData = { placeId: String(place.id) }
|
||||
}}
|
||||
onClick={() => onPlaceClick(isSelected ? null : place.id)}
|
||||
onClick={() => {
|
||||
if (isMobile && days?.length > 0) {
|
||||
setDayPickerPlace(place)
|
||||
} else {
|
||||
onPlaceClick(isSelected ? null : place.id)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '9px 14px 9px 16px',
|
||||
@@ -180,6 +188,57 @@ export default function PlacesSidebar({
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dayPickerPlace && days?.length > 0 && ReactDOM.createPortal(
|
||||
<div
|
||||
onClick={() => setDayPickerPlace(null)}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ background: 'var(--bg-card)', borderRadius: '20px 20px 0 0', width: '100%', maxWidth: 500, maxHeight: '60vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||
>
|
||||
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-secondary)' }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>{dayPickerPlace.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2 }}>{t('places.assignToDay')}</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 12px 16px' }}>
|
||||
{days.map((day, i) => {
|
||||
const alreadyAssigned = (assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id)
|
||||
return (
|
||||
<button
|
||||
key={day.id}
|
||||
disabled={alreadyAssigned}
|
||||
onClick={() => { onAssignToDay(dayPickerPlace.id, day.id); setDayPickerPlace(null) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
|
||||
padding: '12px 14px', borderRadius: 12, border: 'none', cursor: alreadyAssigned ? 'default' : 'pointer',
|
||||
background: 'transparent', fontFamily: 'inherit', textAlign: 'left',
|
||||
opacity: alreadyAssigned ? 0.4 : 1, transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!alreadyAssigned) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<div style={{
|
||||
width: 32, height: 32, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 13, fontWeight: 700, color: 'var(--text-primary)', flexShrink: 0,
|
||||
}}>{i + 1}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
{day.title || `${t('dayplan.dayN', { n: i + 1 })}`}
|
||||
</div>
|
||||
{day.date && <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{new Date(day.date + 'T00:00:00').toLocaleDateString()}</div>}
|
||||
</div>
|
||||
{alreadyAssigned && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>✓</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import React, { useState, useCallback, useEffect, useRef, useMemo, useLayoutEffect } from 'react'
|
||||
import { FixedSizeList } from 'react-window'
|
||||
import {
|
||||
Plus, Search, X, Navigation, RotateCcw, ExternalLink,
|
||||
ChevronDown, ChevronRight, ChevronUp, Clock, MapPin,
|
||||
@@ -65,6 +66,17 @@ export default function PlannerSidebar({
|
||||
const tripStore = useTripStore()
|
||||
const toast = useToast()
|
||||
const dayNotes = tripStore.dayNotes || {}
|
||||
const placesListRef = useRef(null)
|
||||
const [placesListHeight, setPlacesListHeight] = useState(400)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!placesListRef.current) return
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
setPlacesListHeight(entry.contentRect.height)
|
||||
})
|
||||
ro.observe(placesListRef.current)
|
||||
return () => ro.disconnect()
|
||||
}, [activeSegment])
|
||||
|
||||
// Auto-expand selected day
|
||||
useEffect(() => {
|
||||
@@ -88,12 +100,12 @@ export default function PlannerSidebar({
|
||||
const selectedDayAssignments = selectedDayId ? getDayAssignments(selectedDayId) : []
|
||||
const selectedDay = selectedDayId ? days.find(d => d.id === selectedDayId) : null
|
||||
|
||||
const filteredPlaces = places.filter(p => {
|
||||
const filteredPlaces = useMemo(() => places.filter(p => {
|
||||
const matchSearch = !search || p.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(p.address || '').toLowerCase().includes(search.toLowerCase())
|
||||
const matchCat = !categoryFilter || String(p.category_id) === String(categoryFilter)
|
||||
return matchSearch && matchCat
|
||||
})
|
||||
}), [places, search, categoryFilter])
|
||||
|
||||
const isAssignedToDay = (placeId) =>
|
||||
selectedDayId && selectedDayAssignments.some(a => a.place?.id === placeId)
|
||||
@@ -279,13 +291,11 @@ export default function PlannerSidebar({
|
||||
}
|
||||
}
|
||||
|
||||
// Inspector: show when a place is selected
|
||||
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white relative overflow-hidden" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif" }}>
|
||||
|
||||
{/* Trip header */}
|
||||
<div className="px-4 pt-4 pb-3 flex-shrink-0 border-b border-gray-100">
|
||||
<button onClick={onEditTrip} className="w-full text-left group">
|
||||
<h1 className="font-semibold text-gray-900 text-[15px] leading-tight truncate group-hover:text-slate-600 transition-colors">
|
||||
@@ -302,7 +312,6 @@ export default function PlannerSidebar({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Segmented control */}
|
||||
<div className="px-3 py-2 flex-shrink-0 border-b border-gray-100">
|
||||
<div className="flex bg-gray-100 rounded-[10px] p-0.5 gap-0.5">
|
||||
{SEGMENTS.map(seg => (
|
||||
@@ -321,13 +330,11 @@ export default function PlannerSidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
|
||||
{/* ── PLAN ── */}
|
||||
{activeSegment === 'plan' && (
|
||||
<div className="pb-4">
|
||||
{/* Alle Orte */}
|
||||
<button
|
||||
onClick={() => onSelectDay(null)}
|
||||
className={`w-full text-left px-4 py-3 flex items-center gap-3 transition-colors border-b border-gray-50 ${
|
||||
@@ -368,7 +375,6 @@ export default function PlannerSidebar({
|
||||
|
||||
return (
|
||||
<div key={day.id} className="border-b border-gray-50">
|
||||
{/* Day header row */}
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-3 cursor-pointer select-none transition-colors ${
|
||||
isSelected ? 'bg-slate-100/60' : 'hover:bg-gray-50/80'
|
||||
@@ -420,7 +426,6 @@ export default function PlannerSidebar({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded items: places + notes interleaved */}
|
||||
{isExpanded && (
|
||||
<div className="bg-gray-50/40">
|
||||
{merged.length === 0 && !dayNoteUi ? (
|
||||
@@ -509,7 +514,6 @@ export default function PlannerSidebar({
|
||||
)
|
||||
}
|
||||
|
||||
// Note card
|
||||
const note = item.data
|
||||
const isEditingThis = dayNoteUi?.mode === 'edit' && dayNoteUi.noteId === note.id
|
||||
if (isEditingThis) {
|
||||
@@ -576,7 +580,6 @@ export default function PlannerSidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline "add note" form */}
|
||||
{dayNoteUi?.mode === 'add' && (
|
||||
<div className="px-3 py-2 border-t border-amber-100 bg-amber-50/60">
|
||||
<div className="flex gap-2 mb-1.5">
|
||||
@@ -608,7 +611,6 @@ export default function PlannerSidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add note button */}
|
||||
{!dayNoteUi && (
|
||||
<div className="px-4 py-2 border-t border-gray-100/60 flex gap-2">
|
||||
<button
|
||||
@@ -679,7 +681,6 @@ export default function PlannerSidebar({
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Budget footer */}
|
||||
{totalCost > 0 && (
|
||||
<div className="px-4 py-3 border-t border-gray-100 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">Gesamtkosten</span>
|
||||
@@ -691,7 +692,7 @@ export default function PlannerSidebar({
|
||||
|
||||
{/* ── ORTE ── */}
|
||||
{activeSegment === 'orte' && (
|
||||
<div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div className="p-3 space-y-2 border-b border-gray-100">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-[9px] w-3.5 h-3.5 text-gray-400 pointer-events-none" />
|
||||
@@ -738,52 +739,62 @@ export default function PlannerSidebar({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-50">
|
||||
{filteredPlaces.map(place => {
|
||||
const category = categories.find(c => c.id === place.category_id)
|
||||
const inDay = isAssignedToDay(place.id)
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
return (
|
||||
<div
|
||||
key={place.id}
|
||||
onClick={() => onPlaceClick(isSelected ? null : place.id)}
|
||||
className={`flex items-center gap-3 px-4 py-3 cursor-pointer transition-colors ${
|
||||
isSelected ? 'bg-slate-50' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div ref={placesListRef} style={{ flex: 1, minHeight: 0 }}>
|
||||
<FixedSizeList
|
||||
height={placesListHeight}
|
||||
itemCount={filteredPlaces.length}
|
||||
itemSize={68}
|
||||
overscanCount={10}
|
||||
width="100%"
|
||||
>
|
||||
{({ index, style }) => {
|
||||
const place = filteredPlaces[index]
|
||||
const category = categories.find(c => c.id === place.category_id)
|
||||
const inDay = isAssignedToDay(place.id)
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
return (
|
||||
<div
|
||||
className="w-9 h-9 rounded-[10px] overflow-hidden flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: (category?.color || '#6366f1') + '22' }}
|
||||
style={style}
|
||||
key={place.id}
|
||||
onClick={() => onPlaceClick(isSelected ? null : place.id)}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors border-b border-gray-50 ${
|
||||
isSelected ? 'bg-slate-50' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{place.image_url ? (
|
||||
<img src={place.image_url} alt={place.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-lg">{category?.icon || '📍'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="font-medium text-[13px] text-gray-900 truncate">{place.name}</span>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{inDay
|
||||
? <span className="text-[11px] text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded-full">✓</span>
|
||||
: selectedDayId && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
|
||||
className="text-[11px] text-slate-700 bg-slate-50 px-1.5 py-0.5 rounded hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
+ Tag
|
||||
</button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
className="w-9 h-9 rounded-[10px] overflow-hidden flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: (category?.color || '#6366f1') + '22' }}
|
||||
>
|
||||
{place.image_url ? (
|
||||
<img src={place.image_url} alt={place.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-lg">{category?.icon || '📍'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="font-medium text-[13px] text-gray-900 truncate">{place.name}</span>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{inDay
|
||||
? <span className="text-[11px] text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded-full">✓</span>
|
||||
: selectedDayId && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
|
||||
className="text-[11px] text-slate-700 bg-slate-50 px-1.5 py-0.5 rounded hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
+ Tag
|
||||
</button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{category && <p className="text-xs text-gray-500 mt-0.5">{category.icon} {category.name}</p>}
|
||||
{place.address && <p className="text-xs text-gray-400 truncate">{place.address}</p>}
|
||||
</div>
|
||||
{category && <p className="text-xs text-gray-500 mt-0.5">{category.icon} {category.name}</p>}
|
||||
{place.address && <p className="text-xs text-gray-400 truncate">{place.address}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
)
|
||||
}}
|
||||
</FixedSizeList>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -878,7 +889,6 @@ export default function PlannerSidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reservation modal */}
|
||||
<ReservationModal
|
||||
isOpen={showReservationModal}
|
||||
onClose={() => { setShowReservationModal(false); setEditingReservation(null) }}
|
||||
|
||||
@@ -115,7 +115,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
padding: '8px 14px', fontSize: 13, fontFamily: 'inherit',
|
||||
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)', background: 'var(--bg-input)',
|
||||
}
|
||||
const labelStyle = { display: 'block', fontSize: 12, fontWeight: 600, color: '#374151', marginBottom: 5 }
|
||||
const labelStyle = { display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 5 }
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')} size="md">
|
||||
@@ -130,9 +130,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
padding: '6px 11px', borderRadius: 99, border: '1px solid',
|
||||
fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
|
||||
background: form.type === value ? '#111827' : 'white',
|
||||
borderColor: form.type === value ? '#111827' : '#e5e7eb',
|
||||
color: form.type === value ? 'white' : '#6b7280',
|
||||
background: form.type === value ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: form.type === value ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
color: form.type === value ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
}}>
|
||||
<Icon size={12} /> {t(labelKey)}
|
||||
</button>
|
||||
@@ -231,14 +231,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
<label style={labelStyle}>{t('files.title')}</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{attachedFiles.map(f => (
|
||||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: '#f9fafb', borderRadius: 8, border: '1px solid #e5e7eb' }}>
|
||||
<FileText size={13} style={{ color: '#6b7280', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 12.5, color: '#374151', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
<a href={f.url} target="_blank" rel="noreferrer" style={{ color: '#9ca3af', display: 'flex', flexShrink: 0 }} title={t('common.open')}>
|
||||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
|
||||
<FileText size={13} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 12.5, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
<a href={f.url} target="_blank" rel="noreferrer" style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }} title={t('common.open')}>
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
{onFileDelete && (
|
||||
<button type="button" onClick={() => onFileDelete(f.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#9ca3af', display: 'flex', padding: 0, flexShrink: 0 }}
|
||||
<button type="button" onClick={() => onFileDelete(f.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}>
|
||||
<X size={12} />
|
||||
@@ -247,12 +247,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
</div>
|
||||
))}
|
||||
{pendingFiles.map((f, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: '#f9fafb', borderRadius: 8, border: '1px solid #e5e7eb' }}>
|
||||
<FileText size={13} style={{ color: '#6b7280', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 12.5, color: '#374151', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
|
||||
<span style={{ fontSize: 11, color: '#9ca3af', flexShrink: 0 }}>{t('reservations.pendingSave')}</span>
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
|
||||
<FileText size={13} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 12.5, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{t('reservations.pendingSave')}</span>
|
||||
<button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#9ca3af', display: 'flex', padding: 0, flexShrink: 0 }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}>
|
||||
<X size={12} />
|
||||
@@ -262,12 +262,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, padding: '7px 12px',
|
||||
border: '1px dashed #d1d5db', borderRadius: 8, background: 'white',
|
||||
fontSize: 12.5, color: '#6b7280', cursor: uploadingFile ? 'default' : 'pointer',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'var(--bg-card)',
|
||||
fontSize: 12.5, color: 'var(--text-muted)', cursor: uploadingFile ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', transition: 'all 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!uploadingFile) { e.currentTarget.style.borderColor = '#9ca3af'; e.currentTarget.style.color = '#374151' } }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = '#d1d5db'; e.currentTarget.style.color = '#6b7280' }}>
|
||||
onMouseEnter={e => { if (!uploadingFile) { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.color = 'var(--text-secondary)' } }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-muted)' }}>
|
||||
<Paperclip size={13} />
|
||||
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
||||
</button>
|
||||
@@ -275,11 +275,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid #f3f4f6' }}>
|
||||
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid #e5e7eb', background: 'white', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: '#374151' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-secondary)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: '#111827', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
|
||||
<button type="submit" disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
|
||||
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +56,6 @@ const inputStyle = {
|
||||
}
|
||||
const labelStyle = { display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 5 }
|
||||
|
||||
// Inline modal for editing place reservation fields
|
||||
function PlaceReservationEditModal({ item, tripId, onClose }) {
|
||||
const { updatePlace } = useTripStore()
|
||||
const toast = useToast()
|
||||
@@ -148,7 +147,6 @@ function PlaceReservationEditModal({ item, tripId, onClose }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Card for real reservations (reservations table)
|
||||
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles }) {
|
||||
const { toggleReservationStatus } = useTripStore()
|
||||
const toast = useToast()
|
||||
@@ -172,8 +170,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
<div style={{ display: 'flex', alignItems: 'stretch' }}>
|
||||
<div style={{
|
||||
width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: confirmed ? '#f0fdf4' : '#fefce8',
|
||||
borderRight: `1px solid ${confirmed ? '#bbf7d0' : '#fef08a'}`,
|
||||
background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(161,98,7,0.1)',
|
||||
borderRight: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(161,98,7,0.2)'}`,
|
||||
}}>
|
||||
<TypeIcon size={16} style={{ color: confirmed ? '#16a34a' : '#a16207' }} />
|
||||
</div>
|
||||
@@ -188,7 +186,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
<button onClick={handleToggle} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 3, padding: '3px 8px', borderRadius: 99,
|
||||
border: 'none', cursor: 'pointer', fontSize: 11, fontWeight: 500,
|
||||
background: confirmed ? '#dcfce7' : '#fef9c3',
|
||||
background: confirmed ? 'rgba(22,163,74,0.12)' : 'rgba(161,98,7,0.12)',
|
||||
color: confirmed ? '#16a34a' : '#a16207',
|
||||
}}>
|
||||
{confirmed ? <><CheckCircle2 size={11} /> {t('reservations.confirmed')}</> : <><Circle size={11} /> {t('reservations.pending')}</>}
|
||||
@@ -214,7 +212,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
|
||||
<div style={{ marginTop: 5, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{r.confirmation_number && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 10.5, color: '#15803d', background: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 99, padding: '1px 7px', fontWeight: 600 }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 10.5, color: '#16a34a', background: 'rgba(22,163,74,0.1)', border: '1px solid rgba(22,163,74,0.2)', borderRadius: 99, padding: '1px 7px', fontWeight: 600 }}>
|
||||
<Hash size={8} />{r.confirmation_number}
|
||||
</span>
|
||||
)}
|
||||
@@ -247,7 +245,6 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
)
|
||||
}
|
||||
|
||||
// Card for place-level reservations (from day plan)
|
||||
function PlaceReservationCard({ item, tripId }) {
|
||||
const { updatePlace } = useTripStore()
|
||||
const toast = useToast()
|
||||
@@ -276,8 +273,8 @@ function PlaceReservationCard({ item, tripId }) {
|
||||
<div style={{ display: 'flex', alignItems: 'stretch' }}>
|
||||
<div style={{
|
||||
width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: confirmed ? '#f0fdf4' : '#fefce8',
|
||||
borderRight: `1px solid ${confirmed ? '#bbf7d0' : '#fef08a'}`,
|
||||
background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(161,98,7,0.1)',
|
||||
borderRight: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(161,98,7,0.2)'}`,
|
||||
}}>
|
||||
<MapPinned size={16} style={{ color: confirmed ? '#16a34a' : '#a16207' }} />
|
||||
</div>
|
||||
@@ -295,7 +292,7 @@ function PlaceReservationCard({ item, tripId }) {
|
||||
<span style={{
|
||||
display: 'flex', alignItems: 'center', gap: 3, padding: '3px 8px', borderRadius: 99,
|
||||
fontSize: 11, fontWeight: 500,
|
||||
background: confirmed ? '#dcfce7' : '#fef9c3',
|
||||
background: confirmed ? 'rgba(22,163,74,0.12)' : 'rgba(161,98,7,0.12)',
|
||||
color: confirmed ? '#16a34a' : '#a16207',
|
||||
}}>
|
||||
{confirmed ? <><CheckCircle2 size={11} /> {t('reservations.confirmed')}</> : <><Circle size={11} /> {t('reservations.pending')}</>}
|
||||
@@ -344,7 +341,7 @@ function Section({ title, count, children, defaultOpen = true, accent }) {
|
||||
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>{title}</span>
|
||||
<span style={{
|
||||
fontSize: 11, fontWeight: 600, padding: '1px 8px', borderRadius: 99,
|
||||
background: accent === 'green' ? '#dcfce7' : 'var(--bg-tertiary)',
|
||||
background: accent === 'green' ? 'rgba(22,163,74,0.12)' : 'var(--bg-tertiary)',
|
||||
color: accent === 'green' ? '#16a34a' : 'var(--text-muted)',
|
||||
}}>{count}</span>
|
||||
</button>
|
||||
@@ -409,7 +406,7 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
||||
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
fontSize: 12.5, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Plus size={13} /> {t('reservations.addManual')}
|
||||
<Plus size={13} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -172,13 +172,13 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
<Calendar className="inline w-4 h-4 mr-1" />{t('dashboard.startDate')}
|
||||
</label>
|
||||
<CustomDatePicker value={formData.start_date} onChange={v => update('start_date', v)} placeholder="Start" />
|
||||
<CustomDatePicker value={formData.start_date} onChange={v => update('start_date', v)} placeholder={t('dashboard.startDate')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||
<Calendar className="inline w-4 h-4 mr-1" />{t('dashboard.endDate')}
|
||||
</label>
|
||||
<CustomDatePicker value={formData.end_date} onChange={v => update('end_date', v)} placeholder="End" />
|
||||
<CustomDatePicker value={formData.end_date} onChange={v => update('end_date', v)} placeholder={t('dashboard.endDate')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,7 +20,17 @@ function WeatherIcon({ main, size = 13 }) {
|
||||
return <Icon size={size} strokeWidth={1.8} />
|
||||
}
|
||||
|
||||
const weatherCache = {}
|
||||
function getWeatherCache(key) {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key)
|
||||
if (raw === null) return undefined
|
||||
return JSON.parse(raw)
|
||||
} catch { return undefined }
|
||||
}
|
||||
|
||||
function setWeatherCache(key, value) {
|
||||
try { sessionStorage.setItem(key, JSON.stringify(value)) } catch {}
|
||||
}
|
||||
|
||||
export default function WeatherWidget({ lat, lng, date, compact = false }) {
|
||||
const [weather, setWeather] = useState(null)
|
||||
@@ -30,24 +40,27 @@ export default function WeatherWidget({ lat, lng, date, compact = false }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!lat || !lng || !date) return
|
||||
const cacheKey = `${lat},${lng},${date}`
|
||||
if (weatherCache[cacheKey] !== undefined) {
|
||||
if (weatherCache[cacheKey] === null) setFailed(true)
|
||||
else setWeather(weatherCache[cacheKey])
|
||||
const rLat = Math.round(lat * 100) / 100
|
||||
const rLng = Math.round(lng * 100) / 100
|
||||
const cacheKey = `weather_${rLat}_${rLng}_${date}`
|
||||
const cached = getWeatherCache(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
if (cached === null) setFailed(true)
|
||||
else setWeather(cached)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
weatherApi.get(lat, lng, date)
|
||||
.then(data => {
|
||||
if (data.error || data.temp === undefined) {
|
||||
weatherCache[cacheKey] = null
|
||||
setWeatherCache(cacheKey, null)
|
||||
setFailed(true)
|
||||
} else {
|
||||
weatherCache[cacheKey] = data
|
||||
setWeatherCache(cacheKey, data)
|
||||
setWeather(data)
|
||||
}
|
||||
})
|
||||
.catch(() => { weatherCache[cacheKey] = null; setFailed(true) })
|
||||
.catch(() => { setWeatherCache(cacheKey, null); setFailed(true) })
|
||||
.finally(() => setLoading(false))
|
||||
}, [lat, lng, date])
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ function getWeekday(year, month, day) { return new Date(year, month, day).getDay
|
||||
|
||||
// ── Datum-Only Picker ────────────────────────────────────────────────────────
|
||||
export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
|
||||
const { locale } = useTranslation()
|
||||
const { locale, t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef(null)
|
||||
const dropRef = useRef(null)
|
||||
@@ -67,7 +67,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
||||
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}>
|
||||
<Calendar size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span>{displayValue || placeholder || 'Datum'}</span>
|
||||
<span>{displayValue || placeholder || t('common.date')}</span>
|
||||
</button>
|
||||
|
||||
{open && ReactDOM.createPortal(
|
||||
|
||||
@@ -19,6 +19,7 @@ const de = {
|
||||
'common.no': 'Nein',
|
||||
'common.or': 'oder',
|
||||
'common.none': 'Keine',
|
||||
'common.date': 'Datum',
|
||||
'common.rename': 'Umbenennen',
|
||||
'common.name': 'Name',
|
||||
'common.email': 'E-Mail',
|
||||
@@ -139,10 +140,24 @@ const de = {
|
||||
// Login
|
||||
'login.error': 'Anmeldung fehlgeschlagen. Bitte Zugangsdaten prüfen.',
|
||||
'login.tagline': 'Deine Reisen.\nDein Plan.',
|
||||
'login.description': 'Plane Reisen mit interaktiven Karten, Tagesabläufen und smarten Checklisten.',
|
||||
'login.features.places': 'Orte',
|
||||
'login.features.schedule': 'Tagesplan',
|
||||
'login.features.packing': 'Packliste',
|
||||
'login.description': 'Plane Reisen gemeinsam mit interaktiven Karten, Budgets und Echtzeit-Sync.',
|
||||
'login.features.maps': 'Interaktive Karten',
|
||||
'login.features.mapsDesc': 'Google Places, Routen & Clustering',
|
||||
'login.features.realtime': 'Echtzeit-Sync',
|
||||
'login.features.realtimeDesc': 'Gemeinsam planen via WebSocket',
|
||||
'login.features.budget': 'Budget-Tracking',
|
||||
'login.features.budgetDesc': 'Kategorien, Diagramme & Pro-Kopf',
|
||||
'login.features.collab': 'Zusammenarbeit',
|
||||
'login.features.collabDesc': 'Multi-User mit geteilten Reisen',
|
||||
'login.features.packing': 'Packlisten',
|
||||
'login.features.packingDesc': 'Kategorien & Fortschritt',
|
||||
'login.features.bookings': 'Buchungen',
|
||||
'login.features.bookingsDesc': 'Flüge, Hotels, Restaurants & mehr',
|
||||
'login.features.files': 'Dokumente',
|
||||
'login.features.filesDesc': 'Dateien hochladen & verwalten',
|
||||
'login.features.routes': 'Routenoptimierung',
|
||||
'login.features.routesDesc': 'Auto-Optimierung & Google Maps Export',
|
||||
'login.selfHosted': 'Self-hosted \u00B7 Open Source \u00B7 Deine Daten bleiben bei dir',
|
||||
'login.title': 'Anmelden',
|
||||
'login.subtitle': 'Willkommen zurück',
|
||||
'login.signingIn': 'Anmelden…',
|
||||
@@ -155,6 +170,7 @@ const de = {
|
||||
'login.noAccount': 'Noch kein Konto?',
|
||||
'login.hasAccount': 'Bereits ein Konto?',
|
||||
'login.register': 'Registrieren',
|
||||
'login.emailPlaceholder': 'deine@email.de',
|
||||
'login.username': 'Benutzername',
|
||||
|
||||
// Admin
|
||||
@@ -186,6 +202,10 @@ const de = {
|
||||
'admin.toast.userDeleted': 'Benutzer gelöscht',
|
||||
'admin.toast.deleteError': 'Fehler beim Löschen',
|
||||
'admin.toast.cannotDeleteSelf': 'Eigenes Konto kann nicht gelöscht werden',
|
||||
'admin.toast.userCreated': 'Benutzer erstellt',
|
||||
'admin.toast.createError': 'Fehler beim Erstellen des Benutzers',
|
||||
'admin.toast.fieldsRequired': 'Benutzername, E-Mail und Passwort sind erforderlich',
|
||||
'admin.createUser': 'Benutzer anlegen',
|
||||
'admin.tabs.settings': 'Einstellungen',
|
||||
'admin.allowRegistration': 'Registrierung erlauben',
|
||||
'admin.allowRegistrationHint': 'Neue Benutzer können sich selbst registrieren',
|
||||
@@ -201,9 +221,9 @@ const de = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Planung',
|
||||
'trip.tabs.reservations': 'Reservierungen',
|
||||
'trip.tabs.reservations': 'Buchungen',
|
||||
'trip.tabs.packing': 'Packliste',
|
||||
'trip.tabs.packingShort': 'Packen',
|
||||
'trip.tabs.packingShort': 'Packliste',
|
||||
'trip.tabs.budget': 'Budget',
|
||||
'trip.tabs.files': 'Dateien',
|
||||
'trip.loading': 'Reise wird geladen...',
|
||||
@@ -247,6 +267,7 @@ const de = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Ort hinzufügen',
|
||||
'places.assignToDay': 'Zu welchem Tag hinzufügen?',
|
||||
'places.all': 'Alle',
|
||||
'places.unplanned': 'Ungeplant',
|
||||
'places.search': 'Orte suchen...',
|
||||
@@ -297,7 +318,7 @@ const de = {
|
||||
'inspector.website': 'Webseite öffnen',
|
||||
|
||||
// Reservations
|
||||
'reservations.title': 'Reservierungen',
|
||||
'reservations.title': 'Buchungen',
|
||||
'reservations.empty': 'Keine Reservierungen vorhanden',
|
||||
'reservations.emptyHint': 'Füge Reservierungen für Flüge, Hotels und mehr hinzu',
|
||||
'reservations.add': 'Reservierung hinzufügen',
|
||||
@@ -401,6 +422,7 @@ const de = {
|
||||
'packing.empty': 'Packliste ist leer',
|
||||
'packing.progress': '{packed} von {total} gepackt ({percent}%)',
|
||||
'packing.clearChecked': '{count} abgehakte entfernen',
|
||||
'packing.clearCheckedShort': '{count} entfernen',
|
||||
'packing.suggestions': 'Vorschläge',
|
||||
'packing.suggestionsTitle': 'Vorschläge hinzufügen',
|
||||
'packing.allSuggested': 'Alle Vorschläge hinzugefügt',
|
||||
|
||||
@@ -19,6 +19,7 @@ const en = {
|
||||
'common.no': 'No',
|
||||
'common.or': 'or',
|
||||
'common.none': 'None',
|
||||
'common.date': 'Date',
|
||||
'common.rename': 'Rename',
|
||||
'common.name': 'Name',
|
||||
'common.email': 'Email',
|
||||
@@ -139,10 +140,24 @@ const en = {
|
||||
// Login
|
||||
'login.error': 'Login failed. Please check your credentials.',
|
||||
'login.tagline': 'Your Trips.\nYour Plan.',
|
||||
'login.description': 'Plan trips with interactive maps, daily schedules and smart checklists.',
|
||||
'login.features.places': 'Places',
|
||||
'login.features.schedule': 'Schedule',
|
||||
'login.features.packing': 'Packing List',
|
||||
'login.description': 'Plan trips collaboratively with interactive maps, budgets, and real-time sync.',
|
||||
'login.features.maps': 'Interactive Maps',
|
||||
'login.features.mapsDesc': 'Google Places, routes & clustering',
|
||||
'login.features.realtime': 'Real-Time Sync',
|
||||
'login.features.realtimeDesc': 'Plan together via WebSocket',
|
||||
'login.features.budget': 'Budget Tracking',
|
||||
'login.features.budgetDesc': 'Categories, charts & per-person costs',
|
||||
'login.features.collab': 'Collaboration',
|
||||
'login.features.collabDesc': 'Multi-user with shared trips',
|
||||
'login.features.packing': 'Packing Lists',
|
||||
'login.features.packingDesc': 'Categories, progress & suggestions',
|
||||
'login.features.bookings': 'Reservations',
|
||||
'login.features.bookingsDesc': 'Flights, hotels, restaurants & more',
|
||||
'login.features.files': 'Documents',
|
||||
'login.features.filesDesc': 'Upload & manage documents',
|
||||
'login.features.routes': 'Smart Routes',
|
||||
'login.features.routesDesc': 'Auto-optimize & Google Maps export',
|
||||
'login.selfHosted': 'Self-hosted \u00B7 Open Source \u00B7 Your data stays yours',
|
||||
'login.title': 'Sign In',
|
||||
'login.subtitle': 'Welcome back',
|
||||
'login.signingIn': 'Signing in…',
|
||||
@@ -155,6 +170,7 @@ const en = {
|
||||
'login.noAccount': "Don't have an account?",
|
||||
'login.hasAccount': 'Already have an account?',
|
||||
'login.register': 'Register',
|
||||
'login.emailPlaceholder': 'your@email.com',
|
||||
'login.username': 'Username',
|
||||
|
||||
// Admin
|
||||
@@ -186,6 +202,10 @@ const en = {
|
||||
'admin.toast.userDeleted': 'User deleted',
|
||||
'admin.toast.deleteError': 'Failed to delete',
|
||||
'admin.toast.cannotDeleteSelf': 'Cannot delete your own account',
|
||||
'admin.toast.userCreated': 'User created',
|
||||
'admin.toast.createError': 'Failed to create user',
|
||||
'admin.toast.fieldsRequired': 'Username, email and password are required',
|
||||
'admin.createUser': 'Create User',
|
||||
'admin.tabs.settings': 'Settings',
|
||||
'admin.allowRegistration': 'Allow Registration',
|
||||
'admin.allowRegistrationHint': 'New users can register themselves',
|
||||
@@ -247,6 +267,7 @@ const en = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Add Place',
|
||||
'places.assignToDay': 'Add to which day?',
|
||||
'places.all': 'All',
|
||||
'places.unplanned': 'Unplanned',
|
||||
'places.search': 'Search places...',
|
||||
@@ -401,6 +422,7 @@ const en = {
|
||||
'packing.empty': 'Packing list is empty',
|
||||
'packing.progress': '{packed} of {total} packed ({percent}%)',
|
||||
'packing.clearChecked': 'Remove {count} checked',
|
||||
'packing.clearCheckedShort': 'Remove {count}',
|
||||
'packing.suggestions': 'Suggestions',
|
||||
'packing.suggestionsTitle': 'Add Suggestions',
|
||||
'packing.allSuggested': 'All suggestions added',
|
||||
|
||||
@@ -2,6 +2,37 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Reorder buttons: desktop = original style; mobile = always visible, larger touch targets */
|
||||
.reorder-buttons {
|
||||
flex-direction: column;
|
||||
opacity: 0;
|
||||
}
|
||||
.reorder-buttons button {
|
||||
background: none !important;
|
||||
padding: 1px 2px;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.reorder-buttons {
|
||||
flex-direction: row !important;
|
||||
opacity: 1 !important;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
.reorder-buttons button {
|
||||
background: var(--bg-tertiary) !important;
|
||||
border-radius: 6px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
.note-edit-buttons {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ort-Zeile Hover: Sortier-Buttons anzeigen */
|
||||
.place-row:hover .reorder-btns {
|
||||
opacity: 1 !important;
|
||||
@@ -84,6 +115,49 @@ body {
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
/* ── Marker cluster custom styling ────────────── */
|
||||
.marker-cluster-wrapper {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.marker-cluster-custom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: #111827;
|
||||
border: 2.5px solid rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25), 0 0 0 2px rgba(17, 24, 39, 0.15);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.marker-cluster-custom:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35), 0 0 0 3px rgba(17, 24, 39, 0.2);
|
||||
}
|
||||
|
||||
.marker-cluster-custom span {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Hide default markercluster styles we don't need */
|
||||
.marker-cluster-small,
|
||||
.marker-cluster-medium,
|
||||
.marker-cluster-large {
|
||||
background: transparent !important;
|
||||
}
|
||||
.marker-cluster-small div,
|
||||
.marker-cluster-medium div,
|
||||
.marker-cluster-large div {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* ── Leaflet z-index ───────────────────────────── */
|
||||
.leaflet-container {
|
||||
z-index: 0;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand'
|
||||
import { authApi } from '../api/client'
|
||||
import { connect, disconnect } from '../api/websocket'
|
||||
|
||||
export const useAuthStore = create((set, get) => ({
|
||||
user: null,
|
||||
@@ -20,6 +21,7 @@ export const useAuthStore = create((set, get) => ({
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
connect(data.token)
|
||||
return data
|
||||
} catch (err) {
|
||||
const error = err.response?.data?.error || 'Anmeldung fehlgeschlagen'
|
||||
@@ -40,6 +42,7 @@ export const useAuthStore = create((set, get) => ({
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
connect(data.token)
|
||||
return data
|
||||
} catch (err) {
|
||||
const error = err.response?.data?.error || 'Registrierung fehlgeschlagen'
|
||||
@@ -49,6 +52,7 @@ export const useAuthStore = create((set, get) => ({
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
disconnect()
|
||||
localStorage.removeItem('auth_token')
|
||||
set({
|
||||
user: null,
|
||||
@@ -72,6 +76,7 @@ export const useAuthStore = create((set, get) => ({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
})
|
||||
connect(token)
|
||||
} catch (err) {
|
||||
localStorage.removeItem('auth_token')
|
||||
set({
|
||||
|
||||
@@ -19,6 +19,214 @@ export const useTripStore = create((set, get) => ({
|
||||
|
||||
setSelectedDay: (dayId) => set({ selectedDayId: dayId }),
|
||||
|
||||
// Handle remote WebSocket events without making API calls
|
||||
handleRemoteEvent: (event) => {
|
||||
const { type, ...payload } = event
|
||||
|
||||
set(state => {
|
||||
switch (type) {
|
||||
// Places
|
||||
case 'place:created':
|
||||
if (state.places.some(p => p.id === payload.place.id)) return {}
|
||||
return { places: [payload.place, ...state.places] }
|
||||
case 'place:updated':
|
||||
return {
|
||||
places: state.places.map(p => p.id === payload.place.id ? payload.place : p),
|
||||
assignments: Object.fromEntries(
|
||||
Object.entries(state.assignments).map(([dayId, items]) => [
|
||||
dayId,
|
||||
items.map(a => a.place?.id === payload.place.id ? { ...a, place: payload.place } : a)
|
||||
])
|
||||
),
|
||||
}
|
||||
case 'place:deleted':
|
||||
return {
|
||||
places: state.places.filter(p => p.id !== payload.placeId),
|
||||
assignments: Object.fromEntries(
|
||||
Object.entries(state.assignments).map(([dayId, items]) => [
|
||||
dayId,
|
||||
items.filter(a => a.place?.id !== payload.placeId)
|
||||
])
|
||||
),
|
||||
}
|
||||
|
||||
// Assignments
|
||||
case 'assignment:created': {
|
||||
const dayKey = String(payload.assignment.day_id)
|
||||
const existing = (state.assignments[dayKey] || [])
|
||||
// Skip if already present (by id OR by place_id to handle optimistic temp ids)
|
||||
const placeId = payload.assignment.place?.id || payload.assignment.place_id
|
||||
if (existing.some(a => a.id === payload.assignment.id || (placeId && a.place?.id === placeId))) {
|
||||
// Replace temp entry with server version if needed
|
||||
const hasTempVersion = existing.some(a => a.id < 0 && a.place?.id === placeId)
|
||||
if (hasTempVersion) {
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: existing.map(a => (a.id < 0 && a.place?.id === placeId) ? payload.assignment : a),
|
||||
}
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: [...existing, payload.assignment],
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'assignment:deleted': {
|
||||
const dayKey = String(payload.dayId)
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: (state.assignments[dayKey] || []).filter(a => a.id !== payload.assignmentId),
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'assignment:moved': {
|
||||
const oldKey = String(payload.oldDayId)
|
||||
const newKey = String(payload.newDayId)
|
||||
const movedAssignment = payload.assignment
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[oldKey]: (state.assignments[oldKey] || []).filter(a => a.id !== movedAssignment.id),
|
||||
[newKey]: [...(state.assignments[newKey] || []).filter(a => a.id !== movedAssignment.id), movedAssignment],
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'assignment:reordered': {
|
||||
const dayKey = String(payload.dayId)
|
||||
const currentItems = state.assignments[dayKey] || []
|
||||
const orderedIds = payload.orderedIds || []
|
||||
const reordered = orderedIds.map((id, idx) => {
|
||||
const item = currentItems.find(a => a.id === id)
|
||||
return item ? { ...item, order_index: idx } : null
|
||||
}).filter(Boolean)
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: reordered,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Days
|
||||
case 'day:created':
|
||||
if (state.days.some(d => d.id === payload.day.id)) return {}
|
||||
return { days: [...state.days, payload.day] }
|
||||
case 'day:updated':
|
||||
return {
|
||||
days: state.days.map(d => d.id === payload.day.id ? payload.day : d),
|
||||
}
|
||||
case 'day:deleted': {
|
||||
const removedDayId = String(payload.dayId)
|
||||
const newAssignments = { ...state.assignments }
|
||||
delete newAssignments[removedDayId]
|
||||
const newDayNotes = { ...state.dayNotes }
|
||||
delete newDayNotes[removedDayId]
|
||||
return {
|
||||
days: state.days.filter(d => d.id !== payload.dayId),
|
||||
assignments: newAssignments,
|
||||
dayNotes: newDayNotes,
|
||||
}
|
||||
}
|
||||
|
||||
// Day Notes
|
||||
case 'dayNote:created': {
|
||||
const dayKey = String(payload.dayId)
|
||||
const existingNotes = (state.dayNotes[dayKey] || [])
|
||||
if (existingNotes.some(n => n.id === payload.note.id)) return {}
|
||||
return {
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[dayKey]: [...existingNotes, payload.note],
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'dayNote:updated': {
|
||||
const dayKey = String(payload.dayId)
|
||||
return {
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[dayKey]: (state.dayNotes[dayKey] || []).map(n => n.id === payload.note.id ? payload.note : n),
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'dayNote:deleted': {
|
||||
const dayKey = String(payload.dayId)
|
||||
return {
|
||||
dayNotes: {
|
||||
...state.dayNotes,
|
||||
[dayKey]: (state.dayNotes[dayKey] || []).filter(n => n.id !== payload.noteId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Packing
|
||||
case 'packing:created':
|
||||
if (state.packingItems.some(i => i.id === payload.item.id)) return {}
|
||||
return { packingItems: [...state.packingItems, payload.item] }
|
||||
case 'packing:updated':
|
||||
return {
|
||||
packingItems: state.packingItems.map(i => i.id === payload.item.id ? payload.item : i),
|
||||
}
|
||||
case 'packing:deleted':
|
||||
return {
|
||||
packingItems: state.packingItems.filter(i => i.id !== payload.itemId),
|
||||
}
|
||||
|
||||
// Budget
|
||||
case 'budget:created':
|
||||
if (state.budgetItems.some(i => i.id === payload.item.id)) return {}
|
||||
return { budgetItems: [...state.budgetItems, payload.item] }
|
||||
case 'budget:updated':
|
||||
return {
|
||||
budgetItems: state.budgetItems.map(i => i.id === payload.item.id ? payload.item : i),
|
||||
}
|
||||
case 'budget:deleted':
|
||||
return {
|
||||
budgetItems: state.budgetItems.filter(i => i.id !== payload.itemId),
|
||||
}
|
||||
|
||||
// Reservations
|
||||
case 'reservation:created':
|
||||
if (state.reservations.some(r => r.id === payload.reservation.id)) return {}
|
||||
return { reservations: [payload.reservation, ...state.reservations] }
|
||||
case 'reservation:updated':
|
||||
return {
|
||||
reservations: state.reservations.map(r => r.id === payload.reservation.id ? payload.reservation : r),
|
||||
}
|
||||
case 'reservation:deleted':
|
||||
return {
|
||||
reservations: state.reservations.filter(r => r.id !== payload.reservationId),
|
||||
}
|
||||
|
||||
// Trip
|
||||
case 'trip:updated':
|
||||
return { trip: payload.trip }
|
||||
|
||||
// Files
|
||||
case 'file:created':
|
||||
if (state.files.some(f => f.id === payload.file.id)) return {}
|
||||
return { files: [payload.file, ...state.files] }
|
||||
case 'file:updated':
|
||||
return {
|
||||
files: state.files.map(f => f.id === payload.file.id ? payload.file : f),
|
||||
}
|
||||
case 'file:deleted':
|
||||
return {
|
||||
files: state.files.filter(f => f.id !== payload.fileId),
|
||||
}
|
||||
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// Load everything for a trip
|
||||
loadTrip: async (tripId) => {
|
||||
set({ isLoading: true, error: null })
|
||||
@@ -56,7 +264,6 @@ export const useTripStore = create((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// Refresh just the places
|
||||
refreshPlaces: async (tripId) => {
|
||||
try {
|
||||
const data = await placesApi.list(tripId)
|
||||
@@ -66,7 +273,6 @@ export const useTripStore = create((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// Places
|
||||
addPlace: async (tripId, placeData) => {
|
||||
try {
|
||||
const data = await placesApi.create(tripId, placeData)
|
||||
@@ -112,13 +318,11 @@ export const useTripStore = create((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// Assignments
|
||||
assignPlaceToDay: async (tripId, dayId, placeId, position) => {
|
||||
const state = get()
|
||||
const place = state.places.find(p => p.id === parseInt(placeId))
|
||||
if (!place) return
|
||||
|
||||
// Check if already assigned
|
||||
const existing = (state.assignments[String(dayId)] || []).find(a => a.place?.id === parseInt(placeId))
|
||||
if (existing) return
|
||||
|
||||
@@ -154,11 +358,29 @@ export const useTripStore = create((set, get) => ({
|
||||
),
|
||||
}
|
||||
}))
|
||||
// Reihenfolge am Server aktualisieren
|
||||
// Reihenfolge am Server aktualisieren und lokalen State mit Server-Antwort synchronisieren
|
||||
if (position != null) {
|
||||
const updated = get().assignments[String(dayId)] || []
|
||||
const orderedIds = updated.map(a => a.id)
|
||||
try { await assignmentsApi.reorder(tripId, dayId, orderedIds) } catch {}
|
||||
const orderedIds = updated.map(a => a.id).filter(id => id > 0)
|
||||
if (orderedIds.length > 0) {
|
||||
try {
|
||||
await assignmentsApi.reorder(tripId, dayId, orderedIds)
|
||||
// Lokalen State auf die gesendete Reihenfolge setzen
|
||||
set(state => {
|
||||
const items = state.assignments[String(dayId)] || []
|
||||
const reordered = orderedIds.map((id, idx) => {
|
||||
const item = items.find(a => a.id === id)
|
||||
return item ? { ...item, order_index: idx } : null
|
||||
}).filter(Boolean)
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[String(dayId)]: reordered,
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return data.assignment
|
||||
} catch (err) {
|
||||
@@ -251,7 +473,6 @@ export const useTripStore = create((set, get) => ({
|
||||
const note = (state.dayNotes[String(fromDayId)] || []).find(n => n.id === noteId)
|
||||
if (!note) return
|
||||
|
||||
// Optimistic: remove from old day
|
||||
set(s => ({
|
||||
dayNotes: {
|
||||
...s.dayNotes,
|
||||
@@ -271,7 +492,6 @@ export const useTripStore = create((set, get) => ({
|
||||
}
|
||||
}))
|
||||
} catch (err) {
|
||||
// Rollback
|
||||
set(s => ({
|
||||
dayNotes: {
|
||||
...s.dayNotes,
|
||||
@@ -286,7 +506,6 @@ export const useTripStore = create((set, get) => ({
|
||||
set({ assignments })
|
||||
},
|
||||
|
||||
// Packing
|
||||
addPackingItem: async (tripId, data) => {
|
||||
try {
|
||||
const result = await packingApi.create(tripId, data)
|
||||
@@ -337,7 +556,6 @@ export const useTripStore = create((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// Days
|
||||
updateDayNotes: async (tripId, dayId, notes) => {
|
||||
try {
|
||||
await daysApi.update(tripId, dayId, { notes })
|
||||
@@ -360,7 +578,6 @@ export const useTripStore = create((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// Tags and categories
|
||||
addTag: async (data) => {
|
||||
try {
|
||||
const result = await tagsApi.create(data)
|
||||
@@ -381,7 +598,6 @@ export const useTripStore = create((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// Update trip
|
||||
updateTrip: async (tripId, data) => {
|
||||
try {
|
||||
const result = await tripsApi.update(tripId, data)
|
||||
@@ -400,7 +616,6 @@ export const useTripStore = create((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// Budget
|
||||
loadBudgetItems: async (tripId) => {
|
||||
try {
|
||||
const data = await budgetApi.list(tripId)
|
||||
@@ -443,7 +658,6 @@ export const useTripStore = create((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// Files
|
||||
loadFiles: async (tripId) => {
|
||||
try {
|
||||
const data = await filesApi.list(tripId)
|
||||
@@ -472,7 +686,6 @@ export const useTripStore = create((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// Reservations
|
||||
loadReservations: async (tripId) => {
|
||||
try {
|
||||
const data = await reservationsApi.list(tripId)
|
||||
@@ -528,7 +741,6 @@ export const useTripStore = create((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// Day Notes
|
||||
addDayNote: async (tripId, dayId, data) => {
|
||||
try {
|
||||
const result = await dayNotesApi.create(tripId, dayId, data)
|
||||
|
||||
@@ -13,6 +13,10 @@ export default defineConfig({
|
||||
'/uploads': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'http://localhost:3001',
|
||||
ws: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user