diff --git a/client/package-lock.json b/client/package-lock.json index f4fc143..8e63feb 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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 ", + "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", diff --git a/client/package.json b/client/package.json index 9431714..727484e 100644 --- a/client/package.json +++ b/client/package.json @@ -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" }, diff --git a/client/src/api/client.js b/client/src/api/client.js index 6850ecc..17958a5 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -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), diff --git a/client/src/api/websocket.js b/client/src/api/websocket.js new file mode 100644 index 0000000..d17a0b1 --- /dev/null +++ b/client/src/api/websocket.js @@ -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) +} diff --git a/client/src/components/Budget/BudgetPanel.jsx b/client/src/components/Budget/BudgetPanel.jsx index eca7328..a7323ad 100644 --- a/client/src/components/Budget/BudgetPanel.jsx +++ b/client/src/components/Budget/BudgetPanel.jsx @@ -37,7 +37,7 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder return 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 || '-'} @@ -101,7 +101,7 @@ function AddItemRow({ onAdd, t }) { @@ -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 */}

{t('budget.emptyTitle')}

{t('budget.emptyText')}

-
+
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 }} />
@@ -223,7 +222,6 @@ export default function BudgetPanel({ tripId }) { // ── Main Layout ────────────────────────────────────────────────────────── return (
- {/* Header */}
@@ -231,10 +229,7 @@ export default function BudgetPanel({ tripId }) {
- {/* Main: table + sidebar */}
- - {/* Left: Tables */}
{categoryNames.map((cat, ci) => { const items = grouped[cat] @@ -243,7 +238,6 @@ export default function BudgetPanel({ tripId }) { return (
- {/* Category header */}
@@ -259,7 +253,6 @@ export default function BudgetPanel({ tripId }) {
- {/* Table */}
@@ -317,9 +310,7 @@ export default function BudgetPanel({ tripId }) { })} - {/* Right: Sidebar */}
- {/* Currency selector */}
- {/* Add category */}
- {/* Grand total card */}
{SYMBOLS[currency] || currency} {currency}
- {/* Pie chart card */} {pieSegments.length > 0 && (
- {/* Legend */}
{pieSegments.map(seg => { const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0' @@ -389,7 +376,6 @@ export default function BudgetPanel({ tripId }) { })}
- {/* Category amounts */}
{pieSegments.map(seg => (
diff --git a/client/src/components/Map/MapView.jsx b/client/src/components/Map/MapView.jsx index 391d892..98b4664 100644 --- a/client/src/components/Map/MapView.jsx +++ b/client/src/components/Map/MapView.jsx @@ -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({ - {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) + { + const count = cluster.getChildCount() + const size = count < 10 ? 36 : count < 50 ? 42 : 48 + return L.divIcon({ + html: `
+ ${count} +
`, + 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 ( - onMarkerClick && onMarkerClick(place.id), - }} - zIndexOffset={isSelected ? 1000 : 0} - > - onMarkerClick && onMarkerClick(place.id), + }} + zIndexOffset={isSelected ? 1000 : 0} > -
-
- {place.name} -
- {place.category_name && (() => { - const CatIcon = getCategoryIcon(place.category_icon) - return ( -
- - {place.category_name} -
- ) - })()} - {place.address && ( -
- {place.address} + +
+
+ {place.name}
- )} -
-
- - ) - })} + {place.category_name && (() => { + const CatIcon = getCategoryIcon(place.category_icon) + return ( +
+ + {place.category_name} +
+ ) + })()} + {place.address && ( +
+ {place.address} +
+ )} +
+ + + ) + })} + {route && route.length > 1 && ( = 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 */} - {/* Name */} {editing ? ( )} - {/* Actions — always in DOM, visible on hover */}
- {/* Category change */}
{showCatPicker && (
- + {cat} ))} @@ -154,13 +163,11 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange }) { )}
- {/* Edit */} - {/* Delete */}
- {/* Items */} {offen && (
{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 })} + {t('packing.clearChecked', { count: abgehakt })} + {t('packing.clearCheckedShort', { count: abgehakt })} )}
- {/* Fortschrittsbalken */} - {items.length > 0 && ( + {items.length > 0 && (
)} - {/* Artikel hinzufügen */}
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 */}
e.currentTarget.style.background = 'var(--bg-tertiary)'} onMouseLeave={e => e.currentTarget.style.background = 'none'} > - + {cat} ))}
)}
- @@ -489,8 +487,8 @@ export default function PackingListPanel({ tripId, items }) { ))}
diff --git a/client/src/components/Planner/DayPlanSidebar.jsx b/client/src/components/Planner/DayPlanSidebar.jsx index 9e241df..ee43083 100644 --- a/client/src/components/Planner/DayPlanSidebar.jsx +++ b/client/src/components/Planner/DayPlanSidebar.jsx @@ -661,7 +661,7 @@ export default function DayPlanSidebar({
)}
-
+
@@ -736,14 +736,14 @@ export default function DayPlanSidebar({
{note.time}
)}
-
- - -
-
+
+
+ + +
) diff --git a/client/src/components/Planner/PlaceInspector.jsx b/client/src/components/Planner/PlaceInspector.jsx index 8192f85..527e5c4 100644 --- a/client/src/components/Planner/PlaceInspector.jsx +++ b/client/src/components/Planner/PlaceInspector.jsx @@ -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]) diff --git a/client/src/components/Planner/PlacesSidebar.jsx b/client/src/components/Planner/PlacesSidebar.jsx index 9af7e77..6641cf4 100644 --- a/client/src/components/Planner/PlacesSidebar.jsx +++ b/client/src/components/Planner/PlacesSidebar.jsx @@ -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({ }) )}
+ + {dayPickerPlace && days?.length > 0 && ReactDOM.createPortal( +
setDayPickerPlace(null)} + style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }} + > +
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)' }} + > +
+
{dayPickerPlace.name}
+
{t('places.assignToDay')}
+
+
+ {days.map((day, i) => { + const alreadyAssigned = (assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id) + return ( + + ) + })} +
+
+
, + document.body + )}
) } diff --git a/client/src/components/Planner/PlannerSidebar.jsx b/client/src/components/Planner/PlannerSidebar.jsx index 8c60208..8bf400d 100644 --- a/client/src/components/Planner/PlannerSidebar.jsx +++ b/client/src/components/Planner/PlannerSidebar.jsx @@ -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 (
- {/* Trip header */}
- {/* Segmented control */}
{SEGMENTS.map(seg => ( @@ -321,13 +330,11 @@ export default function PlannerSidebar({
- {/* Scrollable content */}
{/* ── PLAN ── */} {activeSegment === 'plan' && (
- {/* Alle Orte */}
) : ( -
- {filteredPlaces.map(place => { - const category = categories.find(c => c.id === place.category_id) - const inDay = isAssignedToDay(place.id) - const isSelected = place.id === selectedPlaceId - return ( -
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' - }`} - > +
+ + {({ 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 (
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 ? ( - {place.name} - ) : ( - {category?.icon || '📍'} - )} -
-
-
- {place.name} -
- {inDay - ? - : selectedDayId && ( - - ) - } -
+
+ {place.image_url ? ( + {place.name} + ) : ( + {category?.icon || '📍'} + )} +
+
+
+ {place.name} +
+ {inDay + ? + : selectedDayId && ( + + ) + } +
+
+ {category &&

{category.icon} {category.name}

} + {place.address &&

{place.address}

}
- {category &&

{category.icon} {category.name}

} - {place.address &&

{place.address}

}
-
- ) - })} + ) + }} +
)}
@@ -878,7 +889,6 @@ export default function PlannerSidebar({
)} - {/* Reservation modal */} { setShowReservationModal(false); setEditingReservation(null) }} diff --git a/client/src/components/Planner/ReservationModal.jsx b/client/src/components/Planner/ReservationModal.jsx index 7c0990a..8a3c640 100644 --- a/client/src/components/Planner/ReservationModal.jsx +++ b/client/src/components/Planner/ReservationModal.jsx @@ -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 ( @@ -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)', }}> {t(labelKey)} @@ -231,14 +231,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
{attachedFiles.map(f => ( -
- - {f.original_name} - +
+ + {f.original_name} + {onFileDelete && ( -
))} {pendingFiles.map((f, i) => ( -
- - {f.name} - {t('reservations.pendingSave')} +
+ + {f.name} + {t('reservations.pendingSave')} @@ -275,11 +275,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
{/* Actions */} -
- -
diff --git a/client/src/components/Planner/ReservationsPanel.jsx b/client/src/components/Planner/ReservationsPanel.jsx index f708194..d40bbd2 100644 --- a/client/src/components/Planner/ReservationsPanel.jsx +++ b/client/src/components/Planner/ReservationsPanel.jsx @@ -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
@@ -188,7 +186,7 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo @@ -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', }}> - {t('reservations.addManual')} + {t('reservations.addManual')}
diff --git a/client/src/components/Trips/TripFormModal.jsx b/client/src/components/Trips/TripFormModal.jsx index 869bdcb..a3f3cff 100644 --- a/client/src/components/Trips/TripFormModal.jsx +++ b/client/src/components/Trips/TripFormModal.jsx @@ -172,13 +172,13 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp - update('start_date', v)} placeholder="Start" /> + update('start_date', v)} placeholder={t('dashboard.startDate')} />
- update('end_date', v)} placeholder="End" /> + update('end_date', v)} placeholder={t('dashboard.endDate')} />
diff --git a/client/src/components/Weather/WeatherWidget.jsx b/client/src/components/Weather/WeatherWidget.jsx index dfaa0b4..e1e787c 100644 --- a/client/src/components/Weather/WeatherWidget.jsx +++ b/client/src/components/Weather/WeatherWidget.jsx @@ -20,7 +20,17 @@ function WeatherIcon({ main, size = 13 }) { return } -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]) diff --git a/client/src/components/shared/CustomDateTimePicker.jsx b/client/src/components/shared/CustomDateTimePicker.jsx index bdb649b..26bd333 100644 --- a/client/src/components/shared/CustomDateTimePicker.jsx +++ b/client/src/components/shared/CustomDateTimePicker.jsx @@ -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)' }}> - {displayValue || placeholder || 'Datum'} + {displayValue || placeholder || t('common.date')} {open && ReactDOM.createPortal( diff --git a/client/src/i18n/translations/de.js b/client/src/i18n/translations/de.js index 97e9192..e63e35f 100644 --- a/client/src/i18n/translations/de.js +++ b/client/src/i18n/translations/de.js @@ -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', diff --git a/client/src/i18n/translations/en.js b/client/src/i18n/translations/en.js index 10941ed..da2fba9 100644 --- a/client/src/i18n/translations/en.js +++ b/client/src/i18n/translations/en.js @@ -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', diff --git a/client/src/index.css b/client/src/index.css index 46b3aae..837000d 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -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; diff --git a/client/src/pages/AdminPage.jsx b/client/src/pages/AdminPage.jsx index 69ca681..33a8a59 100644 --- a/client/src/pages/AdminPage.jsx +++ b/client/src/pages/AdminPage.jsx @@ -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' && (
-
+

{t('admin.tabs.users')} ({users.length})

+
{isLoading ? ( @@ -464,6 +490,74 @@ export default function AdminPage() {
+ {/* Create user modal */} + setShowCreateUser(false)} + title={t('admin.createUser')} + size="sm" + footer={ +
+ + +
+ } + > +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + setCreateForm(f => ({ ...f, role: value }))} + options={[ + { value: 'user', label: t('settings.roleUser') }, + { value: 'admin', label: t('settings.roleAdmin') }, + ]} + /> +
+
+
+ {/* Edit user modal */}
- + onChange={value => setEditForm(f => ({ ...f, role: value }))} + options={[ + { value: 'user', label: t('settings.roleUser') }, + { value: 'admin', label: t('settings.roleAdmin') }, + ]} + />
)} diff --git a/client/src/pages/LoginPage.jsx b/client/src/pages/LoginPage.jsx index 33ad986..76179d8 100644 --- a/client/src/pages/LoginPage.jsx +++ b/client/src/pages/LoginPage.jsx @@ -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() { {/* Left — branding */} -
- {/* Logo */} -
-
- -
- NOMAD + {/* Stars */} +
+ {Array.from({ length: 40 }, (_, i) => ( +
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`, + }} /> + ))}
-
-

+ {/* Animated glow orbs */} +
+
+ + {/* Animated planes — realistic silhouettes at different sizes/speeds */} +
+ {/* Plane 1 — large, slow, foreground */} + + + + + + + + + + + + {/* Plane 2 — small, faster, higher */} + + + + + + + + + + {/* Plane 3 — medium, mid-speed */} + + + + + + + + + + + + {/* Plane 4 — tiny, fast, high */} + + + + + + + + + + {/* Plane 5 — medium, right to left, lower */} + + + + + + + + + + + + {/* Plane 6 — tiny distant */} + + + + + + + + +
+ + +
+ {/* Logo */} +
+
+ +
+ NOMAD +
+ +

{t('login.tagline')}

-

+

{t('login.description')}

-
+
{[ - { Icon: MapPin, label: t('login.features.places') }, - { Icon: Calendar, label: t('login.features.schedule') }, - { Icon: Package, label: t('login.features.packing') }, - ].map(({ Icon, label }) => ( -
- -
{label}
+ { 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 }) => ( +
{ 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)' }}> + +
{label}
+
{desc}
))}
+ +

+ {t('login.selfHosted')} +

@@ -168,7 +271,7 @@ export default function LoginPage() { 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() {
- +
) } diff --git a/client/src/pages/TripPlannerPage.jsx b/client/src/pages/TripPlannerPage.jsx index d68f4af..f990eab 100644 --- a/client/src/pages/TripPlannerPage.jsx +++ b/client/src/pages/TripPlannerPage.jsx @@ -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() {
navigate('/dashboard')} onShare={() => setShowMembersModal(true)} /> - {/* Tab bar */}
- {/* Content — offset by navbar (56px) + tab bar (44px) */} + {/* Offset by navbar (56px) + tab bar (44px) */}
- {/* PLAN MODE */} {activeTab === 'plan' && (
- {/* Map fills entire space */} - {/* Route info overlay */} {routeInfo && (
)} - {/* LEFT SIDEBAR — glass, absolute, floating rounded */}
- {/* Collapse toggle — am rechten Rand der Sidebar, halb herausstehend */}
- {/* RIGHT SIDEBAR — glass, absolute, floating rounded */}
- {/* Collapse toggle — am linken Rand der Sidebar, halb herausstehend */}
- {/* Mobile controls */}
- {/* Bottom inspector */} {selectedPlace && ( )} - {/* Mobile bottom sheet */} {mobileSidebarOpen && (
setMobileSidebarOpen(null)}> -
e.stopPropagation()}> -
- {mobileSidebarOpen === 'left' ? t('trip.mobilePlan') : t('trip.mobilePlaces')} -
{mobileSidebarOpen === 'left' ? { 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) }} /> - : { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} /> + : { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile /> }
@@ -475,7 +471,6 @@ export default function TripPlannerPage() {
)} - {/* BUCHUNGEN */} {activeTab === 'buchungen' && (
)} - {/* PACKLISTE */} {activeTab === 'packliste' && (
)} - {/* FINANZPLAN */} {activeTab === 'finanzplan' && (
)} - {/* DATEIEN */} {activeTab === 'dateien' && (
({ 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({ diff --git a/client/src/store/tripStore.js b/client/src/store/tripStore.js index cda6898..faaa2f0 100644 --- a/client/src/store/tripStore.js +++ b/client/src/store/tripStore.js @@ -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) diff --git a/client/vite.config.js b/client/vite.config.js index 5aabf92..1ca1c15 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -13,6 +13,10 @@ export default defineConfig({ '/uploads': { target: 'http://localhost:3001', changeOrigin: true, + }, + '/ws': { + target: 'http://localhost:3001', + ws: true, } } } diff --git a/server/package-lock.json b/server/package-lock.json index aeb1a85..638de08 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -18,7 +18,8 @@ "node-cron": "^4.2.1", "node-fetch": "^2.7.0", "unzipper": "^0.12.3", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "ws": "^8.19.0" }, "devDependencies": { "nodemon": "^3.1.0" @@ -2106,6 +2107,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/server/package.json b/server/package.json index 91a6546..8deb258 100644 --- a/server/package.json +++ b/server/package.json @@ -17,7 +17,8 @@ "node-cron": "^4.2.1", "node-fetch": "^2.7.0", "unzipper": "^0.12.3", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "ws": "^8.19.0" }, "devDependencies": { "nodemon": "^3.1.0" diff --git a/server/src/config.js b/server/src/config.js index fc9bd36..2a8f4bc 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -1,10 +1,28 @@ const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); let JWT_SECRET = process.env.JWT_SECRET; if (!JWT_SECRET) { - JWT_SECRET = crypto.randomBytes(32).toString('hex'); - console.warn('WARNING: No JWT_SECRET set — using auto-generated secret. Sessions will reset on server restart. Set JWT_SECRET for persistent sessions.'); + // Try to read a persisted secret from disk + const dataDir = path.resolve(__dirname, '../data'); + const secretFile = path.join(dataDir, '.jwt_secret'); + + try { + JWT_SECRET = fs.readFileSync(secretFile, 'utf8').trim(); + } catch { + // File doesn't exist yet — generate and persist a new secret + JWT_SECRET = crypto.randomBytes(32).toString('hex'); + try { + if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync(secretFile, JWT_SECRET, { mode: 0o600 }); + console.log('Generated and saved JWT secret to', secretFile); + } catch (writeErr) { + console.warn('WARNING: Could not persist JWT secret to disk:', writeErr.message); + console.warn('Sessions will reset on server restart. Set JWT_SECRET env var for persistent sessions.'); + } + } } module.exports = { JWT_SECRET }; diff --git a/server/src/db/database.js b/server/src/db/database.js index fc47ebf..5256908 100644 --- a/server/src/db/database.js +++ b/server/src/db/database.js @@ -21,6 +21,7 @@ function initDb() { _db = new DatabaseSync(dbPath); _db.exec('PRAGMA journal_mode = WAL'); + _db.exec('PRAGMA busy_timeout = 5000'); _db.exec('PRAGMA foreign_keys = ON'); // Create all tables @@ -212,6 +213,26 @@ function initDb() { ); `); + // Create indexes for performance + _db.exec(` + CREATE INDEX IF NOT EXISTS idx_places_trip_id ON places(trip_id); + CREATE INDEX IF NOT EXISTS idx_places_category_id ON places(category_id); + CREATE INDEX IF NOT EXISTS idx_days_trip_id ON days(trip_id); + CREATE INDEX IF NOT EXISTS idx_day_assignments_day_id ON day_assignments(day_id); + CREATE INDEX IF NOT EXISTS idx_day_assignments_place_id ON day_assignments(place_id); + CREATE INDEX IF NOT EXISTS idx_place_tags_place_id ON place_tags(place_id); + CREATE INDEX IF NOT EXISTS idx_place_tags_tag_id ON place_tags(tag_id); + CREATE INDEX IF NOT EXISTS idx_trip_members_trip_id ON trip_members(trip_id); + CREATE INDEX IF NOT EXISTS idx_trip_members_user_id ON trip_members(user_id); + CREATE INDEX IF NOT EXISTS idx_packing_items_trip_id ON packing_items(trip_id); + CREATE INDEX IF NOT EXISTS idx_budget_items_trip_id ON budget_items(trip_id); + CREATE INDEX IF NOT EXISTS idx_reservations_trip_id ON reservations(trip_id); + CREATE INDEX IF NOT EXISTS idx_trip_files_trip_id ON trip_files(trip_id); + CREATE INDEX IF NOT EXISTS idx_day_notes_day_id ON day_notes(day_id); + CREATE INDEX IF NOT EXISTS idx_photos_trip_id ON photos(trip_id); + CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + `); + // Migrations const migrations = [ `ALTER TABLE users ADD COLUMN unsplash_api_key TEXT`, diff --git a/server/src/index.js b/server/src/index.js index 1d25bda..4f3741d 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -22,13 +22,24 @@ const tmpDir = path.join(__dirname, '../data/tmp'); const allowedOrigins = process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(',') : null; + +let corsOrigin; +if (allowedOrigins) { + // Explicit whitelist from env var + corsOrigin = (origin, callback) => { + if (!origin || allowedOrigins.includes(origin)) callback(null, true); + else callback(new Error('Not allowed by CORS')); + }; +} else if (process.env.NODE_ENV === 'production') { + // Production: same-origin only (Express serves the static client) + corsOrigin = false; +} else { + // Development: allow all origins (needed for Vite dev server) + corsOrigin = true; +} + app.use(cors({ - origin: allowedOrigins - ? (origin, callback) => { - if (!origin || allowedOrigins.includes(origin)) callback(null, true); - else callback(new Error('Not allowed by CORS')); - } - : true, + origin: corsOrigin, credentials: true })); app.use(express.json()); @@ -101,10 +112,12 @@ app.use((err, req, res, next) => { const scheduler = require('./scheduler'); const PORT = process.env.PORT || 3001; -app.listen(PORT, () => { +const server = app.listen(PORT, () => { console.log(`NOMAD API running on port ${PORT}`); console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); scheduler.start(); + const { setupWebSocket } = require('./websocket'); + setupWebSocket(server); }); module.exports = app; diff --git a/server/src/routes/admin.js b/server/src/routes/admin.js index de34f87..682847d 100644 --- a/server/src/routes/admin.js +++ b/server/src/routes/admin.js @@ -11,11 +11,42 @@ router.use(authenticate, adminOnly); // GET /api/admin/users router.get('/users', (req, res) => { const users = db.prepare( - 'SELECT id, username, email, role, maps_api_key, unsplash_api_key, openweather_api_key, created_at, updated_at FROM users ORDER BY created_at DESC' + 'SELECT id, username, email, role, created_at, updated_at FROM users ORDER BY created_at DESC' ).all(); res.json({ users }); }); +// POST /api/admin/users +router.post('/users', (req, res) => { + const { username, email, password, role } = req.body; + + if (!username?.trim() || !email?.trim() || !password?.trim()) { + return res.status(400).json({ error: 'Benutzername, E-Mail und Passwort sind erforderlich' }); + } + + if (role && !['user', 'admin'].includes(role)) { + return res.status(400).json({ error: 'Ungültige Rolle' }); + } + + const existingUsername = db.prepare('SELECT id FROM users WHERE username = ?').get(username.trim()); + if (existingUsername) return res.status(409).json({ error: 'Benutzername bereits vergeben' }); + + const existingEmail = db.prepare('SELECT id FROM users WHERE email = ?').get(email.trim()); + if (existingEmail) return res.status(409).json({ error: 'E-Mail bereits vergeben' }); + + const passwordHash = bcrypt.hashSync(password.trim(), 10); + + const result = db.prepare( + 'INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)' + ).run(username.trim(), email.trim(), passwordHash, role || 'user'); + + const user = db.prepare( + 'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?' + ).get(result.lastInsertRowid); + + res.status(201).json({ user }); +}); + // PUT /api/admin/users/:id router.put('/users/:id', (req, res) => { const { username, email, role, password } = req.body; diff --git a/server/src/routes/assignments.js b/server/src/routes/assignments.js index b602f4c..bbff7d7 100644 --- a/server/src/routes/assignments.js +++ b/server/src/routes/assignments.js @@ -1,6 +1,7 @@ const express = require('express'); const { db, canAccessTrip } = require('../db/database'); const { authenticate } = require('../middleware/auth'); +const { broadcast } = require('../websocket'); const router = express.Router({ mergeParams: true }); @@ -90,11 +91,23 @@ router.get('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) => ORDER BY da.order_index ASC, da.created_at ASC `).all(dayId); - const result = assignments.map(a => { - const tags = db.prepare(` - SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ? - `).all(a.place_id); + // Batch-load all tags for all places in one query to avoid N+1 + const placeIds = [...new Set(assignments.map(a => a.place_id))]; + const tagsByPlaceId = {}; + if (placeIds.length > 0) { + const placeholders = placeIds.map(() => '?').join(','); + const allTags = db.prepare(` + SELECT t.*, pt.place_id FROM tags t + JOIN place_tags pt ON t.id = pt.tag_id + WHERE pt.place_id IN (${placeholders}) + `).all(...placeIds); + for (const tag of allTags) { + if (!tagsByPlaceId[tag.place_id]) tagsByPlaceId[tag.place_id] = []; + tagsByPlaceId[tag.place_id].push({ id: tag.id, name: tag.name, color: tag.color, created_at: tag.created_at }); + } + } + const result = assignments.map(a => { return { id: a.id, day_id: a.day_id, @@ -128,7 +141,7 @@ router.get('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) => color: a.category_color, icon: a.category_icon, } : null, - tags, + tags: tagsByPlaceId[a.place_id] || [], } }; }); @@ -150,7 +163,6 @@ router.post('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) = const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId); if (!place) return res.status(404).json({ error: 'Ort nicht gefunden' }); - // Check for duplicate const existing = db.prepare('SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ?').get(dayId, place_id); if (existing) return res.status(409).json({ error: 'Ort ist bereits diesem Tag zugewiesen' }); @@ -163,6 +175,7 @@ router.post('/trips/:tripId/days/:dayId/assignments', authenticate, (req, res) = const assignment = getAssignmentWithPlace(result.lastInsertRowid); res.status(201).json({ assignment }); + broadcast(tripId, 'assignment:created', { assignment }, req.headers['x-socket-id']); }); // DELETE /api/trips/:tripId/days/:dayId/assignments/:id @@ -180,6 +193,7 @@ router.delete('/trips/:tripId/days/:dayId/assignments/:id', authenticate, (req, db.prepare('DELETE FROM day_assignments WHERE id = ?').run(id); res.json({ success: true }); + broadcast(tripId, 'assignment:deleted', { assignmentId: Number(id), dayId: Number(dayId) }, req.headers['x-socket-id']); }); // PUT /api/trips/:tripId/days/:dayId/assignments/reorder @@ -205,6 +219,7 @@ router.put('/trips/:tripId/days/:dayId/assignments/reorder', authenticate, (req, throw e; } res.json({ success: true }); + broadcast(tripId, 'assignment:reordered', { dayId: Number(dayId), orderedIds }, req.headers['x-socket-id']); }); // PUT /api/trips/:tripId/assignments/:id/move @@ -226,10 +241,12 @@ router.put('/trips/:tripId/assignments/:id/move', authenticate, (req, res) => { const newDay = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(new_day_id, tripId); if (!newDay) return res.status(404).json({ error: 'Zieltag nicht gefunden' }); + const oldDayId = assignment.day_id; db.prepare('UPDATE day_assignments SET day_id = ?, order_index = ? WHERE id = ?').run(new_day_id, order_index || 0, id); const updated = getAssignmentWithPlace(id); res.json({ assignment: updated }); + broadcast(tripId, 'assignment:moved', { assignment: updated, oldDayId: Number(oldDayId), newDayId: Number(new_day_id) }, req.headers['x-socket-id']); }); module.exports = router; diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index ec55dc4..289725f 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -19,9 +19,13 @@ const avatarStorage = multer.diskStorage({ destination: (req, file, cb) => cb(null, avatarDir), filename: (req, file, cb) => cb(null, uuid() + path.extname(file.originalname)) }); +const ALLOWED_AVATAR_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; const avatarUpload = multer({ storage: avatarStorage, limits: { fileSize: 5 * 1024 * 1024 }, fileFilter: (req, file, cb) => { - if (file.mimetype.startsWith('image/')) cb(null, true); - else cb(new Error('Only images allowed')); + const ext = path.extname(file.originalname).toLowerCase(); + if (!file.mimetype.startsWith('image/') || !ALLOWED_AVATAR_EXTS.includes(ext)) { + return cb(new Error('Only .jpg, .jpeg, .png, .gif, .webp images are allowed')); + } + cb(null, true); }}); // Simple rate limiter @@ -90,7 +94,7 @@ router.post('/register', authLimiter, (req, res) => { return res.status(400).json({ error: 'Invalid email format' }); } - const existingUser = db.prepare('SELECT id FROM users WHERE email = ? OR username = ?').get(email, username); + const existingUser = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?) OR LOWER(username) = LOWER(?)').get(email, username); if (existingUser) { return res.status(409).json({ error: 'A user with this email or username already exists' }); } @@ -123,7 +127,7 @@ router.post('/login', authLimiter, (req, res) => { return res.status(400).json({ error: 'E-Mail und Passwort sind erforderlich' }); } - const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email); + const user = db.prepare('SELECT * FROM users WHERE LOWER(email) = LOWER(?)').get(email); if (!user) { return res.status(401).json({ error: 'Ungültige E-Mail oder Passwort' }); } @@ -134,15 +138,15 @@ router.post('/login', authLimiter, (req, res) => { } const token = generateToken(user); - const { password_hash, ...userWithoutPassword } = user; + const { password_hash, maps_api_key, openweather_api_key, unsplash_api_key, ...userWithoutSensitive } = user; - res.json({ token, user: { ...userWithoutPassword, avatar_url: avatarUrl(user) } }); + res.json({ token, user: { ...userWithoutSensitive, avatar_url: avatarUrl(user) } }); }); // GET /api/auth/me router.get('/me', authenticate, (req, res) => { const user = db.prepare( - 'SELECT id, username, email, role, maps_api_key, openweather_api_key, avatar, created_at FROM users WHERE id = ?' + 'SELECT id, username, email, role, avatar, created_at FROM users WHERE id = ?' ).get(req.user.id); if (!user) { @@ -207,13 +211,14 @@ router.put('/me/settings', authenticate, (req, res) => { res.json({ success: true, user: { ...updated, avatar_url: avatarUrl(updated) } }); }); -// GET /api/auth/me/settings +// GET /api/auth/me/settings (admin only — returns API keys) router.get('/me/settings', authenticate, (req, res) => { const user = db.prepare( - 'SELECT maps_api_key, openweather_api_key FROM users WHERE id = ?' + 'SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?' ).get(req.user.id); + if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' }); - res.json({ settings: user }); + res.json({ settings: { maps_api_key: user.maps_api_key, openweather_api_key: user.openweather_api_key } }); }); // POST /api/auth/avatar — upload avatar diff --git a/server/src/routes/budget.js b/server/src/routes/budget.js index 8a390c1..a21e8ed 100644 --- a/server/src/routes/budget.js +++ b/server/src/routes/budget.js @@ -1,6 +1,7 @@ const express = require('express'); const { db, canAccessTrip } = require('../db/database'); const { authenticate } = require('../middleware/auth'); +const { broadcast } = require('../websocket'); const router = express.Router({ mergeParams: true }); @@ -50,6 +51,7 @@ router.post('/', authenticate, (req, res) => { const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid); res.status(201).json({ item }); + broadcast(tripId, 'budget:created', { item }, req.headers['x-socket-id']); }); // PUT /api/trips/:tripId/budget/:id @@ -86,6 +88,7 @@ router.put('/:id', authenticate, (req, res) => { const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id); res.json({ item: updated }); + broadcast(tripId, 'budget:updated', { item: updated }, req.headers['x-socket-id']); }); // DELETE /api/trips/:tripId/budget/:id @@ -100,6 +103,7 @@ router.delete('/:id', authenticate, (req, res) => { db.prepare('DELETE FROM budget_items WHERE id = ?').run(id); res.json({ success: true }); + broadcast(tripId, 'budget:deleted', { itemId: Number(id) }, req.headers['x-socket-id']); }); module.exports = router; diff --git a/server/src/routes/dayNotes.js b/server/src/routes/dayNotes.js index 0f34967..c55a911 100644 --- a/server/src/routes/dayNotes.js +++ b/server/src/routes/dayNotes.js @@ -1,6 +1,7 @@ const express = require('express'); const { db, canAccessTrip } = require('../db/database'); const { authenticate } = require('../middleware/auth'); +const { broadcast } = require('../websocket'); const router = express.Router({ mergeParams: true }); @@ -37,6 +38,7 @@ router.post('/', authenticate, (req, res) => { const note = db.prepare('SELECT * FROM day_notes WHERE id = ?').get(result.lastInsertRowid); res.status(201).json({ note }); + broadcast(tripId, 'dayNote:created', { dayId: Number(dayId), note }, req.headers['x-socket-id']); }); // PUT /api/trips/:tripId/days/:dayId/notes/:id @@ -60,6 +62,7 @@ router.put('/:id', authenticate, (req, res) => { const updated = db.prepare('SELECT * FROM day_notes WHERE id = ?').get(id); res.json({ note: updated }); + broadcast(tripId, 'dayNote:updated', { dayId: Number(dayId), note: updated }, req.headers['x-socket-id']); }); // DELETE /api/trips/:tripId/days/:dayId/notes/:id @@ -72,6 +75,7 @@ router.delete('/:id', authenticate, (req, res) => { db.prepare('DELETE FROM day_notes WHERE id = ?').run(id); res.json({ success: true }); + broadcast(tripId, 'dayNote:deleted', { noteId: Number(id), dayId: Number(dayId) }, req.headers['x-socket-id']); }); module.exports = router; diff --git a/server/src/routes/days.js b/server/src/routes/days.js index e3e6806..864e568 100644 --- a/server/src/routes/days.js +++ b/server/src/routes/days.js @@ -1,6 +1,7 @@ const express = require('express'); const { db, canAccessTrip } = require('../db/database'); const { authenticate } = require('../middleware/auth'); +const { broadcast } = require('../websocket'); const router = express.Router({ mergeParams: true }); @@ -79,12 +80,99 @@ router.get('/', authenticate, (req, res) => { const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId); + if (days.length === 0) { + return res.json({ days: [] }); + } + + const dayIds = days.map(d => d.id); + const dayPlaceholders = dayIds.map(() => '?').join(','); + + // Load ALL assignments for all days in one query + const allAssignments = db.prepare(` + SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description, + p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency, + p.reservation_status, p.reservation_notes, p.reservation_datetime, p.place_time, p.duration_minutes, p.notes as place_notes, + p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone, + c.name as category_name, c.color as category_color, c.icon as category_icon + FROM day_assignments da + JOIN places p ON da.place_id = p.id + LEFT JOIN categories c ON p.category_id = c.id + WHERE da.day_id IN (${dayPlaceholders}) + ORDER BY da.order_index ASC, da.created_at ASC + `).all(...dayIds); + + // Batch-load ALL tags for all places across all assignments + const placeIds = [...new Set(allAssignments.map(a => a.place_id))]; + const tagsByPlaceId = {}; + if (placeIds.length > 0) { + const placePlaceholders = placeIds.map(() => '?').join(','); + const allTags = db.prepare(` + SELECT t.*, pt.place_id FROM tags t + JOIN place_tags pt ON t.id = pt.tag_id + WHERE pt.place_id IN (${placePlaceholders}) + `).all(...placeIds); + for (const tag of allTags) { + if (!tagsByPlaceId[tag.place_id]) tagsByPlaceId[tag.place_id] = []; + tagsByPlaceId[tag.place_id].push({ id: tag.id, name: tag.name, color: tag.color, created_at: tag.created_at }); + } + } + + // Group assignments by day_id + const assignmentsByDayId = {}; + for (const a of allAssignments) { + if (!assignmentsByDayId[a.day_id]) assignmentsByDayId[a.day_id] = []; + assignmentsByDayId[a.day_id].push({ + id: a.id, + day_id: a.day_id, + order_index: a.order_index, + notes: a.notes, + created_at: a.created_at, + place: { + id: a.place_id, + name: a.place_name, + description: a.place_description, + lat: a.lat, + lng: a.lng, + address: a.address, + category_id: a.category_id, + price: a.price, + currency: a.place_currency, + reservation_status: a.reservation_status, + reservation_notes: a.reservation_notes, + reservation_datetime: a.reservation_datetime, + place_time: a.place_time, + duration_minutes: a.duration_minutes, + notes: a.place_notes, + image_url: a.image_url, + transport_mode: a.transport_mode, + google_place_id: a.google_place_id, + website: a.website, + phone: a.phone, + category: a.category_id ? { + id: a.category_id, + name: a.category_name, + color: a.category_color, + icon: a.category_icon, + } : null, + tags: tagsByPlaceId[a.place_id] || [], + } + }); + } + + // Load ALL day_notes for all days in one query + const allNotes = db.prepare( + `SELECT * FROM day_notes WHERE day_id IN (${dayPlaceholders}) ORDER BY sort_order ASC, created_at ASC` + ).all(...dayIds); + const notesByDayId = {}; + for (const note of allNotes) { + if (!notesByDayId[note.day_id]) notesByDayId[note.day_id] = []; + notesByDayId[note.day_id].push(note); + } + const daysWithAssignments = days.map(day => ({ ...day, - assignments: getAssignmentsForDay(day.id), - notes_items: db.prepare( - 'SELECT * FROM day_notes WHERE day_id = ? ORDER BY sort_order ASC, created_at ASC' - ).all(day.id), + assignments: assignmentsByDayId[day.id] || [], + notes_items: notesByDayId[day.id] || [], })); res.json({ days: daysWithAssignments }); @@ -110,7 +198,9 @@ router.post('/', authenticate, (req, res) => { const day = db.prepare('SELECT * FROM days WHERE id = ?').get(result.lastInsertRowid); - res.status(201).json({ day: { ...day, assignments: [] } }); + const dayResult = { ...day, assignments: [] }; + res.status(201).json({ day: dayResult }); + broadcast(tripId, 'day:created', { day: dayResult }, req.headers['x-socket-id']); }); // PUT /api/trips/:tripId/days/:id @@ -131,7 +221,9 @@ router.put('/:id', authenticate, (req, res) => { db.prepare('UPDATE days SET notes = ?, title = ? WHERE id = ?').run(notes || null, title !== undefined ? title : day.title, id); const updatedDay = db.prepare('SELECT * FROM days WHERE id = ?').get(id); - res.json({ day: { ...updatedDay, assignments: getAssignmentsForDay(id) } }); + const dayWithAssignments = { ...updatedDay, assignments: getAssignmentsForDay(id) }; + res.json({ day: dayWithAssignments }); + broadcast(tripId, 'day:updated', { day: dayWithAssignments }, req.headers['x-socket-id']); }); // DELETE /api/trips/:tripId/days/:id @@ -150,6 +242,7 @@ router.delete('/:id', authenticate, (req, res) => { db.prepare('DELETE FROM days WHERE id = ?').run(id); res.json({ success: true }); + broadcast(tripId, 'day:deleted', { dayId: Number(id) }, req.headers['x-socket-id']); }); module.exports = router; diff --git a/server/src/routes/files.js b/server/src/routes/files.js index 6b1326a..e5e29ed 100644 --- a/server/src/routes/files.js +++ b/server/src/routes/files.js @@ -5,6 +5,7 @@ const fs = require('fs'); const { v4: uuidv4 } = require('uuid'); const { db, canAccessTrip } = require('../db/database'); const { authenticate } = require('../middleware/auth'); +const { broadcast } = require('../websocket'); const router = express.Router({ mergeParams: true }); @@ -106,6 +107,7 @@ router.post('/', authenticate, upload.single('file'), (req, res) => { WHERE f.id = ? `).get(result.lastInsertRowid); res.status(201).json({ file: formatFile(file) }); + broadcast(tripId, 'file:created', { file: formatFile(file) }, req.headers['x-socket-id']); }); // PUT /api/trips/:tripId/files/:id @@ -139,6 +141,7 @@ router.put('/:id', authenticate, (req, res) => { WHERE f.id = ? `).get(id); res.json({ file: formatFile(updated) }); + broadcast(tripId, 'file:updated', { file: formatFile(updated) }, req.headers['x-socket-id']); }); // DELETE /api/trips/:tripId/files/:id @@ -158,6 +161,7 @@ router.delete('/:id', authenticate, (req, res) => { db.prepare('DELETE FROM trip_files WHERE id = ?').run(id); res.json({ success: true }); + broadcast(tripId, 'file:deleted', { fileId: Number(id) }, req.headers['x-socket-id']); }); module.exports = router; diff --git a/server/src/routes/maps.js b/server/src/routes/maps.js index 0f8e780..149db1b 100644 --- a/server/src/routes/maps.js +++ b/server/src/routes/maps.js @@ -5,6 +5,14 @@ const { authenticate } = require('../middleware/auth'); const router = express.Router(); +// Get API key: user's own key, or fall back to any admin's key +function getMapsKey(userId) { + const user = db.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(userId); + if (user?.maps_api_key) return user.maps_api_key; + const admin = db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get(); + return admin?.maps_api_key || null; +} + // In-memory photo cache: placeId → { photoUrl, attribution, fetchedAt } const photoCache = new Map(); const PHOTO_TTL = 12 * 60 * 60 * 1000; // 12 hours @@ -15,8 +23,8 @@ router.post('/search', authenticate, async (req, res) => { if (!query) return res.status(400).json({ error: 'Suchanfrage ist erforderlich' }); - const user = db.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(req.user.id); - if (!user || !user.maps_api_key) { + const apiKey = getMapsKey(req.user.id); + if (!apiKey) { return res.status(400).json({ error: 'Google Maps API-Schlüssel nicht konfiguriert. Bitte in den Einstellungen hinzufügen.' }); } @@ -25,7 +33,7 @@ router.post('/search', authenticate, async (req, res) => { method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-Goog-Api-Key': user.maps_api_key, + 'X-Goog-Api-Key': apiKey, 'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types', }, body: JSON.stringify({ textQuery: query, languageCode: req.query.lang || 'en' }), @@ -59,8 +67,8 @@ router.post('/search', authenticate, async (req, res) => { router.get('/details/:placeId', authenticate, async (req, res) => { const { placeId } = req.params; - const user = db.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(req.user.id); - if (!user || !user.maps_api_key) { + const apiKey = getMapsKey(req.user.id); + if (!apiKey) { return res.status(400).json({ error: 'Google Maps API-Schlüssel nicht konfiguriert' }); } @@ -69,7 +77,7 @@ router.get('/details/:placeId', authenticate, async (req, res) => { const response = await fetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${lang}`, { method: 'GET', headers: { - 'X-Goog-Api-Key': user.maps_api_key, + 'X-Goog-Api-Key': apiKey, 'X-Goog-FieldMask': 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri,reviews,editorialSummary', }, }); @@ -121,8 +129,8 @@ router.get('/place-photo/:placeId', authenticate, async (req, res) => { return res.json({ photoUrl: cached.photoUrl, attribution: cached.attribution }); } - const user = db.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(req.user.id); - if (!user?.maps_api_key) { + const apiKey = getMapsKey(req.user.id); + if (!apiKey) { return res.status(400).json({ error: 'Google Maps API-Schlüssel nicht konfiguriert' }); } @@ -130,12 +138,17 @@ router.get('/place-photo/:placeId', authenticate, async (req, res) => { // Fetch place details to get photo reference const detailsRes = await fetch(`https://places.googleapis.com/v1/places/${placeId}`, { headers: { - 'X-Goog-Api-Key': user.maps_api_key, + 'X-Goog-Api-Key': apiKey, 'X-Goog-FieldMask': 'photos', }, }); const details = await detailsRes.json(); + if (!detailsRes.ok) { + console.error('Google Places photo details error:', details.error?.message || detailsRes.status); + return res.status(404).json({ error: 'Foto konnte nicht abgerufen werden' }); + } + if (!details.photos?.length) { return res.status(404).json({ error: 'Kein Foto verfügbar' }); } @@ -146,7 +159,7 @@ router.get('/place-photo/:placeId', authenticate, async (req, res) => { // Fetch the media URL (skipHttpRedirect returns JSON with photoUri) const mediaRes = await fetch( - `https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=600&key=${user.maps_api_key}&skipHttpRedirect=true` + `https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=600&key=${apiKey}&skipHttpRedirect=true` ); const mediaData = await mediaRes.json(); const photoUrl = mediaData.photoUri; @@ -156,6 +169,17 @@ router.get('/place-photo/:placeId', authenticate, async (req, res) => { } photoCache.set(placeId, { photoUrl, attribution, fetchedAt: Date.now() }); + + // Persist the photo URL to all places with this google_place_id so future + // loads serve image_url directly without hitting the Google API again. + try { + db.prepare( + 'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = ?)' + ).run(photoUrl, placeId, ''); + } catch (dbErr) { + console.error('Failed to persist photo URL to database:', dbErr); + } + res.json({ photoUrl, attribution }); } catch (err) { console.error('Place photo error:', err); diff --git a/server/src/routes/packing.js b/server/src/routes/packing.js index 612d253..86dc2f4 100644 --- a/server/src/routes/packing.js +++ b/server/src/routes/packing.js @@ -1,6 +1,7 @@ const express = require('express'); const { db, canAccessTrip } = require('../db/database'); const { authenticate } = require('../middleware/auth'); +const { broadcast } = require('../websocket'); const router = express.Router({ mergeParams: true }); @@ -41,6 +42,7 @@ router.post('/', authenticate, (req, res) => { const item = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid); res.status(201).json({ item }); + broadcast(tripId, 'packing:created', { item }, req.headers['x-socket-id']); }); // PUT /api/trips/:tripId/packing/:id @@ -70,6 +72,7 @@ router.put('/:id', authenticate, (req, res) => { const updated = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(id); res.json({ item: updated }); + broadcast(tripId, 'packing:updated', { item: updated }, req.headers['x-socket-id']); }); // DELETE /api/trips/:tripId/packing/:id @@ -84,6 +87,7 @@ router.delete('/:id', authenticate, (req, res) => { db.prepare('DELETE FROM packing_items WHERE id = ?').run(id); res.json({ success: true }); + broadcast(tripId, 'packing:deleted', { itemId: Number(id) }, req.headers['x-socket-id']); }); // PUT /api/trips/:tripId/packing/reorder diff --git a/server/src/routes/places.js b/server/src/routes/places.js index b4b8d3a..7570d33 100644 --- a/server/src/routes/places.js +++ b/server/src/routes/places.js @@ -2,6 +2,7 @@ const express = require('express'); const fetch = require('node-fetch'); const { db, getPlaceWithTags, canAccessTrip } = require('../db/database'); const { authenticate } = require('../middleware/auth'); +const { broadcast } = require('../websocket'); const router = express.Router({ mergeParams: true }); @@ -47,13 +48,26 @@ router.get('/', authenticate, (req, res) => { const places = db.prepare(query).all(...params); - const placesWithTags = places.map(p => { - const tags = db.prepare(` - SELECT t.* FROM tags t + // Load all tags for these places in a single query to avoid N+1 + const placeIds = places.map(p => p.id); + const tagsByPlaceId = {}; + if (placeIds.length > 0) { + const placeholders = placeIds.map(() => '?').join(','); + const allTags = db.prepare(` + SELECT t.*, pt.place_id FROM tags t JOIN place_tags pt ON t.id = pt.tag_id - WHERE pt.place_id = ? - `).all(p.id); + WHERE pt.place_id IN (${placeholders}) + `).all(...placeIds); + for (const tag of allTags) { + const pid = tag.place_id; + delete tag.place_id; + if (!tagsByPlaceId[pid]) tagsByPlaceId[pid] = []; + tagsByPlaceId[pid].push(tag); + } + } + + const placesWithTags = places.map(p => { return { ...p, category: p.category_id ? { @@ -62,7 +76,7 @@ router.get('/', authenticate, (req, res) => { color: p.category_color, icon: p.category_icon, } : null, - tags, + tags: tagsByPlaceId[p.id] || [], }; }); @@ -113,6 +127,7 @@ router.post('/', authenticate, (req, res) => { const place = getPlaceWithTags(placeId); res.status(201).json({ place }); + broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id']); }); // GET /api/trips/:tripId/places/:id @@ -258,6 +273,7 @@ router.put('/:id', authenticate, (req, res) => { const place = getPlaceWithTags(id); res.json({ place }); + broadcast(tripId, 'place:updated', { place }, req.headers['x-socket-id']); }); // DELETE /api/trips/:tripId/places/:id @@ -276,6 +292,7 @@ router.delete('/:id', authenticate, (req, res) => { db.prepare('DELETE FROM places WHERE id = ?').run(id); res.json({ success: true }); + broadcast(tripId, 'place:deleted', { placeId: Number(id) }, req.headers['x-socket-id']); }); module.exports = router; diff --git a/server/src/routes/reservations.js b/server/src/routes/reservations.js index 13729fb..a894d53 100644 --- a/server/src/routes/reservations.js +++ b/server/src/routes/reservations.js @@ -1,6 +1,7 @@ const express = require('express'); const { db, canAccessTrip } = require('../db/database'); const { authenticate } = require('../middleware/auth'); +const { broadcast } = require('../websocket'); const router = express.Router({ mergeParams: true }); @@ -62,6 +63,7 @@ router.post('/', authenticate, (req, res) => { `).get(result.lastInsertRowid); res.status(201).json({ reservation }); + broadcast(tripId, 'reservation:created', { reservation }, req.headers['x-socket-id']); }); // PUT /api/trips/:tripId/reservations/:id @@ -109,6 +111,7 @@ router.put('/:id', authenticate, (req, res) => { `).get(id); res.json({ reservation: updated }); + broadcast(tripId, 'reservation:updated', { reservation: updated }, req.headers['x-socket-id']); }); // DELETE /api/trips/:tripId/reservations/:id @@ -123,6 +126,7 @@ router.delete('/:id', authenticate, (req, res) => { db.prepare('DELETE FROM reservations WHERE id = ?').run(id); res.json({ success: true }); + broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, req.headers['x-socket-id']); }); module.exports = router; diff --git a/server/src/routes/trips.js b/server/src/routes/trips.js index a1262ee..92f83e1 100644 --- a/server/src/routes/trips.js +++ b/server/src/routes/trips.js @@ -5,6 +5,7 @@ const fs = require('fs'); const { v4: uuidv4 } = require('uuid'); const { db, canAccessTrip, isOwner } = require('../db/database'); const { authenticate } = require('../middleware/auth'); +const { broadcast } = require('../websocket'); const router = express.Router(); @@ -134,6 +135,7 @@ router.put('/:id', authenticate, (req, res) => { const updatedTrip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: req.user.id, tripId: req.params.id }); res.json({ trip: updatedTrip }); + broadcast(req.params.id, 'trip:updated', { trip: updatedTrip }, req.headers['x-socket-id']); }); // POST /api/trips/:id/cover @@ -147,7 +149,11 @@ router.post('/:id/cover', authenticate, uploadCover.single('cover'), (req, res) if (trip.cover_image) { const oldPath = path.join(__dirname, '../../', trip.cover_image.replace(/^\//, '')); - if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath); + const resolvedPath = path.resolve(oldPath); + const uploadsDir = path.resolve(__dirname, '../../uploads'); + if (resolvedPath.startsWith(uploadsDir) && fs.existsSync(resolvedPath)) { + fs.unlinkSync(resolvedPath); + } } const coverUrl = `/uploads/covers/${req.file.filename}`; @@ -159,8 +165,10 @@ router.post('/:id/cover', authenticate, uploadCover.single('cover'), (req, res) router.delete('/:id', authenticate, (req, res) => { if (!isOwner(req.params.id, req.user.id)) return res.status(403).json({ error: 'Nur der Eigentümer kann die Reise löschen' }); + const deletedTripId = Number(req.params.id); db.prepare('DELETE FROM trips WHERE id = ?').run(req.params.id); res.json({ success: true }); + broadcast(deletedTripId, 'trip:deleted', { id: deletedTripId }, req.headers['x-socket-id']); }); // ── Member Management ──────────────────────────────────────────────────────── diff --git a/server/src/routes/weather.js b/server/src/routes/weather.js index 4269744..afd7d7a 100644 --- a/server/src/routes/weather.js +++ b/server/src/routes/weather.js @@ -5,6 +5,33 @@ const { authenticate } = require('../middleware/auth'); const router = express.Router(); +// --------------- In-memory weather cache --------------- +const weatherCache = new Map(); + +const TTL_FORECAST_MS = 60 * 60 * 1000; // 1 hour +const TTL_CURRENT_MS = 15 * 60 * 1000; // 15 minutes + +function cacheKey(lat, lng, date, units) { + const rlat = parseFloat(lat).toFixed(2); + const rlng = parseFloat(lng).toFixed(2); + return `${rlat}_${rlng}_${date || 'current'}_${units}`; +} + +function getCached(key) { + const entry = weatherCache.get(key); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { + weatherCache.delete(key); + return null; + } + return entry.data; +} + +function setCache(key, data, ttlMs) { + weatherCache.set(key, { data, expiresAt: Date.now() + ttlMs }); +} +// ------------------------------------------------------- + function formatItem(item) { return { temp: Math.round(item.main.temp), @@ -24,16 +51,28 @@ router.get('/', authenticate, async (req, res) => { return res.status(400).json({ error: 'Breiten- und Längengrad sind erforderlich' }); } + // User's own key, or fall back to admin's key + let key = null; const user = db.prepare('SELECT openweather_api_key FROM users WHERE id = ?').get(req.user.id); - if (!user || !user.openweather_api_key) { + if (user?.openweather_api_key) { + key = user.openweather_api_key; + } else { + const admin = db.prepare("SELECT openweather_api_key FROM users WHERE role = 'admin' AND openweather_api_key IS NOT NULL AND openweather_api_key != '' LIMIT 1").get(); + key = admin?.openweather_api_key || null; + } + if (!key) { return res.status(400).json({ error: 'Kein API-Schlüssel konfiguriert' }); } - const key = user.openweather_api_key; + const ck = cacheKey(lat, lng, date, units); try { // If a date is requested, try the 5-day forecast first if (date) { + // Check cache + const cached = getCached(ck); + if (cached) return res.json(cached); + const targetDate = new Date(date); const now = new Date(); const diffDays = (targetDate - now) / (1000 * 60 * 60 * 24); @@ -58,7 +97,9 @@ router.get('/', authenticate, async (req, res) => { const hour = new Date(item.dt * 1000).getHours(); return hour >= 11 && hour <= 14; }) || filtered[0]; - return res.json(formatItem(midday)); + const result = formatItem(midday); + setCache(ck, result, TTL_FORECAST_MS); + return res.json(result); } } @@ -67,6 +108,9 @@ router.get('/', authenticate, async (req, res) => { } // No date — return current weather + const cached = getCached(ck); + if (cached) return res.json(cached); + const url = `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lng}&appid=${key}&units=${units}&lang=de`; const response = await fetch(url); const data = await response.json(); @@ -75,7 +119,9 @@ router.get('/', authenticate, async (req, res) => { return res.status(response.status).json({ error: data.message || 'OpenWeatherMap API Fehler' }); } - res.json(formatItem(data)); + const result = formatItem(data); + setCache(ck, result, TTL_CURRENT_MS); + res.json(result); } catch (err) { console.error('Weather error:', err); res.status(500).json({ error: 'Fehler beim Abrufen der Wetterdaten' }); diff --git a/server/src/websocket.js b/server/src/websocket.js new file mode 100644 index 0000000..a898be4 --- /dev/null +++ b/server/src/websocket.js @@ -0,0 +1,144 @@ +const { WebSocketServer } = require('ws'); +const jwt = require('jsonwebtoken'); +const { JWT_SECRET } = require('./config'); +const { db, canAccessTrip } = require('./db/database'); + +// Room management: tripId → Set +const rooms = new Map(); + +// Track which rooms each socket is in +const socketRooms = new WeakMap(); + +// Track user info per socket +const socketUser = new WeakMap(); + +// Track unique socket ID +const socketId = new WeakMap(); +let nextSocketId = 1; + +let wss; + +function setupWebSocket(server) { + wss = new WebSocketServer({ server, path: '/ws' }); + + // Heartbeat: ping every 30s, terminate if no pong + const heartbeat = setInterval(() => { + wss.clients.forEach((ws) => { + if (ws.isAlive === false) return ws.terminate(); + ws.isAlive = false; + ws.ping(); + }); + }, 30000); + + wss.on('close', () => clearInterval(heartbeat)); + + wss.on('connection', (ws, req) => { + // Extract token from query param + const url = new URL(req.url, 'http://localhost'); + const token = url.searchParams.get('token'); + + if (!token) { + ws.close(4001, 'Authentication required'); + return; + } + + let user; + try { + const decoded = jwt.verify(token, JWT_SECRET); + user = db.prepare( + 'SELECT id, username, email, role FROM users WHERE id = ?' + ).get(decoded.id); + if (!user) { + ws.close(4001, 'User not found'); + return; + } + } catch (err) { + ws.close(4001, 'Invalid or expired token'); + return; + } + + ws.isAlive = true; + const sid = nextSocketId++; + socketId.set(ws, sid); + socketUser.set(ws, user); + socketRooms.set(ws, new Set()); + ws.send(JSON.stringify({ type: 'welcome', socketId: sid })); + + ws.on('pong', () => { ws.isAlive = true; }); + + ws.on('message', (data) => { + let msg; + try { + msg = JSON.parse(data.toString()); + } catch { + return; + } + + if (msg.type === 'join' && msg.tripId) { + const tripId = Number(msg.tripId); + // Verify the user has access to this trip + if (!canAccessTrip(tripId, user.id)) { + ws.send(JSON.stringify({ type: 'error', message: 'Access denied' })); + return; + } + // Add to room + if (!rooms.has(tripId)) rooms.set(tripId, new Set()); + rooms.get(tripId).add(ws); + socketRooms.get(ws).add(tripId); + ws.send(JSON.stringify({ type: 'joined', tripId })); + } + + if (msg.type === 'leave' && msg.tripId) { + const tripId = Number(msg.tripId); + leaveRoom(ws, tripId); + ws.send(JSON.stringify({ type: 'left', tripId })); + } + }); + + ws.on('close', () => { + // Clean up all rooms this socket was in + const myRooms = socketRooms.get(ws); + if (myRooms) { + for (const tripId of myRooms) { + leaveRoom(ws, tripId); + } + } + }); + }); + + console.log('WebSocket server attached at /ws'); +} + +function leaveRoom(ws, tripId) { + const room = rooms.get(tripId); + if (room) { + room.delete(ws); + if (room.size === 0) rooms.delete(tripId); + } + const myRooms = socketRooms.get(ws); + if (myRooms) myRooms.delete(tripId); +} + +/** + * Broadcast an event to all sockets in a trip room, optionally excluding a user. + * @param {number} tripId + * @param {string} eventType e.g. 'place:created' + * @param {object} payload the data to send + * @param {number} [excludeUserId] don't send to this user (the one who triggered the change) + */ +function broadcast(tripId, eventType, payload, excludeSid) { + tripId = Number(tripId); + const room = rooms.get(tripId); + if (!room || room.size === 0) return; + + const excludeNum = excludeSid ? Number(excludeSid) : null; + + for (const ws of room) { + if (ws.readyState !== 1) continue; // WebSocket.OPEN === 1 + // Exclude the specific socket that triggered the change + if (excludeNum && socketId.get(ws) === excludeNum) continue; + ws.send(JSON.stringify({ type: eventType, tripId, ...payload })); + } +} + +module.exports = { setupWebSocket, broadcast };