diff --git a/cmd/media.go b/cmd/media.go index 0f7be5cd..31ad9d13 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -101,7 +101,7 @@ func handleMediaUpload(r *fastglue.Request) error { // Generate and upload thumbnail and store image dimensions in the media meta. var meta = []byte("{}") - if slices.Contains(image.Exts, srcExt) { + if slices.Contains(image.Exts, srcExt) || image.IsImageByContent(file) { file.Seek(0, 0) thumbFile, err := image.CreateThumb(image.DefThumbSize, file) if err != nil { @@ -152,8 +152,8 @@ func handleMediaUpload(r *fastglue.Request) error { // Supports both authenticated access (with permission checks) and signed URL access (no permission checks). func handleServeMedia(r *fastglue.Request) error { var ( - app = r.Context.(*App) - uuid = r.RequestCtx.UserValue("uuid").(string) + app = r.Context.(*App) + uuid = r.RequestCtx.UserValue("uuid").(string) authMethod = r.RequestCtx.UserValue("auth_method") ) @@ -184,7 +184,7 @@ func handleServeMedia(r *fastglue.Request) error { // For messages, check access to the conversation this message is part of. // Skip if model_id is not set (media uploaded but not yet attached to a message). - if media.Model.String == "messages" && media.ModelID.Int > 0 { + if media.Model.String == mmodels.ModelMessages && media.ModelID.Int > 0 { conversation, err := app.conversation.GetConversationByMessageID(media.ModelID.Int) if err != nil { return sendErrorEnvelope(r, err) @@ -214,13 +214,16 @@ func serveMediaFile(r *fastglue.Request, app *App, uuid string, media *mmodels.M media = &m } + forceDownload := string(r.RequestCtx.QueryArgs().Peek("download")) == "1" + consts := app.consts.Load().(*constants) switch consts.UploadProvider { case "fs": disposition := "attachment" // Inline images/videos/pdfs. SVG excluded. - if media.ContentType != "image/svg+xml" && + if !forceDownload && + media.ContentType != "image/svg+xml" && (strings.HasPrefix(media.ContentType, "image/") || strings.HasPrefix(media.ContentType, "video/") || media.ContentType == "application/pdf") { @@ -233,7 +236,11 @@ func serveMediaFile(r *fastglue.Request, app *App, uuid string, media *mmodels.M fasthttp.ServeFile(r.RequestCtx, filepath.Join(ko.String("upload.fs.upload_path"), uuid)) case "s3": - r.RequestCtx.Redirect(app.media.GetURL(uuid, media.ContentType, media.Filename), http.StatusFound) + url := app.media.GetURL(uuid, media.ContentType, media.Filename) + if forceDownload { + url = app.media.GetURLForDownload(uuid, media.Filename) + } + r.RequestCtx.Redirect(url, http.StatusFound) } return nil } diff --git a/frontend/apps/main/src/components/DownloadLink.vue b/frontend/apps/main/src/components/DownloadLink.vue new file mode 100644 index 00000000..032c448b --- /dev/null +++ b/frontend/apps/main/src/components/DownloadLink.vue @@ -0,0 +1,24 @@ + + + diff --git a/frontend/apps/main/src/components/ImageLightbox.vue b/frontend/apps/main/src/components/ImageLightbox.vue new file mode 100644 index 00000000..6bec3c71 --- /dev/null +++ b/frontend/apps/main/src/components/ImageLightbox.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/frontend/apps/main/src/components/banner/AdminBanner.vue b/frontend/apps/main/src/components/banner/AdminBanner.vue index 0805b6c5..2234aed3 100644 --- a/frontend/apps/main/src/components/banner/AdminBanner.vue +++ b/frontend/apps/main/src/components/banner/AdminBanner.vue @@ -15,7 +15,7 @@ {{ appSettingsStore.settings['app.update'].update.release_version }} diff --git a/frontend/apps/main/src/components/editor/TextEditor.vue b/frontend/apps/main/src/components/editor/TextEditor.vue index 4406030a..a95715c1 100644 --- a/frontend/apps/main/src/components/editor/TextEditor.vue +++ b/frontend/apps/main/src/components/editor/TextEditor.vue @@ -3,6 +3,7 @@ @@ -141,7 +142,7 @@ import { DialogDescription } from '@shared-ui/components/ui/dialog' import Placeholder from '@tiptap/extension-placeholder' -import Image from '@tiptap/extension-image' +import ResizableImage from './extensions/ResizableImage' import StarterKit from '@tiptap/starter-kit' import Link from '@tiptap/extension-link' import Mention from '@tiptap/extension-mention' @@ -151,6 +152,7 @@ import TableCell from '@tiptap/extension-table-cell' import TableHeader from '@tiptap/extension-table-header' import { useTypingIndicator } from '@shared-ui/composables' import { useConversationStore } from '@main/stores/conversation' +import { useInlineImageUpload } from '@main/composables/useInlineImageUpload' import mentionSuggestion from './mentionSuggestion' const textContent = defineModel('textContent', { default: '' }) @@ -181,13 +183,33 @@ const props = defineProps({ getSuggestions: { type: Function, default: null + }, + enableInlineImages: { + type: Boolean, + default: false } }) -const emit = defineEmits(['send', 'aiPromptSelected', 'mentionsChanged']) +const emit = defineEmits(['send', 'aiPromptSelected', 'mentionsChanged', 'filesDropped']) const emitPrompt = (key) => emit('aiPromptSelected', key) +// Suppress the formatting bubble when an image node is selected so it +// doesn't fight with the image's own size/remove toolbar. +const shouldShowBubble = ({ editor: e, state }) => { + const { selection } = state + if (selection.empty) return false + if (!e.view.hasFocus()) return false + if (selection.node?.type?.name === 'image') return false + return true +} + +const { handlePaste, handleDrop } = useInlineImageUpload({ + getEditor: () => editor.value, + isInlineEnabled: () => props.enableInlineImages, + onOtherFiles: (files) => emit('filesDropped', files) +}) + // Set up typing indicator const conversationStore = useConversationStore() const { startTyping, stopTyping } = useTypingIndicator(conversationStore.sendTyping, { @@ -257,7 +279,10 @@ const isInternalUpdate = ref(false) const buildExtensions = () => { const extensions = [ StarterKit.configure(), - Image.configure({ HTMLAttributes: { class: 'inline-image' } }), + ResizableImage.configure({ + HTMLAttributes: { class: 'inline-image', style: 'max-width: 100%; height: auto;' }, + allowBase64: false + }), Placeholder.configure({ placeholder: () => props.placeholder }), Link, CustomTable.configure({ resizable: false }), @@ -309,6 +334,8 @@ const editor = useEditor({ editorProps: { attributes: { class: 'outline-none' }, getSuggestions: props.getSuggestions, + handlePaste, + handleDrop, handleKeyDown: (view, event) => { if (event.ctrlKey && event.key.toLowerCase() === 'b') { event.stopPropagation() @@ -451,5 +478,138 @@ defineExpose({ focus, extractMentions }) color: hsl(var(--primary)); font-weight: 500; } + + .image-resizer { + display: inline-block; + position: relative; + margin: 4px 5px; + + .image-upload-placeholder { + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 28px 32px; + min-width: 360px; + min-height: 220px; + max-width: 100%; + background: hsl(var(--muted)); + border: 1px dashed hsl(var(--border)); + border-radius: 6px; + line-height: 1.4; + gap: 12px; + } + + .image-upload-placeholder-row { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + font-size: 13px; + color: hsl(var(--muted-foreground)); + } + + .image-upload-placeholder-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 320px; + } + + &.uploading { + .inline-image { + display: none; + } + .image-upload-placeholder { + display: inline-flex; + } + } + + .image-resize-handle { + display: none; + position: absolute; + width: 12px; + height: 12px; + background: hsl(var(--primary)); + border: 2px solid hsl(var(--background)); + border-radius: 2px; + z-index: 10; + box-shadow: 0 0 0 1px hsl(var(--border)); + } + + .image-resize-handle-tl { top: -6px; left: -6px; cursor: nwse-resize; } + .image-resize-handle-tr { top: -6px; right: -6px; cursor: nesw-resize; } + .image-resize-handle-bl { bottom: -6px; left: -6px; cursor: nesw-resize; } + .image-resize-handle-br { bottom: -6px; right: -6px; cursor: nwse-resize; } + + // Anchored to the image's left edge (no centering) so the toolbar + // never extends past the image's left side and into adjacent UI when + // the image sits near the editor's left edge. + .image-size-toolbar { + display: none; + position: absolute; + top: 4px; + left: 0; + background: hsl(var(--background) / 0.95); + border: 1px solid hsl(var(--border)); + border-radius: 6px; + padding: 2px; + z-index: 10000; + white-space: nowrap; + box-shadow: 0 2px 8px hsl(var(--foreground) / 0.15); + backdrop-filter: blur(4px); + + button { + padding: 2px 8px; + font-size: 11px; + color: hsl(var(--muted-foreground)); + background: none; + border: none; + border-radius: 4px; + cursor: pointer; + line-height: 1.6; + + &:hover { + background: hsl(var(--accent)); + color: hsl(var(--accent-foreground)); + } + } + + .image-toolbar-sep { + width: 1px; + height: 14px; + background: hsl(var(--border)); + margin: 0 2px; + align-self: center; + } + + .image-toolbar-remove { + color: hsl(var(--destructive)) !important; + + &:hover { + background: hsl(var(--destructive) / 0.1) !important; + color: hsl(var(--destructive)) !important; + } + } + } + + &.ProseMirror-selectednode .image-resize-handle, + &.resizing .image-resize-handle { + display: block; + } + + &.ProseMirror-selectednode .image-size-toolbar { + display: flex; + } + + &.ProseMirror-selectednode .inline-image, + &.resizing .inline-image { + outline: 2px solid #0066cc; + } + + &.resizing .inline-image { + opacity: 0.8; + } + } } - \ No newline at end of file + diff --git a/frontend/apps/main/src/components/editor/extensions/ResizableImage.js b/frontend/apps/main/src/components/editor/extensions/ResizableImage.js new file mode 100644 index 00000000..73b5b2a9 --- /dev/null +++ b/frontend/apps/main/src/components/editor/extensions/ResizableImage.js @@ -0,0 +1,214 @@ +import Image from '@tiptap/extension-image' +import { getI18n } from '@main/i18n' + +// Styles for `.image-resizer`, `.image-resize-handle*`, `.image-size-toolbar`, +// and `.image-upload-placeholder*` are in TextEditor.vue's global \ No newline at end of file diff --git a/frontend/apps/main/src/features/conversation/message/attachment/BubbleAttachmentItem.vue b/frontend/apps/main/src/features/conversation/message/attachment/BubbleAttachmentItem.vue new file mode 100644 index 00000000..fe917dcc --- /dev/null +++ b/frontend/apps/main/src/features/conversation/message/attachment/BubbleAttachmentItem.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/frontend/apps/main/src/features/conversation/message/attachment/BubbleAttachmentPreview.vue b/frontend/apps/main/src/features/conversation/message/attachment/BubbleAttachmentPreview.vue new file mode 100644 index 00000000..5fbf30cb --- /dev/null +++ b/frontend/apps/main/src/features/conversation/message/attachment/BubbleAttachmentPreview.vue @@ -0,0 +1,41 @@ + + + diff --git a/frontend/apps/main/src/features/conversation/message/attachment/FileAttachmentPreview.vue b/frontend/apps/main/src/features/conversation/message/attachment/FileAttachmentPreview.vue deleted file mode 100644 index fcb6f4c5..00000000 --- a/frontend/apps/main/src/features/conversation/message/attachment/FileAttachmentPreview.vue +++ /dev/null @@ -1,42 +0,0 @@ - - - diff --git a/frontend/apps/main/src/features/conversation/message/attachment/ImageAttachmentPreview.vue b/frontend/apps/main/src/features/conversation/message/attachment/ImageAttachmentPreview.vue deleted file mode 100644 index 059a348a..00000000 --- a/frontend/apps/main/src/features/conversation/message/attachment/ImageAttachmentPreview.vue +++ /dev/null @@ -1,38 +0,0 @@ - - - diff --git a/frontend/apps/main/src/features/conversation/message/attachment/MessageAttachmentPreview.vue b/frontend/apps/main/src/features/conversation/message/attachment/MessageAttachmentPreview.vue deleted file mode 100644 index af0857a0..00000000 --- a/frontend/apps/main/src/features/conversation/message/attachment/MessageAttachmentPreview.vue +++ /dev/null @@ -1,30 +0,0 @@ - - - diff --git a/frontend/apps/main/src/features/conversation/message/attachment/AttachmentsPreview.vue b/frontend/apps/main/src/features/conversation/message/attachment/ReplyBoxAttachmentPreview.vue similarity index 100% rename from frontend/apps/main/src/features/conversation/message/attachment/AttachmentsPreview.vue rename to frontend/apps/main/src/features/conversation/message/attachment/ReplyBoxAttachmentPreview.vue diff --git a/frontend/apps/main/src/features/conversation/sidebar/ConversationSideBarContact.vue b/frontend/apps/main/src/features/conversation/sidebar/ConversationSideBarContact.vue index ac684561..05cebdf8 100644 --- a/frontend/apps/main/src/features/conversation/sidebar/ConversationSideBarContact.vue +++ b/frontend/apps/main/src/features/conversation/sidebar/ConversationSideBarContact.vue @@ -204,7 +204,7 @@ const openContextLink = async (app) => { try { loadingAppId.value = app.id const resp = await api.getContextLinkURL(app.id, uuid) - window.open(resp.data.data, '_blank') + window.open(resp.data.data, '_blank', 'noopener,noreferrer') } catch { // Silently ignore. } finally { diff --git a/frontend/apps/main/src/features/conversation/sidebar/ConversationSideBarPageVisits.vue b/frontend/apps/main/src/features/conversation/sidebar/ConversationSideBarPageVisits.vue index 31c6f3e9..277a9d06 100644 --- a/frontend/apps/main/src/features/conversation/sidebar/ConversationSideBarPageVisits.vue +++ b/frontend/apps/main/src/features/conversation/sidebar/ConversationSideBarPageVisits.vue @@ -9,7 +9,7 @@ :key="idx" :href="page.url" target="_blank" - rel="noopener" + rel="noopener noreferrer" class="block p-2 rounded hover:bg-muted" >
diff --git a/frontend/apps/widget/src/components/AnnouncementCard.vue b/frontend/apps/widget/src/components/AnnouncementCard.vue index 4b89f4d2..18834c3d 100644 --- a/frontend/apps/widget/src/components/AnnouncementCard.vue +++ b/frontend/apps/widget/src/components/AnnouncementCard.vue @@ -1,5 +1,5 @@