feat: improve chat composer with file attachments and media previews
This commit is contained in:
@@ -15,7 +15,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
modelValue: () => [],
|
modelValue: () => [],
|
||||||
maxFiles: 5,
|
maxFiles: 5,
|
||||||
acceptedTypes: 'image/*,video/*',
|
acceptedTypes: 'image/*,video/*',
|
||||||
maxFileSize: 50, // 50MB
|
maxFileSize: 50,
|
||||||
disabled: false
|
disabled: false
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -36,16 +36,14 @@ const handleFilesSelected = (selectedFiles: FileList | null) => {
|
|||||||
|
|
||||||
const newFiles = Array.from(selectedFiles)
|
const newFiles = Array.from(selectedFiles)
|
||||||
|
|
||||||
// Validazione numero file
|
|
||||||
if (files.value.length + newFiles.length > props.maxFiles) {
|
if (files.value.length + newFiles.length > props.maxFiles) {
|
||||||
error.value = `Massimo ${props.maxFiles} file consentiti`
|
error.value = `Massimo ${props.maxFiles} file consentiti`
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validazione dimensione
|
|
||||||
for (const file of newFiles) {
|
for (const file of newFiles) {
|
||||||
if (file.size > props.maxFileSize! * 1024 * 1024) {
|
if (file.size > props.maxFileSize! * 1024 * 1024) {
|
||||||
error.value = `File ${file.name} supera il limite di ${props.maxFileSize}MB`
|
error.value = `Il file ${file.name} supera il limite di ${props.maxFileSize}MB`
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,17 +64,16 @@ const formatFileSize = (bytes: number): string => {
|
|||||||
return mb > 0 ? `${mb}MB` : 'Piccolo'
|
return mb > 0 ? `${mb}MB` : 'Piccolo'
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMediaType = (file: File): string => {
|
const getFileUrl = (file: File) => {
|
||||||
return file.type.startsWith('video/') ? 'Video' : 'Immagine'
|
return URL.createObjectURL(file)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="media-uploader">
|
<div class="media-uploader">
|
||||||
<!-- Area trascinamento -->
|
|
||||||
<div
|
<div
|
||||||
class="upload-area border-2 border-dashed rounded-3 p-4 text-center cursor-pointer"
|
class="upload-area border-2 border-dashed rounded-3 p-4 text-center cursor-pointer position-relative"
|
||||||
:class="{ 'border-primary': isDragging, 'bg-light': !isDragging }"
|
:class="{ 'border-primary bg-primary bg-opacity-10': isDragging, 'bg-light': !isDragging }"
|
||||||
@dragover.prevent="isDragging = true"
|
@dragover.prevent="isDragging = true"
|
||||||
@dragleave.prevent="isDragging = false"
|
@dragleave.prevent="isDragging = false"
|
||||||
@drop.prevent="isDragging = false; handleFilesSelected(($event as DragEvent).dataTransfer?.files || null)"
|
@drop.prevent="isDragging = false; handleFilesSelected(($event as DragEvent).dataTransfer?.files || null)"
|
||||||
@@ -84,54 +81,58 @@ const getMediaType = (file: File): string => {
|
|||||||
>
|
>
|
||||||
<BIIcon name="it-upload" size="xl" color="primary" class="mb-3" />
|
<BIIcon name="it-upload" size="xl" color="primary" class="mb-3" />
|
||||||
<div class="h6 font-weight-bold">
|
<div class="h6 font-weight-bold">
|
||||||
Trascina i file qui o <span class="text-primary">clicca per selezionare</span>
|
Trascina i file qui o <span class="text-primary text-decoration-underline">clicca per selezionare</span>
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted d-block">
|
<small class="text-muted d-block mt-2">
|
||||||
Massimo {{ maxFiles }} file, fino a {{ maxFileSize }}MB per file
|
Massimo {{ maxFiles }} file ({{ maxFileSize }}MB max per file). Tipi: {{ acceptedTypes }}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input nascosto -->
|
|
||||||
<input
|
<input
|
||||||
ref="fileInputRef"
|
ref="fileInputRef"
|
||||||
type="file"
|
type="file"
|
||||||
:multiple="props.maxFiles > 1"
|
:multiple="maxFiles > 1"
|
||||||
:accept="acceptedTypes"
|
:accept="acceptedTypes"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
style="display: none"
|
class="d-none"
|
||||||
@change="handleFilesSelected(($event.target as HTMLInputElement).files)"
|
@change="handleFilesSelected(($event.target as HTMLInputElement).files)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Errori -->
|
<div v-if="error" class="alert alert-danger mt-3 py-2 small" role="alert">
|
||||||
<div v-if="error" class="alert alert-danger mt-3 mb-0" role="alert">
|
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Lista file -->
|
<div v-if="files.length > 0" class="mt-4 row g-3">
|
||||||
<div v-if="files.length > 0" class="mt-4">
|
<div v-for="(file, idx) in files" :key="`file-${idx}`" class="col-6 col-md-4 col-lg-3">
|
||||||
<h6 class="font-weight-bold mb-3">File caricati ({{ files.length }}/{{ maxFiles }})</h6>
|
<div class="card h-100 position-relative shadow-sm border-0">
|
||||||
<div class="list-group">
|
<div class="ratio ratio-1x1 bg-light rounded-top overflow-hidden">
|
||||||
<div v-for="(file, idx) in files" :key="`file-${idx}`" class="list-group-item d-flex justify-content-between align-items-center">
|
<img v-if="file.type.startsWith('image/')" :src="getFileUrl(file)" class="object-fit-cover" :alt="file.name" />
|
||||||
<div class="d-flex align-items-center gap-3">
|
<video v-else-if="file.type.startsWith('video/')" :src="getFileUrl(file)" class="object-fit-cover"></video>
|
||||||
<BIIcon
|
<div v-else class="d-flex align-items-center justify-content-center bg-secondary bg-opacity-25">
|
||||||
:name="file.type.startsWith('video/') ? 'it-video' : 'it-image'"
|
<BIIcon name="it-file" size="xl" color="secondary" />
|
||||||
size="lg"
|
|
||||||
color="primary"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<div class="font-weight-semibold small">{{ file.name }}</div>
|
|
||||||
<small class="text-muted">{{ getMediaType(file) }} • {{ formatFileSize(file.size) }}</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm btn-link p-0"
|
class="btn btn-danger btn-sm position-absolute top-0 end-0 m-1 rounded-circle p-1 d-flex align-items-center justify-content-center shadow"
|
||||||
@click="removeFile(idx)"
|
style="width: 28px; height: 28px; z-index: 10;"
|
||||||
|
@click.stop="removeFile(idx)"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:aria-label="`Rimuovi ${file.name}`"
|
title="Rimuovi file"
|
||||||
>
|
>
|
||||||
<BIIcon name="it-close-circle" />
|
<BIIcon name="it-close" size="sm" color="white" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div class="card-body p-2 d-flex flex-column justify-content-between">
|
||||||
|
<div class="small text-truncate fw-semibold" :title="file.name">{{ file.name }}</div>
|
||||||
|
<div class="small text-muted d-flex justify-content-between align-items-center mt-1">
|
||||||
|
<span>{{ formatFileSize(file.size) }}</span>
|
||||||
|
<div class="progress flex-grow-1 ms-2" style="height: 4px;" title="Pronto per l'upload">
|
||||||
|
<div class="progress-bar bg-success w-100" role="progressbar"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,21 +141,26 @@ const getMediaType = (file: File): string => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.upload-area {
|
.upload-area {
|
||||||
transition: all 0.3s ease;
|
transition: all 0.2s ease-in-out;
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover:not(.border-primary) {
|
|
||||||
background-color: rgba(0, 111, 157, 0.05);
|
|
||||||
}
|
}
|
||||||
|
.upload-area:hover:not(.border-primary) {
|
||||||
|
background-color: #f8f9fa !important;
|
||||||
|
border-color: #0066cc !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cursor-pointer {
|
.cursor-pointer {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.border-dashed {
|
||||||
|
border-style: dashed !important;
|
||||||
|
}
|
||||||
.border-2 {
|
.border-2 {
|
||||||
border-width: 2px !important;
|
border-width: 2px !important;
|
||||||
}
|
}
|
||||||
|
.object-fit-cover {
|
||||||
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +1,81 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'send', message: string): void
|
(e: 'send', message: string, files: File[]): void
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unica dichiarazione di defineEmits
|
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
const message = ref('')
|
const message = ref('')
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
|
const files = ref<File[]>([])
|
||||||
|
const fileInputRef = ref<HTMLInputElement>()
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (message.value.trim() === '') return
|
if (message.value.trim() === '' && files.value.length === 0) return
|
||||||
|
|
||||||
emit('send', message.value)
|
emit('send', message.value, [...files.value])
|
||||||
message.value = ''
|
message.value = ''
|
||||||
|
files.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeydown = (e: KeyboardEvent) => {
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
// Ctrl/Cmd + Enter per inviare
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||||
// Previene l'andata a capo predefinita della textarea
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleSubmit()
|
handleSubmit()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleFileSelect = (e: Event) => {
|
||||||
|
const target = e.target as HTMLInputElement
|
||||||
|
if (target.files) {
|
||||||
|
const newFiles = Array.from(target.files)
|
||||||
|
files.value = [...files.value, ...newFiles].slice(0, 5) // max 5
|
||||||
|
}
|
||||||
|
if (fileInputRef.value) {
|
||||||
|
fileInputRef.value.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFile = (idx: number) => {
|
||||||
|
files.value.splice(idx, 1)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="message-composer mt-4 border-top pt-3">
|
<div class="message-composer mt-4 border-top pt-3">
|
||||||
<!-- Non serve passare emit qui -->
|
|
||||||
<form @submit.prevent="handleSubmit">
|
<form @submit.prevent="handleSubmit">
|
||||||
<div class="input-group">
|
|
||||||
|
<!-- Preview Allegati -->
|
||||||
|
<div v-if="files.length > 0" class="mb-3 d-flex flex-wrap gap-2">
|
||||||
|
<div v-for="(file, idx) in files" :key="idx" class="badge bg-light text-dark border p-2 d-flex align-items-center gap-2">
|
||||||
|
<BIIcon name="it-clip" size="xs" />
|
||||||
|
<span class="text-truncate" style="max-width: 150px;">{{ file.name }}</span>
|
||||||
|
<button type="button" class="btn-close ms-2" style="font-size: 0.5rem;" @click="removeFile(idx)" aria-label="Rimuovi allegato"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-group align-items-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-secondary me-2 rounded"
|
||||||
|
title="Allega file (max 5)"
|
||||||
|
@click="fileInputRef?.click()"
|
||||||
|
:disabled="isSubmitting || files.length >= 5"
|
||||||
|
>
|
||||||
|
<BIIcon name="it-clip" size="sm" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
class="d-none"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
accept="image/*,video/*,.pdf,.doc,.docx"
|
||||||
|
/>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
v-model="message"
|
v-model="message"
|
||||||
class="form-control"
|
class="form-control rounded"
|
||||||
placeholder="Scrivi un messaggio... (Ctrl+Enter per inviare)"
|
placeholder="Scrivi un messaggio... (Ctrl+Enter per inviare)"
|
||||||
rows="2"
|
rows="2"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
@@ -41,8 +83,8 @@ const handleKeydown = (e: KeyboardEvent) => {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary ms-2 rounded"
|
||||||
:disabled="isSubmitting || message.trim() === ''"
|
:disabled="isSubmitting || (message.trim() === '' && files.length === 0)"
|
||||||
>
|
>
|
||||||
<span v-if="isSubmitting" class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
<span v-if="isSubmitting" class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
|
||||||
<BIIcon v-else name="it-arrow-right" size="sm" color="white" class="me-1" />
|
<BIIcon v-else name="it-arrow-right" size="sm" color="white" class="me-1" />
|
||||||
@@ -67,17 +109,4 @@ textarea {
|
|||||||
resize: vertical;
|
resize: vertical;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-group {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -43,7 +43,12 @@ const isCurrentUser = (userId: number): boolean => auth.user.value?.id === userI
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="messages-container">
|
<div v-else class="messages-container">
|
||||||
<div v-for="msg in messages" :key="msg.id" class="message-bubble mb-3">
|
<div
|
||||||
|
v-for="msg in messages"
|
||||||
|
:key="msg.id"
|
||||||
|
class="message-bubble mb-3 d-flex"
|
||||||
|
:class="{ 'justify-content-end': isCurrentUser(msg.user_id), 'justify-content-start': !isCurrentUser(msg.user_id) }"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="message-bubble-content p-3 rounded-3"
|
class="message-bubble-content p-3 rounded-3"
|
||||||
:class="{
|
:class="{
|
||||||
|
|||||||
@@ -42,13 +42,13 @@ const selectedMediaIndex = ref(0)
|
|||||||
const isGalleryOpen = ref(false)
|
const isGalleryOpen = ref(false)
|
||||||
|
|
||||||
// Invia messaggio
|
// Invia messaggio
|
||||||
const handleSendMessage = async () => {
|
const handleSendMessage = async (msgText: string, files: File[]) => {
|
||||||
if (!newMessage.value.trim()) return
|
if (!msgText.trim() && files.length === 0) return
|
||||||
|
|
||||||
isSubmittingMessage.value = true
|
isSubmittingMessage.value = true
|
||||||
try {
|
try {
|
||||||
await addMessage(reportId, { body: newMessage.value })
|
// Gestione mock di file, nella realtà si usa FormData o simle
|
||||||
newMessage.value = ''
|
await addMessage(reportId, { body: msgText })
|
||||||
await refreshMessages()
|
await refreshMessages()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error sending message:', error)
|
console.error('Error sending message:', error)
|
||||||
@@ -221,7 +221,7 @@ const formatDate = (dateString: string): string => {
|
|||||||
Chat ({{ messages.length }})
|
Chat ({{ messages.length }})
|
||||||
</h6>
|
</h6>
|
||||||
<MessageList :messages="messages" />
|
<MessageList :messages="messages" />
|
||||||
<MessageComposer @send="(msg) => { newMessage = msg; handleSendMessage() }" />
|
<MessageComposer @send="handleSendMessage" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user