v2.6.0 — Collab overhaul, route travel times, chat & notes redesign

## Collab — Complete Redesign
- iMessage-style live chat with blue bubbles, grouped messages, date separators
- Emoji reactions via right-click (desktop) or double-tap (mobile)
- Twemoji (Apple-style) emoji picker with categories
- Link previews with OG image/title/description
- Soft-delete messages with "deleted a message" placeholder
- Message reactions with real-time WebSocket sync
- Chat timestamps respect 12h/24h setting and timezone

## Collab Notes
- Redesigned note cards with colored header bar (booking-card style)
- 2-column grid layout (desktop), 1-column (mobile)
- Category settings modal for managing categories with colors
- File/image attachments on notes with mini-preview thumbnails
- Website links with OG image preview on note cards
- File preview portal (lightbox for images, inline viewer for PDF/TXT)
- Note files appear in Files tab with "From Collab Notes" badge
- Pin highlighting with tinted background
- Author avatar chip in header bar with custom tooltip

## Collab Polls
- Complete rewrite — clean Apple-style poll cards
- Animated progress bars with vote percentages
- Blue check circles for own votes, voter avatars
- Create poll modal with multi-choice toggle
- Active/closed poll sections
- Custom tooltips on voter chips

## What's Next Widget
- New widget showing upcoming trip activities
- Time display with "until" separator
- Participant chips per activity
- Day grouping (Today, Tomorrow, dates)
- Respects 12h/24h and locale settings

## Route Travel Times
- Auto-calculated walking + driving times via OSRM (free, no API key)
- Floating badge on each route segment between places
- Walking person icon + car icon with times
- Hides when zoomed out (< zoom 16)
- Toggle in Settings > Display to enable/disable

## Other Improvements
- Collab addon enabled by default for new installations
- Coming Soon removed from Collab in admin settings
- Tab state persisted across page reloads (sessionStorage)
- Day sidebar expanded/collapsed state persisted
- File preview with extension badges (PDF, TXT, etc.) in Files tab
- Collab Notes filter tab in Files
- Reservations section in Day Detail view
- Dark mode fix for invite button text color
- Chat scroll hidden (no visible scrollbar)
- Mobile: tab icons removed for space, touch-friendly UI
- Fixed 6 backend data structure bugs in Collab (polls, chat, notes)
- Soft-delete for chat messages (persists in history)
- Message reactions table (migration 28)
- Note attachments via trip_files with note_id (migration 30)

