Files
TREK/client/src/components/Collab/CollabChat.jsx
Maurice 785e8264cd Health endpoint, file types config, budget rename, UI fixes
- Add /api/health endpoint (returns 200 OK without auth)
- Update docker-compose healthcheck to use /api/health
- Admin: configurable allowed file types
- Budget categories can now be renamed (inline edit)
- Place inspector: opening hours + files side by side on desktop
- Address clamped to 2 lines, coordinates hidden on mobile
- Category icon-only on mobile, rating hidden on mobile
- Time validation: "10" becomes "10:00"
- Hotel picker: separate save button, edit opens full popup
- Day header background improved for dark mode
- Notes: 150 char limit with counter, textarea input
- Files grid: full width when no opening hours
- Various responsive fixes
2026-03-25 00:14:53 +01:00

616 lines
20 KiB
JavaScript

import React, { useState, useEffect, useRef, useCallback } from 'react'
import { Send, Trash2, Reply, ChevronUp, MessageCircle } from 'lucide-react'
import { collabApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
import { useTranslation } from '../../i18n'
/* ───────── relative timestamp ───────── */
function formatRelativeTime(isoString, t) {
const now = Date.now()
const then = new Date(isoString).getTime()
const diff = now - then
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
if (seconds < 60) return t('collab.chat.justNow')
if (minutes < 60) return t('collab.chat.minutesAgo', { n: minutes })
if (hours < 24) return t('collab.chat.hoursAgo', { n: hours })
const d = new Date(isoString)
const today = new Date()
const yesterday = new Date()
yesterday.setDate(today.getDate() - 1)
if (
d.getDate() === yesterday.getDate() &&
d.getMonth() === yesterday.getMonth() &&
d.getFullYear() === yesterday.getFullYear()
) {
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return `yesterday ${hh}:${mm}`
}
const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
return `${monthNames[d.getMonth()]} ${d.getDate()}`
}
/* ─────────────────────────────────────── */
/* Component */
/* ─────────────────────────────────────── */
export default function CollabChat({ tripId, currentUser }) {
const { t } = useTranslation()
const [messages, setMessages] = useState([])
const [loading, setLoading] = useState(true)
const [hasMore, setHasMore] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [text, setText] = useState('')
const [replyTo, setReplyTo] = useState(null)
const [hoveredId, setHoveredId] = useState(null)
const [sending, setSending] = useState(false)
const scrollRef = useRef(null)
const textareaRef = useRef(null)
const isAtBottom = useRef(true)
/* ── scroll helpers ── */
const scrollToBottom = useCallback((behavior = 'auto') => {
const el = scrollRef.current
if (!el) return
requestAnimationFrame(() => {
el.scrollTo({ top: el.scrollHeight, behavior })
})
}, [])
const checkAtBottom = useCallback(() => {
const el = scrollRef.current
if (!el) return
isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48
}, [])
/* ── load messages ── */
useEffect(() => {
let cancelled = false
setLoading(true)
collabApi.getMessages(tripId).then(data => {
if (cancelled) return
const msgs = Array.isArray(data) ? data : data.messages || []
setMessages(msgs)
setHasMore(msgs.length >= 100)
setLoading(false)
setTimeout(() => scrollToBottom(), 30)
}).catch(() => {
if (!cancelled) setLoading(false)
})
return () => { cancelled = true }
}, [tripId, scrollToBottom])
/* ── load more (older messages) ── */
const handleLoadMore = useCallback(async () => {
if (loadingMore || messages.length === 0) return
setLoadingMore(true)
const el = scrollRef.current
const prevHeight = el ? el.scrollHeight : 0
try {
const oldestId = messages[0]?.id
const data = await collabApi.getMessages(tripId, oldestId)
const older = Array.isArray(data) ? data : data.messages || []
if (older.length === 0) {
setHasMore(false)
} else {
setMessages(prev => [...older, ...prev])
setHasMore(older.length >= 100)
requestAnimationFrame(() => {
if (el) el.scrollTop = el.scrollHeight - prevHeight
})
}
} catch {
// silently ignore
} finally {
setLoadingMore(false)
}
}, [tripId, loadingMore, messages])
/* ── websocket ── */
useEffect(() => {
const handler = (event) => {
if (event.type === 'collab:message:created' && String(event.tripId) === String(tripId)) {
setMessages(prev => {
if (prev.some(m => m.id === event.message.id)) return prev
return [...prev, event.message]
})
if (isAtBottom.current) {
setTimeout(() => scrollToBottom('smooth'), 30)
}
}
if (event.type === 'collab:message:deleted' && String(event.tripId) === String(tripId)) {
setMessages(prev => prev.filter(m => m.id !== event.messageId))
}
}
addListener(handler)
return () => removeListener(handler)
}, [tripId, scrollToBottom])
/* ── auto-resize textarea ── */
const handleTextChange = useCallback((e) => {
setText(e.target.value)
const ta = textareaRef.current
if (ta) {
ta.style.height = 'auto'
ta.style.height = Math.min(ta.scrollHeight, 3 * 20 + 18) + 'px'
}
}, [])
/* ── send ── */
const handleSend = useCallback(async () => {
const body = text.trim()
if (!body || sending) return
setSending(true)
try {
const payload = { text: body }
if (replyTo) payload.reply_to = replyTo.id
const data = await collabApi.sendMessage(tripId, payload)
if (data?.message) {
setMessages(prev => {
if (prev.some(m => m.id === data.message.id)) return prev
return [...prev, data.message]
})
}
setText('')
setReplyTo(null)
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
scrollToBottom('smooth')
} catch {
// keep text on failure so user can retry
} finally {
setSending(false)
}
}, [text, sending, replyTo, tripId, scrollToBottom])
/* ── keyboard ── */
const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}, [handleSend])
/* ── delete ── */
const handleDelete = useCallback(async (msgId) => {
try {
await collabApi.deleteMessage(tripId, msgId)
} catch {
// ignore
}
}, [tripId])
/* ── find a replied-to message ── */
const findMessage = useCallback((id) => messages.find(m => m.id === id), [messages])
/* ── helpers ── */
const isOwn = (msg) => String(msg.user_id) === String(currentUser.id)
const font = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif"
/* ───────── render: loading ───────── */
if (loading) {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
flex: 1,
fontFamily: font,
position: 'relative',
overflow: 'hidden',
}}>
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
color: 'var(--text-faint)',
userSelect: 'none',
padding: 32,
}}>
<div style={{
width: 24,
height: 24,
border: '2px solid var(--border-faint)',
borderTopColor: 'var(--accent)',
borderRadius: '50%',
animation: 'spin .7s linear infinite',
}} />
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
</div>
)
}
/* ───────── render: main ───────── */
return (
<div style={{
display: 'flex',
flexDirection: 'column',
flex: 1,
fontFamily: font,
position: 'relative',
overflow: 'hidden',
}}>
{/* ── messages area ── */}
{messages.length === 0 ? (
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
color: 'var(--text-faint)',
userSelect: 'none',
padding: 32,
}}>
<MessageCircle size={36} strokeWidth={1.3} style={{ opacity: 0.5 }} />
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-faint)' }}>
{t('collab.chat.empty')}
</span>
<span style={{ fontSize: 12, color: 'var(--text-faint)', opacity: 0.7 }}>
{t('collab.chat.emptyDesc') || ''}
</span>
</div>
) : (
<div
ref={scrollRef}
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
padding: '12px 16px 8px',
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
onScroll={checkAtBottom}
>
{/* load more */}
{hasMore && (
<div style={{
display: 'flex',
justifyContent: 'center',
padding: '4px 0 8px',
}}>
<button
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
fontSize: 11,
fontWeight: 600,
color: 'var(--text-muted)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-faint)',
borderRadius: 99,
padding: '5px 14px',
cursor: 'pointer',
fontFamily: font,
textTransform: 'uppercase',
letterSpacing: 0.3,
}}
onClick={handleLoadMore}
disabled={loadingMore}
>
<ChevronUp size={13} />
{loadingMore ? '...' : t('collab.chat.loadMore')}
</button>
</div>
)}
{messages.map((msg, idx) => {
const own = isOwn(msg)
const repliedMsg = msg.reply_to_id ? findMessage(msg.reply_to_id) : null
const prevMsg = messages[idx - 1]
const isNewGroup = idx === 0 || String(prevMsg?.user_id) !== String(msg.user_id)
const showHeader = !own && isNewGroup
return (
<div
key={msg.id}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: own ? 'flex-end' : 'flex-start',
marginTop: isNewGroup ? 12 : 0,
}}
>
{/* username + avatar for others */}
{showHeader && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 4,
paddingLeft: 2,
}}>
{msg.user_avatar ? (
<img
src={msg.user_avatar}
alt=""
style={{
width: 20,
height: 20,
borderRadius: '50%',
objectFit: 'cover',
flexShrink: 0,
}}
/>
) : (
<span style={{
width: 20,
height: 20,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 9,
fontWeight: 700,
color: 'var(--text-primary)',
background: 'var(--bg-tertiary)',
flexShrink: 0,
lineHeight: 1,
}}>
{(msg.username || '?')[0].toUpperCase()}
</span>
)}
<span style={{
fontSize: 10,
fontWeight: 600,
color: 'var(--text-faint)',
lineHeight: 1,
}}>
{msg.username}
</span>
</div>
)}
{/* reply quote */}
{repliedMsg && (
<div style={{
padding: '6px 10px',
borderLeft: '2px solid var(--accent)',
background: 'var(--bg-secondary)',
borderRadius: 6,
fontSize: 11,
lineHeight: 1.35,
marginBottom: 4,
maxWidth: '75%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: 'var(--text-muted)',
alignSelf: own ? 'flex-end' : 'flex-start',
}}>
<strong style={{ fontWeight: 600 }}>{repliedMsg.username}: </strong>
{(repliedMsg.text || '').slice(0, 80)}
{(repliedMsg.text || '').length > 80 ? '...' : ''}
</div>
)}
{/* bubble with hover actions */}
<div
style={{ position: 'relative', maxWidth: '75%' }}
onMouseEnter={() => setHoveredId(msg.id)}
onMouseLeave={() => setHoveredId(null)}
>
<div style={{
background: own ? 'var(--accent)' : 'var(--bg-secondary)',
color: own ? 'var(--accent-text)' : 'var(--text-primary)',
borderRadius: own ? '14px 14px 4px 14px' : '14px 14px 14px 4px',
padding: '8px 12px',
fontSize: 13,
lineHeight: 1.45,
wordBreak: 'break-word',
whiteSpace: 'pre-wrap',
}}>
{msg.text}
</div>
{/* action buttons */}
<div style={{
position: 'absolute',
top: -10,
display: 'flex',
gap: 2,
opacity: hoveredId === msg.id ? 1 : 0,
pointerEvents: hoveredId === msg.id ? 'auto' : 'none',
transition: 'opacity .12s ease',
...(own ? { right: 4 } : { left: 4 }),
}}>
<button
style={{
width: 22,
height: 22,
borderRadius: '50%',
border: '1px solid var(--border-faint)',
background: 'var(--bg-card)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: 'var(--text-muted)',
padding: 0,
flexShrink: 0,
}}
title="Reply"
onClick={() => setReplyTo(msg)}
>
<Reply size={11} />
</button>
{own && (
<button
style={{
width: 22,
height: 22,
borderRadius: '50%',
border: '1px solid var(--border-faint)',
background: 'var(--bg-card)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: 'var(--text-muted)',
padding: 0,
flexShrink: 0,
}}
title="Delete"
onClick={() => handleDelete(msg.id)}
>
<Trash2 size={11} />
</button>
)}
</div>
</div>
{/* timestamp */}
<span style={{
fontSize: 9,
color: 'var(--text-faint)',
marginTop: 2,
paddingLeft: 2,
paddingRight: 2,
lineHeight: 1,
}}>
{formatRelativeTime(msg.created_at, t)}
</span>
</div>
)
})}
</div>
)}
{/* ── composer ── */}
<div style={{
flexShrink: 0,
padding: 12,
borderTop: '1px solid var(--border-faint)',
background: 'var(--bg-card)',
}}>
{/* reply preview */}
{replyTo && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: 8,
marginBottom: 8,
padding: '6px 10px',
borderRadius: 6,
background: 'var(--bg-secondary)',
borderLeft: '2px solid var(--accent)',
fontSize: 12,
color: 'var(--text-muted)',
lineHeight: 1.3,
}}>
<Reply size={13} style={{ flexShrink: 0, opacity: 0.6 }} />
<span style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
<strong>{replyTo.username}</strong>: {(replyTo.text || '').slice(0, 60)}
{(replyTo.text || '').length > 60 ? '...' : ''}
</span>
<span
style={{
marginLeft: 'auto',
cursor: 'pointer',
color: 'var(--text-faint)',
fontSize: 16,
fontWeight: 400,
lineHeight: 1,
padding: '0 2px',
flexShrink: 0,
}}
onClick={() => setReplyTo(null)}
>
&times;
</span>
</div>
)}
<div style={{
display: 'flex',
alignItems: 'flex-end',
gap: 8,
}}>
<textarea
ref={textareaRef}
rows={1}
style={{
flex: 1,
resize: 'none',
border: '1px solid var(--border-primary)',
borderRadius: 10,
padding: '8px 12px',
fontSize: 13,
lineHeight: 1.4,
fontFamily: font,
background: 'var(--bg-input)',
color: 'var(--text-primary)',
outline: 'none',
maxHeight: 3 * 20 + 18,
overflow: 'auto',
transition: 'border-color .15s ease',
}}
placeholder={t('collab.chat.placeholder')}
value={text}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
onFocus={() => {
if (textareaRef.current) {
textareaRef.current.style.borderColor = 'var(--accent)'
}
}}
onBlur={() => {
if (textareaRef.current) {
textareaRef.current.style.borderColor = 'var(--border-primary)'
}
}}
/>
<button
style={{
width: 32,
height: 32,
borderRadius: '50%',
border: 'none',
background: 'var(--accent)',
color: 'var(--accent-text)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: (!text.trim() || sending) ? 'default' : 'pointer',
flexShrink: 0,
opacity: (!text.trim() || sending) ? 0.4 : 1,
transition: 'opacity .15s ease',
}}
onClick={handleSend}
disabled={!text.trim() || sending}
>
<Send size={14} style={{ marginLeft: 1 }} />
</button>
</div>
</div>
</div>
)
}