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:
@@ -3,6 +3,7 @@
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
:documents="componentDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
|
||||
@@ -174,8 +175,8 @@
|
||||
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-12 w-10"
|
||||
>
|
||||
<img
|
||||
v-if="isImageDocument(document) && document.path"
|
||||
:src="document.path"
|
||||
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||
:src="document.fileUrl || document.path"
|
||||
class="h-full w-full object-cover"
|
||||
:alt="`Aperçu de ${document.name}`"
|
||||
>
|
||||
@@ -332,8 +333,8 @@
|
||||
:class="documentThumbnailClass(document)"
|
||||
>
|
||||
<img
|
||||
v-if="isImageDocument(document) && document.path"
|
||||
:src="document.path"
|
||||
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||
:src="document.fileUrl || document.path"
|
||||
class="h-full w-full object-cover"
|
||||
:alt="`Aperçu de ${document.name}`"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -40,6 +40,8 @@ type GenericDocument = {
|
||||
filename?: string | null;
|
||||
mimeType?: string | null;
|
||||
path?: string | null;
|
||||
fileUrl?: string | null;
|
||||
downloadUrl?: string | null;
|
||||
size?: number | null;
|
||||
};
|
||||
|
||||
@@ -52,7 +54,7 @@ const normalizedDocument = computed(() => props.document ?? null);
|
||||
|
||||
const canRenderImage = computed(() => {
|
||||
const doc = normalizedDocument.value;
|
||||
return !!(doc && isImageDocument(doc) && doc.path);
|
||||
return !!(doc && isImageDocument(doc) && (doc.fileUrl || doc.path));
|
||||
});
|
||||
|
||||
const canRenderPdf = computed(() => {
|
||||
@@ -73,13 +75,14 @@ const appendPdfViewerParams = (src: string) => {
|
||||
|
||||
const previewSrc = computed(() => {
|
||||
const doc = normalizedDocument.value;
|
||||
if (!doc || !doc.path) {
|
||||
const url = doc?.fileUrl || doc?.path;
|
||||
if (!doc || !url) {
|
||||
return '';
|
||||
}
|
||||
if (isPdfDocument(doc)) {
|
||||
return appendPdfViewerParams(doc.path);
|
||||
return appendPdfViewerParams(url);
|
||||
}
|
||||
return doc.path;
|
||||
return url;
|
||||
});
|
||||
|
||||
const thumbnailClass = computed(() => (canRenderImage.value || canRenderPdf.value ? 'h-20 w-16' : 'h-16 w-16'));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
:documents="pieceDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
|
||||
@@ -184,8 +185,8 @@
|
||||
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-10 w-8"
|
||||
>
|
||||
<img
|
||||
v-if="isImageDocument(document) && document.path"
|
||||
:src="document.path"
|
||||
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||
:src="document.fileUrl || document.path"
|
||||
class="h-full w-full object-cover"
|
||||
:alt="`Aperçu de ${document.name}`"
|
||||
>
|
||||
@@ -413,8 +414,8 @@
|
||||
:class="documentThumbnailClass(document)"
|
||||
>
|
||||
<img
|
||||
v-if="isImageDocument(document) && document.path"
|
||||
:src="document.path"
|
||||
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||
:src="document.fileUrl || document.path"
|
||||
class="h-full w-full object-cover"
|
||||
:alt="`Aperçu de ${document.name}`"
|
||||
>
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
:class="documentThumbnailClass(doc)"
|
||||
>
|
||||
<img
|
||||
v-if="isImageDocument(doc) && doc.path"
|
||||
:src="doc.path"
|
||||
v-if="isImageDocument(doc) && (doc.fileUrl || doc.path)"
|
||||
:src="doc.fileUrl || doc.path"
|
||||
class="h-full w-full object-cover"
|
||||
:alt="`Aperçu de ${doc.name}`"
|
||||
>
|
||||
|
||||
@@ -57,8 +57,8 @@
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<div class="h-14 w-14 flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center">
|
||||
<img
|
||||
v-if="isImageDocument(document) && document.path"
|
||||
:src="document.path"
|
||||
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||
:src="document.fileUrl || document.path"
|
||||
class="h-full w-full object-cover"
|
||||
:alt="`Aperçu de ${document.name}`"
|
||||
>
|
||||
|
||||
@@ -20,11 +20,10 @@ export function useApi() {
|
||||
|
||||
const apiCall = async <T = any>(endpoint: string, options: ApiCallOptions = {}): Promise<ApiResponse<T>> => {
|
||||
const url = `${API_BASE_URL}${endpoint}`
|
||||
const isFormData = options.body instanceof FormData
|
||||
const defaultOptions: ApiCallOptions = {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: isFormData ? {} : { 'Content-Type': 'application/json' },
|
||||
}
|
||||
|
||||
// Ajouter un timeout à la requête
|
||||
@@ -115,6 +114,13 @@ export function useApi() {
|
||||
})
|
||||
}
|
||||
|
||||
const postFormData = async <T = any>(endpoint: string, formData: FormData): Promise<ApiResponse<T>> => {
|
||||
return apiCall<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
}
|
||||
|
||||
const del = async <T = any>(endpoint: string): Promise<ApiResponse<T>> => {
|
||||
return apiCall<T>(endpoint, { method: 'DELETE' })
|
||||
}
|
||||
@@ -123,6 +129,7 @@ export function useApi() {
|
||||
apiCall,
|
||||
get,
|
||||
post,
|
||||
postFormData,
|
||||
patch,
|
||||
put,
|
||||
delete: del,
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface Composant {
|
||||
id: string
|
||||
name: string
|
||||
reference?: string | null
|
||||
description?: string | null
|
||||
typeComposantId?: string | null
|
||||
typeComposant?: { id: string; name?: string } | null
|
||||
productId?: string | null
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
import { useToast } from './useToast'
|
||||
import { normalizeRelationIds } from '~/shared/apiRelations'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface Document {
|
||||
@@ -10,12 +9,21 @@ export interface Document {
|
||||
filename: string
|
||||
mimeType: string
|
||||
size: number
|
||||
path: string
|
||||
fileUrl: string
|
||||
downloadUrl: string
|
||||
/** @deprecated Legacy Base64 data URI — use fileUrl instead */
|
||||
path?: string
|
||||
createdAt?: string
|
||||
siteId?: string
|
||||
machineId?: string
|
||||
composantId?: string
|
||||
productId?: string
|
||||
pieceId?: string
|
||||
site?: { id: string; name?: string } | null
|
||||
machine?: { id: string; name?: string } | null
|
||||
composant?: { id: string; name?: string } | null
|
||||
piece?: { id: string; name?: string } | null
|
||||
product?: { id: string; name?: string } | null
|
||||
}
|
||||
|
||||
export interface UploadContext {
|
||||
@@ -32,19 +40,30 @@ export interface DocumentResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
const documents = ref<Document[]>([])
|
||||
const loading = ref(false)
|
||||
interface LoadDocumentsOptions {
|
||||
search?: string
|
||||
page?: number
|
||||
itemsPerPage?: number
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
attachmentFilter?: string
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
const fileToBase64 = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = () => reject(new Error(`Lecture du fichier ${file.name} impossible`))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
const documents = ref<Document[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
||||
const p = payload as Record<string, unknown> | null
|
||||
if (typeof p?.totalItems === 'number') return p.totalItems
|
||||
if (typeof p?.['hydra:totalItems'] === 'number') return p['hydra:totalItems']
|
||||
return fallbackLength
|
||||
}
|
||||
|
||||
export function useDocuments() {
|
||||
const { get, post, delete: del } = useApi()
|
||||
const { get, postFormData, delete: del } = useApi()
|
||||
const { showError, showSuccess } = useToast()
|
||||
|
||||
const loadFromEndpoint = async (
|
||||
@@ -76,10 +95,61 @@ export function useDocuments() {
|
||||
}
|
||||
}
|
||||
|
||||
const loadDocuments = async (
|
||||
options: { updateStore?: boolean; itemsPerPage?: number } = {},
|
||||
): Promise<DocumentResult> => {
|
||||
return loadFromEndpoint('/documents', { updateStore: options.updateStore ?? true, itemsPerPage: options.itemsPerPage })
|
||||
const loadDocuments = async (options: LoadDocumentsOptions = {}): Promise<DocumentResult> => {
|
||||
const {
|
||||
search = '',
|
||||
page = 1,
|
||||
itemsPerPage = 30,
|
||||
orderBy = 'createdAt',
|
||||
orderDir = 'desc',
|
||||
attachmentFilter = 'all',
|
||||
force = false,
|
||||
} = options
|
||||
|
||||
if (!force && loaded.value && !search && page === 1 && attachmentFilter === 'all') {
|
||||
return { success: true, data: documents.value }
|
||||
}
|
||||
|
||||
if (loading.value) {
|
||||
return { success: true, data: documents.value }
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', String(itemsPerPage))
|
||||
params.set('page', String(page))
|
||||
|
||||
if (search && search.trim()) {
|
||||
params.set('name', search.trim())
|
||||
}
|
||||
|
||||
if (attachmentFilter && attachmentFilter !== 'all') {
|
||||
params.set(`exists[${attachmentFilter}]`, 'true')
|
||||
}
|
||||
|
||||
params.set(`order[${orderBy}]`, orderDir)
|
||||
|
||||
const result = await get(`/documents?${params.toString()}`)
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
documents.value = items
|
||||
total.value = extractTotal(result.data, items.length)
|
||||
loaded.value = true
|
||||
return { success: true, data: items }
|
||||
}
|
||||
if (result.error) {
|
||||
showError(result.error)
|
||||
}
|
||||
return result as DocumentResult
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
console.error('Erreur lors du chargement des documents:', error)
|
||||
showError('Impossible de charger les documents')
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadDocumentsBySite = async (
|
||||
@@ -145,18 +215,17 @@ export function useDocuments() {
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
const dataUrl = await fileToBase64(file)
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('name', file.name)
|
||||
|
||||
const payload = normalizeRelationIds({
|
||||
name: file.name,
|
||||
filename: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
path: dataUrl,
|
||||
...context,
|
||||
})
|
||||
if (context.siteId) formData.append('siteId', context.siteId)
|
||||
if (context.machineId) formData.append('machineId', context.machineId)
|
||||
if (context.composantId) formData.append('composantId', context.composantId)
|
||||
if (context.productId) formData.append('productId', context.productId)
|
||||
if (context.pieceId) formData.append('pieceId', context.pieceId)
|
||||
|
||||
const result = await post('/documents', payload)
|
||||
const result = await postFormData('/documents', formData)
|
||||
if (result.success) {
|
||||
created.push(result.data as Document)
|
||||
showSuccess(`Document "${file.name}" ajouté`)
|
||||
@@ -213,7 +282,9 @@ export function useDocuments() {
|
||||
|
||||
return {
|
||||
documents,
|
||||
total,
|
||||
loading,
|
||||
loaded,
|
||||
loadDocuments,
|
||||
loadDocumentsBySite,
|
||||
loadDocumentsByMachine,
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface Piece {
|
||||
id: string
|
||||
name: string
|
||||
reference?: string | null
|
||||
description?: string | null
|
||||
typePieceId?: string | null
|
||||
typePiece?: { id: string; name?: string } | null
|
||||
productId?: string | null
|
||||
|
||||
@@ -23,6 +23,8 @@ type SiteDocument = {
|
||||
mimeType?: string
|
||||
size?: number
|
||||
path?: string
|
||||
fileUrl?: string
|
||||
downloadUrl?: string
|
||||
}
|
||||
|
||||
type SiteWithDocuments = {
|
||||
@@ -209,17 +211,23 @@ export function useSiteManagement() {
|
||||
}
|
||||
|
||||
const downloadDocument = (doc: SiteDocument) => {
|
||||
if (!doc?.path) return
|
||||
if (doc?.downloadUrl) {
|
||||
window.open(doc.downloadUrl, '_blank')
|
||||
return
|
||||
}
|
||||
|
||||
if (doc.path.startsWith('data:')) {
|
||||
const url = doc?.fileUrl || doc?.path
|
||||
if (!url) return
|
||||
|
||||
if (url.startsWith('data:')) {
|
||||
const link = document.createElement('a')
|
||||
link.href = doc.path
|
||||
link.href = url
|
||||
link.download = doc.filename || doc.name || 'document'
|
||||
link.click()
|
||||
return
|
||||
}
|
||||
|
||||
window.open(doc.path, '_blank')
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
const openPreview = (doc: SiteDocument) => {
|
||||
|
||||
@@ -279,7 +279,7 @@ const resolvePrimaryDocument = (component: Record<string, any>) => {
|
||||
return null
|
||||
}
|
||||
const normalized = documents.filter((doc) => doc && typeof doc === 'object')
|
||||
const withPath = normalized.filter((doc) => doc?.path)
|
||||
const withPath = normalized.filter((doc) => doc?.fileUrl || doc?.path)
|
||||
const pdf = withPath.find((doc) => isPdfDocument(doc))
|
||||
if (pdf) {
|
||||
return pdf
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
:documents="componentDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
<main class="container mx-auto px-6 py-10">
|
||||
@@ -386,8 +387,8 @@
|
||||
:class="documentThumbnailClass(document)"
|
||||
>
|
||||
<img
|
||||
v-if="isImageDocument(document) && document.path"
|
||||
:src="document.path"
|
||||
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||
:src="document.fileUrl || document.path"
|
||||
class="h-full w-full object-cover"
|
||||
:alt="`Aperçu de ${document.name}`"
|
||||
>
|
||||
|
||||
@@ -3,46 +3,107 @@
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
:documents="documents"
|
||||
@close="closePreview"
|
||||
/>
|
||||
|
||||
<section class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body space-y-6">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div class="w-full md:w-2/3">
|
||||
<label class="label">
|
||||
<span class="label-text">Recherche</span>
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<label class="w-full sm:w-72">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full mt-1"
|
||||
placeholder="Nom du document..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
type="search"
|
||||
placeholder="Nom du document, type, site, machine..."
|
||||
class="input input-bordered w-full"
|
||||
>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="doc-filter"
|
||||
>
|
||||
Rattachement
|
||||
</label>
|
||||
<select
|
||||
id="doc-filter"
|
||||
v-model="attachmentFilter"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<option value="all">Tous</option>
|
||||
<option value="site">Sites</option>
|
||||
<option value="machine">Machines</option>
|
||||
<option value="composant">Composants</option>
|
||||
<option value="piece">Pièces</option>
|
||||
<option value="product">Produits</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="doc-sort"
|
||||
>
|
||||
Trier par
|
||||
</label>
|
||||
<select
|
||||
id="doc-sort"
|
||||
v-model="sortField"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleSortChange"
|
||||
>
|
||||
<option value="createdAt">Date</option>
|
||||
<option value="name">Nom</option>
|
||||
<option value="size">Taille</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="doc-dir"
|
||||
>
|
||||
Ordre
|
||||
</label>
|
||||
<select
|
||||
id="doc-dir"
|
||||
v-model="sortDirection"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleSortChange"
|
||||
>
|
||||
<option value="asc">Ascendant</option>
|
||||
<option value="desc">Descendant</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="doc-per-page"
|
||||
>
|
||||
Par page
|
||||
</label>
|
||||
<select
|
||||
id="doc-per-page"
|
||||
v-model.number="itemsPerPage"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handlePerPageChange"
|
||||
>
|
||||
<option :value="20">20</option>
|
||||
<option :value="50">50</option>
|
||||
<option :value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full md:w-1/3">
|
||||
<label class="label">
|
||||
<span class="label-text">Filtrer par rattachement</span>
|
||||
</label>
|
||||
<select v-model="attachmentFilter" class="select select-bordered w-full">
|
||||
<option value="all">
|
||||
Tous
|
||||
</option>
|
||||
<option value="site">
|
||||
Sites
|
||||
</option>
|
||||
<option value="machine">
|
||||
Machines
|
||||
</option>
|
||||
<option value="composant">
|
||||
Composants
|
||||
</option>
|
||||
<option value="piece">
|
||||
Pièces
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/50 lg:text-right">
|
||||
{{ documentsOnPage }} / {{ documentsTotal }} résultat{{ documentsTotal > 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="divider my-0" />
|
||||
@@ -52,181 +113,191 @@
|
||||
Chargement des documents...
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredDocuments.length === 0" class="text-center py-16 text-sm text-gray-500">
|
||||
<div v-else-if="!documentsTotal" class="text-center py-16 text-sm text-gray-500">
|
||||
<IconLucideFileSearch class="mx-auto mb-4 h-14 w-14 text-gray-400" aria-hidden="true" />
|
||||
Aucun document ne correspond à votre recherche pour l'instant.
|
||||
Aucun document n'a encore été ajouté.
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr class="text-xs uppercase">
|
||||
<th>Nom</th>
|
||||
<th>Type</th>
|
||||
<th>Taille</th>
|
||||
<th>Rattaché à</th>
|
||||
<th>Date</th>
|
||||
<th class="text-right">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="document in filteredDocuments" :key="document.id" class="text-sm">
|
||||
<td>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xl" :class="documentIcon(document).colorClass">
|
||||
<component
|
||||
:is="documentIcon(document).component"
|
||||
class="h-6 w-6"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<div class="font-semibold">
|
||||
{{ document.name }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ document.filename }}
|
||||
<div v-else-if="!documents.length" class="text-center py-16 text-sm text-gray-500">
|
||||
<IconLucideFileSearch class="mx-auto mb-4 h-14 w-14 text-gray-400" aria-hidden="true" />
|
||||
Aucun document ne correspond à votre recherche.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr class="text-xs uppercase">
|
||||
<th>Nom</th>
|
||||
<th>Type</th>
|
||||
<th>Taille</th>
|
||||
<th>Rattaché à</th>
|
||||
<th>Date</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="doc in documents" :key="doc.id" class="text-sm">
|
||||
<td>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xl" :class="documentIcon(doc).colorClass">
|
||||
<component
|
||||
:is="documentIcon(doc).component"
|
||||
class="h-6 w-6"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<div class="font-semibold">{{ doc.name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ doc.filename }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ document.mimeType || 'Inconnu' }}</td>
|
||||
<td>{{ formatSize(document.size) }}</td>
|
||||
<td>
|
||||
<div class="flex flex-col text-xs">
|
||||
<span v-if="document.site">Site · {{ document.site.name }}</span>
|
||||
<span v-else-if="document.machine">Machine · {{ document.machine.name }}</span>
|
||||
<span v-else-if="document.composant">Composant · {{ document.composant.name }}</span>
|
||||
<span v-else-if="document.piece">Pièce · {{ document.piece.name }}</span>
|
||||
<span v-else class="text-gray-400">Non défini</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ formatFrenchDate(document.createdAt) }}</td>
|
||||
<td class="text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
class="btn btn-ghost btn-xs"
|
||||
type="button"
|
||||
:disabled="!canPreviewDocument(document)"
|
||||
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
|
||||
@click="openPreview(document)"
|
||||
>
|
||||
Consulter
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-xs" type="button" @click="downloadDocument(document)">
|
||||
Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ doc.mimeType || 'Inconnu' }}</td>
|
||||
<td>{{ formatSize(doc.size) }}</td>
|
||||
<td>
|
||||
<div class="flex flex-col text-xs">
|
||||
<span v-if="doc.site">Site · {{ doc.site.name }}</span>
|
||||
<span v-else-if="doc.machine">Machine · {{ doc.machine.name }}</span>
|
||||
<span v-else-if="doc.composant">Composant · {{ doc.composant.name }}</span>
|
||||
<span v-else-if="doc.piece">Pièce · {{ doc.piece.name }}</span>
|
||||
<span v-else-if="doc.product">Produit · {{ doc.product.name }}</span>
|
||||
<span v-else class="text-gray-400">Non défini</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ formatFrenchDate(doc.createdAt) }}</td>
|
||||
<td class="text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
class="btn btn-ghost btn-xs"
|
||||
type="button"
|
||||
:disabled="!canPreviewDocument(doc)"
|
||||
:title="canPreviewDocument(doc) ? 'Consulter le document' : 'Aucun aper\u00E7u disponible pour ce type'"
|
||||
@click="openPreview(doc)"
|
||||
>
|
||||
Consulter
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-xs" type="button" @click="downloadDocument(doc)">
|
||||
Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
:current-page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
@update:current-page="handlePageChange"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useUrlState } from '~/composables/useUrlState'
|
||||
import { getFileIcon } from '~/utils/fileIcons'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import { formatFrenchDate } from '~/utils/date'
|
||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import Pagination from '~/components/common/Pagination.vue'
|
||||
import IconLucideFileSearch from '~icons/lucide/file-search'
|
||||
|
||||
const { documents, loading, loadDocuments } = useDocuments()
|
||||
const { get } = useApi()
|
||||
const { documents, total, loading, loadDocuments } = useDocuments()
|
||||
|
||||
const { q: searchTerm, filter: attachmentFilter } = useUrlState({
|
||||
const {
|
||||
page: currentPage,
|
||||
perPage: itemsPerPage,
|
||||
q: searchTerm,
|
||||
filter: attachmentFilter,
|
||||
sort: sortField,
|
||||
dir: sortDirection,
|
||||
} = useUrlState({
|
||||
page: { default: 1, type: 'number' },
|
||||
perPage: { default: 30, type: 'number' },
|
||||
q: { default: '', debounce: 300 },
|
||||
filter: { default: 'all' },
|
||||
sort: { default: 'createdAt' },
|
||||
dir: { default: 'desc' },
|
||||
}, {
|
||||
onRestore: () => fetchDocuments(),
|
||||
})
|
||||
const previewDocument = ref(null)
|
||||
|
||||
const previewDocument = ref<any>(null)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
loadDocuments({ itemsPerPage: 200 })
|
||||
})
|
||||
const documentsTotal = computed(() => total.value)
|
||||
const documentsOnPage = computed(() => documents.value.length)
|
||||
const totalPages = computed(() => Math.ceil(documentsTotal.value / itemsPerPage.value) || 1)
|
||||
|
||||
const filteredDocuments = computed(() => {
|
||||
const term = searchTerm.value.trim().toLowerCase()
|
||||
const filter = attachmentFilter.value
|
||||
|
||||
return documents.value.filter((document) => {
|
||||
const matchesFilter =
|
||||
filter === 'all' ||
|
||||
(filter === 'site' && document.site) ||
|
||||
(filter === 'machine' && document.machine) ||
|
||||
(filter === 'composant' && document.composant) ||
|
||||
(filter === 'piece' && document.piece)
|
||||
|
||||
if (!matchesFilter) { return false }
|
||||
|
||||
if (!term) { return true }
|
||||
|
||||
const searchable = [
|
||||
document.name,
|
||||
document.filename,
|
||||
document.mimeType,
|
||||
document.site?.name,
|
||||
document.machine?.name,
|
||||
document.composant?.name,
|
||||
document.piece?.name
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map(value => value.toLowerCase())
|
||||
|
||||
return searchable.some(value => value.includes(term))
|
||||
const fetchDocuments = async () => {
|
||||
await loadDocuments({
|
||||
search: searchTerm.value,
|
||||
page: currentPage.value,
|
||||
itemsPerPage: itemsPerPage.value,
|
||||
orderBy: sortField.value,
|
||||
orderDir: sortDirection.value as 'asc' | 'desc',
|
||||
attachmentFilter: attachmentFilter.value,
|
||||
force: true,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const formatSize = (size) => {
|
||||
if (size === undefined || size === null) { return '—' }
|
||||
if (size === 0) { return '0 B' }
|
||||
// Search debounce
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const debouncedSearch = () => {
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage.value = 1
|
||||
fetchDocuments()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
fetchDocuments()
|
||||
}
|
||||
|
||||
const handleSortChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchDocuments()
|
||||
}
|
||||
|
||||
const handleFilterChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchDocuments()
|
||||
}
|
||||
|
||||
const handlePerPageChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchDocuments()
|
||||
}
|
||||
|
||||
const formatSize = (size: number | undefined | null) => {
|
||||
if (size === undefined || size === null) return '\u2014'
|
||||
if (size === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
|
||||
const formatted = size / Math.pow(1024, index)
|
||||
return `${formatted.toFixed(1)} ${units[index]}`
|
||||
}
|
||||
|
||||
const documentIcon = doc => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
|
||||
const documentIcon = (doc: any) => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
|
||||
|
||||
/** Fetch the full document (with path) from the API on demand. */
|
||||
const fetchDocumentPath = async (doc) => {
|
||||
if (doc?.path) { return doc.path }
|
||||
if (!doc?.id) { return null }
|
||||
const result = await get(`/documents/${doc.id}`)
|
||||
if (result.success && result.data?.path) {
|
||||
doc.path = result.data.path
|
||||
return result.data.path
|
||||
const downloadDocument = (doc: any) => {
|
||||
if (doc?.downloadUrl) {
|
||||
window.open(doc.downloadUrl, '_blank')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const downloadDocument = async (doc) => {
|
||||
const path = await fetchDocumentPath(doc)
|
||||
if (!path) { return }
|
||||
|
||||
if (path.startsWith('data:')) {
|
||||
const link = document.createElement('a')
|
||||
link.href = path
|
||||
link.download = doc.filename || doc.name || 'document'
|
||||
link.click()
|
||||
return
|
||||
}
|
||||
|
||||
window.open(path, '_blank')
|
||||
}
|
||||
|
||||
const openPreview = async (doc) => {
|
||||
if (!canPreviewDocument(doc)) { return }
|
||||
await fetchDocumentPath(doc)
|
||||
const openPreview = (doc: any) => {
|
||||
if (!canPreviewDocument(doc)) return
|
||||
previewDocument.value = doc
|
||||
previewVisible.value = true
|
||||
}
|
||||
@@ -235,4 +306,8 @@ const closePreview = () => {
|
||||
previewVisible.value = false
|
||||
previewDocument.value = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDocuments()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<DocumentPreviewModal
|
||||
:document="d.previewDocument.value"
|
||||
:visible="d.previewVisible.value"
|
||||
:documents="d.machineDocumentsList.value"
|
||||
@close="d.closePreview"
|
||||
/>
|
||||
|
||||
|
||||
@@ -301,7 +301,7 @@ const resolvePrimaryDocument = (piece: Record<string, any>) => {
|
||||
return null
|
||||
}
|
||||
const normalized = documents.filter((doc) => doc && typeof doc === 'object')
|
||||
const withPath = normalized.filter((doc) => doc?.path)
|
||||
const withPath = normalized.filter((doc) => doc?.fileUrl || doc?.path)
|
||||
|
||||
const pdf = withPath.find((doc) => isPdfDocument(doc))
|
||||
if (pdf) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
:documents="pieceDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
<main class="container mx-auto px-6 py-10">
|
||||
@@ -333,8 +334,8 @@
|
||||
:class="documentThumbnailClass(document)"
|
||||
>
|
||||
<img
|
||||
v-if="isImageDocument(document) && document.path"
|
||||
:src="document.path"
|
||||
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||
:src="document.fileUrl || document.path"
|
||||
class="h-full w-full object-cover"
|
||||
:alt="`Aperçu de ${document.name}`"
|
||||
>
|
||||
|
||||
@@ -367,7 +367,7 @@ const resolvePrimaryDocument = (product: Record<string, any>) => {
|
||||
return null
|
||||
}
|
||||
const normalized = documents.filter((doc) => doc && typeof doc === 'object')
|
||||
const withPath = normalized.filter((doc) => doc?.path)
|
||||
const withPath = normalized.filter((doc) => doc?.fileUrl || doc?.path)
|
||||
if (!withPath.length) {
|
||||
return normalized[0] ?? null
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
:documents="productDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
<main class="container mx-auto px-6 py-10">
|
||||
@@ -244,8 +245,8 @@
|
||||
:class="documentThumbnailClass(document)"
|
||||
>
|
||||
<img
|
||||
v-if="isImageDocument(document) && document.path"
|
||||
:src="document.path"
|
||||
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||
:src="document.fileUrl || document.path"
|
||||
class="h-full w-full object-cover"
|
||||
:alt="`Aperçu de ${document.name}`"
|
||||
>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
:documents="siteDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
|
||||
|
||||
@@ -19,26 +19,32 @@ export const formatSize = (size: number | null | undefined): string => {
|
||||
return `${formatted.toFixed(1)} ${units[index]}`
|
||||
}
|
||||
|
||||
const resolveUrl = (doc: any): string => doc?.fileUrl || doc?.path || ''
|
||||
|
||||
export const shouldInlinePdf = (doc: any): boolean => {
|
||||
if (!doc || !isPdfDocument(doc) || !doc.path) return false
|
||||
if (!doc || !isPdfDocument(doc)) return false
|
||||
const url = resolveUrl(doc)
|
||||
if (!url) return false
|
||||
if (typeof doc.size === 'number' && doc.size > PDF_PREVIEW_MAX_BYTES) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export const appendPdfViewerParams = (src: string): string => {
|
||||
if (!src || src.startsWith('data:')) return src || ''
|
||||
if (!src) return ''
|
||||
if (src.startsWith('data:')) return src
|
||||
if (src.includes('#')) return `${src}&toolbar=0&navpanes=0`
|
||||
return `${src}#toolbar=0&navpanes=0`
|
||||
}
|
||||
|
||||
export const documentPreviewSrc = (doc: any): string => {
|
||||
if (!doc?.path) return ''
|
||||
if (isPdfDocument(doc)) return appendPdfViewerParams(doc.path)
|
||||
return doc.path
|
||||
const url = resolveUrl(doc)
|
||||
if (!url) return ''
|
||||
if (isPdfDocument(doc)) return appendPdfViewerParams(url)
|
||||
return url
|
||||
}
|
||||
|
||||
export const documentThumbnailClass = (doc: any): string => {
|
||||
if (shouldInlinePdf(doc) || (isImageDocument(doc) && doc?.path)) return 'h-24 w-20'
|
||||
if (shouldInlinePdf(doc) || (isImageDocument(doc) && resolveUrl(doc))) return 'h-24 w-20'
|
||||
return 'h-16 w-16'
|
||||
}
|
||||
|
||||
@@ -52,8 +58,14 @@ export const documentIcon = (doc: any): FileIconResult =>
|
||||
getFileIcon({ name: doc?.filename || doc?.name, mime: doc?.mimeType })
|
||||
|
||||
export const downloadDocument = (doc: any): void => {
|
||||
if (!doc?.path) return
|
||||
const target = String(doc.path)
|
||||
// Prefer dedicated download endpoint
|
||||
if (doc?.downloadUrl) {
|
||||
window.open(doc.downloadUrl, '_blank')
|
||||
return
|
||||
}
|
||||
// Fallback for legacy data: URIs during migration
|
||||
const target = resolveUrl(doc)
|
||||
if (!target) return
|
||||
if (target.startsWith('data:')) {
|
||||
const link = document.createElement('a')
|
||||
link.href = target
|
||||
|
||||
@@ -3,19 +3,19 @@ import { getFileIcon } from './fileIcons'
|
||||
export const getPreviewType = (document) => {
|
||||
if (!document) { return null }
|
||||
const mime = (document.mimeType || '').toLowerCase()
|
||||
const path = document.path || ''
|
||||
|
||||
const check = prefix => mime.startsWith(prefix) || path.startsWith(`data:${prefix}`)
|
||||
|
||||
if (check('image/')) { return 'image' }
|
||||
if (mime === 'application/pdf' || path.startsWith('data:application/pdf')) { return 'pdf' }
|
||||
if (check('audio/')) { return 'audio' }
|
||||
if (check('video/')) { return 'video' }
|
||||
if (check('text/') || mime.includes('json') || mime.includes('xml') || path.startsWith('data:application/json')) { return 'text' }
|
||||
if (mime.startsWith('image/')) { return 'image' }
|
||||
if (mime === 'application/pdf') { return 'pdf' }
|
||||
if (mime.startsWith('audio/')) { return 'audio' }
|
||||
if (mime.startsWith('video/')) { return 'video' }
|
||||
if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) { return 'text' }
|
||||
return null
|
||||
}
|
||||
|
||||
export const canPreviewDocument = (document = {}) => !!getPreviewType(document)
|
||||
export const canPreviewDocument = (document = {}) => {
|
||||
if (!getPreviewType(document)) return false
|
||||
return !!(document.fileUrl || document.path)
|
||||
}
|
||||
|
||||
export const isImageDocument = (document = {}) => getPreviewType(document) === 'image'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user