From 785e8264cdc392778752d7bc51a5af00b2cc218a Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 25 Mar 2026 00:14:53 +0100 Subject: [PATCH] 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 --- client/src/api/client.js | 18 + client/src/components/Admin/AddonManager.jsx | 21 +- client/src/components/Collab/CollabChat.jsx | 615 ++++++++++ client/src/components/Collab/CollabNotes.jsx | 1024 ++++++++++++++++ client/src/components/Collab/CollabPanel.jsx | 34 + client/src/components/Collab/CollabPolls.jsx | 1046 +++++++++++++++++ .../src/components/Planner/PlaceInspector.jsx | 4 +- client/src/i18n/translations/de.js | 38 + client/src/i18n/translations/en.js | 38 + client/src/pages/TripPlannerPage.jsx | 10 +- docker-compose.yml | 2 +- server/src/db/database.js | 100 ++ server/src/index.js | 3 + server/src/routes/collab.js | 365 ++++++ 14 files changed, 3307 insertions(+), 11 deletions(-) create mode 100644 client/src/components/Collab/CollabChat.jsx create mode 100644 client/src/components/Collab/CollabNotes.jsx create mode 100644 client/src/components/Collab/CollabPanel.jsx create mode 100644 client/src/components/Collab/CollabPolls.jsx create mode 100644 server/src/routes/collab.js diff --git a/client/src/api/client.js b/client/src/api/client.js index 72f1673..e793772 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -194,6 +194,24 @@ export const dayNotesApi = { delete: (tripId, dayId, id) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data), } +export const collabApi = { + // Notes + getNotes: (tripId) => apiClient.get(`/trips/${tripId}/collab/notes`).then(r => r.data), + createNote: (tripId, data) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data), + updateNote: (tripId, id, data) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data), + deleteNote: (tripId, id) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data), + // Polls + getPolls: (tripId) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data), + createPoll: (tripId, data) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data), + votePoll: (tripId, id, optionIndex) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex }).then(r => r.data), + closePoll: (tripId, id) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data), + deletePoll: (tripId, id) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data), + // Chat + getMessages: (tripId, before) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data), + sendMessage: (tripId, data) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data), + deleteMessage: (tripId, id) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data), +} + export const backupApi = { list: () => apiClient.get('/backup/list').then(r => r.data), create: () => apiClient.post('/backup/create').then(r => r.data), diff --git a/client/src/components/Admin/AddonManager.jsx b/client/src/components/Admin/AddonManager.jsx index 30bba76..3f91448 100644 --- a/client/src/components/Admin/AddonManager.jsx +++ b/client/src/components/Admin/AddonManager.jsx @@ -118,8 +118,9 @@ export default function AddonManager() { } function AddonRow({ addon, onToggle, t }) { + const isComingSoon = addon.id === 'collab' return ( -
+
{/* Icon */}
@@ -129,6 +130,11 @@ function AddonRow({ addon, onToggle, t }) {
{addon.name} + {isComingSoon && ( + + Coming Soon + + )} - - {addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} + + {isComingSoon ? t('admin.addons.disabled') : addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} diff --git a/client/src/components/Collab/CollabChat.jsx b/client/src/components/Collab/CollabChat.jsx new file mode 100644 index 0000000..78b22e9 --- /dev/null +++ b/client/src/components/Collab/CollabChat.jsx @@ -0,0 +1,615 @@ +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 ( +
+
+
+ +
+
+ ) + } + + /* ───────── render: main ───────── */ + return ( +
+ {/* ── messages area ── */} + {messages.length === 0 ? ( +
+ + + {t('collab.chat.empty')} + + + {t('collab.chat.emptyDesc') || ''} + +
+ ) : ( +
+ {/* load more */} + {hasMore && ( +
+ +
+ )} + + {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 ( +
+ {/* username + avatar for others */} + {showHeader && ( +
+ {msg.user_avatar ? ( + + ) : ( + + {(msg.username || '?')[0].toUpperCase()} + + )} + + {msg.username} + +
+ )} + + {/* reply quote */} + {repliedMsg && ( +
+ {repliedMsg.username}: + {(repliedMsg.text || '').slice(0, 80)} + {(repliedMsg.text || '').length > 80 ? '...' : ''} +
+ )} + + {/* bubble with hover actions */} +
setHoveredId(msg.id)} + onMouseLeave={() => setHoveredId(null)} + > +
+ {msg.text} +
+ + {/* action buttons */} +
+ + {own && ( + + )} +
+
+ + {/* timestamp */} + + {formatRelativeTime(msg.created_at, t)} + +
+ ) + })} +
+ )} + + {/* ── composer ── */} +
+ {/* reply preview */} + {replyTo && ( +
+ + + {replyTo.username}: {(replyTo.text || '').slice(0, 60)} + {(replyTo.text || '').length > 60 ? '...' : ''} + + setReplyTo(null)} + > + × + +
+ )} + +
+