feat(documents): migrate storage to filesystem, add server-side pagination
- Replace Base64 data URIs with file-based storage served via dedicated endpoints - Add DocumentPreviewModal navigation, DocumentThumbnail fileUrl support - Refactor documents page with server-side pagination, search, sort and filters - Update all components to use fileUrl/downloadUrl instead of raw path - Add pagination composable support (total, page, itemsPerPage, attachmentFilter) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,9 +10,12 @@
|
||||
<div class="min-w-0">
|
||||
<h3 class="font-bold text-xl truncate">
|
||||
Prévisualisation
|
||||
<span v-if="navTotal > 1" class="text-base font-normal text-gray-500">
|
||||
{{ activeIndex + 1 }} / {{ navTotal }}
|
||||
</span>
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 truncate">
|
||||
{{ document?.name || document?.filename }}<span v-if="documentDescription"> • {{ documentDescription }}</span>
|
||||
{{ activeDoc?.name || activeDoc?.filename }}<span v-if="documentDescription"> • {{ documentDescription }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-ghost btn-sm shrink-0" @click="close">
|
||||
@@ -20,15 +23,35 @@
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="flex-1 bg-base-200/40 px-6 py-5 overflow-hidden">
|
||||
<section class="flex-1 bg-base-200/40 px-6 py-5 overflow-hidden relative">
|
||||
<button
|
||||
v-if="hasPrev"
|
||||
type="button"
|
||||
class="absolute left-8 top-1/2 -translate-y-1/2 z-10 btn btn-circle bg-base-100/80 hover:bg-base-100 shadow-lg border-base-300"
|
||||
title="Document précédent (←)"
|
||||
@click="goToPrev"
|
||||
>
|
||||
❮
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="hasNext"
|
||||
type="button"
|
||||
class="absolute right-8 top-1/2 -translate-y-1/2 z-10 btn btn-circle bg-base-100/80 hover:bg-base-100 shadow-lg border-base-300"
|
||||
title="Document suivant (→)"
|
||||
@click="goToNext"
|
||||
>
|
||||
❯
|
||||
</button>
|
||||
|
||||
<div class="h-full w-full rounded-xl border border-base-300 bg-base-100 flex items-center justify-center overflow-hidden">
|
||||
<template v-if="previewType === 'image'">
|
||||
<img :src="document?.path" alt="preview" class="max-h-full max-w-full object-contain">
|
||||
<img :src="documentSrc" alt="preview" class="max-h-full max-w-full object-contain">
|
||||
</template>
|
||||
|
||||
<template v-else-if="previewType === 'pdf'">
|
||||
<iframe
|
||||
:src="document?.path"
|
||||
:src="documentSrc"
|
||||
class="w-full h-full bg-white"
|
||||
frameborder="0"
|
||||
title="Aperçu PDF"
|
||||
@@ -36,11 +59,11 @@
|
||||
</template>
|
||||
|
||||
<template v-else-if="previewType === 'audio'">
|
||||
<audio :src="document?.path" controls class="w-full" />
|
||||
<audio :src="documentSrc" controls class="w-full" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="previewType === 'video'">
|
||||
<video :src="document?.path" controls class="w-full h-full bg-black" />
|
||||
<video :src="documentSrc" controls class="w-full h-full bg-black" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="previewType === 'text'">
|
||||
@@ -80,31 +103,110 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { getPreviewType, describeDocument } from '~/utils/documentPreview'
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { getPreviewType, describeDocument, canPreviewDocument } from '~/utils/documentPreview'
|
||||
|
||||
const props = defineProps({
|
||||
document: {
|
||||
type: Object,
|
||||
default: null
|
||||
default: null,
|
||||
},
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
default: false,
|
||||
},
|
||||
documents: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const previewType = computed(() => getPreviewType(props.document))
|
||||
const documentDescription = computed(() => describeDocument(props.document))
|
||||
// --- Carousel navigation ---
|
||||
|
||||
const previewableDocuments = computed(() => {
|
||||
if (!props.documents?.length) return []
|
||||
return props.documents.filter((doc) => canPreviewDocument(doc))
|
||||
})
|
||||
|
||||
const navTotal = computed(() => previewableDocuments.value.length)
|
||||
|
||||
const activeIndex = ref(0)
|
||||
|
||||
// Sync index when the parent changes the document prop (e.g. user clicks a different "Consulter")
|
||||
watch(
|
||||
() => props.document,
|
||||
(doc) => {
|
||||
if (!doc || !previewableDocuments.value.length) {
|
||||
activeIndex.value = 0
|
||||
return
|
||||
}
|
||||
const idx = previewableDocuments.value.findIndex((d) => d.id === doc.id)
|
||||
activeIndex.value = idx >= 0 ? idx : 0
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const activeDoc = computed(() => {
|
||||
if (previewableDocuments.value.length && activeIndex.value < previewableDocuments.value.length) {
|
||||
return previewableDocuments.value[activeIndex.value]
|
||||
}
|
||||
return props.document
|
||||
})
|
||||
|
||||
const hasPrev = computed(() => navTotal.value > 1 && activeIndex.value > 0)
|
||||
const hasNext = computed(() => navTotal.value > 1 && activeIndex.value < navTotal.value - 1)
|
||||
|
||||
const goToPrev = () => {
|
||||
if (hasPrev.value) activeIndex.value--
|
||||
}
|
||||
const goToNext = () => {
|
||||
if (hasNext.value) activeIndex.value++
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
const handleKeydown = (e) => {
|
||||
if (!props.visible) return
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
goToPrev()
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
goToNext()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
} else {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
// --- Preview logic (uses activeDoc) ---
|
||||
|
||||
const previewType = computed(() => getPreviewType(activeDoc.value))
|
||||
const documentDescription = computed(() => describeDocument(activeDoc.value))
|
||||
const documentSrc = computed(() => activeDoc.value?.fileUrl || activeDoc.value?.path || '')
|
||||
|
||||
const textContent = ref('')
|
||||
const textLoading = ref(false)
|
||||
const textError = ref('')
|
||||
|
||||
watch(
|
||||
() => props.document,
|
||||
activeDoc,
|
||||
async (doc) => {
|
||||
textContent.value = ''
|
||||
textError.value = ''
|
||||
@@ -115,22 +217,17 @@ watch(
|
||||
|
||||
try {
|
||||
textLoading.value = true
|
||||
const path = doc.path || ''
|
||||
if (path.startsWith('data:')) {
|
||||
const base64Part = path.split(',')[1] || ''
|
||||
if (!base64Part) {
|
||||
textError.value = 'Impossible de lire ce document texte.'
|
||||
return
|
||||
}
|
||||
const decoded = atob(base64Part)
|
||||
textContent.value = decodeURIComponent(escape(decoded))
|
||||
} else {
|
||||
const response = await fetch(path)
|
||||
if (!response.ok) {
|
||||
throw new Error('Téléchargement du document impossible')
|
||||
}
|
||||
textContent.value = await response.text()
|
||||
const url = doc.fileUrl || doc.path || ''
|
||||
if (!url) {
|
||||
textError.value = 'Aucune URL de document disponible.'
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(url, { credentials: 'include' })
|
||||
if (!response.ok) {
|
||||
throw new Error('Téléchargement du document impossible')
|
||||
}
|
||||
textContent.value = await response.text()
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement du texte:', error)
|
||||
textError.value = error.message || 'Impossible de lire ce document.'
|
||||
@@ -138,7 +235,7 @@ watch(
|
||||
textLoading.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const close = () => {
|
||||
@@ -146,11 +243,8 @@ const close = () => {
|
||||
}
|
||||
|
||||
const download = () => {
|
||||
if (!props.document?.path) { return }
|
||||
const link = document.createElement('a')
|
||||
link.href = props.document.path
|
||||
link.download = props.document.filename || props.document.name || 'document'
|
||||
link.target = '_blank'
|
||||
link.click()
|
||||
const url = activeDoc.value?.downloadUrl || activeDoc.value?.fileUrl || activeDoc.value?.path
|
||||
if (!url) { return }
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user