diff --git a/client/src/components/Planner/DayPlanSidebar.jsx b/client/src/components/Planner/DayPlanSidebar.jsx index 95fca07..d9fccfd 100644 --- a/client/src/components/Planner/DayPlanSidebar.jsx +++ b/client/src/components/Planner/DayPlanSidebar.jsx @@ -674,7 +674,7 @@ export default function DayPlanSidebar({ place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') }, (place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') }, { divider: true }, - onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => { if (confirm(t('trip.confirm.deletePlace'))) onDeletePlace(place.id) } }, + onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) }, ])} onMouseEnter={() => setHoveredId(assignment.id)} onMouseLeave={() => setHoveredId(null)} diff --git a/client/src/components/Planner/PlaceInspector.jsx b/client/src/components/Planner/PlaceInspector.jsx index 4fa5ad6..8175c12 100644 --- a/client/src/components/Planner/PlaceInspector.jsx +++ b/client/src/components/Planner/PlaceInspector.jsx @@ -100,16 +100,39 @@ function formatFileSize(bytes) { export default function PlaceInspector({ place, categories, days, selectedDayId, selectedAssignmentId, assignments, reservations = [], onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment, - files, onFileUpload, tripMembers = [], onSetParticipants, + files, onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace, }) { const { t, locale, language } = useTranslation() const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' const [hoursExpanded, setHoursExpanded] = useState(false) const [filesExpanded, setFilesExpanded] = useState(false) const [isUploading, setIsUploading] = useState(false) + const [editingName, setEditingName] = useState(false) + const [nameValue, setNameValue] = useState('') + const nameInputRef = useRef(null) const fileInputRef = useRef(null) const googleDetails = useGoogleDetails(place?.google_place_id, language) + const startNameEdit = () => { + if (!onUpdatePlace) return + setNameValue(place.name || '') + setEditingName(true) + setTimeout(() => nameInputRef.current?.focus(), 0) + } + + const commitNameEdit = () => { + if (!editingName) return + const trimmed = nameValue.trim() + setEditingName(false) + if (!trimmed || trimmed === place.name) return + onUpdatePlace(place.id, { name: trimmed }) + } + + const handleNameKeyDown = (e) => { + if (e.key === 'Enter') { e.preventDefault(); commitNameEdit() } + if (e.key === 'Escape') setEditingName(false) + } + if (!place) return null const category = categories?.find(c => c.id === place.category_id) @@ -192,7 +215,21 @@ export default function PlaceInspector({
- {place.name} + {editingName ? ( + setNameValue(e.target.value)} + onBlur={commitNameEdit} + onKeyDown={handleNameKeyDown} + style={{ fontWeight: 600, fontSize: 15, color: 'var(--text-primary)', lineHeight: '1.3', background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, padding: '1px 6px', fontFamily: 'inherit', outline: 'none', width: '100%' }} + /> + ) : ( + {place.name} + )} {category && (() => { const CatIcon = getCategoryIcon(category.icon) return ( diff --git a/client/src/components/Planner/PlacesSidebar.jsx b/client/src/components/Planner/PlacesSidebar.jsx index 01e562c..7cb23a1 100644 --- a/client/src/components/Planner/PlacesSidebar.jsx +++ b/client/src/components/Planner/PlacesSidebar.jsx @@ -146,7 +146,7 @@ export default function PlacesSidebar({ place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') }, (place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') }, { divider: true }, - onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => { if (confirm(t('trip.confirm.deletePlace'))) onDeletePlace(place.id) } }, + onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) }, ])} style={{ display: 'flex', alignItems: 'center', gap: 10, diff --git a/client/src/components/shared/ConfirmDialog.jsx b/client/src/components/shared/ConfirmDialog.jsx new file mode 100644 index 0000000..7d2a1b8 --- /dev/null +++ b/client/src/components/shared/ConfirmDialog.jsx @@ -0,0 +1,90 @@ +import React, { useEffect, useCallback } from 'react' +import { AlertTriangle } from 'lucide-react' +import { useTranslation } from '../../i18n' + +export default function ConfirmDialog({ + isOpen, + onClose, + onConfirm, + title, + message, + confirmLabel, + cancelLabel, + danger = true, +}) { + const { t } = useTranslation() + + const handleEsc = useCallback((e) => { + if (e.key === 'Escape') onClose() + }, [onClose]) + + useEffect(() => { + if (isOpen) { + document.addEventListener('keydown', handleEsc) + } + return () => document.removeEventListener('keydown', handleEsc) + }, [isOpen, handleEsc]) + + if (!isOpen) return null + + return ( +
+
e.stopPropagation()} + > +
+ {danger && ( +
+ +
+ )} +
+

+ {title || t('common.confirm')} +

+

+ {message} +

+
+
+ +
+ + +
+
+ + +
+ ) +} diff --git a/client/src/i18n/translations/de.js b/client/src/i18n/translations/de.js index 80eb61b..8086965 100644 --- a/client/src/i18n/translations/de.js +++ b/client/src/i18n/translations/de.js @@ -956,6 +956,7 @@ const de = { 'collab.chat.placeholder': 'Nachricht eingeben...', 'collab.chat.empty': 'Starte die Unterhaltung', 'collab.chat.emptyHint': 'Nachrichten werden mit allen Reiseteilnehmern geteilt', + 'collab.chat.emptyDesc': 'Teile Ideen, Pläne und Updates mit deiner Reisegruppe', 'collab.chat.today': 'Heute', 'collab.chat.yesterday': 'Gestern', 'collab.chat.deletedMessage': 'hat eine Nachricht gelöscht', diff --git a/client/src/i18n/translations/en.js b/client/src/i18n/translations/en.js index b3092e4..d0d055b 100644 --- a/client/src/i18n/translations/en.js +++ b/client/src/i18n/translations/en.js @@ -956,6 +956,7 @@ const en = { 'collab.chat.placeholder': 'Type a message...', 'collab.chat.empty': 'Start the conversation', 'collab.chat.emptyHint': 'Messages are shared with all trip members', + 'collab.chat.emptyDesc': 'Share ideas, plans, and updates with your travel group', 'collab.chat.today': 'Today', 'collab.chat.yesterday': 'Yesterday', 'collab.chat.deletedMessage': 'deleted a message', diff --git a/client/src/pages/TripPlannerPage.jsx b/client/src/pages/TripPlannerPage.jsx index ae736f5..dcbcf48 100644 --- a/client/src/pages/TripPlannerPage.jsx +++ b/client/src/pages/TripPlannerPage.jsx @@ -24,6 +24,7 @@ import { useTranslation } from '../i18n' import { joinTrip, leaveTrip, addListener, removeListener } from '../api/websocket' import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi } from '../api/client' import { calculateRoute } from '../components/Map/RouteCalculator' +import ConfirmDialog from '../components/shared/ConfirmDialog' const MIN_SIDEBAR = 200 const MAX_SIDEBAR = 520 @@ -111,6 +112,7 @@ export default function TripPlannerPage() { const [routeSegments, setRouteSegments] = useState([]) // { from, to, walkingText, drivingText } const [fitKey, setFitKey] = useState(0) const [mobileSidebarOpen, setMobileSidebarOpen] = useState(null) // 'left' | 'right' | null + const [deletePlaceId, setDeletePlaceId] = useState(null) // Load trip + files (needed for place inspector file section) useEffect(() => { @@ -278,14 +280,18 @@ export default function TripPlannerPage() { } }, [editingPlace, editingAssignmentId, tripId, tripStore, toast]) - const handleDeletePlace = useCallback(async (placeId) => { - if (!confirm(t('trip.confirm.deletePlace'))) return + const handleDeletePlace = useCallback((placeId) => { + setDeletePlaceId(placeId) + }, []) + + const confirmDeletePlace = useCallback(async () => { + if (!deletePlaceId) return try { - await tripStore.deletePlace(tripId, placeId) - if (selectedPlaceId === placeId) setSelectedPlaceId(null) + await tripStore.deletePlace(tripId, deletePlaceId) + if (selectedPlaceId === deletePlaceId) setSelectedPlaceId(null) toast.success(t('trip.toast.placeDeleted')) } catch (err) { toast.error(err.message) } - }, [tripId, tripStore, toast, selectedPlaceId]) + }, [deletePlaceId, tripId, tripStore, toast, selectedPlaceId]) const handleAssignToDay = useCallback(async (placeId, dayId, position) => { const target = dayId || selectedDayId @@ -650,6 +656,7 @@ export default function TripPlannerPage() { })) } catch {} }} + onUpdatePlace={async (placeId, data) => { try { await tripStore.updatePlace(tripId, placeId, data) } catch (err) { toast.error(err.message) } }} /> )} @@ -729,6 +736,13 @@ export default function TripPlannerPage() { setShowTripForm(false)} onSave={async (data) => { await tripStore.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} /> setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} /> { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={(fd) => tripStore.addFile(tripId, fd)} onFileDelete={(id) => tripStore.deleteFile(tripId, id)} /> + setDeletePlaceId(null)} + onConfirm={confirmDeletePlace} + title={t('common.delete')} + message={t('trip.confirm.deletePlace')} + />
) } diff --git a/server/src/routes/trips.js b/server/src/routes/trips.js index bee67de..131bf45 100644 --- a/server/src/routes/trips.js +++ b/server/src/routes/trips.js @@ -46,20 +46,84 @@ const TRIP_SELECT = ` `; function generateDays(tripId, startDate, endDate) { - db.prepare('DELETE FROM days WHERE trip_id = ?').run(tripId); + const existing = db.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ?').all(tripId); + if (!startDate || !endDate) { - const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)'); - for (let i = 1; i <= 7; i++) insert.run(tripId, i); + // No dates — keep up to 7 dateless days, reuse existing ones + const datelessExisting = existing.filter(d => !d.date).sort((a, b) => a.day_number - b.day_number); + // Remove days with dates (they no longer apply) + const withDates = existing.filter(d => d.date); + if (withDates.length > 0) { + db.prepare(`DELETE FROM days WHERE trip_id = ? AND date IS NOT NULL`).run(tripId); + } + // Ensure exactly 7 dateless days + const needed = 7 - datelessExisting.length; + if (needed > 0) { + const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)'); + for (let i = 0; i < needed; i++) insert.run(tripId, datelessExisting.length + i + 1); + } else if (needed < 0) { + // Too many dateless days — remove extras (highest day_number first to preserve earlier assignments) + const toRemove = datelessExisting.slice(7); + const del = db.prepare('DELETE FROM days WHERE id = ?'); + for (const d of toRemove) del.run(d.id); + } + // Renumber — use negative temp values first to avoid UNIQUE conflicts + const remaining = db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId); + const tmpUpd = db.prepare('UPDATE days SET day_number = ? WHERE id = ?'); + remaining.forEach((d, i) => tmpUpd.run(-(i + 1), d.id)); + remaining.forEach((d, i) => tmpUpd.run(i + 1, d.id)); return; } - const start = new Date(startDate); - const end = new Date(endDate); - const numDays = Math.min(Math.floor((end - start) / 86400000) + 1, 90); - const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)'); + + // Use pure string-based date math to avoid timezone/DST issues + const [sy, sm, sd] = startDate.split('-').map(Number); + const [ey, em, ed] = endDate.split('-').map(Number); + const startMs = Date.UTC(sy, sm - 1, sd); + const endMs = Date.UTC(ey, em - 1, ed); + const numDays = Math.min(Math.floor((endMs - startMs) / 86400000) + 1, 90); + + // Build target dates + const targetDates = []; for (let i = 0; i < numDays; i++) { - const d = new Date(start); - d.setDate(start.getDate() + i); - insert.run(tripId, i + 1, d.toISOString().split('T')[0]); + const d = new Date(startMs + i * 86400000); + const yyyy = d.getUTCFullYear(); + const mm = String(d.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(d.getUTCDate()).padStart(2, '0'); + targetDates.push(`${yyyy}-${mm}-${dd}`); + } + + // Index existing days by date + const existingByDate = new Map(); + for (const d of existing) { + if (d.date) existingByDate.set(d.date, d); + } + + const targetDateSet = new Set(targetDates); + + // Delete days whose date is no longer in the new range + const toDelete = existing.filter(d => d.date && !targetDateSet.has(d.date)); + // Also delete dateless days (they are replaced by dated ones) + const datelessToDelete = existing.filter(d => !d.date); + const del = db.prepare('DELETE FROM days WHERE id = ?'); + for (const d of [...toDelete, ...datelessToDelete]) del.run(d.id); + + // Move all kept days to negative day_numbers to avoid UNIQUE conflicts + const setTemp = db.prepare('UPDATE days SET day_number = ? WHERE id = ?'); + const kept = existing.filter(d => d.date && targetDateSet.has(d.date)); + for (let i = 0; i < kept.length; i++) setTemp.run(-(i + 1), kept[i].id); + + // Now assign correct day_numbers and insert missing days + const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)'); + const update = db.prepare('UPDATE days SET day_number = ? WHERE id = ?'); + + for (let i = 0; i < targetDates.length; i++) { + const date = targetDates[i]; + const ex = existingByDate.get(date); + if (ex) { + update.run(i + 1, ex.id); + } else { + insert.run(tripId, i + 1, date); + } } }