feat(collab): add shared links, chat images, and plugin fullscreen

Adds a Collab Links panel (add/pin/delete) with translations in every locale so tab and button labels are not raw i18n keys, chat image attachments (up to 4), and native fullscreen on plugin iframes. Includes Zod request DTOs and CollabPanel coverage for the Links tab.
This commit is contained in:
Godric Hood
2026-09-06 18:21:18 +01:00
committed by Maurice
parent 59a1756529
commit 9543fdc3f2
47 changed files with 743 additions and 76 deletions
+7 -1
View File
@@ -1382,13 +1382,19 @@ export const collabApi = {
deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data),
uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => postMultipart(`/trips/${tripId}/collab/notes/${noteId}/files`, formData),
deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data),
getLinks: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/links`).then(r => r.data),
createLink: (tripId: number | string, data: { title: string; url: string; pinned?: boolean }) => apiClient.post(`/trips/${tripId}/collab/links`, data).then(r => r.data),
updateLink: (tripId: number | string, id: number, data: { title?: string; url?: string; pinned?: boolean }) => apiClient.put(`/trips/${tripId}/collab/links/${id}`, data).then(r => r.data),
deleteLink: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/links/${id}`).then(r => r.data),
getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
createPoll: (tripId: number | string, data: CollabPollCreateRequest) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex } satisfies CollabPollVoteRequest).then(r => r.data),
closePoll: (tripId: number | string, id: number) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data),
deletePoll: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data),
getMessages: (tripId: number | string, before?: string) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data),
sendMessage: (tripId: number | string, data: CollabMessageCreateRequest) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
sendMessage: (tripId: number | string, data: CollabMessageCreateRequest | FormData, opts?: UploadOptions) => data instanceof FormData
? postMultipart(`/trips/${tripId}/collab/messages`, data, opts)
: apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
deleteMessage: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data),
reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji } satisfies CollabReactionRequest).then(r => r.data),
linkPreview: (tripId: number | string, url: string) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data),
+2 -1
View File
@@ -74,11 +74,12 @@ const TYPE_META: Record<string, { icon: ComponentType<{ size?: number }>; labelK
integration: { icon: Link2, labelKey: 'admin.addons.type.integration', hintKey: 'admin.addons.integrationHint' },
}
interface CollabFeatures { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }
interface CollabFeatures { chat: boolean; notes: boolean; links?: boolean; polls: boolean; whatsnext: boolean }
const COLLAB_SUB_FEATURES = [
{ key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' },
{ key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' },
{ key: 'links', icon: Link2, titleKey: 'collab.tabs.links', subtitleKey: 'collab.links.empty' },
{ key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' },
{ key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' },
] as const
+11 -6
View File
@@ -1,5 +1,5 @@
import { createPortal } from 'react-dom'
import { ArrowUp, Reply, Smile, X } from 'lucide-react'
import { ArrowUp, ImagePlus, Reply, Smile, X } from 'lucide-react'
import type { User } from '../../types'
import { useCollabChat } from './useCollabChat'
import { ChatMessages } from './CollabChatMessages'
@@ -14,7 +14,7 @@ interface CollabChatProps {
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
const S = useCollabChat(tripId, currentUser)
const { t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = S
const { t, canEdit, loading, text, replyTo, setReplyTo, sending, showEmoji, setShowEmoji, reactMenu, setReactMenu, containerRef, textareaRef, emojiBtnRef, imageInputRef, imageFiles, imagePreviews, uploadProgress, addImageFiles, removeImage, handlePaste, handleDrop, handleTextChange, handleSend, handleKeyDown, handleReact, handleEmojiSelect } = S
if (loading) {
return (
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center' }}>
@@ -27,7 +27,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, height: '100%' }}>
<ChatMessages {...S} />
{/* Composer */}
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)' }} className="pb-3 bg-surface-card">
<div onDragOver={e => e.preventDefault()} onDrop={handleDrop} style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)' }} className="pb-3 bg-surface-card">
{/* Reply preview */}
{replyTo && (
<div style={{
@@ -48,6 +48,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
</div>
)}
{imagePreviews.length > 0 && <div style={{ display: 'flex', gap: 8, marginBottom: 8, overflowX: 'auto' }}>{imagePreviews.map((url, i) => <div key={url} style={{ position: 'relative' }}><img src={url} alt="" style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 8 }} /><button type="button" onClick={() => removeImage(i)} aria-label="Remove image" style={{ position: 'absolute', top: -6, right: -6, border: 0, borderRadius: '50%', background: 'var(--text-primary)', color: 'var(--bg-primary)', width: 18, height: 18, cursor: 'pointer' }}>×</button></div>)}</div>}
{uploadProgress > 0 && uploadProgress < 100 && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 4 }}>Uploading {uploadProgress}%</div>}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
{/* Emoji button */}
{canEdit && (
@@ -61,6 +63,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
</button>
)}
{canEdit && <><input ref={imageInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple hidden onChange={e => { if (e.target.files) addImageFiles(e.target.files); e.currentTarget.value = '' }} /><button type="button" onClick={() => imageInputRef.current?.click()} aria-label="Attach images" style={{ width: 34, height: 34, borderRadius: '50%', border: 'none', background: 'transparent', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', padding: 0 }}><ImagePlus size={19} /></button></>}
<textarea
ref={textareaRef}
rows={1}
@@ -76,15 +80,16 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
value={text}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
/>
{/* Send */}
{canEdit && (
<button type="button" onClick={handleSend} disabled={!text.trim() || sending} style={{
<button type="button" onClick={handleSend} disabled={(!text.trim() && !imageFiles.length) || sending} style={{
width: 34, height: 34, borderRadius: '50%', border: 'none',
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
background: text.trim() || imageFiles.length ? '#007AFF' : 'var(--border-primary)',
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
cursor: text.trim() || imageFiles.length ? 'pointer' : 'default', flexShrink: 0,
transition: 'background 0.15s',
}}>
<ArrowUp size={18} strokeWidth={2.5} />
@@ -0,0 +1,10 @@
import { useState } from 'react'
import { X } from 'lucide-react'
export function CollabChatAttachment({ attachment }: { attachment: { url: string; original_name?: string; mime_type?: string } }) {
const [open, setOpen] = useState(false)
return <>
<img src={attachment.url} alt={attachment.original_name || 'Attached image'} onClick={() => setOpen(true)} style={{ display: 'block', width: 180, maxWidth: '100%', maxHeight: 220, objectFit: 'cover', borderRadius: 10, cursor: 'zoom-in', marginTop: 4 }} />
{open && <div role="dialog" onClick={() => setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,.82)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}><button aria-label="Close image" onClick={() => setOpen(false)} style={{ position: 'fixed', top: 18, right: 18, background: 'rgba(255,255,255,.15)', color: '#fff', border: 0, borderRadius: '50%', width: 36, height: 36 }}><X size={20} /></button><img src={attachment.url} alt={attachment.original_name || 'Attached image'} onClick={e => e.stopPropagation()} style={{ maxWidth: '95vw', maxHeight: '90vh', objectFit: 'contain' }} /></div>}
</>
}
@@ -3,6 +3,7 @@ import { Trash2, Reply, ChevronUp } from 'lucide-react'
import { URL_REGEX } from './CollabChat.constants'
import { formatTime, formatDateSeparator, shouldShowDateSeparator } from './CollabChat.helpers'
import { MessageText } from './CollabChatMessageText'
import { CollabChatAttachment } from './CollabChatAttachment'
import { LinkPreview } from './CollabChatLinkPreview'
import { ReactionBadge } from './CollabChatReactionBadge'
import EmptyState from '../shared/EmptyState'
@@ -166,6 +167,7 @@ export function ChatMessages(props: any) {
{hasReply ? (
<div style={{ padding: '0 10px 4px' }}><MessageText text={msg.text} /></div>
) : <MessageText text={msg.text} />}
{msg.attachments?.map((attachment: any) => <CollabChatAttachment key={attachment.id} attachment={attachment} />)}
{(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => (
<LinkPreview key={url} url={url} tripId={tripId} own={own} onLoad={() => { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} />
))}
@@ -0,0 +1,47 @@
import { FormEvent, useEffect, useState } from 'react'
import { ExternalLink, Link2, Pin, Plus, Trash2, X } from 'lucide-react'
import { collabApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
function LinkIcon({ url, title }: { url: string; title: string }) {
const [failed, setFailed] = useState(false)
let favicon = ''
try { favicon = new URL('/favicon.ico', url).href } catch { favicon = '' }
if (!favicon || failed) return <span aria-hidden="true" style={{ display: 'grid', placeItems: 'center', width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, background: 'var(--bg-secondary)', color: 'var(--text-faint)' }}><Link2 size={15} /></span>
return <img src={favicon} alt="" aria-hidden="true" title={title} onError={() => setFailed(true)} style={{ width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, objectFit: 'contain', background: 'var(--bg-secondary)' }} />
}
export default function CollabLinks({ tripId }: { tripId: number }) {
const { t } = useTranslation()
const trip = useTripStore(s => s.trip)
const canEdit = useCanDo()('collab_edit', trip)
const [links, setLinks] = useState<any[]>([])
const [title, setTitle] = useState('')
const [url, setUrl] = useState('')
const [busy, setBusy] = useState(false)
const [expanded, setExpanded] = useState(false)
const load = () => collabApi.getLinks(tripId).then(d => setLinks(d.links || [])).catch(() => {})
useEffect(() => { load() }, [tripId])
const add = async (event?: FormEvent) => {
event?.preventDefault()
if (!title.trim() || !url.trim() || busy) return
setBusy(true)
try { const d = await collabApi.createLink(tripId, { title: title.trim(), url: url.trim() }); setLinks(v => [d.link, ...v]); setTitle(''); setUrl(''); setExpanded(false) } finally { setBusy(false) }
}
const toggle = async (link: any) => { const d = await collabApi.updateLink(tripId, link.id, { pinned: !link.pinned }); setLinks(v => v.map(x => x.id === link.id ? d.link : x).sort((a, b) => Number(b.pinned) - Number(a.pinned))) }
const remove = async (id: number) => { await collabApi.deleteLink(tripId, id); setLinks(v => v.filter(x => x.id !== id)) }
return <div style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 14px', borderBottom: '1px solid var(--border-faint)' }}><Link2 size={18} aria-hidden="true" /><strong>{t('collab.tabs.links') || 'Links'}</strong><span style={{ marginLeft: 'auto', color: 'var(--text-faint)', fontSize: 12 }}>{links.length}</span></div>
{canEdit && <div style={{ padding: 10, borderBottom: '1px solid var(--border-faint)' }}>
<button type="button" onClick={() => setExpanded(v => !v)} aria-expanded={expanded} aria-controls={`collab-link-form-${tripId}`} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, border: 0, borderRadius: 8, padding: '7px 10px', background: 'var(--bg-secondary)', color: 'var(--text-primary)', cursor: 'pointer', fontWeight: 600 }}>{expanded ? <X size={16} aria-hidden="true" /> : <Plus size={16} aria-hidden="true" />}{expanded ? (t('collab.links.cancel') || 'Cancel') : (t('collab.links.add') || 'Add link')}</button>
{expanded && <form id={`collab-link-form-${tripId}`} onSubmit={add} style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8 }}>
<label htmlFor={`collab-link-title-${tripId}`} style={{ position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0,0,0,0)', whiteSpace: 'nowrap', border: 0 }}>{t('collab.links.titlePlaceholder') || 'Link title'}</label>
<input id={`collab-link-title-${tripId}`} value={title} onChange={e => setTitle(e.target.value)} placeholder={t('collab.links.titlePlaceholder') || 'Link title'} autoFocus style={{ padding: 8, borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)' }} />
<div style={{ display: 'flex', gap: 6 }}><label htmlFor={`collab-link-url-${tripId}`} style={{ position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0,0,0,0)', whiteSpace: 'nowrap', border: 0 }}>{t('collab.links.urlPlaceholder') || 'Link URL'}</label><input id={`collab-link-url-${tripId}`} type="url" required value={url} onChange={e => setUrl(e.target.value)} placeholder={t('collab.links.urlPlaceholder') || 'https://...'} style={{ flex: 1, minWidth: 0, padding: 8, borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)' }} /><button type="submit" disabled={busy || !title.trim() || !url.trim()} aria-label={t('collab.links.save') || 'Save link'} style={{ width: 36, border: 0, borderRadius: 8, background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer' }}><Plus size={18} aria-hidden="true" /></button></div>
</form>}
</div>}
<div style={{ flex: 1, overflowY: 'auto', padding: 10 }}>{links.length === 0 ? <div style={{ padding: 24, textAlign: 'center', color: 'var(--text-faint)' }}>{t('collab.links.empty') || 'No shared links yet'}</div> : links.map(link => <div key={link.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 6px', borderBottom: '1px solid var(--border-faint)' }}><LinkIcon url={link.url} title={link.title} /><a href={link.url} target="_blank" rel="noreferrer" style={{ flex: 1, minWidth: 0, color: 'var(--text-primary)', textDecoration: 'none' }}><div style={{ fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.title}</div><div style={{ fontSize: 11, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.url}</div></a><ExternalLink size={15} color="var(--text-faint)" aria-hidden="true" />{canEdit && <><button type="button" onClick={() => toggle(link)} aria-label={link.pinned ? (t('collab.links.unpin') || 'Unpin link') : (t('collab.links.pin') || 'Pin link')} style={{ border: 0, background: 'transparent', color: link.pinned ? 'var(--accent)' : 'var(--text-faint)', cursor: 'pointer' }}><Pin size={15} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" /></button><button type="button" onClick={() => remove(link.id)} aria-label={t('collab.links.delete') || 'Delete link'} style={{ border: 0, background: 'transparent', color: 'var(--text-faint)', cursor: 'pointer' }}><Trash2 size={15} aria-hidden="true" /></button></>}</div>)}</div>
</div>
}
@@ -6,6 +6,7 @@ import { useAuthStore } from '../../store/authStore'
vi.mock('./CollabChat', () => ({ default: () => <div data-testid="collab-chat">Chat</div> }))
vi.mock('./CollabNotes', () => ({ default: () => <div data-testid="collab-notes">Notes</div> }))
vi.mock('./CollabLinks', () => ({ default: () => <div data-testid="collab-links">Links</div> }))
vi.mock('./CollabPolls', () => ({ default: () => <div data-testid="collab-polls">Polls</div> }))
vi.mock('./WhatsNextWidget', () => ({ default: () => <div data-testid="whats-next">WhatsNext</div> }))
vi.mock('../../api/websocket', () => ({
@@ -39,11 +40,12 @@ describe('CollabPanel', () => {
})
// FE-COMP-COLLABPANEL-001
it('desktop layout renders all four panels', () => {
it('desktop layout renders all five panels', () => {
setViewport(1280)
render(<CollabPanel tripId={1} />)
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
expect(screen.getByTestId('collab-links')).toBeInTheDocument()
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
})
@@ -55,6 +57,7 @@ describe('CollabPanel', () => {
// Tab buttons exist
expect(screen.getByRole('button', { name: /chat/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /notes/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^links$/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /polls/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /what.?s next/i })).toBeInTheDocument()
// Only chat visible by default
@@ -73,6 +76,13 @@ describe('CollabPanel', () => {
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
})
it('mobile: Links tab shows the translated label, not the i18n key', () => {
setViewport(375)
render(<CollabPanel tripId={1} />)
expect(screen.getByRole('button', { name: 'Links' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'collab.tabs.links' })).not.toBeInTheDocument()
})
// FE-COMP-COLLABPANEL-004
it('mobile: clicking Polls tab switches to CollabPolls', () => {
setViewport(375)
@@ -147,7 +157,7 @@ describe('CollabPanel', () => {
// FE-W5CPN-001 to FE-W5CPN-013
// The desktop branch picks a different layout for every combination of enabled
// collab features, so each combination gets its own case.
const allOff = { chat: false, notes: false, polls: false, whatsnext: false }
const allOff = { chat: false, notes: false, links: false, polls: false, whatsnext: false }
describe('CollabPanel feature combinations', () => {
beforeEach(() => {
@@ -172,6 +182,7 @@ describe('CollabPanel feature combinations', () => {
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, chat: true }} />)
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
expect(screen.queryByTestId('collab-links')).not.toBeInTheDocument()
expect(screen.queryByTestId('collab-polls')).not.toBeInTheDocument()
expect(screen.queryByTestId('whats-next')).not.toBeInTheDocument()
})
+36 -27
View File
@@ -1,11 +1,12 @@
import { useState, useEffect, useMemo } from 'react'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react'
import { MessageCircle, StickyNote, Link2, BarChart3, Sparkles } from 'lucide-react'
import CollabChat from './CollabChat'
import CollabNotes from './CollabNotes'
import CollabPolls from './CollabPolls'
import WhatsNextWidget from './WhatsNextWidget'
import CollabLinks from './CollabLinks'
function useIsDesktop(breakpoint = 1024) {
const [isDesktop, setIsDesktop] = useState(window.innerWidth >= breakpoint)
@@ -28,6 +29,7 @@ interface TripMember {
interface CollabFeatures {
chat: boolean
notes: boolean
links?: boolean
polls: boolean
whatsnext: boolean
}
@@ -39,10 +41,11 @@ interface CollabPanelProps {
}
const ALL_TABS = [
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', icon: MessageCircle },
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', icon: StickyNote },
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', icon: BarChart3 },
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', icon: Sparkles },
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', fallback: 'Chat', icon: MessageCircle },
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', fallback: 'Notes', icon: StickyNote },
{ id: 'links', featureKey: 'links' as const, labelKey: 'collab.tabs.links', fallback: 'Links', icon: Link2 },
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', fallback: 'Polls', icon: BarChart3 },
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', fallback: "What's Next", icon: Sparkles },
]
export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }: CollabPanelProps) {
@@ -50,12 +53,14 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
const { t } = useTranslation()
const isDesktop = useIsDesktop()
const features = collabFeatures || { chat: true, notes: true, polls: true, whatsnext: true }
// Older server/admin configs predate the Links feature; merge defaults so a
// missing `links` key does not silently hide the new panel.
const features = { chat: true, notes: true, links: true, polls: true, whatsnext: true, ...(collabFeatures || {}) }
const tabs = useMemo(() =>
ALL_TABS.filter(tab => features[tab.featureKey]).map(tab => ({
...tab,
label: t(tab.labelKey),
label: t(tab.labelKey) || tab.fallback,
})),
[features, t])
@@ -70,7 +75,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
const chatOn = features.chat
const rightPanels = [
features.notes && 'notes',
features.notes && 'notes', features.links && 'links',
features.polls && 'polls',
features.whatsnext && 'whatsnext',
].filter(Boolean) as string[]
@@ -102,32 +107,33 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
{rightPanels.length === 1 && (
<div className={cardClass} style={{ flex: 1 }}>
{rightPanels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{rightPanels[0] === 'links' && <CollabLinks tripId={tripId} />}
{rightPanels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{rightPanels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
)}
{rightPanels.length === 2 && rightPanels.map(p => (
<div key={p} className={cardClass} style={{ flex: 1 }}>
{rightPanels.length === 2 && <div style={{ flex: 1, display: 'flex', gap: 12, minHeight: 0 }}>
{rightPanels.map(p => (
<div key={p} className={cardClass} style={{ flex: 1, minWidth: 0 }}>
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{p === 'links' && <CollabLinks tripId={tripId} />}
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
))}
{rightPanels.length === 3 && (
<>
<div className={cardClass} style={{ flex: 1 }}>
<CollabNotes tripId={tripId} currentUser={user} />
</div>
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
<div className={cardClass} style={{ flex: 1 }}>
<CollabPolls tripId={tripId} currentUser={user} />
</div>
<div className={cardClass} style={{ flex: 1 }}>
<WhatsNextWidget tripMembers={tripMembers} />
</div>
</div>
</>
)}
))}
</div>}
{rightPanels.length >= 3 && <>
{rightPanels.includes('notes') && rightPanels.includes('links') && <div style={{ flex: 1, display: 'flex', gap: 12, minHeight: 0 }}>
<div className={cardClass} style={{ flex: 1, minWidth: 0 }}><CollabNotes tripId={tripId} currentUser={user} /></div>
<div className={cardClass} style={{ flex: 1, minWidth: 0 }}><CollabLinks tripId={tripId} /></div>
</div>}
<div style={{ flex: 1, display: 'flex', gap: 12, minHeight: 0 }}>
{rightPanels.filter(p => !(p === 'notes' || p === 'links')).map(p => <div key={p} className={cardClass} style={{ flex: 1, minWidth: 0 }}>
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>)}
</div>
</>}
</div>
</div>
)
@@ -140,6 +146,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
<div className={cardClass} style={{ flex: 1 }}>
{panels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{panels[0] === 'links' && <CollabLinks tripId={tripId} />}
{panels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{panels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
@@ -152,6 +159,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
{panels.map(p => (
<div key={p} className={cardClass} style={{ flex: 1 }}>
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
{p === 'links' && <CollabLinks tripId={tripId} />}
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
@@ -170,7 +178,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
{tabs.map(tab => {
const active = mobileTab === tab.id
return (
<button type="button" key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
<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',
@@ -187,6 +195,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
{mobileTab === 'chat' && features.chat && <CollabChat tripId={tripId} currentUser={user} />}
{mobileTab === 'notes' && features.notes && <CollabNotes tripId={tripId} currentUser={user} />}
{mobileTab === 'links' && features.links && <CollabLinks tripId={tripId} />}
{mobileTab === 'polls' && features.polls && <CollabPolls tripId={tripId} currentUser={user} />}
{mobileTab === 'next' && features.whatsnext && <WhatsNextWidget tripMembers={tripMembers} />}
</div>
+32 -8
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useState, useEffect, useRef, useCallback, type ClipboardEvent, type DragEvent } from 'react'
import { collabApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
import { useCanDo } from '../../store/permissionsStore'
@@ -23,6 +23,9 @@ export function useCollabChat(tripId: any, currentUser: any) {
const [replyTo, setReplyTo] = useState(null)
const [hoveredId, setHoveredId] = useState(null)
const [sending, setSending] = useState(false)
const [imageFiles, setImageFiles] = useState<File[]>([])
const [imagePreviews, setImagePreviews] = useState<string[]>([])
const [uploadProgress, setUploadProgress] = useState(0)
const [showEmoji, setShowEmoji] = useState(false)
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
const [deletingIds, setDeletingIds] = useState(new Set())
@@ -38,6 +41,7 @@ export function useCollabChat(tripId: any, currentUser: any) {
const scrollRef = useRef(null)
const textareaRef = useRef(null)
const emojiBtnRef = useRef(null)
const imageInputRef = useRef<HTMLInputElement>(null)
const isAtBottom = useRef(true)
const scrollToBottom = useCallback((behavior = 'auto') => {
@@ -117,23 +121,43 @@ export function useCollabChat(tripId: any, currentUser: any) {
}, [])
/* ── send ── */
const addImageFiles = useCallback((incoming: File[] | FileList) => {
const valid = Array.from(incoming).filter(f => ['image/jpeg','image/png','image/gif','image/webp'].includes(f.type) && f.size <= 10 * 1024 * 1024)
if (valid.length !== Array.from(incoming).length) toast.error('Only JPEG, PNG, GIF, and WebP images up to 10 MB are allowed')
setImageFiles(prev => {
const next = [...prev, ...valid].slice(0, 4)
setImagePreviews(next.map(f => URL.createObjectURL(f)))
return next
})
}, [toast])
const removeImage = useCallback((index: number) => {
setImageFiles(prev => { const next = prev.filter((_, i) => i !== index); setImagePreviews(urls => { if (urls[index]) URL.revokeObjectURL(urls[index]); return next.map(f => URL.createObjectURL(f)) }); return next })
}, [])
const handlePaste = useCallback((e: ClipboardEvent) => { if (e.clipboardData.files.length) addImageFiles(e.clipboardData.files) }, [addImageFiles])
const handleDrop = useCallback((e: DragEvent) => { e.preventDefault(); addImageFiles(e.dataTransfer.files) }, [addImageFiles])
const handleSend = useCallback(async () => {
const body = text.trim()
if (!body || sending) return
if ((!body && !imageFiles.length) || sending) return
setSending(true)
try {
const payload: { text: string; reply_to?: number } = { text: body }
if (replyTo) payload.reply_to = replyTo.id
const data = await collabApi.sendMessage(tripId, payload)
let data
if (imageFiles.length) {
const form = new FormData(); if (body) form.append('text', body); if (replyTo) form.append('reply_to', String(replyTo.id)); imageFiles.forEach(file => form.append('images', file))
data = await collabApi.sendMessage(tripId, form, { onUploadProgress: e => setUploadProgress(e.total ? Math.round((e.loaded / e.total) * 100) : 0) })
} else {
const payload: { text: string; reply_to?: number } = { text: body }; if (replyTo) payload.reply_to = replyTo.id
data = await collabApi.sendMessage(tripId, payload)
}
if (data?.message) {
setMessages(prev => prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message])
}
setText(''); setReplyTo(null); setShowEmoji(false)
setText(''); setReplyTo(null); setShowEmoji(false); imagePreviews.forEach(URL.revokeObjectURL); setImageFiles([]); setImagePreviews([]); setUploadProgress(0)
if (textareaRef.current) textareaRef.current.style.height = 'auto'
isAtBottom.current = true
setTimeout(() => scrollToBottom('smooth'), 50)
} catch { toast.error(t('common.error')) } finally { setSending(false) }
}, [text, sending, replyTo, tripId, scrollToBottom, toast, t])
}, [text, sending, replyTo, tripId, scrollToBottom, toast, t, imageFiles, imagePreviews])
const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
@@ -175,5 +199,5 @@ export function useCollabChat(tripId: any, currentUser: any) {
return emojiRegex.test(text.trim())
}
return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly }
return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, imageInputRef, imageFiles, imagePreviews, uploadProgress, addImageFiles, removeImage, handlePaste, handleDrop, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly }
}
@@ -702,6 +702,8 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d
}}
onError={() => setLoadFailed(true)}
sandbox="allow-scripts allow-forms"
allowFullScreen
allow="fullscreen"
referrerPolicy="no-referrer"
loading="lazy"
title={title || pluginId}
@@ -64,11 +64,12 @@ function AddonIcon({ name, size = 18 }: AddonIconProps) {
return <Icon size={size} />
}
interface CollabFeatures { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }
interface CollabFeatures { chat: boolean; notes: boolean; links?: boolean; polls: boolean; whatsnext: boolean }
const COLLAB_SUB_FEATURES = [
{ key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' },
{ key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' },
{ key: 'links', icon: Link2, titleKey: 'collab.tabs.links', subtitleKey: 'collab.links.empty' },
{ key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' },
{ key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' },
] as const
@@ -35,7 +35,7 @@ export type TripPlanner = ReturnType<typeof useTripPlanner>
export type MTripView = 'plan' | 'map'
export type MTripMode = 'go' | 'edit' | 'browse'
export type MTripListsTab = 'packing' | 'todo'
export type MTripCollabTab = 'chat' | 'notes' | 'polls'
export type MTripCollabTab = 'chat' | 'notes' | 'links' | 'polls'
/**
* Currently open bottom/floating sheet. Well-known ids (owned by the sheets
@@ -537,6 +537,7 @@ export default function MTripShell({
{([
{ value: 'chat' as const, label: t('collab.tabs.chat') },
{ value: 'notes' as const, label: t('collab.tabs.notes') },
{ value: 'links' as const, label: t('collab.tabs.links') || 'Links' },
{ value: 'polls' as const, label: t('collab.tabs.polls') },
]).map(seg => (
<button
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { ArrowUp, ChevronUp, Loader2, Reply, Trash2 } from 'lucide-react'
import { ArrowUp, ChevronUp, ImagePlus, Loader2, Reply, Trash2 } from 'lucide-react'
import MDancingTrek from '../../../components/MDancingTrek'
import { collabApi } from '../../../../api/client'
import { addListener, removeListener } from '../../../../api/websocket'
@@ -63,10 +63,13 @@ export default function MCollabChat({ planner }: MCollabChatProps) {
const [text, setText] = useState('')
const [replyTo, setReplyTo] = useState<ChatMessage | null>(null)
const [sending, setSending] = useState(false)
const [imageFiles, setImageFiles] = useState<File[]>([])
const [imagePreviews, setImagePreviews] = useState<string[]>([])
const [popover, setPopover] = useState<{ msg: ChatMessage; x: number; y: number } | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const imageInputRef = useRef<HTMLInputElement>(null)
const isAtBottomRef = useRef(true)
const messagesRef = useRef<ChatMessage[]>([])
messagesRef.current = messages
@@ -156,19 +159,40 @@ export default function MCollabChat({ planner }: MCollabChatProps) {
}
}
const addImageFiles = (incoming: FileList | File[]) => {
const valid = Array.from(incoming).filter(f => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(f.type) && f.size <= 10 * 1024 * 1024)
setImageFiles(prev => {
const next = [...prev, ...valid].slice(0, 4)
setImagePreviews(next.map(f => URL.createObjectURL(f)))
return next
})
}
const handleSend = useCallback(async () => {
const body = text.trim()
if (!body || sending || !canEdit) return
if ((!body && !imageFiles.length) || sending || !canEdit) return
setSending(true)
try {
const payload: { text: string; reply_to?: number } = { text: body }
if (replyTo) payload.reply_to = replyTo.id
const data = (await collabApi.sendMessage(tripId, payload)) as SendMessageResponse
let data: SendMessageResponse
if (imageFiles.length) {
const form = new FormData()
if (body) form.append('text', body)
if (replyTo) form.append('reply_to', String(replyTo.id))
imageFiles.forEach(file => form.append('images', file))
data = (await collabApi.sendMessage(tripId, form)) as SendMessageResponse
} else {
const payload: { text: string; reply_to?: number } = { text: body }
if (replyTo) payload.reply_to = replyTo.id
data = (await collabApi.sendMessage(tripId, payload)) as SendMessageResponse
}
if (data.message) {
setMessages(prev => (prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message]))
}
setText('')
setReplyTo(null)
imagePreviews.forEach(URL.revokeObjectURL)
setImageFiles([])
setImagePreviews([])
if (textareaRef.current) textareaRef.current.style.height = 'auto'
isAtBottomRef.current = true
setTimeout(() => scrollToBottom('smooth'), 50)
@@ -177,7 +201,7 @@ export default function MCollabChat({ planner }: MCollabChatProps) {
} finally {
setSending(false)
}
}, [text, sending, canEdit, replyTo, tripId, scrollToBottom, toast, t])
}, [text, sending, canEdit, replyTo, tripId, scrollToBottom, toast, t, imageFiles, imagePreviews])
const handleDelete = useCallback(async (msgId: number) => {
setPopover(null)
@@ -293,12 +317,28 @@ export default function MCollabChat({ planner }: MCollabChatProps) {
)}
{canEdit ? (
<div className="flex items-end gap-2">
<>
{imagePreviews.length > 0 && (
<div className="flex gap-2 overflow-x-auto">
{imagePreviews.map((url, i) => (
<div key={url} className="relative">
<img src={url} alt="" className="h-14 w-14 rounded-lg object-cover" />
<button type="button" aria-label="Remove image" onClick={() => { setImageFiles(prev => prev.filter((_, idx) => idx !== i)); setImagePreviews(prev => prev.filter((_, idx) => idx !== i)) }} className="absolute -right-1 -top-1 h-4 w-4 rounded-full bg-m-ink text-[10px] text-m-bg">×</button>
</div>
))}
</div>
)}
<div className="flex items-end gap-2">
<input ref={imageInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple hidden onChange={e => { if (e.target.files) addImageFiles(e.target.files); e.currentTarget.value = '' }} />
<button type="button" aria-label="Attach images" onClick={() => imageInputRef.current?.click()} className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full text-m-muted">
<ImagePlus size={18} strokeWidth={2.2} />
</button>
<textarea
ref={textareaRef}
rows={1}
value={text}
onChange={handleTextChange}
onPaste={e => { if (e.clipboardData.files.length) addImageFiles(e.clipboardData.files) }}
placeholder={t('collab.chat.placeholder')}
maxLength={5000}
className="max-h-[100px] min-w-0 flex-1 resize-none rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] py-[11px] font-[inherit] text-[0.8125rem] text-m-ink outline-none placeholder:text-m-faint"
@@ -306,13 +346,14 @@ export default function MCollabChat({ planner }: MCollabChatProps) {
<button
type="button"
onClick={handleSend}
disabled={!text.trim() || sending}
disabled={(!text.trim() && !imageFiles.length) || sending}
aria-label={t('collab.chat.send')}
className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full bg-m-act text-m-actfg disabled:opacity-40"
>
<ArrowUp size={17} strokeWidth={2.4} />
</button>
</div>
</>
) : (
<div className="rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] py-[11px] text-center font-geist text-[0.75rem] text-m-faint">
{t('collab.chat.readOnly')}
@@ -443,6 +484,9 @@ function ChatBubbleRow({ msg, own, showHeader, isLastInGroup, marginTop, is12h,
</div>
)}
<span className="whitespace-pre-wrap break-words">{msg.text}</span>
{msg.attachments?.map(att => (
<img key={att.id} src={att.url} alt={att.original_name || ''} className="mt-2 max-h-52 w-full rounded-[10px] object-cover" />
))}
</div>
)}
</button>
@@ -1,10 +1,11 @@
import type { LucideIcon } from 'lucide-react'
import { BarChart3, MessageCircle, StickyNote } from 'lucide-react'
import { BarChart3, Link2, MessageCircle, StickyNote } from 'lucide-react'
import type { MTabScreenProps } from './tabModel'
import { TabScroller } from './tabChrome'
import MCollabChat from './MCollabChat'
import MCollabNotes from './MCollabNotes'
import MCollabPolls from './MCollabPolls'
import CollabLinks from '../../../../components/Collab/CollabLinks'
/**
* Tab 6 — Collab. Routes `shell.collabTab` ('chat' | 'notes' | 'polls', the
@@ -50,6 +51,23 @@ export default function MCollabTab({ planner, shell }: MTabScreenProps) {
return <MCollabNotes planner={planner} />
}
if (shell.collabTab === 'links') {
if (planner.collabFeatures.links === false) {
return (
<TabScroller>
<CollabDisabledNotice icon={Link2} label={label} />
</TabScroller>
)
}
return (
<TabScroller>
<div className="min-h-[70vh] overflow-hidden rounded-[18px] bg-[color:var(--m-card)]">
<CollabLinks tripId={planner.tripId} />
</div>
</TabScroller>
)
}
if (shell.collabTab === 'polls') {
if (!planner.collabFeatures.polls) {
return (
@@ -42,6 +42,7 @@ export interface ChatMessage {
created_at: string
deleted?: number | boolean
reactions: ChatReaction[]
attachments?: Array<{ id: number; url: string; original_name?: string; mime_type?: string }>
/** Client-only: set once a delete (own or via WS) has landed. */
_deleted?: boolean
}
@@ -122,7 +122,7 @@ export function useTripPlanner() {
return next
})
}, [tripId])
const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, polls: true, whatsnext: true })
const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; links: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, links: true, polls: true, whatsnext: true })
const [tripAccommodations, setTripAccommodations] = useState<Accommodation[]>([])
const [allowedFileTypes, setAllowedFileTypes] = useState<string | null>(null)
const [tripMembers, setTripMembers] = useState<TripMember[]>([])
+41
View File
@@ -4442,6 +4442,47 @@ function runMigrations(db: Database.Database): void {
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_file_links_file_budget ON file_links(file_id, budget_item_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_file_links_budget_item_id ON file_links(budget_item_id)');
},
/**
* Which chat message an uploaded image belongs to.
*
* A column on `trip_files` rather than a link row, matching the two that
* predate it: a chat image is uploaded for exactly one message and dies with
* it, so the cascade is the whole relationship. Guarded through
* pragma_table_info like every other column add here, not through a caught
* "duplicate column name": that swallows the next error too.
*
* Appended LAST: the array is index-addressed against schema_version.
*/
() => {
const cols = db.prepare("SELECT name FROM pragma_table_info('trip_files')").all() as Array<{ name: string }>;
if (!cols.some((c) => c.name === 'message_id')) {
db.exec('ALTER TABLE trip_files ADD COLUMN message_id INTEGER REFERENCES collab_messages(id) ON DELETE CASCADE');
}
db.exec('CREATE INDEX IF NOT EXISTS idx_trip_files_message_id ON trip_files(message_id)');
},
/**
* Links somebody shared with the trip.
*
* Its own table rather than a note with a URL in it: a link is pinned,
* ordered and opened, and none of that is what a note does. `user_id` is who
* shared it, so the list can say so and so a member leaving takes their rows
* with them.
*
* Appended LAST: the array is index-addressed against schema_version.
*/
() => {
db.exec(`CREATE TABLE IF NOT EXISTS collab_links (
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,
title TEXT NOT NULL,
url TEXT NOT NULL,
pinned INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
db.exec('CREATE INDEX IF NOT EXISTS idx_collab_links_trip ON collab_links(trip_id)');
},
];
if (currentVersion < migrations.length) {
+4 -2
View File
@@ -53,7 +53,7 @@ export class AddonsService {
getCollabFeatures() {
const rows = this.db
.prepare(
"SELECT key, value FROM app_settings WHERE key IN ('collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')",
"SELECT key, value FROM app_settings WHERE key IN ('collab_chat_enabled', 'collab_notes_enabled', 'collab_links_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')",
)
.all() as { key: string; value: string }[];
const map: Record<string, string> = {};
@@ -61,15 +61,17 @@ export class AddonsService {
return {
chat: map['collab_chat_enabled'] !== 'false',
notes: map['collab_notes_enabled'] !== 'false',
links: map['collab_links_enabled'] !== 'false',
polls: map['collab_polls_enabled'] !== 'false',
whatsnext: map['collab_whatsnext_enabled'] !== 'false',
};
}
updateCollabFeatures(features: { chat?: boolean; notes?: boolean; polls?: boolean; whatsnext?: boolean }) {
updateCollabFeatures(features: { chat?: boolean; notes?: boolean; links?: boolean; polls?: boolean; whatsnext?: boolean }) {
const mapping: Record<string, string> = {
chat: 'collab_chat_enabled',
notes: 'collab_notes_enabled',
links: 'collab_links_enabled',
polls: 'collab_polls_enabled',
whatsnext: 'collab_whatsnext_enabled',
};
+92 -10
View File
@@ -11,10 +11,11 @@ import {
Put,
Query,
UploadedFile,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
import type { Options } from 'multer';
import path from 'path';
import fs from 'fs';
@@ -26,6 +27,8 @@ import {
CollabNoteUpdateDto,
CollabPollCreateDto,
CollabPollVoteDto,
CollabLinkCreateDto,
CollabLinkUpdateDto,
CollabMessageCreateDto,
CollabReactionDto,
} from './collab.dto';
@@ -35,6 +38,16 @@ import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.g
import { BLOCKED_EXTENSIONS } from '../files/files.constants';
export const MAX_NOTE_FILE_SIZE = 50 * 1024 * 1024;
const MAX_CHAT_IMAGES = 4;
const CHAT_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
export const collabChatImageFilter: Options['fileFilter'] = (_req, file, cb) => {
if (!CHAT_IMAGE_TYPES.has(file.mimetype)) {
const err: Error & { statusCode?: number } = new Error('Only JPEG, PNG, GIF, and WebP images are allowed');
err.statusCode = 400;
return cb(err);
}
cb(null, true);
};
// Consumed by collab.module.ts's MulterModule factory; the rest of the multer
// options (spool destination, filename, limits) come from the storage upload
// factory.
@@ -189,6 +202,45 @@ export class CollabController {
return { success: true };
}
// ── Shared links ────────────────────────────────────────────────────────
@UseGuards(TripAccessGuard)
@Get('links')
listLinks(@CurrentUser() user: User, @Param('tripId') tripId: string) {
return { links: this.collab.listLinks(tripId) };
}
@UseGuards(TripAccessGuard)
@RequirePermission('collab_edit')
@Post('links')
createLink(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabLinkCreateDto, @Headers('x-socket-id') socketId?: string) {
const link = this.collab.createLink(tripId, user.id, { title: body.title, url: body.url, pinned: Boolean(body.pinned) });
this.collab.broadcast(tripId, 'collab:link:created', { link }, socketId);
return { link };
}
@UseGuards(TripAccessGuard)
@RequirePermission('collab_edit')
@Put('links/:id')
updateLink(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Body() body: CollabLinkUpdateDto, @Headers('x-socket-id') socketId?: string) {
const link = this.collab.updateLink(tripId, id, {
title: body.title,
url: body.url,
pinned: body.pinned === undefined ? undefined : Boolean(body.pinned),
});
if (!link) throw new HttpException({ error: 'Link not found' }, 404);
this.collab.broadcast(tripId, 'collab:link:updated', { link }, socketId);
return { link };
}
@UseGuards(TripAccessGuard)
@RequirePermission('collab_edit')
@Delete('links/:id')
deleteLink(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string, @Headers('x-socket-id') socketId?: string) {
if (!this.collab.deleteLink(tripId, id)) throw new HttpException({ error: 'Link not found' }, 404);
this.collab.broadcast(tripId, 'collab:link:deleted', { linkId: Number(id) }, socketId);
return { success: true };
}
// ── Polls ───────────────────────────────────────────────────────────────
@UseGuards(TripAccessGuard)
@Get('polls')
@@ -257,20 +309,50 @@ export class CollabController {
@UseGuards(TripAccessGuard)
@RequirePermission('collab_edit')
@Post('messages')
createMessage(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabMessageCreateDto, @Headers('x-socket-id') socketId?: string) {
// The pipe's min(1)/max(5000) replaced the bespoke length checks (and still
// rejects before the trip-access check, like the legacy pre-access check
// did); min(1) doesn't trim, so whitespace-only text keeps its bespoke 400.
if (!body.text.trim()) {
throw new HttpException({ error: 'Message text is required' }, 400);
@UseInterceptors(FilesInterceptor('images', MAX_CHAT_IMAGES, { fileFilter: collabChatImageFilter, limits: { files: MAX_CHAT_IMAGES, fileSize: 10 * 1024 * 1024 } }))
async createMessage(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body() body: CollabMessageCreateDto, @UploadedFiles() files: Express.Multer.File[] | undefined, @Headers('x-socket-id') socketId?: string) {
const uploaded = files || [];
const cleanupSpool = () => uploaded.forEach(file => { if (file.path) { try { fs.unlinkSync(file.path); } catch { /* best-effort */ } } });
if (body.text && body.text.length > 5000) {
cleanupSpool();
throw new HttpException({ error: 'text must be 5000 characters or less' }, 400);
}
const result = this.collab.createMessage(tripId, user.id, body.text, body.reply_to);
const trip = this.requireTrip(tripId, user);
if (uploaded.length && !this.collab.canUploadFiles(trip, user)) {
cleanupSpool();
throw new HttpException({ error: 'No permission to upload files' }, 403);
}
const text = (body.text || '').trim();
if (!text && !uploaded.length) {
cleanupSpool();
throw new HttpException({ error: 'Message text or image is required' }, 400);
}
const committed: string[] = [];
try {
for (const file of uploaded) {
await this.storage.put('files', file.filename, { tmpPath: file.path });
committed.push(file.filename);
}
} catch (err) {
cleanupSpool();
for (const name of committed) await this.storage.delete('files', name).catch(() => {});
throw err;
}
const replyTo = body.reply_to === undefined || body.reply_to === '' || body.reply_to === null ? null : Number(body.reply_to);
const result = this.collab.createMessage(
tripId,
user.id,
text,
Number.isFinite(replyTo as number) ? replyTo : null,
uploaded.map(file => ({ filename: file.filename, originalname: file.originalname, size: file.size, mimetype: file.mimetype })),
);
if (result.error === 'reply_not_found') {
for (const name of committed) await this.storage.delete('files', name).catch(() => {});
throw new HttpException({ error: 'Reply target message not found' }, 400);
}
this.collab.broadcast(tripId, 'collab:message:created', { message: result.message }, socketId);
const t = body.text.trim();
this.collab.notifyCollab(tripId, user, t.length > 80 ? t.substring(0, 80) + '...' : t);
const preview = text || 'sent an image';
this.collab.notifyCollab(tripId, user, preview.length > 80 ? preview.substring(0, 80) + '...' : preview);
return { message: result.message };
}
+4
View File
@@ -4,6 +4,8 @@ import {
collabNoteUpdateRequestSchema,
collabPollCreateRequestSchema,
collabPollVoteRequestSchema,
collabLinkCreateRequestSchema,
collabLinkUpdateRequestSchema,
collabMessageCreateRequestSchema,
collabReactionRequestSchema,
} from '@trek/shared';
@@ -17,5 +19,7 @@ export class CollabNoteCreateDto extends createZodDto(collabNoteCreateRequestSch
export class CollabNoteUpdateDto extends createZodDto(collabNoteUpdateRequestSchema) {}
export class CollabPollCreateDto extends createZodDto(collabPollCreateRequestSchema) {}
export class CollabPollVoteDto extends createZodDto(collabPollVoteRequestSchema) {}
export class CollabLinkCreateDto extends createZodDto(collabLinkCreateRequestSchema) {}
export class CollabLinkUpdateDto extends createZodDto(collabLinkUpdateRequestSchema) {}
export class CollabMessageCreateDto extends createZodDto(collabMessageCreateRequestSchema) {}
export class CollabReactionDto extends createZodDto(collabReactionRequestSchema) {}
+80 -2
View File
@@ -429,12 +429,71 @@ export class CollabService {
return true;
}
/* ------------------------------------------------------------------ */
/* Shared links */
/* ------------------------------------------------------------------ */
listLinks(tripId: string | number) {
return this.db.all(
`SELECT l.*, u.username FROM collab_links l JOIN users u ON u.id = l.user_id
WHERE l.trip_id = ? ORDER BY l.pinned DESC, l.created_at DESC`,
tripId,
);
}
createLink(tripId: string | number, userId: number, data: { title: string; url: string; pinned?: boolean }) {
const result = this.db.run(
'INSERT INTO collab_links (trip_id,user_id,title,url,pinned) VALUES (?,?,?,?,?)',
tripId, userId, data.title.trim(), data.url.trim(), data.pinned ? 1 : 0,
);
return this.db.get('SELECT l.*, u.username FROM collab_links l JOIN users u ON u.id = l.user_id WHERE l.id = ?', result.lastInsertRowid);
}
updateLink(tripId: string | number, linkId: string | number, data: { title?: string; url?: string; pinned?: boolean | number }) {
const existing = this.db.get<any>('SELECT * FROM collab_links WHERE id = ? AND trip_id = ?', linkId, tripId);
if (!existing) return null;
this.db.run(
'UPDATE collab_links SET title = ?, url = ?, pinned = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?',
data.title?.trim() || existing.title,
data.url?.trim() || existing.url,
data.pinned === undefined ? existing.pinned : (data.pinned ? 1 : 0),
linkId,
tripId,
);
return this.db.get('SELECT l.*, u.username FROM collab_links l JOIN users u ON u.id = l.user_id WHERE l.id = ?', linkId);
}
deleteLink(tripId: string | number, linkId: string | number): boolean {
return this.db.run('DELETE FROM collab_links WHERE id = ? AND trip_id = ?', linkId, tripId).changes > 0;
}
/* ------------------------------------------------------------------ */
/* Messages */
/* ------------------------------------------------------------------ */
private formatMessage(msg: CollabMessage, reactions?: GroupedReaction[]) {
return { ...msg, user_avatar: avatarUrl(msg), avatar_url: avatarUrl(msg), reactions: reactions || [] };
const attachments = msg.id && msg.trip_id
? this.db.all<any>(
`SELECT id, trip_id, message_id, filename, original_name, file_size, mime_type
FROM trip_files WHERE message_id = ? AND trip_id = ? AND deleted_at IS NULL
ORDER BY id ASC`,
msg.id, msg.trip_id,
)
: [];
return {
...msg,
user_avatar: avatarUrl(msg),
avatar_url: avatarUrl(msg),
reactions: reactions || [],
attachments: attachments.map((a: any) => ({
id: a.id,
filename: a.filename,
original_name: a.original_name,
file_size: a.file_size,
mime_type: a.mime_type,
url: `/api/trips/${a.trip_id}/files/${a.id}/download`,
})),
};
}
countMessages(tripId: string | number): number {
@@ -486,7 +545,13 @@ export class CollabService {
return messages.map(m => this.formatMessage(m, this.groupReactions(reactionsByMsg[m.id] || [])));
}
createMessage(tripId: string | number, userId: number, text: string, replyTo?: number | null): { error?: string; message?: ReturnType<CollabService['formatMessage']> } {
createMessage(
tripId: string | number,
userId: number,
text: string,
replyTo?: number | null,
files: Array<{ filename: string; originalname: string; size: number; mimetype: string }> = [],
): { error?: string; message?: ReturnType<CollabService['formatMessage']> } {
if (replyTo) {
// A soft-deleted message is gone as far as anyone replying is concerned:
// its row survives only so the placeholder can be drawn where it was.
@@ -500,6 +565,14 @@ export class CollabService {
INSERT INTO collab_messages (trip_id, user_id, text, reply_to) VALUES (?, ?, ?, ?)
`, tripId, userId, text.trim(), replyTo || null);
for (const file of files) {
this.db.run(
`INSERT INTO trip_files (trip_id, message_id, filename, original_name, file_size, mime_type, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
tripId, result.lastInsertRowid, file.filename, file.originalname, file.size, file.mimetype, userId,
);
}
const message = this.db.get<CollabMessage>(`
SELECT m.*, u.username, u.avatar,
CASE WHEN rm.deleted = 1 THEN '' ELSE rm.text END AS reply_text,
@@ -519,6 +592,11 @@ export class CollabService {
if (!message) return { error: 'not_found' };
if (Number(message.user_id) !== Number(userId)) return { error: 'not_owner' };
const attachments = this.db.all<{ filename: string }>('SELECT filename FROM trip_files WHERE message_id = ? AND trip_id = ?', messageId, tripId);
for (const file of attachments) {
void this.storage.delete('files', path.basename(file.filename)).catch(() => { /* best effort */ });
}
this.db.run('DELETE FROM trip_files WHERE message_id = ? AND trip_id = ?', messageId, tripId);
this.db.run('UPDATE collab_messages SET deleted = 1 WHERE id = ?', messageId);
return { username: message.username };
}
+3 -1
View File
@@ -226,7 +226,9 @@ export class FilesService {
}
listFiles(tripId: string | number, showTrash: boolean) {
const where = showTrash ? 'f.trip_id = ? AND f.deleted_at IS NOT NULL' : 'f.trip_id = ? AND f.deleted_at IS NULL';
const where = showTrash
? 'f.trip_id = ? AND f.deleted_at IS NOT NULL AND f.message_id IS NULL'
: 'f.trip_id = ? AND f.deleted_at IS NULL AND f.message_id IS NULL';
const files = this.db.all<TripFile>(`${FILE_SELECT} WHERE ${where} ORDER BY f.starred DESC, f.created_at DESC`, tripId);
const fileIds = files.map(f => f.id);
+22 -2
View File
@@ -3,6 +3,8 @@ import {
collabNoteUpdateRequestSchema,
collabPollCreateRequestSchema,
collabPollVoteRequestSchema,
collabLinkCreateRequestSchema,
collabLinkUpdateRequestSchema,
collabMessageCreateRequestSchema,
collabReactionRequestSchema,
} from './collab.schema';
@@ -71,11 +73,29 @@ describe('collabPollVoteRequestSchema', () => {
});
});
describe('collabLinkCreateRequestSchema', () => {
it('requires a title and an http(s) URL', () => {
expect(collabLinkCreateRequestSchema.safeParse({ title: 'Docs', url: 'https://example.com' }).success).toBe(true);
expect(collabLinkCreateRequestSchema.safeParse({ title: 'Docs', url: 'ftp://example.com' }).success).toBe(false);
expect(collabLinkCreateRequestSchema.safeParse({ title: ' ', url: 'https://example.com' }).success).toBe(false);
expect(collabLinkCreateRequestSchema.safeParse({ title: 'Docs', url: 'not-a-url' }).success).toBe(false);
});
});
describe('collabLinkUpdateRequestSchema', () => {
it('allows partial updates and still rejects non-http URLs', () => {
expect(collabLinkUpdateRequestSchema.safeParse({ pinned: true }).success).toBe(true);
expect(collabLinkUpdateRequestSchema.safeParse({ url: 'https://example.com/x' }).success).toBe(true);
expect(collabLinkUpdateRequestSchema.safeParse({ url: 'javascript:alert(1)' }).success).toBe(false);
});
});
describe('collabMessageCreateRequestSchema', () => {
it('requires text, caps it at 5000, allows a nullable reply_to', () => {
it('caps text at 5000, allows empty text for image-only messages, and accepts multipart reply_to strings', () => {
expect(collabMessageCreateRequestSchema.safeParse({ text: 'hi', reply_to: null }).success).toBe(true);
expect(collabMessageCreateRequestSchema.safeParse({ text: 'hi', reply_to: 4 }).success).toBe(true);
expect(collabMessageCreateRequestSchema.safeParse({ text: '' }).success).toBe(false);
expect(collabMessageCreateRequestSchema.safeParse({ text: '', reply_to: '4' }).success).toBe(true);
expect(collabMessageCreateRequestSchema.safeParse({}).success).toBe(true);
expect(collabMessageCreateRequestSchema.safeParse({ text: 'x'.repeat(5001) }).success).toBe(false);
});
});
+28 -2
View File
@@ -48,9 +48,35 @@ export const collabPollVoteRequestSchema = z.object({
});
export type CollabPollVoteRequest = z.infer<typeof collabPollVoteRequestSchema>;
const httpUrl = z.string().trim().min(1).refine((value) => {
try {
const parsed = new URL(value);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}, 'A valid http(s) URL is required');
export const collabLinkCreateRequestSchema = z.object({
title: z.string().trim().min(1),
url: httpUrl,
pinned: z.union([z.boolean(), z.number()]).optional(),
});
export type CollabLinkCreateRequest = z.infer<typeof collabLinkCreateRequestSchema>;
export const collabLinkUpdateRequestSchema = z.object({
title: z.string().trim().min(1).optional(),
url: httpUrl.optional(),
pinned: z.union([z.boolean(), z.number()]).optional(),
});
export type CollabLinkUpdateRequest = z.infer<typeof collabLinkUpdateRequestSchema>;
// text may be empty when the chat message is image-only (multipart). The
// controller still rejects a request with neither text nor files.
export const collabMessageCreateRequestSchema = z.object({
text: z.string().min(1).max(5000),
reply_to: z.number().nullable().optional(),
text: z.string().max(5000).optional(),
// Multipart fields arrive as strings; JSON chat still sends a number/null.
reply_to: z.union([z.number(), z.string(), z.null()]).optional(),
});
export type CollabMessageCreateRequest = z.infer<typeof collabMessageCreateRequestSchema>;
+10
View File
@@ -12,6 +12,16 @@ const collab: TranslationStrings = {
'collab.tabs.chat': 'الدردشة',
'collab.tabs.notes': 'الملاحظات',
'collab.tabs.polls': 'الاستطلاعات',
'collab.tabs.links': 'الروابط',
'collab.links.add': 'إضافة رابط',
'collab.links.cancel': 'إلغاء',
'collab.links.save': 'حفظ الرابط',
'collab.links.titlePlaceholder': 'عنوان الرابط',
'collab.links.urlPlaceholder': 'https://...',
'collab.links.empty': 'لا توجد روابط مشتركة بعد',
'collab.links.pin': 'تثبيت الرابط',
'collab.links.unpin': 'إلغاء تثبيت الرابط',
'collab.links.delete': 'حذف الرابط',
'collab.whatsNext.title': 'ما التالي',
'collab.whatsNext.today': 'اليوم',
'collab.whatsNext.tomorrow': 'غدًا',
+10
View File
@@ -12,6 +12,16 @@ const collab: TranslationStrings = {
'collab.tabs.chat': 'Chat',
'collab.tabs.notes': 'Notas',
'collab.tabs.polls': 'Enquetes',
'collab.tabs.links': 'Links',
'collab.links.add': 'Adicionar link',
'collab.links.cancel': 'Cancelar',
'collab.links.save': 'Salvar link',
'collab.links.titlePlaceholder': 'Título do link',
'collab.links.urlPlaceholder': 'https://...',
'collab.links.empty': 'Nenhum link compartilhado ainda',
'collab.links.pin': 'Fixar link',
'collab.links.unpin': 'Desafixar link',
'collab.links.delete': 'Excluir link',
'collab.whatsNext.title': 'Próximos passos',
'collab.whatsNext.today': 'Hoje',
'collab.whatsNext.tomorrow': 'Amanhã',
+10
View File
@@ -4,6 +4,16 @@ const collab: TranslationStrings = {
'collab.tabs.chat': 'Missatges',
'collab.tabs.notes': 'Notes',
'collab.tabs.polls': 'Enquestes',
'collab.tabs.links': 'Enllaços',
'collab.links.add': 'Afegeix un enllaç',
'collab.links.cancel': 'Cancel·la',
'collab.links.save': 'Desa l\'enllaç',
'collab.links.titlePlaceholder': 'Títol de l\'enllaç',
'collab.links.urlPlaceholder': 'https://...',
'collab.links.empty': 'Encara no hi ha enllaços compartits',
'collab.links.pin': 'Fixa l\'enllaç',
'collab.links.unpin': 'Desfixa l\'enllaç',
'collab.links.delete': 'Suprimeix l\'enllaç',
'collab.whatsNext.title': 'Què ve ara',
'collab.whatsNext.today': 'Avui',
'collab.whatsNext.tomorrow': 'Demà',
+10
View File
@@ -12,6 +12,16 @@ const collab: TranslationStrings = {
'collab.tabs.chat': 'Chat',
'collab.tabs.notes': 'Poznámky',
'collab.tabs.polls': 'Hlasování',
'collab.tabs.links': 'Odkazy',
'collab.links.add': 'Přidat odkaz',
'collab.links.cancel': 'Zrušit',
'collab.links.save': 'Uložit odkaz',
'collab.links.titlePlaceholder': 'Název odkazu',
'collab.links.urlPlaceholder': 'https://...',
'collab.links.empty': 'Zatím žádné sdílené odkazy',
'collab.links.pin': 'Připnout odkaz',
'collab.links.unpin': 'Odepnout odkaz',
'collab.links.delete': 'Smazat odkaz',
'collab.whatsNext.title': 'Co následuje',
'collab.whatsNext.today': 'Dnes',
'collab.whatsNext.tomorrow': 'Zítra',
+10
View File
@@ -12,6 +12,16 @@ const collab: TranslationStrings = {
'collab.tabs.chat': 'Chat',
'collab.tabs.notes': 'Notizen',
'collab.tabs.polls': 'Umfragen',
'collab.tabs.links': 'Links',
'collab.links.add': 'Link hinzufügen',
'collab.links.cancel': 'Abbrechen',
'collab.links.save': 'Link speichern',
'collab.links.titlePlaceholder': 'Linktitel',
'collab.links.urlPlaceholder': 'https://...',
'collab.links.empty': 'Noch keine geteilten Links',
'collab.links.pin': 'Link anheften',
'collab.links.unpin': 'Link lösen',
'collab.links.delete': 'Link löschen',
'collab.whatsNext.title': 'Nächste',
'collab.whatsNext.today': 'Heute',
'collab.whatsNext.tomorrow': 'Morgen',
+10
View File
@@ -12,6 +12,16 @@ const collab: TranslationStrings = {
'collab.tabs.chat': 'Chat',
'collab.tabs.notes': 'Notes',
'collab.tabs.polls': 'Polls',
'collab.tabs.links': 'Links',
'collab.links.add': 'Add link',
'collab.links.cancel': 'Cancel',
'collab.links.save': 'Save link',
'collab.links.titlePlaceholder': 'Link title',
'collab.links.urlPlaceholder': 'https://...',
'collab.links.empty': 'No shared links yet',
'collab.links.pin': 'Pin link',
'collab.links.unpin': 'Unpin link',
'collab.links.delete': 'Delete link',
'collab.whatsNext.title': "What's Next",
'collab.whatsNext.today': 'Today',
'collab.whatsNext.tomorrow': 'Tomorrow',
+10
View File
@@ -12,6 +12,16 @@ const collab: TranslationStrings = {
'collab.tabs.chat': 'Mensajes',
'collab.tabs.notes': 'Notas',
'collab.tabs.polls': 'Encuestas',
'collab.tabs.links': 'Enlaces',
'collab.links.add': 'Añadir enlace',
'collab.links.cancel': 'Cancelar',
'collab.links.save': 'Guardar enlace',
'collab.links.titlePlaceholder': 'Título del enlace',
'collab.links.urlPlaceholder': 'https://...',
'collab.links.empty': 'Aún no hay enlaces compartidos',
'collab.links.pin': 'Fijar enlace',
'collab.links.unpin': 'Desfijar enlace',
'collab.links.delete': 'Eliminar enlace',
'collab.whatsNext.title': 'Qué viene ahora',
'collab.whatsNext.today': 'Hoy',
'collab.whatsNext.tomorrow': 'Mañana',
+10
View File
@@ -12,6 +12,16 @@ const collab: TranslationStrings = {
'collab.tabs.chat': 'Discussion',
'collab.tabs.notes': 'Notes',
'collab.tabs.polls': 'Sondages',
'collab.tabs.links': 'Liens',
'collab.links.add': 'Ajouter un lien',
'collab.links.cancel': 'Annuler',
'collab.links.save': 'Enregistrer le lien',
'collab.links.titlePlaceholder': 'Titre du lien',
'collab.links.urlPlaceholder': 'https://...',
'collab.links.empty': 'Aucun lien partagé pour le moment',
'collab.links.pin': 'Épingler le lien',
'collab.links.unpin': 'Désépingler le lien',
'collab.links.delete': 'Supprimer le lien',
'collab.whatsNext.title': 'À venir',
'collab.whatsNext.today': "Aujourd'hui",
'collab.whatsNext.tomorrow': 'Demain',
+10
View File
@@ -12,6 +12,16 @@ const collab: TranslationStrings = {
'collab.tabs.chat': 'Συνομιλία',
'collab.tabs.notes': 'Σημειώσεις',
'collab.tabs.polls': 'Δημοσκοπήσεις',
'collab.tabs.links': 'Σύνδεσμοι',
'collab.links.add': 'Προσθήκη συνδέσμου',
'collab.links.cancel': 'Ακύρωση',
'collab.links.save': 'Αποθήκευση συνδέσμου',
'collab.links.titlePlaceholder': 'Τίτλος συνδέσμου',
'collab.links.urlPlaceholder': 'https://...',
'collab.links.empty': 'Δεν υπάρχουν κοινόχρηστοι σύνδεσμοι ακόμη',
'collab.links.pin': 'Καρφίτσωμα συνδέσμου',
'collab.links.unpin': 'Ξεκαρφίτσωμα συνδέσμου',
'collab.links.delete': 'Διαγραφή συνδέσμου',
'collab.whatsNext.title': 'Τι Επόμενο',
'collab.whatsNext.today': 'Σήμερα',
'collab.whatsNext.tomorrow': 'Αύριο',
+10
View File
@@ -12,6 +12,16 @@ const collab: TranslationStrings = {
'collab.tabs.chat': 'Csevegés',
'collab.tabs.notes': 'Jegyzetek',
'collab.tabs.polls': 'Szavazások',
'collab.tabs.links': 'Linkek',
'collab.links.add': 'Link hozzáadása',
'collab.links.cancel': 'Mégse',
'collab.links.save': 'Link mentése',
'collab.links.titlePlaceholder': 'Link címe',
'collab.links.urlPlaceholder': 'https://...',
'collab.links.empty': 'Még nincsenek megosztott linkek',
'collab.links.pin': 'Link rögzítése',
'collab.links.unpin': 'Link feloldása',
'collab.links.delete': 'Link törlése',
'collab.whatsNext.title': 'Mi következik',
'collab.whatsNext.today': 'Ma',
'collab.whatsNext.tomorrow': 'Holnap',
+10
View File
@@ -12,6 +12,16 @@ const collab: TranslationStrings = {
'collab.tabs.chat': 'Chat',
'collab.tabs.notes': 'Catatan',
'collab.tabs.polls': 'Polling',
'collab.tabs.links': 'Tautan',
'collab.links.add': 'Tambah tautan',
'collab.links.cancel': 'Batal',
'collab.links.save': 'Simpan tautan',
'collab.links.titlePlaceholder': 'Judul tautan',
'collab.links.urlPlaceholder': 'https://...',
'collab.links.empty': 'Belum ada tautan bersama',
'collab.links.pin': 'Sematkan tautan',
'collab.links.unpin': 'Lepas sematan tautan',
'collab.links.delete': 'Hapus tautan',
'collab.whatsNext.title': 'Berikutnya',
'collab.whatsNext.today': 'Hari ini',
'collab.whatsNext.tomorrow': 'Besok',
+10
View File
@@ -12,6 +12,16 @@ const collab: TranslationStrings = {
'collab.tabs.chat': 'Chat',
'collab.tabs.notes': 'Note',
'collab.tabs.polls': 'Sondaggi',
'collab.tabs.links': 'Link',
'collab.links.add': 'Aggiungi link',
'collab.links.cancel': 'Annulla',
'collab.links.save': 'Salva link',
'collab.links.titlePlaceholder': 'Titolo del link',
'collab.links.urlPlaceholder': 'https://...',
'collab.links.empty': 'Nessun link condiviso',
'collab.links.pin': 'Fissa link',
'collab.links.unpin': 'Sblocca link',
'collab.links.delete': 'Elimina link',
'collab.whatsNext.title': "Cosa c'è dopo",
'collab.whatsNext.today': 'Oggi',
'collab.whatsNext.tomorrow': 'Domani',
+10
View File
@@ -12,6 +12,16 @@ const collab: TranslationStrings = {
'collab.tabs.chat': 'チャット',
'collab.tabs.notes': 'ノート',
'collab.tabs.polls': '投票',
'collab.tabs.links': 'リンク',
'collab.links.add': 'リンクを追加',
'collab.links.cancel': 'キャンセル',
'collab.links.save': 'リンクを保存',
'collab.links.titlePlaceholder': 'リンクのタイトル',
'collab.links.urlPlaceholder': 'https://...',
'collab.links.empty': '共有リンクはまだありません',
'collab.links.pin': 'リンクを固定',
'collab.links.unpin': 'リンクの固定を解除',
'collab.links.delete': 'リンクを削除',
'collab.whatsNext.title': '次にすること',
'collab.whatsNext.today': '今日',
'collab.whatsNext.tomorrow': '明日',
+10
View File
@@ -12,6 +12,16 @@ const collab: TranslationStrings = {
'collab.tabs.chat': '채팅',
'collab.tabs.notes': '메모',
'collab.tabs.polls': '투표',
'collab.tabs.links': '링크',
'collab.links.add': '링크 추가',
'collab.links.cancel': '취소',
'collab.links.save': '링크 저장',
'collab.links.titlePlaceholder': '링크 제목',
'collab.links.urlPlaceholder': 'https://...',
'collab.links.empty': '공유된 링크가 아직 없습니다',
'collab.links.pin': '링크 고정',
'collab.links.unpin': '링크 고정 해제',
'collab.links.delete': '링크 삭제',
'collab.whatsNext.title': '다음 할 일',
'collab.whatsNext.today': '오늘',
'collab.whatsNext.tomorrow': '내일',
+10
View File
@@ -12,6 +12,16 @@ const collab: TranslationStrings = {
'collab.tabs.chat': 'Chat',
'collab.tabs.notes': 'Notities',
'collab.tabs.polls': 'Polls',
'collab.tabs.links': 'Links',
'collab.links.add': 'Link toevoegen',
'collab.links.cancel': 'Annuleren',
'collab.links.save': 'Link opslaan',
'collab.links.titlePlaceholder': 'Linktitel',
'collab.links.urlPlaceholder': 'https://...',
'collab.links.empty': 'Nog geen gedeelde links',
'collab.links.pin': 'Link vastmaken',
'collab.links.unpin': 'Link losmaken',
'collab.links.delete': 'Link verwijderen',
'collab.whatsNext.title': 'Wat komt er',
'collab.whatsNext.today': 'Vandaag',
'collab.whatsNext.tomorrow': 'Morgen',
+10
View File
@@ -12,6 +12,16 @@ const collab: TranslationStrings = {
'collab.tabs.chat': 'Czat',
'collab.tabs.notes': 'Notatki',
'collab.tabs.polls': 'Ankiety',
'collab.tabs.links': 'Linki',
'collab.links.add': 'Dodaj link',
'collab.links.cancel': 'Anuluj',
'collab.links.save': 'Zapisz link',
'collab.links.titlePlaceholder': 'Tytuł linku',
'collab.links.urlPlaceholder': 'https://...',
'collab.links.empty': 'Brak udostępnionych linków',
'collab.links.pin': 'Przypnij link',
'collab.links.unpin': 'Odepnij link',
'collab.links.delete': 'Usuń link',
'collab.whatsNext.title': 'Co dalej',
'collab.whatsNext.today': 'Dzisiaj',
'collab.whatsNext.tomorrow': 'Jutro',
+10
View File
@@ -12,6 +12,16 @@ const collab: TranslationStrings = {
'collab.tabs.chat': 'Чат',
'collab.tabs.notes': 'Заметки',
'collab.tabs.polls': 'Опросы',
'collab.tabs.links': 'Ссылки',
'collab.links.add': 'Добавить ссылку',
'collab.links.cancel': 'Отмена',
'collab.links.save': 'Сохранить ссылку',
'collab.links.titlePlaceholder': 'Название ссылки',
'collab.links.urlPlaceholder': 'https://...',
'collab.links.empty': 'Пока нет общих ссылок',
'collab.links.pin': 'Закрепить ссылку',
'collab.links.unpin': 'Открепить ссылку',
'collab.links.delete': 'Удалить ссылку',
'collab.whatsNext.title': 'Что дальше',
'collab.whatsNext.today': 'Сегодня',
'collab.whatsNext.tomorrow': 'Завтра',
+10
View File
@@ -12,6 +12,16 @@ const collab: TranslationStrings = {
'collab.tabs.chat': 'Chatt',
'collab.tabs.notes': 'Noteringar',
'collab.tabs.polls': 'Omröstningar',
'collab.tabs.links': 'Länkar',
'collab.links.add': 'Lägg till länk',
'collab.links.cancel': 'Avbryt',
'collab.links.save': 'Spara länk',
'collab.links.titlePlaceholder': 'Länktitel',
'collab.links.urlPlaceholder': 'https://...',
'collab.links.empty': 'Inga delade länkar än',
'collab.links.pin': 'Fäst länk',
'collab.links.unpin': 'Lossa länk',
'collab.links.delete': 'Ta bort länk',
'collab.whatsNext.title': 'Vad händer härnäst?',
'collab.whatsNext.today': 'Idag',
'collab.whatsNext.tomorrow': 'I morgon',
+10
View File
@@ -12,6 +12,16 @@ const collab: TranslationStrings = {
'collab.tabs.chat': 'Sohbet',
'collab.tabs.notes': 'Notlar',
'collab.tabs.polls': 'Anketler',
'collab.tabs.links': 'Bağlantılar',
'collab.links.add': 'Bağlantı ekle',
'collab.links.cancel': 'İptal',
'collab.links.save': 'Bağlantıyı kaydet',
'collab.links.titlePlaceholder': 'Bağlantı başlığı',
'collab.links.urlPlaceholder': 'https://...',
'collab.links.empty': 'Henüz paylaşılan bağlantı yok',
'collab.links.pin': 'Bağlantıyı sabitle',
'collab.links.unpin': 'Sabitlemeyi kaldır',
'collab.links.delete': 'Bağlantıyı sil',
'collab.whatsNext.title': "What's Next",
'collab.whatsNext.today': 'Bugün',
'collab.whatsNext.tomorrow': 'Yarın',
+10
View File
@@ -12,6 +12,16 @@ const collab: TranslationStrings = {
'collab.tabs.chat': 'Чат',
'collab.tabs.notes': 'Нотатки',
'collab.tabs.polls': 'Опитування',
'collab.tabs.links': 'Посилання',
'collab.links.add': 'Додати посилання',
'collab.links.cancel': 'Скасувати',
'collab.links.save': 'Зберегти посилання',
'collab.links.titlePlaceholder': 'Назва посилання',
'collab.links.urlPlaceholder': 'https://...',
'collab.links.empty': 'Ще немає спільних посилань',
'collab.links.pin': 'Закріпити посилання',
'collab.links.unpin': 'Відкріпити посилання',
'collab.links.delete': 'Видалити посилання',
'collab.whatsNext.title': 'Що далі',
'collab.whatsNext.today': 'Сьогодні',
'collab.whatsNext.tomorrow': 'Завтра',
+10
View File
@@ -12,6 +12,16 @@ const collab: TranslationStrings = {
'collab.tabs.chat': 'Trò chuyện',
'collab.tabs.notes': 'Ghi chú',
'collab.tabs.polls': 'Thăm dò ý kiến',
'collab.tabs.links': 'Liên kết',
'collab.links.add': 'Thêm liên kết',
'collab.links.cancel': 'Hủy',
'collab.links.save': 'Lưu liên kết',
'collab.links.titlePlaceholder': 'Tiêu đề liên kết',
'collab.links.urlPlaceholder': 'https://...',
'collab.links.empty': 'Chưa có liên kết được chia sẻ',
'collab.links.pin': 'Ghim liên kết',
'collab.links.unpin': 'Bỏ ghim liên kết',
'collab.links.delete': 'Xóa liên kết',
'collab.whatsNext.title': 'Tiếp theo là gì',
'collab.whatsNext.today': 'Hôm nay',
'collab.whatsNext.tomorrow': 'Ngày mai',
+10
View File
@@ -12,6 +12,16 @@ const collab: TranslationStrings = {
'collab.tabs.chat': '聊天',
'collab.tabs.notes': '筆記',
'collab.tabs.polls': '投票',
'collab.tabs.links': '連結',
'collab.links.add': '新增連結',
'collab.links.cancel': '取消',
'collab.links.save': '儲存連結',
'collab.links.titlePlaceholder': '連結標題',
'collab.links.urlPlaceholder': 'https://...',
'collab.links.empty': '尚無共享連結',
'collab.links.pin': '釘選連結',
'collab.links.unpin': '取消釘選連結',
'collab.links.delete': '刪除連結',
'collab.whatsNext.title': '接下來',
'collab.whatsNext.today': '今天',
'collab.whatsNext.tomorrow': '明天',
+10
View File
@@ -12,6 +12,16 @@ const collab: TranslationStrings = {
'collab.tabs.chat': '聊天',
'collab.tabs.notes': '笔记',
'collab.tabs.polls': '投票',
'collab.tabs.links': '链接',
'collab.links.add': '添加链接',
'collab.links.cancel': '取消',
'collab.links.save': '保存链接',
'collab.links.titlePlaceholder': '链接标题',
'collab.links.urlPlaceholder': 'https://...',
'collab.links.empty': '还没有共享链接',
'collab.links.pin': '固定链接',
'collab.links.unpin': '取消固定链接',
'collab.links.delete': '删除链接',
'collab.whatsNext.title': '接下来',
'collab.whatsNext.today': '今天',
'collab.whatsNext.tomorrow': '明天',