@innertia-solutions/innertia-nuxt 0.1.48 → 0.1.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -42,19 +42,22 @@ const props = defineProps({
|
|
|
42
42
|
|
|
43
43
|
const emit = defineEmits(['click', 'delete'])
|
|
44
44
|
|
|
45
|
-
const { download, view,
|
|
45
|
+
const { download, view, fileViewUrl, formatSize, iconFor } = useFile()
|
|
46
46
|
|
|
47
47
|
const isImage = computed(() => props.file?.mime_type?.startsWith('image/'))
|
|
48
|
+
|
|
49
|
+
// Thumbnail: view_url firmado del backend (o fallback a la ruta por-id).
|
|
50
|
+
const thumbSrc = computed(() => fileViewUrl(props.file))
|
|
48
51
|
const showThumb = computed(() => props.previewImage && isImage.value)
|
|
49
52
|
const icon = computed(() => iconFor(props.file?.mime_type))
|
|
50
53
|
|
|
51
54
|
const handleDownload = (e) => {
|
|
52
55
|
e?.stopPropagation?.()
|
|
53
|
-
download(props.file
|
|
56
|
+
download(props.file)
|
|
54
57
|
}
|
|
55
58
|
const handleView = (e) => {
|
|
56
59
|
e?.stopPropagation?.()
|
|
57
|
-
view(props.file
|
|
60
|
+
view(props.file)
|
|
58
61
|
}
|
|
59
62
|
const handleDelete = (e) => {
|
|
60
63
|
e?.stopPropagation?.()
|
|
@@ -83,7 +86,7 @@ const handleDelete = (e) => {
|
|
|
83
86
|
<div class="shrink-0">
|
|
84
87
|
<img
|
|
85
88
|
v-if="showThumb"
|
|
86
|
-
:src="
|
|
89
|
+
:src="thumbSrc"
|
|
87
90
|
:alt="file.original_name"
|
|
88
91
|
class="size-10 rounded-control object-cover bg-muted"
|
|
89
92
|
/>
|
|
@@ -147,7 +150,7 @@ const handleDelete = (e) => {
|
|
|
147
150
|
<!-- Imagen real -->
|
|
148
151
|
<img
|
|
149
152
|
v-if="showThumb"
|
|
150
|
-
:src="
|
|
153
|
+
:src="thumbSrc"
|
|
151
154
|
:alt="file.original_name"
|
|
152
155
|
class="size-full object-cover"
|
|
153
156
|
/>
|
|
@@ -37,10 +37,15 @@ const props = defineProps({
|
|
|
37
37
|
showToolbar: { type: Boolean, default: true },
|
|
38
38
|
})
|
|
39
39
|
|
|
40
|
-
const {
|
|
40
|
+
const { fileViewUrl, download, view, formatSize, iconFor } = useFile()
|
|
41
41
|
|
|
42
42
|
const fileIcon = computed(() => iconFor(props.file?.mime_type))
|
|
43
43
|
|
|
44
|
+
// URL de serving preferida: el view_url FIRMADO del backend (dominio propio, la
|
|
45
|
+
// firma es la credencial → sirve en <img>/<iframe>/fetch sin Bearer ni cookie).
|
|
46
|
+
// Cae a la ruta por-id solo si el resource no trajo la URL firmada.
|
|
47
|
+
const src = computed(() => fileViewUrl(props.file))
|
|
48
|
+
|
|
44
49
|
// ─── Tipo de viewer ──────────────────────────────────────────────────────────
|
|
45
50
|
const viewerType = computed(() => {
|
|
46
51
|
const mime = (props.file?.mime_type || '').toLowerCase()
|
|
@@ -55,10 +60,13 @@ const viewerType = computed(() => {
|
|
|
55
60
|
if (mime === 'text/markdown' || ['md', 'markdown'].includes(ext)) return 'markdown'
|
|
56
61
|
if (mime === 'text/csv' || ['csv', 'tsv'].includes(ext)) return 'csv'
|
|
57
62
|
|
|
58
|
-
|
|
63
|
+
// Office por EXTENSIÓN antes del check de text/*: un .xls/.xlsx/.doc/.docx
|
|
64
|
+
// suele llegar con mime mal detectado (p.ej. text/plain u octet-stream) y aun
|
|
65
|
+
// así debe usar el renderer rico (Excel con pestañas / docx), no el de texto.
|
|
66
|
+
if (['xlsx', 'xls'].includes(ext) || mime.includes('spreadsheet') || mime.includes('excel')) return 'xlsx'
|
|
67
|
+
if (['docx', 'doc'].includes(ext) || mime.includes('wordprocessing') || mime.includes('msword')) return 'docx'
|
|
59
68
|
|
|
60
|
-
if (
|
|
61
|
-
if (['docx','doc'].includes(ext) || mime.includes('wordprocessing') || mime.includes('msword')) return 'docx'
|
|
69
|
+
if (mime.startsWith('text/') || ['log','yml','yaml','env','sh','js','ts','tsx','jsx','css','html','xml','php','py','rb','go','rs','java','sql','toml','ini'].includes(ext)) return 'text'
|
|
62
70
|
|
|
63
71
|
return 'unknown'
|
|
64
72
|
})
|
|
@@ -94,7 +102,7 @@ const fetchText = async () => {
|
|
|
94
102
|
isLoadingTxt.value = true
|
|
95
103
|
textError.value = null
|
|
96
104
|
try {
|
|
97
|
-
const res = await fetch(
|
|
105
|
+
const res = await fetch(src.value)
|
|
98
106
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
99
107
|
textContent.value = await res.text()
|
|
100
108
|
} catch (e) {
|
|
@@ -280,7 +288,7 @@ const loadXlsx = async () => {
|
|
|
280
288
|
console.warn('[FilesPreview] exceljs module shape:', mod)
|
|
281
289
|
throw new Error('Para previsualizar .xlsx instalá la dep: pnpm add exceljs')
|
|
282
290
|
}
|
|
283
|
-
const res = await fetch(
|
|
291
|
+
const res = await fetch(src.value)
|
|
284
292
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
285
293
|
const buf = await res.arrayBuffer()
|
|
286
294
|
const wb = new ExcelJS.Workbook()
|
|
@@ -308,7 +316,7 @@ const loadDocx = async () => {
|
|
|
308
316
|
console.warn('[FilesPreview] mammoth module shape:', mod)
|
|
309
317
|
throw new Error('Para previsualizar .docx instalá la dep: pnpm add mammoth')
|
|
310
318
|
}
|
|
311
|
-
const res = await fetch(
|
|
319
|
+
const res = await fetch(src.value)
|
|
312
320
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
313
321
|
const buf = await res.arrayBuffer()
|
|
314
322
|
const result = await mammoth.convertToHtml({ arrayBuffer: buf })
|
|
@@ -448,7 +456,7 @@ const jsonPretty = computed(() => {
|
|
|
448
456
|
type="button"
|
|
449
457
|
title="Abrir en pestaña nueva"
|
|
450
458
|
class="inline-flex items-center justify-center size-8 rounded-control text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors"
|
|
451
|
-
@click="view(file
|
|
459
|
+
@click="view(file)"
|
|
452
460
|
>
|
|
453
461
|
<IconExternalLink class="size-4" />
|
|
454
462
|
</button>
|
|
@@ -456,7 +464,7 @@ const jsonPretty = computed(() => {
|
|
|
456
464
|
type="button"
|
|
457
465
|
title="Descargar"
|
|
458
466
|
class="inline-flex items-center justify-center size-8 rounded-control text-muted-foreground hover:text-foreground hover:bg-muted-hover transition-colors"
|
|
459
|
-
@click="download(file
|
|
467
|
+
@click="download(file)"
|
|
460
468
|
>
|
|
461
469
|
<IconDownload class="size-4" />
|
|
462
470
|
</button>
|
|
@@ -471,27 +479,27 @@ const jsonPretty = computed(() => {
|
|
|
471
479
|
view=FitH ajusta al ancho. -->
|
|
472
480
|
<iframe
|
|
473
481
|
v-if="viewerType === 'pdf'"
|
|
474
|
-
:src="`${
|
|
482
|
+
:src="`${src}#navpanes=0&view=FitH`"
|
|
475
483
|
class="absolute inset-0 w-full h-full border-0"
|
|
476
484
|
:title="file.original_name"
|
|
477
485
|
/>
|
|
478
486
|
|
|
479
487
|
<!-- Image -->
|
|
480
488
|
<div v-else-if="viewerType === 'image'" class="absolute inset-0 flex items-center justify-center bg-muted/30 p-4">
|
|
481
|
-
<img :src="
|
|
489
|
+
<img :src="src" :alt="file.original_name" class="max-w-full max-h-full object-contain rounded-control shadow-sm" />
|
|
482
490
|
</div>
|
|
483
491
|
|
|
484
492
|
<!-- Video -->
|
|
485
493
|
<video
|
|
486
494
|
v-else-if="viewerType === 'video'"
|
|
487
|
-
:src="
|
|
495
|
+
:src="src"
|
|
488
496
|
controls
|
|
489
497
|
class="absolute inset-0 w-full h-full bg-black"
|
|
490
498
|
/>
|
|
491
499
|
|
|
492
500
|
<!-- Audio -->
|
|
493
501
|
<div v-else-if="viewerType === 'audio'" class="size-full flex items-center justify-center p-8 bg-muted/20">
|
|
494
|
-
<audio :src="
|
|
502
|
+
<audio :src="src" controls class="w-full max-w-md" />
|
|
495
503
|
</div>
|
|
496
504
|
|
|
497
505
|
<!-- Markdown -->
|
|
@@ -646,7 +654,7 @@ const jsonPretty = computed(() => {
|
|
|
646
654
|
</div>
|
|
647
655
|
<button
|
|
648
656
|
type="button"
|
|
649
|
-
@click="download(file
|
|
657
|
+
@click="download(file)"
|
|
650
658
|
class="px-3 py-1.5 text-xs font-medium rounded-control bg-primary text-primary-foreground hover:bg-primary-hover inline-flex items-center gap-1.5"
|
|
651
659
|
>
|
|
652
660
|
<IconDownload :size="14" /> Descargar
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { IconSearch, IconLoader2 } from '@tabler/icons-vue'
|
|
2
|
+
import { IconSearch, IconLoader2, IconDownload } from '@tabler/icons-vue'
|
|
3
3
|
import { useInfiniteQuery } from '@tanstack/vue-query'
|
|
4
4
|
|
|
5
5
|
/*
|
|
@@ -31,6 +31,12 @@ const props = defineProps({
|
|
|
31
31
|
height: { type: String, default: 'calc(100dvh - 13rem)' },
|
|
32
32
|
/** Auto-refresco por realtime (se suscribe a los meta.channels del backend). */
|
|
33
33
|
realtime: { type: Boolean, default: true },
|
|
34
|
+
/** Cachea la lista (opt-in, TTL 10min) — misma estrategia que Table.Standard/DataTable. */
|
|
35
|
+
cached: { type: Boolean, default: false },
|
|
36
|
+
/** Muestra el botón "Exportar" (xlsx) en el header de la lista. */
|
|
37
|
+
exportable: { type: Boolean, default: true },
|
|
38
|
+
/** Formato de export (por ahora solo 'xlsx'). */
|
|
39
|
+
exportFormat: { type: String, default: 'xlsx' },
|
|
34
40
|
})
|
|
35
41
|
|
|
36
42
|
const emit = defineEmits(['select'])
|
|
@@ -53,6 +59,42 @@ watch(search, (v) => {
|
|
|
53
59
|
searchTimeout = setTimeout(() => { debouncedSearch.value = v }, 400)
|
|
54
60
|
})
|
|
55
61
|
|
|
62
|
+
// Export a archivo (xlsx): pega al mismo endpoint DataTable con ?exportType=…
|
|
63
|
+
// Va por fetch propio + blob (useApi solo maneja json/text), con los headers de
|
|
64
|
+
// auth de los interceptors (Bearer + X-Tenant).
|
|
65
|
+
const config = useRuntimeConfig()
|
|
66
|
+
const apiBase = config.public.apiBaseUrl || '/api'
|
|
67
|
+
const { run: runInterceptors } = useRequestInterceptors()
|
|
68
|
+
const exporting = ref(false)
|
|
69
|
+
|
|
70
|
+
async function downloadExport() {
|
|
71
|
+
if (exporting.value) return
|
|
72
|
+
exporting.value = true
|
|
73
|
+
try {
|
|
74
|
+
const headers = { Accept: 'application/octet-stream' }
|
|
75
|
+
runInterceptors(headers)
|
|
76
|
+
const qs = new URLSearchParams({ exportType: props.exportFormat, search: debouncedSearch.value })
|
|
77
|
+
for (const [k, v] of Object.entries(props.params ?? {})) {
|
|
78
|
+
if (v !== null && v !== undefined) qs.append(k, String(v))
|
|
79
|
+
}
|
|
80
|
+
const res = await fetch(`${apiBase}/${props.endpoint}?${qs.toString()}`, { headers })
|
|
81
|
+
if (!res.ok) throw new Error('export failed: ' + res.status)
|
|
82
|
+
const blob = await res.blob()
|
|
83
|
+
const url = URL.createObjectURL(blob)
|
|
84
|
+
const a = document.createElement('a')
|
|
85
|
+
a.href = url
|
|
86
|
+
a.download = `${props.name}.${props.exportFormat}`
|
|
87
|
+
document.body.appendChild(a)
|
|
88
|
+
a.click()
|
|
89
|
+
a.remove()
|
|
90
|
+
URL.revokeObjectURL(url)
|
|
91
|
+
} catch (e) {
|
|
92
|
+
console.error('[Table.MasterDetail] export error', e)
|
|
93
|
+
} finally {
|
|
94
|
+
exporting.value = false
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
56
98
|
const queryKey = computed(() => [props.name, 'md', debouncedSearch.value, props.params])
|
|
57
99
|
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, refetch } = useInfiniteQuery({
|
|
58
100
|
queryKey,
|
|
@@ -65,6 +107,8 @@ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, refetch
|
|
|
65
107
|
return meta.current_page < meta.last_page ? meta.current_page + 1 : undefined
|
|
66
108
|
},
|
|
67
109
|
initialPageParam: 1,
|
|
110
|
+
staleTime: props.cached ? 10 * 60 * 1000 : 0,
|
|
111
|
+
gcTime: props.cached ? 10 * 60 * 1000 : 5 * 60 * 1000,
|
|
68
112
|
})
|
|
69
113
|
const rows = computed(() => data.value?.pages.flatMap(p => p?.data ?? (Array.isArray(p) ? p : [])) ?? [])
|
|
70
114
|
|
|
@@ -101,9 +145,17 @@ function pick(row) {
|
|
|
101
145
|
<div class="flex rounded-card border border-card-line overflow-hidden bg-card min-h-[360px]" :style="{ height }">
|
|
102
146
|
<!-- ── Lista ── -->
|
|
103
147
|
<aside class="w-80 shrink-0 border-e border-card-line flex flex-col">
|
|
104
|
-
<div v-if="title || $slots.action" class="p-3 flex items-center justify-between gap-2 border-b border-card-line">
|
|
105
|
-
<h2 class="text-sm font-semibold text-foreground">{{ title }}</h2>
|
|
106
|
-
<
|
|
148
|
+
<div v-if="title || $slots.action || exportable" class="p-3 flex items-center justify-between gap-2 border-b border-card-line">
|
|
149
|
+
<h2 class="text-sm font-semibold text-foreground truncate">{{ title }}</h2>
|
|
150
|
+
<div class="flex items-center gap-2 shrink-0">
|
|
151
|
+
<slot name="action" />
|
|
152
|
+
<button v-if="exportable" type="button" @click="downloadExport" :disabled="exporting"
|
|
153
|
+
class="inline-flex items-center gap-1.5 rounded-lg border border-card-line px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-surface hover:text-foreground disabled:opacity-50 transition-colors">
|
|
154
|
+
<IconLoader2 v-if="exporting" class="size-3.5 animate-spin" />
|
|
155
|
+
<IconDownload v-else class="size-3.5" :stroke-width="1.5" />
|
|
156
|
+
Exportar
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
107
159
|
</div>
|
|
108
160
|
|
|
109
161
|
<div class="px-3 pt-3 pb-2">
|
package/composables/useFile.ts
CHANGED
|
@@ -9,9 +9,12 @@ import {
|
|
|
9
9
|
*
|
|
10
10
|
* Cubre:
|
|
11
11
|
* - upload(endpoint, file|files, { field, onProgress, extraData, signal })
|
|
12
|
-
* - download(fileId, filename?)
|
|
13
|
-
* - view(fileId)
|
|
14
|
-
* -
|
|
12
|
+
* - download(file|fileId, filename?) — trigger descarga programática
|
|
13
|
+
* - view(file|fileId) — abrir inline en otra pestaña
|
|
14
|
+
* - fileViewUrl(file) / fileDownloadUrl(file) — URL de serving PREFERIDA: usa el
|
|
15
|
+
* view_url/download_url FIRMADO del backend (dominio propio, la firma es la
|
|
16
|
+
* credencial → sirve en <img>/<iframe>/fetch sin Bearer); cae a la ruta por-id.
|
|
17
|
+
* - viewUrl(fileId) / downloadUrl(fileId) — rutas por-id (requieren auth; fallback)
|
|
15
18
|
* - formatSize(bytes) — helper humano-legible
|
|
16
19
|
* - iconFor(mimeType) — devuelve el componente Tabler para el mime
|
|
17
20
|
*
|
|
@@ -26,11 +29,20 @@ export function useFile() {
|
|
|
26
29
|
const { run } = useRequestInterceptors()
|
|
27
30
|
|
|
28
31
|
// ── URLs ───────────────────────────────────────────────────────────────────
|
|
29
|
-
/** GET /files/{id} — inline view (
|
|
32
|
+
/** GET /files/{id} — inline view por ruta (requiere auth; fallback). */
|
|
30
33
|
const viewUrl = (fileId) => `${baseUrl}/files/${fileId}`
|
|
31
|
-
/** GET /files/{id}/download — force download (
|
|
34
|
+
/** GET /files/{id}/download — force download por ruta (requiere auth; fallback). */
|
|
32
35
|
const downloadUrl = (fileId) => `${baseUrl}/files/${fileId}/download`
|
|
33
36
|
|
|
37
|
+
/**
|
|
38
|
+
* URL de serving preferida para un objeto File del backend.
|
|
39
|
+
* Prioriza el `view_url`/`download_url` FIRMADO (dominio propio, la firma es la
|
|
40
|
+
* credencial → funciona en <img>/<iframe>/fetch sin Bearer ni cookie). Cae a la
|
|
41
|
+
* ruta por-id solo si el resource no trae la URL firmada (p.ej. archivos public).
|
|
42
|
+
*/
|
|
43
|
+
const fileViewUrl = (file) => file?.view_url || (file?.id ? viewUrl(file.id) : null)
|
|
44
|
+
const fileDownloadUrl = (file) => file?.download_url || (file?.id ? downloadUrl(file.id) : null)
|
|
45
|
+
|
|
34
46
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
35
47
|
const formatSize = (bytes) => {
|
|
36
48
|
if (bytes == null) return ''
|
|
@@ -57,21 +69,30 @@ export function useFile() {
|
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
// ── View / Download ────────────────────────────────────────────────────────
|
|
60
|
-
/**
|
|
61
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Abre el file inline en una nueva pestaña. Acepta el objeto File (usa su
|
|
74
|
+
* view_url firmado) o un id (fallback a la ruta). Preferir pasar el file.
|
|
75
|
+
*/
|
|
76
|
+
const view = (fileOrId) => {
|
|
62
77
|
if (typeof window === 'undefined') return
|
|
63
|
-
|
|
78
|
+
const url = typeof fileOrId === 'object' ? fileViewUrl(fileOrId) : viewUrl(fileOrId)
|
|
79
|
+
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
|
64
80
|
}
|
|
65
81
|
|
|
66
82
|
/**
|
|
67
|
-
* Trigger descarga programática.
|
|
68
|
-
*
|
|
83
|
+
* Trigger descarga programática. Acepta el objeto File (usa su download_url
|
|
84
|
+
* firmado) o un id (fallback a la ruta). La firma es la credencial, así que
|
|
85
|
+
* funciona con archivos auth/restricted sin Bearer ni cookie.
|
|
69
86
|
*/
|
|
70
|
-
const download = (
|
|
87
|
+
const download = (fileOrId, filename) => {
|
|
71
88
|
if (typeof window === 'undefined') return
|
|
89
|
+
const isFile = typeof fileOrId === 'object'
|
|
90
|
+
const url = isFile ? fileDownloadUrl(fileOrId) : downloadUrl(fileOrId)
|
|
91
|
+
if (!url) return
|
|
92
|
+
const name = filename ?? (isFile ? fileOrId.original_name : undefined)
|
|
72
93
|
const a = document.createElement('a')
|
|
73
|
-
a.href =
|
|
74
|
-
if (
|
|
94
|
+
a.href = url
|
|
95
|
+
if (name) a.download = name
|
|
75
96
|
document.body.appendChild(a)
|
|
76
97
|
a.click()
|
|
77
98
|
document.body.removeChild(a)
|
|
@@ -179,6 +200,8 @@ export function useFile() {
|
|
|
179
200
|
view,
|
|
180
201
|
viewUrl,
|
|
181
202
|
downloadUrl,
|
|
203
|
+
fileViewUrl,
|
|
204
|
+
fileDownloadUrl,
|
|
182
205
|
formatSize,
|
|
183
206
|
iconFor,
|
|
184
207
|
}
|