@innertia-solutions/innertia-nuxt 0.1.48 → 0.1.49
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()
|
|
@@ -94,7 +99,7 @@ const fetchText = async () => {
|
|
|
94
99
|
isLoadingTxt.value = true
|
|
95
100
|
textError.value = null
|
|
96
101
|
try {
|
|
97
|
-
const res = await fetch(
|
|
102
|
+
const res = await fetch(src.value)
|
|
98
103
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
99
104
|
textContent.value = await res.text()
|
|
100
105
|
} catch (e) {
|
|
@@ -280,7 +285,7 @@ const loadXlsx = async () => {
|
|
|
280
285
|
console.warn('[FilesPreview] exceljs module shape:', mod)
|
|
281
286
|
throw new Error('Para previsualizar .xlsx instalá la dep: pnpm add exceljs')
|
|
282
287
|
}
|
|
283
|
-
const res = await fetch(
|
|
288
|
+
const res = await fetch(src.value)
|
|
284
289
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
285
290
|
const buf = await res.arrayBuffer()
|
|
286
291
|
const wb = new ExcelJS.Workbook()
|
|
@@ -308,7 +313,7 @@ const loadDocx = async () => {
|
|
|
308
313
|
console.warn('[FilesPreview] mammoth module shape:', mod)
|
|
309
314
|
throw new Error('Para previsualizar .docx instalá la dep: pnpm add mammoth')
|
|
310
315
|
}
|
|
311
|
-
const res = await fetch(
|
|
316
|
+
const res = await fetch(src.value)
|
|
312
317
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
313
318
|
const buf = await res.arrayBuffer()
|
|
314
319
|
const result = await mammoth.convertToHtml({ arrayBuffer: buf })
|
|
@@ -448,7 +453,7 @@ const jsonPretty = computed(() => {
|
|
|
448
453
|
type="button"
|
|
449
454
|
title="Abrir en pestaña nueva"
|
|
450
455
|
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
|
|
456
|
+
@click="view(file)"
|
|
452
457
|
>
|
|
453
458
|
<IconExternalLink class="size-4" />
|
|
454
459
|
</button>
|
|
@@ -456,7 +461,7 @@ const jsonPretty = computed(() => {
|
|
|
456
461
|
type="button"
|
|
457
462
|
title="Descargar"
|
|
458
463
|
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
|
|
464
|
+
@click="download(file)"
|
|
460
465
|
>
|
|
461
466
|
<IconDownload class="size-4" />
|
|
462
467
|
</button>
|
|
@@ -471,27 +476,27 @@ const jsonPretty = computed(() => {
|
|
|
471
476
|
view=FitH ajusta al ancho. -->
|
|
472
477
|
<iframe
|
|
473
478
|
v-if="viewerType === 'pdf'"
|
|
474
|
-
:src="`${
|
|
479
|
+
:src="`${src}#navpanes=0&view=FitH`"
|
|
475
480
|
class="absolute inset-0 w-full h-full border-0"
|
|
476
481
|
:title="file.original_name"
|
|
477
482
|
/>
|
|
478
483
|
|
|
479
484
|
<!-- Image -->
|
|
480
485
|
<div v-else-if="viewerType === 'image'" class="absolute inset-0 flex items-center justify-center bg-muted/30 p-4">
|
|
481
|
-
<img :src="
|
|
486
|
+
<img :src="src" :alt="file.original_name" class="max-w-full max-h-full object-contain rounded-control shadow-sm" />
|
|
482
487
|
</div>
|
|
483
488
|
|
|
484
489
|
<!-- Video -->
|
|
485
490
|
<video
|
|
486
491
|
v-else-if="viewerType === 'video'"
|
|
487
|
-
:src="
|
|
492
|
+
:src="src"
|
|
488
493
|
controls
|
|
489
494
|
class="absolute inset-0 w-full h-full bg-black"
|
|
490
495
|
/>
|
|
491
496
|
|
|
492
497
|
<!-- Audio -->
|
|
493
498
|
<div v-else-if="viewerType === 'audio'" class="size-full flex items-center justify-center p-8 bg-muted/20">
|
|
494
|
-
<audio :src="
|
|
499
|
+
<audio :src="src" controls class="w-full max-w-md" />
|
|
495
500
|
</div>
|
|
496
501
|
|
|
497
502
|
<!-- Markdown -->
|
|
@@ -646,7 +651,7 @@ const jsonPretty = computed(() => {
|
|
|
646
651
|
</div>
|
|
647
652
|
<button
|
|
648
653
|
type="button"
|
|
649
|
-
@click="download(file
|
|
654
|
+
@click="download(file)"
|
|
650
655
|
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
656
|
>
|
|
652
657
|
<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
|
}
|