feat(comments) : add file attachments UI for comments
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
55
app/components/CommentDocumentList.vue
Normal file
55
app/components/CommentDocumentList.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div v-if="documents.length" class="space-y-1 mt-2">
|
||||
<div
|
||||
v-for="doc in documents"
|
||||
:key="doc.id"
|
||||
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<component
|
||||
:is="documentIcon(doc).component"
|
||||
class="w-4 h-4 flex-shrink-0"
|
||||
:class="documentIcon(doc).colorClass"
|
||||
/>
|
||||
<span class="truncate">{{ doc.name || doc.filename }}</span>
|
||||
<span class="text-base-content/40 flex-shrink-0">{{ formatSize(doc.size) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 flex-shrink-0 ml-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
:disabled="!canPreviewDocument(doc)"
|
||||
:title="canPreviewDocument(doc) ? 'Consulter' : 'Aperçu non disponible'"
|
||||
@click="openPreview(doc)"
|
||||
>
|
||||
Consulter
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@click="downloadDocument(doc)"
|
||||
>
|
||||
Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CommentDocument } from '~/composables/useComments'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import { formatSize, documentIcon, downloadDocument } from '~/shared/utils/documentDisplayUtils'
|
||||
|
||||
defineProps<{
|
||||
documents: CommentDocument[]
|
||||
}>()
|
||||
|
||||
const openPreview = (doc: CommentDocument) => {
|
||||
if (!canPreviewDocument(doc)) return
|
||||
// Open file URL in new tab for preview
|
||||
if (doc.fileUrl) {
|
||||
window.open(doc.fileUrl, '_blank')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -19,24 +19,55 @@
|
||||
</div>
|
||||
|
||||
<!-- Formulaire d'ajout -->
|
||||
<div class="flex gap-2">
|
||||
<textarea
|
||||
v-model="newContent"
|
||||
class="textarea textarea-bordered flex-1 text-sm"
|
||||
rows="2"
|
||||
placeholder="Ajouter un commentaire..."
|
||||
:disabled="submitting"
|
||||
@keydown.ctrl.enter="handleSubmit"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm self-end"
|
||||
:disabled="!newContent.trim() || submitting"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<span v-if="submitting" class="loading loading-spinner loading-xs" />
|
||||
<IconLucideSend v-else class="w-4 h-4" />
|
||||
</button>
|
||||
<div class="space-y-2">
|
||||
<div class="flex gap-2">
|
||||
<textarea
|
||||
v-model="newContent"
|
||||
class="textarea textarea-bordered flex-1 text-sm"
|
||||
rows="2"
|
||||
placeholder="Ajouter un commentaire..."
|
||||
:disabled="submitting"
|
||||
@keydown.ctrl.enter="handleSubmit"
|
||||
/>
|
||||
<div class="flex flex-col gap-1 self-end">
|
||||
<label
|
||||
class="btn btn-ghost btn-sm btn-square tooltip tooltip-left"
|
||||
data-tip="Joindre des fichiers"
|
||||
>
|
||||
<IconLucidePaperclip class="w-4 h-4" />
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="handleFilesSelected"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm btn-square"
|
||||
:disabled="!newContent.trim() || submitting"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<span v-if="submitting" class="loading loading-spinner loading-xs" />
|
||||
<IconLucideSend v-else class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Selected files preview -->
|
||||
<div v-if="selectedFiles.length" class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="(file, i) in selectedFiles"
|
||||
:key="i"
|
||||
class="badge badge-sm badge-outline gap-1"
|
||||
>
|
||||
<IconLucideFile class="w-3 h-3" />
|
||||
{{ file.name }}
|
||||
<button type="button" class="ml-1" @click="removeFile(i)">
|
||||
<IconLucideX class="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste des commentaires ouverts -->
|
||||
@@ -57,6 +88,8 @@
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
|
||||
<!-- Documents attachés -->
|
||||
<CommentDocumentList :documents="getDocuments(comment)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-base-content/60">
|
||||
@@ -97,6 +130,8 @@
|
||||
class="bg-base-200/50 rounded-lg p-3 opacity-60 space-y-1"
|
||||
>
|
||||
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
|
||||
<!-- Documents attachés (résolus) -->
|
||||
<CommentDocumentList :documents="getDocuments(comment)" />
|
||||
<div class="flex items-center justify-between text-xs text-base-content/50">
|
||||
<span>{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}</span>
|
||||
<span v-if="comment.resolvedByName">
|
||||
@@ -110,12 +145,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useComments, type Comment } from '~/composables/useComments'
|
||||
import { useComments, type Comment, type CommentDocument } from '~/composables/useComments'
|
||||
import { usePermissions } from '~/composables/usePermissions'
|
||||
import CommentDocumentList from '~/components/CommentDocumentList.vue'
|
||||
import IconLucideMessageSquare from '~icons/lucide/message-square'
|
||||
import IconLucideSend from '~icons/lucide/send'
|
||||
import IconLucideCheck from '~icons/lucide/check'
|
||||
import IconLucideTrash2 from '~icons/lucide/trash-2'
|
||||
import IconLucidePaperclip from '~icons/lucide/paperclip'
|
||||
import IconLucideFile from '~icons/lucide/file'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
|
||||
const props = defineProps<{
|
||||
entityType: string
|
||||
@@ -138,6 +177,11 @@ const newContent = ref('')
|
||||
const submitting = ref(false)
|
||||
const loadingComments = ref(false)
|
||||
const showResolvedList = ref(false)
|
||||
const selectedFiles = ref<File[]>([])
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const getDocuments = (comment: Comment): CommentDocument[] =>
|
||||
comment.documents?.filter((d): d is CommentDocument => typeof d === 'object' && d !== null && 'id' in d) ?? []
|
||||
|
||||
const openComments = computed(() =>
|
||||
comments.value.filter(c => c.status === 'open'),
|
||||
@@ -159,6 +203,18 @@ const formatCommentDate = (dateStr: string): string => {
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
const handleFilesSelected = (e: Event) => {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (input.files) {
|
||||
selectedFiles.value.push(...Array.from(input.files))
|
||||
}
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
selectedFiles.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const loadComments = async () => {
|
||||
loadingComments.value = true
|
||||
const [openResult, resolvedResult] = await Promise.all([
|
||||
@@ -182,10 +238,12 @@ const handleSubmit = async () => {
|
||||
props.entityId,
|
||||
content,
|
||||
props.entityName,
|
||||
selectedFiles.value.length > 0 ? selectedFiles.value : undefined,
|
||||
)
|
||||
submitting.value = false
|
||||
if (result.success) {
|
||||
newContent.value = ''
|
||||
selectedFiles.value = []
|
||||
await loadComments()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user