From d4daff805511201af137126571f4abb810a191e0 Mon Sep 17 00:00:00 2001 From: Godric Hood Date: Sun, 6 Sep 2026 18:21:18 +0100 Subject: [PATCH 1/6] 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. --- client/src/api/client.ts | 8 +- client/src/components/Admin/AddonManager.tsx | 3 +- client/src/components/Collab/CollabChat.tsx | 17 +-- .../Collab/CollabChatAttachment.tsx | 10 ++ .../components/Collab/CollabChatMessages.tsx | 2 + client/src/components/Collab/CollabLinks.tsx | 47 ++++++++ .../components/Collab/CollabPanel.test.tsx | 15 ++- client/src/components/Collab/CollabPanel.tsx | 63 ++++++----- client/src/components/Collab/useCollabChat.ts | 40 +++++-- client/src/components/Plugins/PluginFrame.tsx | 2 + .../screens/admin/MAdminAddonManager.tsx | 3 +- client/src/mobile/screens/trip/MTripShell.tsx | 3 +- .../mobile/screens/trip/tabs/MCollabChat.tsx | 60 +++++++++-- .../mobile/screens/trip/tabs/MCollabTab.tsx | 20 +++- .../mobile/screens/trip/tabs/collabModel.ts | 1 + .../src/pages/tripPlanner/useTripPlanner.ts | 2 +- server/src/db/migrations.ts | 41 +++++++ server/src/nest/addons/addons.service.ts | 6 +- server/src/nest/collab/collab.controller.ts | 102 ++++++++++++++++-- server/src/nest/collab/collab.dto.ts | 4 + server/src/nest/collab/collab.service.ts | 82 +++++++++++++- server/src/nest/files/files.service.ts | 4 +- shared/src/collab/collab.schema.spec.ts | 24 ++++- shared/src/collab/collab.schema.ts | 30 +++++- shared/src/i18n/ar/collab.ts | 10 ++ shared/src/i18n/br/collab.ts | 10 ++ shared/src/i18n/ca/collab.ts | 10 ++ shared/src/i18n/cs/collab.ts | 10 ++ shared/src/i18n/de/collab.ts | 10 ++ shared/src/i18n/en/collab.ts | 10 ++ shared/src/i18n/es/collab.ts | 10 ++ shared/src/i18n/fr/collab.ts | 10 ++ shared/src/i18n/gr/collab.ts | 10 ++ shared/src/i18n/hu/collab.ts | 10 ++ shared/src/i18n/id/collab.ts | 10 ++ shared/src/i18n/it/collab.ts | 10 ++ shared/src/i18n/ja/collab.ts | 10 ++ shared/src/i18n/ko/collab.ts | 10 ++ shared/src/i18n/nl/collab.ts | 10 ++ shared/src/i18n/pl/collab.ts | 10 ++ shared/src/i18n/ru/collab.ts | 10 ++ shared/src/i18n/sv/collab.ts | 10 ++ shared/src/i18n/tr/collab.ts | 10 ++ shared/src/i18n/uk/collab.ts | 10 ++ shared/src/i18n/vi/collab.ts | 10 ++ shared/src/i18n/zh-TW/collab.ts | 10 ++ shared/src/i18n/zh/collab.ts | 10 ++ 47 files changed, 743 insertions(+), 76 deletions(-) create mode 100644 client/src/components/Collab/CollabChatAttachment.tsx create mode 100644 client/src/components/Collab/CollabLinks.tsx diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 496813a033..07ce7d621d 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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), diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 7a4ed51309..05010f7dd8 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -74,11 +74,12 @@ const TYPE_META: Record; 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 diff --git a/client/src/components/Collab/CollabChat.tsx b/client/src/components/Collab/CollabChat.tsx index 597b8b86f0..f3cc805266 100644 --- a/client/src/components/Collab/CollabChat.tsx +++ b/client/src/components/Collab/CollabChat.tsx @@ -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 (
@@ -27,7 +27,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
{/* Composer */} -
+
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 && (
)} + {imagePreviews.length > 0 &&
{imagePreviews.map((url, i) =>
)}
} + {uploadProgress > 0 && uploadProgress < 100 &&
Uploading {uploadProgress}%
}
{/* Emoji button */} {canEdit && ( @@ -61,6 +63,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) { )} + {canEdit && <> { if (e.target.files) addImageFiles(e.target.files); e.currentTarget.value = '' }} />} +