mirror of
https://github.com/abhinavxd/libredesk.git
synced 2026-09-23 19:06:35 +00:00
Refactor inline images PR for project conventions
This commit is contained in:
@@ -42,16 +42,16 @@
|
||||
:href="currentImage.url"
|
||||
download
|
||||
class="text-white/70 hover:text-white"
|
||||
:title="t('imageLightbox.download')"
|
||||
:aria-label="t('imageLightbox.download')"
|
||||
:title="t('globals.terms.download')"
|
||||
:aria-label="t('globals.terms.download')"
|
||||
@click.stop
|
||||
>
|
||||
<Download :size="20" />
|
||||
</a>
|
||||
<button
|
||||
class="text-white hover:text-gray-300"
|
||||
:title="t('imageLightbox.close')"
|
||||
:aria-label="t('imageLightbox.close')"
|
||||
:title="t('globals.messages.close')"
|
||||
:aria-label="t('globals.messages.close')"
|
||||
@click="close"
|
||||
>
|
||||
<X :size="24" />
|
||||
|
||||
@@ -141,7 +141,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'
|
||||
@@ -374,165 +374,6 @@ const CustomMention = Mention.extend({
|
||||
}
|
||||
})
|
||||
|
||||
// Custom Image extension with drag-handle resizing and Gmail-style size presets
|
||||
// (Small / Best fit / Original / Remove). Renders a node-view that wraps the
|
||||
// <img> with a corner resize handle and a hover toolbar.
|
||||
const ResizableImage = Image.extend({
|
||||
addAttributes () {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute('width') || el.style.width?.replace('px', '') || null,
|
||||
renderHTML: (attrs) => {
|
||||
if (!attrs.width) return {}
|
||||
return { width: attrs.width, style: `width: ${attrs.width}px` }
|
||||
}
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute('height') || null,
|
||||
renderHTML: (attrs) => (attrs.height ? { height: attrs.height } : {})
|
||||
}
|
||||
}
|
||||
},
|
||||
addNodeView () {
|
||||
return ({ node, getPos, editor: nodeEditor }) => {
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.classList.add('image-resizer')
|
||||
wrapper.style.display = 'inline-block'
|
||||
wrapper.style.position = 'relative'
|
||||
wrapper.style.lineHeight = '0'
|
||||
|
||||
const img = document.createElement('img')
|
||||
img.src = node.attrs.src
|
||||
img.alt = node.attrs.alt || ''
|
||||
img.title = node.attrs.title || ''
|
||||
img.classList.add('inline-image')
|
||||
img.style.maxWidth = '100%'
|
||||
img.style.height = 'auto'
|
||||
if (node.attrs.width) img.style.width = node.attrs.width + 'px'
|
||||
wrapper.appendChild(img)
|
||||
|
||||
// Toolbar (visible when wrapper is selected)
|
||||
const toolbar = document.createElement('div')
|
||||
toolbar.classList.add('image-size-toolbar')
|
||||
|
||||
let naturalWidth = 0
|
||||
img.addEventListener('load', () => { naturalWidth = img.naturalWidth })
|
||||
|
||||
const commitWidth = (newWidth) => {
|
||||
const pos = getPos()
|
||||
if (typeof pos !== 'number') return
|
||||
nodeEditor.chain().focus().command(({ tr }) => {
|
||||
tr.setNodeMarkup(pos, undefined, { ...node.attrs, width: newWidth || null })
|
||||
return true
|
||||
}).run()
|
||||
}
|
||||
|
||||
const sizes = [
|
||||
{ label: 'Small', value: 400 },
|
||||
{ label: 'Best fit', value: 'fit' },
|
||||
{ label: 'Original', value: 'original' }
|
||||
]
|
||||
// Toolbar buttons use pointerdown so touch + pen + mouse all work.
|
||||
// preventDefault avoids stealing focus from the editor.
|
||||
sizes.forEach(({ label, value }) => {
|
||||
const btn = document.createElement('button')
|
||||
btn.textContent = label
|
||||
btn.type = 'button'
|
||||
btn.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (value === 'original') {
|
||||
img.style.width = naturalWidth ? naturalWidth + 'px' : 'auto'
|
||||
commitWidth(naturalWidth || null)
|
||||
} else if (value === 'fit') {
|
||||
img.style.width = ''
|
||||
commitWidth(null)
|
||||
} else {
|
||||
img.style.width = value + 'px'
|
||||
commitWidth(value)
|
||||
}
|
||||
})
|
||||
toolbar.appendChild(btn)
|
||||
})
|
||||
|
||||
const sep = document.createElement('span')
|
||||
sep.classList.add('image-toolbar-sep')
|
||||
toolbar.appendChild(sep)
|
||||
|
||||
const removeBtn = document.createElement('button')
|
||||
removeBtn.textContent = 'Remove'
|
||||
removeBtn.type = 'button'
|
||||
removeBtn.classList.add('image-toolbar-remove')
|
||||
removeBtn.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const pos = getPos()
|
||||
if (typeof pos === 'number') {
|
||||
nodeEditor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run()
|
||||
}
|
||||
})
|
||||
toolbar.appendChild(removeBtn)
|
||||
wrapper.appendChild(toolbar)
|
||||
|
||||
// Bottom-right resize handle. We don't manage selected state ourselves;
|
||||
// CSS keys off ProseMirror's `.ProseMirror-selectednode` class which
|
||||
// ProseMirror toggles automatically when the image node is selected.
|
||||
// That avoids a global document click listener per image (which leaks
|
||||
// closures across the entire page for every embedded image).
|
||||
const handle = document.createElement('div')
|
||||
handle.classList.add('image-resize-handle')
|
||||
wrapper.appendChild(handle)
|
||||
|
||||
// Drag the corner handle to resize. Pointer events for touch + pen.
|
||||
let startX = 0
|
||||
let startWidth = 0
|
||||
const onPointerMove = (e) => {
|
||||
const newWidth = Math.max(50, startWidth + (e.clientX - startX))
|
||||
img.style.width = newWidth + 'px'
|
||||
}
|
||||
const onPointerUp = () => {
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
wrapper.classList.remove('resizing')
|
||||
try {
|
||||
commitWidth(Math.round(img.offsetWidth))
|
||||
} catch (err) {
|
||||
// Node may have been removed/replaced mid-drag (autosave
|
||||
// re-render, paste over selection, etc.). Drop the commit.
|
||||
}
|
||||
}
|
||||
const onPointerDown = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
startX = e.clientX
|
||||
startWidth = img.offsetWidth
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
wrapper.classList.add('resizing')
|
||||
}
|
||||
handle.addEventListener('pointerdown', onPointerDown)
|
||||
|
||||
return {
|
||||
dom: wrapper,
|
||||
update: (updatedNode) => {
|
||||
if (updatedNode.type.name !== 'image') return false
|
||||
img.src = updatedNode.attrs.src
|
||||
img.style.width = updatedNode.attrs.width ? updatedNode.attrs.width + 'px' : ''
|
||||
return true
|
||||
},
|
||||
destroy: () => {
|
||||
handle.removeEventListener('pointerdown', onPointerDown)
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const isInternalUpdate = ref(false)
|
||||
|
||||
const buildExtensions = () => {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import Image from '@tiptap/extension-image'
|
||||
|
||||
// Custom Image extension with drag-handle resizing and Gmail-style size presets
|
||||
// (Small / Best fit / Original / Remove). Styles for .image-resizer,
|
||||
// .image-resize-handle, and .image-size-toolbar live in TextEditor.vue's
|
||||
// global <style> block.
|
||||
export const ResizableImage = Image.extend({
|
||||
addAttributes () {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute('width') || el.style.width?.replace('px', '') || null,
|
||||
renderHTML: (attrs) => {
|
||||
if (!attrs.width) return {}
|
||||
return { width: attrs.width, style: `width: ${attrs.width}px` }
|
||||
}
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
parseHTML: (el) => el.getAttribute('height') || null,
|
||||
renderHTML: (attrs) => (attrs.height ? { height: attrs.height } : {})
|
||||
}
|
||||
}
|
||||
},
|
||||
addNodeView () {
|
||||
return ({ node, getPos, editor: nodeEditor }) => {
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.classList.add('image-resizer')
|
||||
wrapper.style.display = 'inline-block'
|
||||
wrapper.style.position = 'relative'
|
||||
wrapper.style.lineHeight = '0'
|
||||
|
||||
const img = document.createElement('img')
|
||||
img.src = node.attrs.src
|
||||
img.alt = node.attrs.alt || ''
|
||||
img.title = node.attrs.title || ''
|
||||
img.classList.add('inline-image')
|
||||
img.style.maxWidth = '100%'
|
||||
img.style.height = 'auto'
|
||||
if (node.attrs.width) img.style.width = node.attrs.width + 'px'
|
||||
wrapper.appendChild(img)
|
||||
|
||||
const toolbar = document.createElement('div')
|
||||
toolbar.classList.add('image-size-toolbar')
|
||||
|
||||
let naturalWidth = 0
|
||||
img.addEventListener('load', () => { naturalWidth = img.naturalWidth })
|
||||
|
||||
const commitWidth = (newWidth) => {
|
||||
const pos = getPos()
|
||||
if (typeof pos !== 'number') return
|
||||
nodeEditor.chain().focus().command(({ tr }) => {
|
||||
tr.setNodeMarkup(pos, undefined, { ...node.attrs, width: newWidth || null })
|
||||
return true
|
||||
}).run()
|
||||
}
|
||||
|
||||
const sizes = [
|
||||
{ label: 'Small', value: 400 },
|
||||
{ label: 'Best fit', value: 'fit' },
|
||||
{ label: 'Original', value: 'original' }
|
||||
]
|
||||
// Toolbar buttons use pointerdown so touch + pen + mouse all work.
|
||||
// preventDefault avoids stealing focus from the editor.
|
||||
sizes.forEach(({ label, value }) => {
|
||||
const btn = document.createElement('button')
|
||||
btn.textContent = label
|
||||
btn.type = 'button'
|
||||
btn.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (value === 'original') {
|
||||
img.style.width = naturalWidth ? naturalWidth + 'px' : 'auto'
|
||||
commitWidth(naturalWidth || null)
|
||||
} else if (value === 'fit') {
|
||||
img.style.width = ''
|
||||
commitWidth(null)
|
||||
} else {
|
||||
img.style.width = value + 'px'
|
||||
commitWidth(value)
|
||||
}
|
||||
})
|
||||
toolbar.appendChild(btn)
|
||||
})
|
||||
|
||||
const sep = document.createElement('span')
|
||||
sep.classList.add('image-toolbar-sep')
|
||||
toolbar.appendChild(sep)
|
||||
|
||||
const removeBtn = document.createElement('button')
|
||||
removeBtn.textContent = 'Remove'
|
||||
removeBtn.type = 'button'
|
||||
removeBtn.classList.add('image-toolbar-remove')
|
||||
removeBtn.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const pos = getPos()
|
||||
if (typeof pos === 'number') {
|
||||
nodeEditor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run()
|
||||
}
|
||||
})
|
||||
toolbar.appendChild(removeBtn)
|
||||
wrapper.appendChild(toolbar)
|
||||
|
||||
// CSS keys off ProseMirror's `.ProseMirror-selectednode` class which
|
||||
// ProseMirror toggles automatically when the image node is selected.
|
||||
// Avoids a global document click listener per image (which would leak
|
||||
// closures across the entire page for every embedded image).
|
||||
const handle = document.createElement('div')
|
||||
handle.classList.add('image-resize-handle')
|
||||
wrapper.appendChild(handle)
|
||||
|
||||
let startX = 0
|
||||
let startWidth = 0
|
||||
const onPointerMove = (e) => {
|
||||
const newWidth = Math.max(50, startWidth + (e.clientX - startX))
|
||||
img.style.width = newWidth + 'px'
|
||||
}
|
||||
const onPointerUp = () => {
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
wrapper.classList.remove('resizing')
|
||||
try {
|
||||
commitWidth(Math.round(img.offsetWidth))
|
||||
} catch (err) {
|
||||
// Node may have been removed/replaced mid-drag (autosave
|
||||
// re-render, paste over selection, etc.). Drop the commit.
|
||||
}
|
||||
}
|
||||
const onPointerDown = (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
startX = e.clientX
|
||||
startWidth = img.offsetWidth
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
wrapper.classList.add('resizing')
|
||||
}
|
||||
handle.addEventListener('pointerdown', onPointerDown)
|
||||
|
||||
return {
|
||||
dom: wrapper,
|
||||
update: (updatedNode) => {
|
||||
if (updatedNode.type.name !== 'image') return false
|
||||
img.src = updatedNode.attrs.src
|
||||
img.style.width = updatedNode.attrs.width ? updatedNode.attrs.width + 'px' : ''
|
||||
return true
|
||||
},
|
||||
destroy: () => {
|
||||
handle.removeEventListener('pointerdown', onPointerDown)
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default ResizableImage
|
||||
@@ -299,4 +299,12 @@ const showEnvelope = computed(() => {
|
||||
props.message.meta?.subject
|
||||
)
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.native-html :deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<div class="flex items-center group text-left">
|
||||
<div
|
||||
class="relative w-36 h-28 rounded border overflow-hidden cursor-pointer transition-colors"
|
||||
:class="
|
||||
isImage
|
||||
? ''
|
||||
: 'flex flex-col items-center justify-between bg-muted/40 hover:bg-muted p-3'
|
||||
"
|
||||
@click="onClick"
|
||||
>
|
||||
<template v-if="isImage">
|
||||
<img
|
||||
:src="getThumbFilepath(attachment.url)"
|
||||
:alt="attachment.name"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 p-1 pr-12 text-gray-50 opacity-0 group-hover:opacity-100 overlay text-wrap"
|
||||
>
|
||||
<p class="font-bold text-xs">{{ shortName(attachment.name) }}</p>
|
||||
<p class="text-xs">{{ formatBytes(attachment.size) }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<component :is="fileIcon" class="w-10 h-10" :class="iconColor" />
|
||||
</div>
|
||||
<div class="w-full text-center">
|
||||
<p
|
||||
class="text-xs font-medium text-foreground truncate"
|
||||
:title="attachment.name"
|
||||
>
|
||||
{{ shortName(attachment.name) }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">{{ formatBytes(attachment.size) }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<a
|
||||
:href="attachment.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="absolute top-1.5 right-1.5 p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
:class="isImage ? 'hover:text-white/80' : 'hover:bg-background'"
|
||||
:title="t('globals.terms.download')"
|
||||
:aria-label="t('globals.terms.download')"
|
||||
@click.stop
|
||||
>
|
||||
<Download
|
||||
class="w-4 h-4"
|
||||
:class="isImage ? '' : 'text-muted-foreground'"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showPdfPreview"
|
||||
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80"
|
||||
@click.self="showPdfPreview = false"
|
||||
>
|
||||
<button
|
||||
class="absolute top-4 right-4 text-white hover:text-gray-300 z-10"
|
||||
:aria-label="t('globals.messages.close')"
|
||||
@click="showPdfPreview = false"
|
||||
>
|
||||
<X :size="28" />
|
||||
</button>
|
||||
<a
|
||||
:href="attachment.url"
|
||||
download
|
||||
class="absolute top-4 right-14 text-white hover:text-gray-300 z-10"
|
||||
:title="t('globals.terms.download')"
|
||||
:aria-label="t('globals.terms.download')"
|
||||
>
|
||||
<Download :size="24" />
|
||||
</a>
|
||||
<iframe
|
||||
:src="attachment.url"
|
||||
:title="attachment.name"
|
||||
class="w-[90vw] h-[90vh] rounded shadow-2xl bg-white"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatBytes, getThumbFilepath } from '@shared-ui/utils/file'
|
||||
import {
|
||||
Download,
|
||||
X,
|
||||
FileText,
|
||||
FileSpreadsheet,
|
||||
File,
|
||||
FileImage,
|
||||
FileArchive,
|
||||
FileCode
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
attachment: { type: Object, required: true }
|
||||
})
|
||||
const emit = defineEmits(['preview'])
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const showPdfPreview = ref(false)
|
||||
|
||||
const shortName = (name) => (name || '').substring(0, 40)
|
||||
|
||||
const isImage = computed(() =>
|
||||
(props.attachment.content_type || '').startsWith('image/')
|
||||
)
|
||||
|
||||
const ext = computed(() => {
|
||||
const parts = (props.attachment.name || '').split('.')
|
||||
return parts.length > 1 ? parts.pop().toLowerCase() : ''
|
||||
})
|
||||
|
||||
const canPreviewPdf = computed(() => !isImage.value && ext.value === 'pdf')
|
||||
|
||||
const fileIcon = computed(() => {
|
||||
const e = ext.value
|
||||
if (e === 'pdf') return FileText
|
||||
if (['xls', 'xlsx', 'csv'].includes(e)) return FileSpreadsheet
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(e)) return FileImage
|
||||
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(e)) return FileArchive
|
||||
if (['html', 'xml', 'json', 'js', 'css'].includes(e)) return FileCode
|
||||
if (['doc', 'docx', 'txt', 'rtf'].includes(e)) return FileText
|
||||
return File
|
||||
})
|
||||
|
||||
const iconColor = computed(() => {
|
||||
const e = ext.value
|
||||
if (e === 'pdf') return 'text-red-500'
|
||||
if (['xls', 'xlsx', 'csv'].includes(e)) return 'text-green-600'
|
||||
if (['doc', 'docx', 'txt', 'rtf'].includes(e)) return 'text-blue-500'
|
||||
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(e)) return 'text-amber-600'
|
||||
return 'text-muted-foreground'
|
||||
})
|
||||
|
||||
const onClick = () => {
|
||||
if (isImage.value) {
|
||||
emit('preview', props.attachment)
|
||||
} else if (canPreviewPdf.value) {
|
||||
showPdfPreview.value = true
|
||||
} else {
|
||||
window.open(props.attachment.url, '_blank')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
-142
@@ -1,142 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center group text-left">
|
||||
<div
|
||||
class="relative w-36 h-28 flex flex-col items-center justify-between rounded-lg border bg-muted/40 p-3 hover:bg-muted transition-colors cursor-pointer"
|
||||
@click="onClick"
|
||||
>
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<component :is="fileIcon" class="w-10 h-10" :class="iconColor" />
|
||||
</div>
|
||||
<div class="w-full text-center">
|
||||
<p
|
||||
class="text-xs font-medium text-foreground truncate"
|
||||
:title="attachment.name"
|
||||
>
|
||||
{{ shortName(attachment.name) }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">{{ formatBytes(attachment.size) }}</p>
|
||||
</div>
|
||||
<div
|
||||
class="absolute top-1.5 right-1.5 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1"
|
||||
>
|
||||
<button
|
||||
v-if="canPreview"
|
||||
class="p-0.5 rounded hover:bg-background"
|
||||
:title="t('attachment.preview')"
|
||||
:aria-label="t('attachment.preview')"
|
||||
@click.stop="openPreview"
|
||||
>
|
||||
<Eye class="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
<a
|
||||
:href="attachment.url"
|
||||
download
|
||||
class="p-0.5 rounded hover:bg-background"
|
||||
:title="t('imageLightbox.download')"
|
||||
:aria-label="t('imageLightbox.download')"
|
||||
@click.stop
|
||||
>
|
||||
<Download class="w-4 h-4 text-muted-foreground" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PDF preview overlay (PDFs only — non-image inline preview) -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showPdfPreview"
|
||||
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80"
|
||||
@click.self="showPdfPreview = false"
|
||||
>
|
||||
<button
|
||||
class="absolute top-4 right-4 text-white hover:text-gray-300 z-10"
|
||||
:aria-label="t('imageLightbox.close')"
|
||||
@click="showPdfPreview = false"
|
||||
>
|
||||
<X :size="28" />
|
||||
</button>
|
||||
<a
|
||||
:href="attachment.url"
|
||||
download
|
||||
class="absolute top-4 right-14 text-white hover:text-gray-300 z-10"
|
||||
:title="t('imageLightbox.download')"
|
||||
:aria-label="t('imageLightbox.download')"
|
||||
>
|
||||
<Download :size="24" />
|
||||
</a>
|
||||
<iframe
|
||||
:src="attachment.url"
|
||||
:title="attachment.name"
|
||||
class="w-[90vw] h-[90vh] rounded shadow-2xl bg-white"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatBytes } from '@shared-ui/utils/file'
|
||||
import {
|
||||
Download,
|
||||
Eye,
|
||||
X,
|
||||
FileText,
|
||||
FileSpreadsheet,
|
||||
File,
|
||||
FileImage,
|
||||
FileArchive,
|
||||
FileCode
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
attachment: { type: Object, required: true }
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const showPdfPreview = ref(false)
|
||||
|
||||
const shortName = (name) => (name || '').substring(0, 30)
|
||||
|
||||
const ext = computed(() => {
|
||||
const name = props.attachment.name || ''
|
||||
const parts = name.split('.')
|
||||
return parts.length > 1 ? parts.pop().toLowerCase() : ''
|
||||
})
|
||||
|
||||
const canPreview = computed(() => ext.value === 'pdf')
|
||||
|
||||
const fileIcon = computed(() => {
|
||||
const e = ext.value
|
||||
if (e === 'pdf') return FileText
|
||||
if (['xls', 'xlsx', 'csv'].includes(e)) return FileSpreadsheet
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(e)) return FileImage
|
||||
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(e)) return FileArchive
|
||||
if (['html', 'xml', 'json', 'js', 'css'].includes(e)) return FileCode
|
||||
if (['doc', 'docx', 'txt', 'rtf'].includes(e)) return FileText
|
||||
return File
|
||||
})
|
||||
|
||||
const iconColor = computed(() => {
|
||||
const e = ext.value
|
||||
if (e === 'pdf') return 'text-red-500'
|
||||
if (['xls', 'xlsx', 'csv'].includes(e)) return 'text-green-600'
|
||||
if (['doc', 'docx', 'txt', 'rtf'].includes(e)) return 'text-blue-500'
|
||||
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(e)) return 'text-amber-600'
|
||||
return 'text-muted-foreground'
|
||||
})
|
||||
|
||||
const openPreview = () => {
|
||||
showPdfPreview.value = true
|
||||
}
|
||||
|
||||
const onClick = () => {
|
||||
if (canPreview.value) {
|
||||
openPreview()
|
||||
} else {
|
||||
window.open(props.attachment.url, '_blank')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center group text-left">
|
||||
<div class="relative cursor-pointer" @click="$emit('preview', attachment)">
|
||||
<img
|
||||
:src="getThumbFilepath(attachment.url)"
|
||||
:alt="attachment.name"
|
||||
class="w-36 h-28 flex items-center object-cover"
|
||||
/>
|
||||
<div class="p-1 absolute inset-0 text-gray-50 opacity-0 group-hover:opacity-100 overlay text-wrap">
|
||||
<div class="flex flex-col justify-between h-full">
|
||||
<div>
|
||||
<p class="font-bold text-xs">{{ trimAttachmentName(attachment.name) }}</p>
|
||||
<p class="text-xs">{{ formatBytes(attachment.size) }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Eye :size="20" />
|
||||
<a
|
||||
:href="attachment.url"
|
||||
download
|
||||
class="hover:text-white/80"
|
||||
:aria-label="t('imageLightbox.download')"
|
||||
@click.stop
|
||||
>
|
||||
<Download :size="20" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatBytes, getThumbFilepath } from '@shared-ui/utils/file'
|
||||
import { Download, Eye } from 'lucide-vue-next'
|
||||
|
||||
defineProps({
|
||||
attachment: { type: Object, required: true }
|
||||
})
|
||||
defineEmits(['preview'])
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const trimAttachmentName = (name) => (name || '').substring(0, 40)
|
||||
</script>
|
||||
+6
-12
@@ -6,14 +6,9 @@
|
||||
class="flex items-center cursor-pointer"
|
||||
>
|
||||
<div>
|
||||
<ImageAttachmentPreview
|
||||
v-if="isImage(attachment)"
|
||||
:attachment="attachment"
|
||||
@preview="openLightbox"
|
||||
/>
|
||||
<div
|
||||
v-else-if="isAudio(attachment)"
|
||||
class="flex items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2"
|
||||
v-if="isAudio(attachment)"
|
||||
class="flex items-center gap-2 rounded border bg-muted/40 px-3 py-2"
|
||||
>
|
||||
<audio controls preload="auto" class="h-8 max-w-[260px]">
|
||||
<source :src="attachment.url" />
|
||||
@@ -22,14 +17,14 @@
|
||||
:href="attachment.url"
|
||||
download
|
||||
class="p-1 rounded hover:bg-muted shrink-0"
|
||||
:title="t('imageLightbox.download')"
|
||||
:aria-label="t('imageLightbox.download')"
|
||||
:title="t('globals.terms.download')"
|
||||
:aria-label="t('globals.terms.download')"
|
||||
@click.stop
|
||||
>
|
||||
<Download class="w-4 h-4 text-muted-foreground" />
|
||||
</a>
|
||||
</div>
|
||||
<FileAttachmentPreview v-else :attachment="attachment" />
|
||||
<AttachmentItem v-else :attachment="attachment" @preview="openLightbox" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,8 +40,7 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Download } from 'lucide-vue-next'
|
||||
import ImageAttachmentPreview from '@/features/conversation/message/attachment/ImageAttachmentPreview.vue'
|
||||
import FileAttachmentPreview from '@/features/conversation/message/attachment/FileAttachmentPreview.vue'
|
||||
import AttachmentItem from '@/features/conversation/message/attachment/AttachmentItem.vue'
|
||||
import ImageLightbox from '@/components/ImageLightbox.vue'
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -51,14 +51,6 @@
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
// Stop emails with explicit width/height attributes from forcing a
|
||||
// wider rendered width than the message bubble.
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
margin-left: 1.5rem;
|
||||
|
||||
+1
-3
@@ -394,7 +394,6 @@
|
||||
"ai.apiKey.description": "{provider} API Key is not set or invalid. Please enter a valid API key to use AI features.",
|
||||
"ai.apiKeyNotSet": "{provider} API Key is not set. Please ask your administrator to set it up",
|
||||
"ai.enterOpenAIAPIKey": "Enter OpenAI API Key",
|
||||
"attachment.preview": "Preview",
|
||||
"auth.backToLogin": "Back to login",
|
||||
"auth.checkEmailForReset": "Check your email for the password reset link.",
|
||||
"auth.confirmPassword": "Confirm password",
|
||||
@@ -690,6 +689,7 @@
|
||||
"globals.terms.description": "Description | Descriptions",
|
||||
"globals.terms.disabled": "Disabled",
|
||||
"globals.terms.draft": "Draft",
|
||||
"globals.terms.download": "Download",
|
||||
"globals.terms.email": "Email | Emails",
|
||||
"globals.terms.enabled": "Enabled",
|
||||
"globals.terms.error": "Error | Errors",
|
||||
@@ -838,8 +838,6 @@
|
||||
"globals.terms.white": "White",
|
||||
"globals.terms.workspace": "Workspace",
|
||||
"globals.terms.you": "You",
|
||||
"imageLightbox.close": "Close",
|
||||
"imageLightbox.download": "Download",
|
||||
"imageLightbox.next": "Next image",
|
||||
"imageLightbox.previous": "Previous image",
|
||||
"imageLightbox.resetZoom": "Reset zoom",
|
||||
|
||||
@@ -1214,12 +1214,6 @@ func (m *Manager) uploadMessageAttachments(message *models.Message) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Now that the file is uploaded, swap any cid: reference for the upload URL.
|
||||
// For non-images this turns the broken <img> tag into a download link.
|
||||
if contentID != "" {
|
||||
message.Content = replaceCIDInContent(message.Content, fmt.Sprintf("cid:%s", contentID), "/uploads/"+media.UUID, attachment.Name, attachment.ContentType)
|
||||
}
|
||||
|
||||
message.Media = append(message.Media, media)
|
||||
}
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user