## Database Migrations
- Migration 27: budget_item_members table
- Migration 28: collab_message_reactions table
- Migration 29: soft-delete column on collab_messages
- Migration 30: note_id on trip_files, website on collab_notes
This commit is contained in:
Maurice
2026-03-25 22:59:39 +01:00
parent 17288f9a0e
commit 068b90ed72
21 changed files with 2367 additions and 2013 deletions
+29
View File
@@ -330,6 +330,35 @@ export default function SettingsPage() {
))}
</div>
</div>
{/* Route Calculation */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.routeCalculation')}</label>
<div className="flex gap-3">
{[
{ value: true, label: t('settings.on') || 'On' },
{ value: false, label: t('settings.off') || 'Off' },
].map(opt => (
<button
key={String(opt.value)}
onClick={async () => {
try { await updateSetting('route_calculation', opt.value) }
catch (e) { toast.error(e.message) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
border: (settings.route_calculation !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: (settings.route_calculation !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
</div>
</Section>
{/* Account */}
+45 -22
View File
@@ -23,6 +23,7 @@ import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen
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'
const MIN_SIDEBAR = 200
const MAX_SIDEBAR = 520
@@ -65,10 +66,14 @@ export default function TripPlannerPage() {
...(enabledAddons.collab ? [{ id: 'collab', label: 'Collab' }] : []),
]
const [activeTab, setActiveTab] = useState('plan')
const [activeTab, setActiveTab] = useState(() => {
const saved = sessionStorage.getItem(`trip-tab-${tripId}`)
return saved || 'plan'
})
const handleTabChange = (tabId) => {
setActiveTab(tabId)
sessionStorage.setItem(`trip-tab-${tripId}`, tabId)
if (tabId === 'finanzplan') tripStore.loadBudgetItems?.(tripId)
if (tabId === 'dateien' && (!files || files.length === 0)) tripStore.loadFiles?.(tripId)
}
@@ -102,7 +107,8 @@ export default function TripPlannerPage() {
const [showReservationModal, setShowReservationModal] = useState(false)
const [editingReservation, setEditingReservation] = useState(null)
const [route, setRoute] = useState(null)
const [routeInfo, setRouteInfo] = useState(null)
const [routeInfo, setRouteInfo] = useState(null) // unused legacy
const [routeSegments, setRouteSegments] = useState([]) // { from, to, walkingText, drivingText }
const [fitKey, setFitKey] = useState(0)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(null) // 'left' | 'right' | null
@@ -130,9 +136,21 @@ export default function TripPlannerPage() {
const handler = useTripStore.getState().handleRemoteEvent
joinTrip(tripId)
addListener(handler)
// Reload files when collab notes change (attachments sync) — from WebSocket (other users)
const collabFileSync = (event) => {
if (event?.type === 'collab:note:deleted' || event?.type === 'collab:note:updated') {
tripStore.loadFiles?.(tripId)
}
}
addListener(collabFileSync)
// Reload files when local collab actions change files (own user)
const localFileSync = () => tripStore.loadFiles?.(tripId)
window.addEventListener('collab-files-changed', localFileSync)
return () => {
leaveTrip(tripId)
removeListener(handler)
removeListener(collabFileSync)
window.removeEventListener('collab-files-changed', localFileSync)
}
}, [tripId])
@@ -167,17 +185,34 @@ export default function TripPlannerPage() {
return places.filter(p => p.lat && p.lng)
}, [places])
const updateRouteForDay = useCallback((dayId) => {
if (!dayId) { setRoute(null); setRouteInfo(null); return }
const routeCalcEnabled = useSettingsStore(s => s.settings.route_calculation) !== false
const updateRouteForDay = useCallback(async (dayId) => {
if (!dayId) { setRoute(null); setRouteSegments([]); return }
const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const waypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng)
if (waypoints.length >= 2) {
setRoute(waypoints.map(p => [p.lat, p.lng]))
if (!routeCalcEnabled) { setRouteSegments([]); return }
// Calculate per-segment travel times
const segments = []
for (let i = 0; i < waypoints.length - 1; i++) {
const from = [waypoints[i].lat, waypoints[i].lng]
const to = [waypoints[i + 1].lat, waypoints[i + 1].lng]
const mid = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2]
try {
const result = await calculateRoute([{ lat: from[0], lng: from[1] }, { lat: to[0], lng: to[1] }], 'walking')
segments.push({ mid, from, to, walkingText: result.walkingText, drivingText: result.drivingText })
} catch {
segments.push({ mid, from, to, walkingText: '?', drivingText: '?' })
}
}
setRouteSegments(segments)
} else {
setRoute(null)
setRouteSegments([])
}
setRouteInfo(null)
}, [tripStore])
}, [tripStore, routeCalcEnabled])
const handleSelectDay = useCallback((dayId, skipFit) => {
const changed = dayId !== selectedDayId
@@ -411,6 +446,7 @@ export default function TripPlannerPage() {
places={mapPlaces()}
dayPlaces={dayPlaces}
route={route}
routeSegments={routeSegments}
selectedPlaceId={selectedPlaceId}
onMarkerClick={handleMarkerClick}
onMapClick={handleMapClick}
@@ -424,19 +460,6 @@ export default function TripPlannerPage() {
hasInspector={!!selectedPlace}
/>
{routeInfo && (
<div style={{
position: 'absolute', bottom: selectedPlace ? 180 : 20, left: '50%', transform: 'translateX(-50%)',
background: 'rgba(255,255,255,0.95)', backdropFilter: 'blur(20px)',
borderRadius: 99, padding: '6px 20px', zIndex: 30,
boxShadow: '0 2px 16px rgba(0,0,0,0.1)',
display: 'flex', gap: 12, fontSize: 13, color: '#374151',
}}>
<span>{routeInfo.distance}</span>
<span style={{ color: '#d1d5db' }}>·</span>
<span>{routeInfo.duration}</span>
</div>
)}
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
<button onClick={() => setLeftCollapsed(c => !c)}
@@ -479,7 +502,7 @@ export default function TripPlannerPage() {
onReorder={handleReorder}
onUpdateDayTitle={handleUpdateDayTitle}
onAssignToDay={handleAssignToDay}
onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } else { setRoute(null); setRouteInfo(null) } }}
onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText, walkingText: r.walkingText, drivingText: r.drivingText }) } else { setRoute(null); setRouteInfo(null) } }}
reservations={reservations}
onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true) }}
onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null) }}
@@ -696,8 +719,8 @@ export default function TripPlannerPage() {
)}
{activeTab === 'collab' && (
<div style={{ height: '100%', overflow: 'hidden', overscrollBehavior: 'contain' }}>
<CollabPanel tripId={tripId} />
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
<CollabPanel tripId={tripId} tripMembers={tripMembers} />
</div>
)}
</div>