mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-07-09 15:05:59 +00:00
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
This commit is contained in:
@@ -194,6 +194,24 @@ export const dayNotesApi = {
|
|||||||
delete: (tripId, dayId, id) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data),
|
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 = {
|
export const backupApi = {
|
||||||
list: () => apiClient.get('/backup/list').then(r => r.data),
|
list: () => apiClient.get('/backup/list').then(r => r.data),
|
||||||
create: () => apiClient.post('/backup/create').then(r => r.data),
|
create: () => apiClient.post('/backup/create').then(r => r.data),
|
||||||
|
|||||||
@@ -118,8 +118,9 @@ export default function AddonManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AddonRow({ addon, onToggle, t }) {
|
function AddonRow({ addon, onToggle, t }) {
|
||||||
|
const isComingSoon = addon.id === 'collab'
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)' }}>
|
<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 */}
|
{/* Icon */}
|
||||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}>
|
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}>
|
||||||
<AddonIcon name={addon.icon} size={20} />
|
<AddonIcon name={addon.icon} size={20} />
|
||||||
@@ -129,6 +130,11 @@ function AddonRow({ addon, onToggle, t }) {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{addon.name}</span>
|
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{addon.name}</span>
|
||||||
|
{isComingSoon && (
|
||||||
|
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full" style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}>
|
||||||
|
Coming Soon
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{
|
||||||
background: addon.type === 'global' ? 'var(--bg-secondary)' : 'var(--bg-secondary)',
|
background: addon.type === 'global' ? 'var(--bg-secondary)' : 'var(--bg-secondary)',
|
||||||
color: 'var(--text-muted)',
|
color: 'var(--text-muted)',
|
||||||
@@ -141,19 +147,20 @@ function AddonRow({ addon, onToggle, t }) {
|
|||||||
|
|
||||||
{/* Toggle */}
|
{/* Toggle */}
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<span className="text-xs font-medium" style={{ color: addon.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
<span className="text-xs font-medium" style={{ color: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||||
{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')}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => onToggle(addon)}
|
onClick={() => !isComingSoon && onToggle(addon)}
|
||||||
|
disabled={isComingSoon}
|
||||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
style={{ background: addon.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
style={{ background: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="inline-block h-4 w-4 transform rounded-full transition-transform"
|
className="inline-block h-4 w-4 transform rounded-full transition-transform"
|
||||||
style={{
|
style={{
|
||||||
background: addon.enabled ? 'var(--bg-card)' : 'var(--bg-card)',
|
background: 'var(--bg-card)',
|
||||||
transform: addon.enabled ? 'translateX(22px)' : 'translateX(4px)',
|
transform: (addon.enabled && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<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)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import CollabChat from './CollabChat'
|
||||||
|
import CollabNotes from './CollabNotes'
|
||||||
|
import CollabPolls from './CollabPolls'
|
||||||
|
|
||||||
|
export default function CollabPanel({ tripId }) {
|
||||||
|
const { user } = useAuthStore()
|
||||||
|
|
||||||
|
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' }}>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{/* 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' }}>
|
||||||
|
<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} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -344,8 +344,8 @@ export default function PlaceInspector({
|
|||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Opening hours + Files — side by side on desktop */}
|
{/* Opening hours + Files — side by side on desktop only if both exist */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<div className={`grid grid-cols-1 ${openingHours?.length > 0 ? 'sm:grid-cols-2' : ''} gap-2`}>
|
||||||
{openingHours && openingHours.length > 0 && (
|
{openingHours && openingHours.length > 0 && (
|
||||||
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -929,6 +929,44 @@ const de = {
|
|||||||
'day.confirmation': 'Bestätigung',
|
'day.confirmation': 'Bestätigung',
|
||||||
'day.editAccommodation': 'Unterkunft bearbeiten',
|
'day.editAccommodation': 'Unterkunft bearbeiten',
|
||||||
'day.reservations': 'Reservierungen',
|
'day.reservations': 'Reservierungen',
|
||||||
|
|
||||||
|
// Collab Addon
|
||||||
|
'collab.tabs.chat': 'Chat',
|
||||||
|
'collab.tabs.notes': 'Notizen',
|
||||||
|
'collab.tabs.polls': 'Umfragen',
|
||||||
|
'collab.chat.send': 'Senden',
|
||||||
|
'collab.chat.placeholder': 'Nachricht eingeben...',
|
||||||
|
'collab.chat.empty': 'Starte die Unterhaltung',
|
||||||
|
'collab.chat.emptyHint': 'Nachrichten werden mit allen Reiseteilnehmern geteilt',
|
||||||
|
'collab.chat.loadMore': 'Ältere Nachrichten laden',
|
||||||
|
'collab.chat.justNow': 'gerade eben',
|
||||||
|
'collab.chat.minutesAgo': 'vor {n} Min.',
|
||||||
|
'collab.chat.hoursAgo': 'vor {n} Std.',
|
||||||
|
'collab.chat.yesterday': 'gestern',
|
||||||
|
'collab.notes.title': 'Notizen',
|
||||||
|
'collab.notes.new': 'Neue Notiz',
|
||||||
|
'collab.notes.empty': 'Noch keine Notizen',
|
||||||
|
'collab.notes.emptyHint': 'Halte Ideen und Pläne fest',
|
||||||
|
'collab.notes.all': 'Alle',
|
||||||
|
'collab.notes.titlePlaceholder': 'Notiztitel',
|
||||||
|
'collab.notes.contentPlaceholder': 'Schreibe etwas...',
|
||||||
|
'collab.notes.categoryPlaceholder': 'Kategorie',
|
||||||
|
'collab.notes.newCategory': 'Neue Kategorie...',
|
||||||
|
'collab.polls.title': 'Umfragen',
|
||||||
|
'collab.polls.new': 'Neue Umfrage',
|
||||||
|
'collab.polls.empty': 'Noch keine Umfragen',
|
||||||
|
'collab.polls.emptyHint': 'Frage die Gruppe und stimmt gemeinsam ab',
|
||||||
|
'collab.polls.question': 'Frage',
|
||||||
|
'collab.polls.questionPlaceholder': 'Was sollen wir machen?',
|
||||||
|
'collab.polls.addOption': '+ Option hinzufügen',
|
||||||
|
'collab.polls.optionPlaceholder': 'Option {n}',
|
||||||
|
'collab.polls.create': 'Umfrage erstellen',
|
||||||
|
'collab.polls.close': 'Schließen',
|
||||||
|
'collab.polls.closed': 'Geschlossen',
|
||||||
|
'collab.polls.votes': '{n} Stimmen',
|
||||||
|
'collab.polls.vote': '{n} Stimme',
|
||||||
|
'collab.polls.multipleChoice': 'Mehrfachauswahl',
|
||||||
|
'collab.polls.deadline': 'Frist',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default de
|
export default de
|
||||||
|
|||||||
@@ -929,6 +929,44 @@ const en = {
|
|||||||
'day.confirmation': 'Confirmation',
|
'day.confirmation': 'Confirmation',
|
||||||
'day.editAccommodation': 'Edit accommodation',
|
'day.editAccommodation': 'Edit accommodation',
|
||||||
'day.reservations': 'Reservations',
|
'day.reservations': 'Reservations',
|
||||||
|
|
||||||
|
// Collab Addon
|
||||||
|
'collab.tabs.chat': 'Chat',
|
||||||
|
'collab.tabs.notes': 'Notes',
|
||||||
|
'collab.tabs.polls': 'Polls',
|
||||||
|
'collab.chat.send': 'Send',
|
||||||
|
'collab.chat.placeholder': 'Type a message...',
|
||||||
|
'collab.chat.empty': 'Start the conversation',
|
||||||
|
'collab.chat.emptyHint': 'Messages are shared with all trip members',
|
||||||
|
'collab.chat.loadMore': 'Load older messages',
|
||||||
|
'collab.chat.justNow': 'just now',
|
||||||
|
'collab.chat.minutesAgo': '{n}m ago',
|
||||||
|
'collab.chat.hoursAgo': '{n}h ago',
|
||||||
|
'collab.chat.yesterday': 'yesterday',
|
||||||
|
'collab.notes.title': 'Notes',
|
||||||
|
'collab.notes.new': 'New Note',
|
||||||
|
'collab.notes.empty': 'No notes yet',
|
||||||
|
'collab.notes.emptyHint': 'Start capturing ideas and plans',
|
||||||
|
'collab.notes.all': 'All',
|
||||||
|
'collab.notes.titlePlaceholder': 'Note title',
|
||||||
|
'collab.notes.contentPlaceholder': 'Write something...',
|
||||||
|
'collab.notes.categoryPlaceholder': 'Category',
|
||||||
|
'collab.notes.newCategory': 'New category...',
|
||||||
|
'collab.polls.title': 'Polls',
|
||||||
|
'collab.polls.new': 'New Poll',
|
||||||
|
'collab.polls.empty': 'No polls yet',
|
||||||
|
'collab.polls.emptyHint': 'Ask the group and vote together',
|
||||||
|
'collab.polls.question': 'Question',
|
||||||
|
'collab.polls.questionPlaceholder': 'What should we do?',
|
||||||
|
'collab.polls.addOption': '+ Add option',
|
||||||
|
'collab.polls.optionPlaceholder': 'Option {n}',
|
||||||
|
'collab.polls.create': 'Create Poll',
|
||||||
|
'collab.polls.close': 'Close',
|
||||||
|
'collab.polls.closed': 'Closed',
|
||||||
|
'collab.polls.votes': '{n} votes',
|
||||||
|
'collab.polls.vote': '{n} vote',
|
||||||
|
'collab.polls.multipleChoice': 'Multiple choice',
|
||||||
|
'collab.polls.deadline': 'Deadline',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default en
|
export default en
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import ReservationsPanel from '../components/Planner/ReservationsPanel'
|
|||||||
import PackingListPanel from '../components/Packing/PackingListPanel'
|
import PackingListPanel from '../components/Packing/PackingListPanel'
|
||||||
import FileManager from '../components/Files/FileManager'
|
import FileManager from '../components/Files/FileManager'
|
||||||
import BudgetPanel from '../components/Budget/BudgetPanel'
|
import BudgetPanel from '../components/Budget/BudgetPanel'
|
||||||
|
import CollabPanel from '../components/Collab/CollabPanel'
|
||||||
import Navbar from '../components/Layout/Navbar'
|
import Navbar from '../components/Layout/Navbar'
|
||||||
import { useToast } from '../components/shared/Toast'
|
import { useToast } from '../components/shared/Toast'
|
||||||
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react'
|
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react'
|
||||||
@@ -48,7 +49,7 @@ export default function TripPlannerPage() {
|
|||||||
addonsApi.enabled().then(data => {
|
addonsApi.enabled().then(data => {
|
||||||
const map = {}
|
const map = {}
|
||||||
data.addons.forEach(a => { map[a.id] = true })
|
data.addons.forEach(a => { map[a.id] = true })
|
||||||
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents })
|
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab })
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
authApi.getAppConfig().then(config => {
|
authApi.getAppConfig().then(config => {
|
||||||
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
||||||
@@ -61,6 +62,7 @@ export default function TripPlannerPage() {
|
|||||||
...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []),
|
...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []),
|
||||||
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget') }] : []),
|
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget') }] : []),
|
||||||
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files') }] : []),
|
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files') }] : []),
|
||||||
|
...(enabledAddons.collab ? [{ id: 'collab', label: 'Collab' }] : []),
|
||||||
]
|
]
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState('plan')
|
const [activeTab, setActiveTab] = useState('plan')
|
||||||
@@ -673,6 +675,12 @@ export default function TripPlannerPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'collab' && (
|
||||||
|
<div style={{ height: '100%', overflow: 'hidden', overscrollBehavior: 'contain' }}>
|
||||||
|
<CollabPanel tripId={tripId} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null) }} onSave={handleSavePlace} place={editingPlace} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} />
|
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null) }} onSave={handleSavePlace} place={editingPlace} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} />
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ services:
|
|||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/auth/me"]
|
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -316,6 +316,54 @@ function initDb() {
|
|||||||
notes TEXT,
|
notes TEXT,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Collab addon tables
|
||||||
|
CREATE TABLE IF NOT EXISTS collab_notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
category TEXT DEFAULT 'General',
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT,
|
||||||
|
color TEXT DEFAULT '#6366f1',
|
||||||
|
pinned INTEGER DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS collab_polls (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
options TEXT NOT NULL,
|
||||||
|
multiple INTEGER DEFAULT 0,
|
||||||
|
closed INTEGER DEFAULT 0,
|
||||||
|
deadline TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS collab_poll_votes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
poll_id INTEGER NOT NULL REFERENCES collab_polls(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
option_index INTEGER NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(poll_id, user_id, option_index)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS collab_messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
reply_to INTEGER REFERENCES collab_messages(id) ON DELETE SET NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_collab_notes_trip ON collab_notes(trip_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_collab_polls_trip ON collab_polls(trip_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_collab_messages_trip ON collab_messages(trip_id);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Create indexes for performance
|
// Create indexes for performance
|
||||||
@@ -457,6 +505,57 @@ function initDb() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
},
|
},
|
||||||
|
// 25: Collab addon tables
|
||||||
|
() => {
|
||||||
|
_db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS collab_notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
category TEXT DEFAULT 'General',
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT,
|
||||||
|
color TEXT DEFAULT '#6366f1',
|
||||||
|
pinned INTEGER DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS collab_polls (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
options TEXT NOT NULL,
|
||||||
|
multiple INTEGER DEFAULT 0,
|
||||||
|
closed INTEGER DEFAULT 0,
|
||||||
|
deadline TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS collab_poll_votes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
poll_id INTEGER NOT NULL REFERENCES collab_polls(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
option_index INTEGER NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(poll_id, user_id, option_index)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS collab_messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
reply_to INTEGER REFERENCES collab_messages(id) ON DELETE SET NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_collab_notes_trip ON collab_notes(trip_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_collab_polls_trip ON collab_polls(trip_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_collab_messages_trip ON collab_messages(trip_id);
|
||||||
|
`);
|
||||||
|
// Ensure collab addon exists for existing installations
|
||||||
|
try {
|
||||||
|
_db.prepare("INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('collab', 'Collab', 'Notes, polls, and live chat for trip collaboration', 'trip', 'Users', 0, 6)").run();
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
// Future migrations go here (append only, never reorder)
|
// Future migrations go here (append only, never reorder)
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -505,6 +604,7 @@ function initDb() {
|
|||||||
{ id: 'documents', name: 'Documents', description: 'Store and manage travel documents', type: 'trip', icon: 'FileText', sort_order: 2 },
|
{ id: 'documents', name: 'Documents', description: 'Store and manage travel documents', type: 'trip', icon: 'FileText', sort_order: 2 },
|
||||||
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', sort_order: 10 },
|
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', sort_order: 10 },
|
||||||
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', sort_order: 11 },
|
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', sort_order: 11 },
|
||||||
|
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', sort_order: 6 },
|
||||||
];
|
];
|
||||||
const insertAddon = _db.prepare('INSERT INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, 1, ?)');
|
const insertAddon = _db.prepare('INSERT INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, 1, ?)');
|
||||||
for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.sort_order);
|
for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.sort_order);
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ const dayNotesRoutes = require('./routes/dayNotes');
|
|||||||
const weatherRoutes = require('./routes/weather');
|
const weatherRoutes = require('./routes/weather');
|
||||||
const settingsRoutes = require('./routes/settings');
|
const settingsRoutes = require('./routes/settings');
|
||||||
const budgetRoutes = require('./routes/budget');
|
const budgetRoutes = require('./routes/budget');
|
||||||
|
const collabRoutes = require('./routes/collab');
|
||||||
const backupRoutes = require('./routes/backup');
|
const backupRoutes = require('./routes/backup');
|
||||||
|
|
||||||
const oidcRoutes = require('./routes/oidc');
|
const oidcRoutes = require('./routes/oidc');
|
||||||
@@ -83,8 +84,10 @@ app.use('/api/trips/:tripId/places', placesRoutes);
|
|||||||
app.use('/api/trips/:tripId/packing', packingRoutes);
|
app.use('/api/trips/:tripId/packing', packingRoutes);
|
||||||
app.use('/api/trips/:tripId/files', filesRoutes);
|
app.use('/api/trips/:tripId/files', filesRoutes);
|
||||||
app.use('/api/trips/:tripId/budget', budgetRoutes);
|
app.use('/api/trips/:tripId/budget', budgetRoutes);
|
||||||
|
app.use('/api/trips/:tripId/collab', collabRoutes);
|
||||||
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
|
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
|
||||||
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
|
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
|
||||||
|
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
|
||||||
app.use('/api', assignmentsRoutes);
|
app.use('/api', assignmentsRoutes);
|
||||||
app.use('/api/tags', tagsRoutes);
|
app.use('/api/tags', tagsRoutes);
|
||||||
app.use('/api/categories', categoriesRoutes);
|
app.use('/api/categories', categoriesRoutes);
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { db, canAccessTrip } = require('../db/database');
|
||||||
|
const { authenticate } = require('../middleware/auth');
|
||||||
|
const { broadcast } = require('../websocket');
|
||||||
|
|
||||||
|
const router = express.Router({ mergeParams: true });
|
||||||
|
|
||||||
|
function verifyTripAccess(tripId, userId) {
|
||||||
|
return canAccessTrip(tripId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── NOTES ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /notes - list all notes for trip
|
||||||
|
router.get('/notes', authenticate, (req, res) => {
|
||||||
|
const { tripId } = req.params;
|
||||||
|
|
||||||
|
const trip = verifyTripAccess(tripId, req.user.id);
|
||||||
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
|
const notes = db.prepare(`
|
||||||
|
SELECT n.*, u.username, u.avatar
|
||||||
|
FROM collab_notes n
|
||||||
|
JOIN users u ON n.user_id = u.id
|
||||||
|
WHERE n.trip_id = ?
|
||||||
|
ORDER BY n.pinned DESC, n.updated_at DESC
|
||||||
|
`).all(tripId);
|
||||||
|
|
||||||
|
res.json({ notes });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /notes - create note
|
||||||
|
router.post('/notes', authenticate, (req, res) => {
|
||||||
|
const { tripId } = req.params;
|
||||||
|
const { title, content, category, color } = req.body;
|
||||||
|
|
||||||
|
const trip = verifyTripAccess(tripId, req.user.id);
|
||||||
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
|
if (!title) return res.status(400).json({ error: 'Title is required' });
|
||||||
|
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO collab_notes (trip_id, user_id, title, content, category, color)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(tripId, req.user.id, title, content || null, category || 'General', color || '#6366f1');
|
||||||
|
|
||||||
|
const note = db.prepare(`
|
||||||
|
SELECT n.*, u.username, u.avatar
|
||||||
|
FROM collab_notes n
|
||||||
|
JOIN users u ON n.user_id = u.id
|
||||||
|
WHERE n.id = ?
|
||||||
|
`).get(result.lastInsertRowid);
|
||||||
|
|
||||||
|
res.status(201).json({ note });
|
||||||
|
broadcast(tripId, 'collab:note:created', { note }, req.headers['x-socket-id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /notes/:id - update note
|
||||||
|
router.put('/notes/:id', authenticate, (req, res) => {
|
||||||
|
const { tripId, id } = req.params;
|
||||||
|
const { title, content, category, color, pinned } = req.body;
|
||||||
|
|
||||||
|
const trip = verifyTripAccess(tripId, req.user.id);
|
||||||
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
|
const existing = db.prepare('SELECT * FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Note not found' });
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE collab_notes SET
|
||||||
|
title = COALESCE(?, title),
|
||||||
|
content = CASE WHEN ? THEN ? ELSE content END,
|
||||||
|
category = COALESCE(?, category),
|
||||||
|
color = COALESCE(?, color),
|
||||||
|
pinned = CASE WHEN ? IS NOT NULL THEN ? ELSE pinned END,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(
|
||||||
|
title || null,
|
||||||
|
content !== undefined ? 1 : 0, content !== undefined ? content : null,
|
||||||
|
category || null,
|
||||||
|
color || null,
|
||||||
|
pinned !== undefined ? 1 : null, pinned ? 1 : 0,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
|
||||||
|
const note = db.prepare(`
|
||||||
|
SELECT n.*, u.username, u.avatar
|
||||||
|
FROM collab_notes n
|
||||||
|
JOIN users u ON n.user_id = u.id
|
||||||
|
WHERE n.id = ?
|
||||||
|
`).get(id);
|
||||||
|
|
||||||
|
res.json({ note });
|
||||||
|
broadcast(tripId, 'collab:note:updated', { note }, req.headers['x-socket-id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /notes/:id - delete note
|
||||||
|
router.delete('/notes/:id', authenticate, (req, res) => {
|
||||||
|
const { tripId, id } = req.params;
|
||||||
|
|
||||||
|
const trip = verifyTripAccess(tripId, req.user.id);
|
||||||
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
|
const existing = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Note not found' });
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM collab_notes WHERE id = ?').run(id);
|
||||||
|
res.json({ success: true });
|
||||||
|
broadcast(tripId, 'collab:note:deleted', { noteId: Number(id) }, req.headers['x-socket-id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── POLLS ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Helper: fetch a poll with aggregated votes
|
||||||
|
function getPollWithVotes(pollId) {
|
||||||
|
const poll = db.prepare(`
|
||||||
|
SELECT p.*, u.username, u.avatar
|
||||||
|
FROM collab_polls p
|
||||||
|
JOIN users u ON p.user_id = u.id
|
||||||
|
WHERE p.id = ?
|
||||||
|
`).get(pollId);
|
||||||
|
|
||||||
|
if (!poll) return null;
|
||||||
|
|
||||||
|
poll.options = JSON.parse(poll.options);
|
||||||
|
|
||||||
|
const votes = db.prepare(`
|
||||||
|
SELECT v.option_index, v.user_id, u.username, u.avatar
|
||||||
|
FROM collab_poll_votes v
|
||||||
|
JOIN users u ON v.user_id = u.id
|
||||||
|
WHERE v.poll_id = ?
|
||||||
|
`).all(pollId);
|
||||||
|
|
||||||
|
poll.votes = votes;
|
||||||
|
return poll;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /polls - list all polls with votes
|
||||||
|
router.get('/polls', authenticate, (req, res) => {
|
||||||
|
const { tripId } = req.params;
|
||||||
|
|
||||||
|
const trip = verifyTripAccess(tripId, req.user.id);
|
||||||
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT p.*, u.username, u.avatar
|
||||||
|
FROM collab_polls p
|
||||||
|
JOIN users u ON p.user_id = u.id
|
||||||
|
WHERE p.trip_id = ?
|
||||||
|
ORDER BY p.created_at DESC
|
||||||
|
`).all(tripId);
|
||||||
|
|
||||||
|
const polls = rows.map(poll => {
|
||||||
|
poll.options = JSON.parse(poll.options);
|
||||||
|
|
||||||
|
const votes = db.prepare(`
|
||||||
|
SELECT v.option_index, v.user_id, u.username, u.avatar
|
||||||
|
FROM collab_poll_votes v
|
||||||
|
JOIN users u ON v.user_id = u.id
|
||||||
|
WHERE v.poll_id = ?
|
||||||
|
`).all(poll.id);
|
||||||
|
|
||||||
|
poll.votes = votes;
|
||||||
|
return poll;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ polls });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /polls - create poll
|
||||||
|
router.post('/polls', authenticate, (req, res) => {
|
||||||
|
const { tripId } = req.params;
|
||||||
|
const { question, options, multiple, deadline } = req.body;
|
||||||
|
|
||||||
|
const trip = verifyTripAccess(tripId, req.user.id);
|
||||||
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
|
if (!question) return res.status(400).json({ error: 'Question is required' });
|
||||||
|
if (!Array.isArray(options) || options.length < 2) {
|
||||||
|
return res.status(400).json({ error: 'At least 2 options are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO collab_polls (trip_id, user_id, question, options, multiple, deadline)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(tripId, req.user.id, question, JSON.stringify(options), multiple ? 1 : 0, deadline || null);
|
||||||
|
|
||||||
|
const poll = getPollWithVotes(result.lastInsertRowid);
|
||||||
|
|
||||||
|
res.status(201).json({ poll });
|
||||||
|
broadcast(tripId, 'collab:poll:created', { poll }, req.headers['x-socket-id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /polls/:id/vote - toggle vote on poll
|
||||||
|
router.post('/polls/:id/vote', authenticate, (req, res) => {
|
||||||
|
const { tripId, id } = req.params;
|
||||||
|
const { option_index } = req.body;
|
||||||
|
|
||||||
|
const trip = verifyTripAccess(tripId, req.user.id);
|
||||||
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
|
const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
|
if (!poll) return res.status(404).json({ error: 'Poll not found' });
|
||||||
|
if (poll.closed) return res.status(400).json({ error: 'Poll is closed' });
|
||||||
|
|
||||||
|
const options = JSON.parse(poll.options);
|
||||||
|
if (option_index < 0 || option_index >= options.length) {
|
||||||
|
return res.status(400).json({ error: 'Invalid option index' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle: if vote exists, remove it; otherwise add it
|
||||||
|
const existingVote = db.prepare(
|
||||||
|
'SELECT id FROM collab_poll_votes WHERE poll_id = ? AND user_id = ? AND option_index = ?'
|
||||||
|
).get(id, req.user.id, option_index);
|
||||||
|
|
||||||
|
if (existingVote) {
|
||||||
|
db.prepare('DELETE FROM collab_poll_votes WHERE id = ?').run(existingVote.id);
|
||||||
|
} else {
|
||||||
|
// If not multiple choice, remove any existing votes by this user first
|
||||||
|
if (!poll.multiple) {
|
||||||
|
db.prepare('DELETE FROM collab_poll_votes WHERE poll_id = ? AND user_id = ?').run(id, req.user.id);
|
||||||
|
}
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO collab_poll_votes (poll_id, user_id, option_index) VALUES (?, ?, ?)'
|
||||||
|
).run(id, req.user.id, option_index);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedPoll = getPollWithVotes(id);
|
||||||
|
|
||||||
|
res.json({ poll: updatedPoll });
|
||||||
|
broadcast(tripId, 'collab:poll:voted', { poll: updatedPoll }, req.headers['x-socket-id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /polls/:id/close - close poll
|
||||||
|
router.put('/polls/:id/close', authenticate, (req, res) => {
|
||||||
|
const { tripId, id } = req.params;
|
||||||
|
|
||||||
|
const trip = verifyTripAccess(tripId, req.user.id);
|
||||||
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
|
const poll = db.prepare('SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
|
if (!poll) return res.status(404).json({ error: 'Poll not found' });
|
||||||
|
|
||||||
|
db.prepare('UPDATE collab_polls SET closed = 1 WHERE id = ?').run(id);
|
||||||
|
|
||||||
|
const updatedPoll = getPollWithVotes(id);
|
||||||
|
|
||||||
|
res.json({ poll: updatedPoll });
|
||||||
|
broadcast(tripId, 'collab:poll:closed', { poll: updatedPoll }, req.headers['x-socket-id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /polls/:id - delete poll
|
||||||
|
router.delete('/polls/:id', authenticate, (req, res) => {
|
||||||
|
const { tripId, id } = req.params;
|
||||||
|
|
||||||
|
const trip = verifyTripAccess(tripId, req.user.id);
|
||||||
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
|
const poll = db.prepare('SELECT id FROM collab_polls WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
|
if (!poll) return res.status(404).json({ error: 'Poll not found' });
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM collab_polls WHERE id = ?').run(id);
|
||||||
|
res.json({ success: true });
|
||||||
|
broadcast(tripId, 'collab:poll:deleted', { pollId: Number(id) }, req.headers['x-socket-id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── MESSAGES (CHAT) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /messages - list messages (last 100, with pagination via ?before=id)
|
||||||
|
router.get('/messages', authenticate, (req, res) => {
|
||||||
|
const { tripId } = req.params;
|
||||||
|
const { before } = req.query;
|
||||||
|
|
||||||
|
const trip = verifyTripAccess(tripId, req.user.id);
|
||||||
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
|
let messages;
|
||||||
|
if (before) {
|
||||||
|
messages = db.prepare(`
|
||||||
|
SELECT m.*, u.username, u.avatar,
|
||||||
|
rm.text AS reply_text, ru.username AS reply_username
|
||||||
|
FROM collab_messages m
|
||||||
|
JOIN users u ON m.user_id = u.id
|
||||||
|
LEFT JOIN collab_messages rm ON m.reply_to = rm.id
|
||||||
|
LEFT JOIN users ru ON rm.user_id = ru.id
|
||||||
|
WHERE m.trip_id = ? AND m.id < ?
|
||||||
|
ORDER BY m.id DESC
|
||||||
|
LIMIT 100
|
||||||
|
`).all(tripId, before);
|
||||||
|
} else {
|
||||||
|
messages = db.prepare(`
|
||||||
|
SELECT m.*, u.username, u.avatar,
|
||||||
|
rm.text AS reply_text, ru.username AS reply_username
|
||||||
|
FROM collab_messages m
|
||||||
|
JOIN users u ON m.user_id = u.id
|
||||||
|
LEFT JOIN collab_messages rm ON m.reply_to = rm.id
|
||||||
|
LEFT JOIN users ru ON rm.user_id = ru.id
|
||||||
|
WHERE m.trip_id = ?
|
||||||
|
ORDER BY m.id DESC
|
||||||
|
LIMIT 100
|
||||||
|
`).all(tripId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return in chronological order (oldest first)
|
||||||
|
messages.reverse();
|
||||||
|
|
||||||
|
res.json({ messages });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /messages - send message
|
||||||
|
router.post('/messages', authenticate, (req, res) => {
|
||||||
|
const { tripId } = req.params;
|
||||||
|
const { text, reply_to } = req.body;
|
||||||
|
|
||||||
|
const trip = verifyTripAccess(tripId, req.user.id);
|
||||||
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
|
if (!text || !text.trim()) return res.status(400).json({ error: 'Message text is required' });
|
||||||
|
|
||||||
|
// Validate reply_to if provided
|
||||||
|
if (reply_to) {
|
||||||
|
const replyMsg = db.prepare('SELECT id FROM collab_messages WHERE id = ? AND trip_id = ?').get(reply_to, tripId);
|
||||||
|
if (!replyMsg) return res.status(400).json({ error: 'Reply target message not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO collab_messages (trip_id, user_id, text, reply_to)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run(tripId, req.user.id, text.trim(), reply_to || null);
|
||||||
|
|
||||||
|
const message = db.prepare(`
|
||||||
|
SELECT m.*, u.username, u.avatar,
|
||||||
|
rm.text AS reply_text, ru.username AS reply_username
|
||||||
|
FROM collab_messages m
|
||||||
|
JOIN users u ON m.user_id = u.id
|
||||||
|
LEFT JOIN collab_messages rm ON m.reply_to = rm.id
|
||||||
|
LEFT JOIN users ru ON rm.user_id = ru.id
|
||||||
|
WHERE m.id = ?
|
||||||
|
`).get(result.lastInsertRowid);
|
||||||
|
|
||||||
|
res.status(201).json({ message });
|
||||||
|
broadcast(tripId, 'collab:message:created', { message }, req.headers['x-socket-id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /messages/:id - delete own message
|
||||||
|
router.delete('/messages/:id', authenticate, (req, res) => {
|
||||||
|
const { tripId, id } = req.params;
|
||||||
|
|
||||||
|
const trip = verifyTripAccess(tripId, req.user.id);
|
||||||
|
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||||
|
|
||||||
|
const message = db.prepare('SELECT * FROM collab_messages WHERE id = ? AND trip_id = ?').get(id, tripId);
|
||||||
|
if (!message) return res.status(404).json({ error: 'Message not found' });
|
||||||
|
|
||||||
|
if (message.user_id !== req.user.id) {
|
||||||
|
return res.status(403).json({ error: 'You can only delete your own messages' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare('DELETE FROM collab_messages WHERE id = ?').run(id);
|
||||||
|
res.json({ success: true });
|
||||||
|
broadcast(tripId, 'collab:message:deleted', { messageId: Number(id) }, req.headers['x-socket-id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
Reference in New Issue
Block a user