mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11:45 +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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user