mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
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:
@@ -118,7 +118,7 @@ export default function AddonManager() {
|
||||
}
|
||||
|
||||
function AddonRow({ addon, onToggle, t }) {
|
||||
const isComingSoon = addon.id === 'collab'
|
||||
const isComingSoon = false
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)', opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
|
||||
{/* Icon */}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,34 +1,101 @@
|
||||
import React from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react'
|
||||
import CollabChat from './CollabChat'
|
||||
import CollabNotes from './CollabNotes'
|
||||
import CollabPolls from './CollabPolls'
|
||||
import WhatsNextWidget from './WhatsNextWidget'
|
||||
|
||||
export default function CollabPanel({ tripId }) {
|
||||
function useIsDesktop(breakpoint = 1024) {
|
||||
const [isDesktop, setIsDesktop] = useState(window.innerWidth >= breakpoint)
|
||||
useEffect(() => {
|
||||
const check = () => setIsDesktop(window.innerWidth >= breakpoint)
|
||||
window.addEventListener('resize', check)
|
||||
return () => window.removeEventListener('resize', check)
|
||||
}, [breakpoint])
|
||||
return isDesktop
|
||||
}
|
||||
|
||||
const card = {
|
||||
display: 'flex', flexDirection: 'column',
|
||||
background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-faint)',
|
||||
overflow: 'hidden', minHeight: 0,
|
||||
}
|
||||
|
||||
export default function CollabPanel({ tripId, tripMembers = [] }) {
|
||||
const { user } = useAuthStore()
|
||||
const { t } = useTranslation()
|
||||
const [mobileTab, setMobileTab] = useState('chat')
|
||||
const isDesktop = useIsDesktop()
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
<div style={{ maxWidth: 1400, margin: '0 auto', padding: '16px' }}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4" style={{ alignItems: 'start' }}>
|
||||
const tabs = [
|
||||
{ id: 'chat', label: t('collab.tabs.chat') || 'Chat', icon: MessageCircle },
|
||||
{ id: 'notes', label: t('collab.tabs.notes') || 'Notes', icon: StickyNote },
|
||||
{ id: 'polls', label: t('collab.tabs.polls') || 'Polls', icon: BarChart3 },
|
||||
{ id: 'next', label: t('collab.whatsNext.title') || "What's Next", icon: Sparkles },
|
||||
]
|
||||
|
||||
{/* Chat — takes 1 column, full height */}
|
||||
<div style={{ background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-faint)', overflow: 'hidden', height: 500 }}>
|
||||
<CollabChat tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{/* Chat — left, fixed width */}
|
||||
<div style={{ ...card, flex: '0 0 380px' }}>
|
||||
<CollabChat tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
|
||||
{/* Notes — takes 1 column */}
|
||||
<div style={{ background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-faint)', overflow: 'hidden', maxHeight: 500, display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Right column: Notes top, Polls + What's Next bottom */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{/* Notes — top */}
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<CollabNotes tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
|
||||
{/* Polls — takes 1 column */}
|
||||
<div style={{ background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-faint)', overflow: 'hidden', maxHeight: 500, display: 'flex', flexDirection: 'column' }}>
|
||||
<CollabPolls tripId={tripId} currentUser={user} />
|
||||
{/* Polls + What's Next — bottom row */}
|
||||
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<CollabPolls tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<WhatsNextWidget tripMembers={tripMembers} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Mobile: tab bar + single panel
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'absolute', inset: 0 }}>
|
||||
<div style={{
|
||||
display: 'flex', gap: 2, padding: '8px 12px', borderBottom: '1px solid var(--border-faint)',
|
||||
background: 'var(--bg-card)', flexShrink: 0,
|
||||
}}>
|
||||
{tabs.map(tab => {
|
||||
const Icon = tab.icon
|
||||
const active = mobileTab === tab.id
|
||||
return (
|
||||
<button key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
padding: '8px 0', borderRadius: 10, border: 'none', cursor: 'pointer',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
color: active ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||
fontSize: 11, fontWeight: 600, fontFamily: 'inherit',
|
||||
transition: 'all 0.15s',
|
||||
}}>
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||
{mobileTab === 'chat' && <CollabChat tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'next' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,189 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MapPin, Clock, Calendar, Users, Sparkles } from 'lucide-react'
|
||||
|
||||
function formatTime(timeStr, is12h) {
|
||||
if (!timeStr) return ''
|
||||
const [h, m] = timeStr.split(':').map(Number)
|
||||
if (is12h) {
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
return `${h12}:${String(m).padStart(2, '0')} ${period}`
|
||||
}
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatDayLabel(date, t, locale) {
|
||||
const d = new Date(date + 'T00:00:00')
|
||||
const now = new Date()
|
||||
const tomorrow = new Date(); tomorrow.setDate(now.getDate() + 1)
|
||||
|
||||
if (d.toDateString() === now.toDateString()) return t('collab.whatsNext.today') || 'Today'
|
||||
if (d.toDateString() === tomorrow.toDateString()) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
|
||||
|
||||
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
export default function WhatsNextWidget({ tripMembers = [] }) {
|
||||
const { days, assignments } = useTripStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
|
||||
const upcoming = useMemo(() => {
|
||||
const now = new Date()
|
||||
const nowDate = now.toISOString().split('T')[0]
|
||||
const nowTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
|
||||
const items = []
|
||||
|
||||
for (const day of (days || [])) {
|
||||
if (!day.date) continue
|
||||
const dayAssignments = assignments[String(day.id)] || []
|
||||
for (const a of dayAssignments) {
|
||||
if (!a.place) continue
|
||||
// Include: today (future times) + all future days
|
||||
const isFutureDay = day.date > nowDate
|
||||
const isTodayFuture = day.date === nowDate && (!a.place.place_time || a.place.place_time >= nowTime)
|
||||
if (isFutureDay || isTodayFuture) {
|
||||
items.push({
|
||||
id: a.id,
|
||||
name: a.place.name,
|
||||
time: a.place.place_time,
|
||||
endTime: a.place.end_time,
|
||||
date: day.date,
|
||||
dayTitle: day.title,
|
||||
category: a.place.category,
|
||||
participants: (a.participants && a.participants.length > 0)
|
||||
? a.participants
|
||||
: tripMembers.map(m => ({ user_id: m.id, username: m.username, avatar: m.avatar })),
|
||||
address: a.place.address,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items.sort((a, b) => {
|
||||
const da = a.date + (a.time || '99:99')
|
||||
const db = b.date + (b.time || '99:99')
|
||||
return da.localeCompare(db)
|
||||
})
|
||||
|
||||
return items.slice(0, 8)
|
||||
}, [days, assignments, tripMembers])
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 7, flexShrink: 0,
|
||||
}}>
|
||||
<Sparkles size={14} color="var(--text-faint)" />
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
{t('collab.whatsNext.title') || "What's Next"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '8px 10px' }}>
|
||||
{upcoming.length === 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: '48px 20px', textAlign: 'center' }}>
|
||||
<Calendar size={36} color="var(--text-faint)" strokeWidth={1.3} style={{ marginBottom: 12 }} />
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.whatsNext.empty')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('collab.whatsNext.emptyHint')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{upcoming.map((item, idx) => {
|
||||
const prevItem = upcoming[idx - 1]
|
||||
const showDayHeader = !prevItem || prevItem.date !== item.date
|
||||
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{showDayHeader && (
|
||||
<div style={{
|
||||
fontSize: 10, fontWeight: 500, color: 'var(--text-faint)',
|
||||
textTransform: 'uppercase', letterSpacing: 0.5,
|
||||
padding: idx === 0 ? '0 4px 4px' : '8px 4px 4px',
|
||||
}}>
|
||||
{formatDayLabel(item.date, t, locale)}
|
||||
{item.dayTitle ? ` — ${item.dayTitle}` : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'flex', gap: 10, padding: '8px 10px', borderRadius: 10,
|
||||
background: 'var(--bg-secondary)', transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
|
||||
>
|
||||
{/* Time column */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: 44, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
|
||||
{item.time ? formatTime(item.time, is12h) : 'TBD'}
|
||||
</span>
|
||||
{item.endTime && (
|
||||
<>
|
||||
<span style={{ fontSize: 7, color: 'var(--text-faint)', fontWeight: 600, letterSpacing: 0.3, margin: '2px 0', textTransform: 'uppercase' }}>
|
||||
{t('collab.whatsNext.until') || 'bis'}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
|
||||
{formatTime(item.endTime, is12h)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{ width: 1, alignSelf: 'stretch', background: 'var(--border-faint)', flexShrink: 0, margin: '2px 0' }} />
|
||||
|
||||
{/* Details */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-primary)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.name}
|
||||
</div>
|
||||
{item.address && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 2 }}>
|
||||
<MapPin size={9} color="var(--text-faint)" style={{ flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.address}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Participants */}
|
||||
{item.participants.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 5 }}>
|
||||
{item.participants.map(p => (
|
||||
<div key={p.user_id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 3px',
|
||||
borderRadius: 99, background: 'var(--bg-tertiary)', border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: '50%', background: 'var(--bg-secondary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 7, fontWeight: 700, color: 'var(--text-muted)',
|
||||
overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{p.avatar
|
||||
? <img src={`/uploads/avatars/${p.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: p.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
<span style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)' }}>{p.username}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket } from 'lucide-react'
|
||||
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote } from 'lucide-react'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
@@ -128,6 +128,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
if (filterType === 'pdf') return f.mime_type === 'application/pdf'
|
||||
if (filterType === 'image') return isImage(f.mime_type)
|
||||
if (filterType === 'doc') return (f.mime_type || '').includes('word') || (f.mime_type || '').includes('excel') || (f.mime_type || '').includes('text')
|
||||
if (filterType === 'collab') return !!f.note_id
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -243,6 +244,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
{ id: 'pdf', label: t('files.filterPdf') },
|
||||
{ id: 'image', label: t('files.filterImages') },
|
||||
{ id: 'doc', label: t('files.filterDocs') },
|
||||
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
|
||||
].map(tab => (
|
||||
<button key={tab.id} onClick={() => setFilterType(tab.id)} style={{
|
||||
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 12,
|
||||
@@ -273,7 +275,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
const linkedReservation = file.reservation_id
|
||||
? (reservations?.find(r => r.id === file.reservation_id) || { title: file.reservation_title })
|
||||
: null
|
||||
const fileUrl = file.url || `/uploads/files/${file.filename}`
|
||||
const fileUrl = file.url || (file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`)
|
||||
|
||||
return (
|
||||
<div key={file.id} style={{
|
||||
@@ -296,7 +298,15 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
>
|
||||
{isImage(file.mime_type)
|
||||
? <img src={fileUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: <FileIcon size={16} style={{ color: 'var(--text-muted)' }} />
|
||||
: (() => {
|
||||
const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||
const isPdf = file.mime_type === 'application/pdf'
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', background: isPdf ? '#ef44441a' : 'var(--bg-tertiary)' }}>
|
||||
<span style={{ fontSize: 9, fontWeight: 700, color: isPdf ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||
</div>
|
||||
)
|
||||
})()
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -325,6 +335,12 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
label={`${t('files.sourceBooking')} · ${linkedReservation.title || t('files.sourceBooking')}`}
|
||||
/>
|
||||
)}
|
||||
{file.note_id && (
|
||||
<SourceBadge
|
||||
icon={StickyNote}
|
||||
label={t('files.sourceCollab') || 'Collab Notes'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{file.description && !linkedReservation && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useRef, useState, useMemo } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, useMap } from 'react-leaflet'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import L from 'leaflet'
|
||||
@@ -158,6 +159,50 @@ function MapClickHandler({ onClick }) {
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Route travel time label ──
|
||||
function RouteLabel({ midpoint, walkingText, drivingText }) {
|
||||
const map = useMap()
|
||||
const [visible, setVisible] = useState(map ? map.getZoom() >= 16 : false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return
|
||||
const check = () => setVisible(map.getZoom() >= 16)
|
||||
check()
|
||||
map.on('zoomend', check)
|
||||
return () => map.off('zoomend', check)
|
||||
}, [map])
|
||||
|
||||
if (!visible || !midpoint) return null
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: 'route-info-pill',
|
||||
html: `<div style="
|
||||
display:flex;align-items:center;gap:5px;
|
||||
background:rgba(0,0,0,0.85);backdrop-filter:blur(8px);
|
||||
color:#fff;border-radius:99px;padding:3px 9px;
|
||||
font-size:9px;font-weight:600;white-space:nowrap;
|
||||
font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
|
||||
box-shadow:0 2px 12px rgba(0,0,0,0.3);
|
||||
pointer-events:none;
|
||||
position:relative;left:-50%;top:-50%;
|
||||
">
|
||||
<span style="display:flex;align-items:center;gap:2px">
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="13" cy="4" r="2"/><path d="M7 21l3-7"/><path d="M10 14l5-5"/><path d="M15 9l-4 7"/><path d="M18 18l-3-7"/></svg>
|
||||
${walkingText}
|
||||
</span>
|
||||
<span style="opacity:0.3">|</span>
|
||||
<span style="display:flex;align-items:center;gap:2px">
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9L18 10l-2-4H7L5 10l-2.5 1.1C1.7 11.3 1 12.1 1 13v3c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><circle cx="17" cy="17" r="2"/></svg>
|
||||
${drivingText}
|
||||
</span>
|
||||
</div>`,
|
||||
iconSize: [0, 0],
|
||||
iconAnchor: [0, 0],
|
||||
})
|
||||
|
||||
return <Marker position={midpoint} icon={icon} interactive={false} zIndexOffset={2000} />
|
||||
}
|
||||
|
||||
// Module-level photo cache shared with PlaceAvatar
|
||||
const mapPhotoCache = new Map()
|
||||
|
||||
@@ -165,6 +210,7 @@ export function MapView({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
route = null,
|
||||
routeSegments = [],
|
||||
selectedPlaceId = null,
|
||||
onMarkerClick,
|
||||
onMapClick,
|
||||
@@ -297,13 +343,18 @@ export function MapView({
|
||||
</MarkerClusterGroup>
|
||||
|
||||
{route && route.length > 1 && (
|
||||
<Polyline
|
||||
positions={route}
|
||||
color="#111827"
|
||||
weight={3}
|
||||
opacity={0.9}
|
||||
dashArray="6, 5"
|
||||
/>
|
||||
<>
|
||||
<Polyline
|
||||
positions={route}
|
||||
color="#111827"
|
||||
weight={3}
|
||||
opacity={0.9}
|
||||
dashArray="6, 5"
|
||||
/>
|
||||
{routeSegments.map((seg, i) => (
|
||||
<RouteLabel key={i} midpoint={seg.mid} from={seg.from} to={seg.to} walkingText={seg.walkingText} drivingText={seg.drivingText} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</MapContainer>
|
||||
)
|
||||
|
||||
@@ -41,12 +41,17 @@ export async function calculateRoute(waypoints, profile = 'driving') {
|
||||
duration = route.duration // driving: use OSRM value
|
||||
}
|
||||
|
||||
const walkingDuration = distance / (5000 / 3600) // 5 km/h
|
||||
const drivingDuration = route.duration // OSRM driving value
|
||||
|
||||
return {
|
||||
coordinates,
|
||||
distance,
|
||||
duration,
|
||||
distanceText: formatDistance(distance),
|
||||
durationText: formatDuration(duration),
|
||||
walkingText: formatDuration(walkingDuration),
|
||||
drivingText: formatDuration(drivingDuration),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -92,7 +92,13 @@ export default function DayPlanSidebar({
|
||||
|
||||
const dayNotes = tripStore.dayNotes || {}
|
||||
|
||||
const [expandedDays, setExpandedDays] = useState(() => new Set(days.map(d => d.id)))
|
||||
const [expandedDays, setExpandedDays] = useState(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(`day-expanded-${tripId}`)
|
||||
if (saved) return new Set(JSON.parse(saved))
|
||||
} catch {}
|
||||
return new Set(days.map(d => d.id))
|
||||
})
|
||||
const [editingDayId, setEditingDayId] = useState(null)
|
||||
const [editTitle, setEditTitle] = useState('')
|
||||
const [isCalculating, setIsCalculating] = useState(false)
|
||||
@@ -128,9 +134,20 @@ export default function DayPlanSidebar({
|
||||
return { placeId, assignmentId: '', noteId: '', fromDayId: 0 }
|
||||
}
|
||||
|
||||
// Only auto-expand genuinely new days (not on initial load from storage)
|
||||
const prevDayCount = React.useRef(days.length)
|
||||
useEffect(() => {
|
||||
setExpandedDays(prev => new Set([...prev, ...days.map(d => d.id)]))
|
||||
}, [days.length])
|
||||
if (days.length > prevDayCount.current) {
|
||||
// New days added — expand only those
|
||||
setExpandedDays(prev => {
|
||||
const n = new Set(prev)
|
||||
days.forEach(d => { if (!prev.has(d.id)) n.add(d.id) })
|
||||
try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {}
|
||||
return n
|
||||
})
|
||||
}
|
||||
prevDayCount.current = days.length
|
||||
}, [days.length, tripId])
|
||||
|
||||
useEffect(() => {
|
||||
if (editingDayId && inputRef.current) inputRef.current.focus()
|
||||
@@ -154,6 +171,7 @@ export default function DayPlanSidebar({
|
||||
setExpandedDays(prev => {
|
||||
const n = new Set(prev)
|
||||
n.has(dayId) ? n.delete(dayId) : n.add(dayId)
|
||||
try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {}
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user