@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, viewUrl, formatSize, iconFor } = useFile()
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.id, props.file.original_name)
56
+ download(props.file)
54
57
  }
55
58
  const handleView = (e) => {
56
59
  e?.stopPropagation?.()
57
- view(props.file.id)
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="viewUrl(file.id)"
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="viewUrl(file.id)"
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 { viewUrl, download, view, formatSize, iconFor } = useFile()
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
- 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'
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 (['xlsx','xls'].includes(ext) || mime.includes('spreadsheet') || mime.includes('excel')) return 'xlsx'
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(viewUrl(props.file.id), { credentials: 'include' })
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(viewUrl(props.file.id), { credentials: 'include' })
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(viewUrl(props.file.id), { credentials: 'include' })
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.id)"
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.id, file.original_name)"
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="`${viewUrl(file.id)}#navpanes=0&view=FitH`"
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="viewUrl(file.id)" :alt="file.original_name" class="max-w-full max-h-full object-contain rounded-control shadow-sm" />
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="viewUrl(file.id)"
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="viewUrl(file.id)" controls class="w-full max-w-md" />
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.id, file.original_name)"
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
- <slot name="action" />
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">
@@ -9,9 +9,12 @@ import {
9
9
  *
10
10
  * Cubre:
11
11
  * - upload(endpoint, file|files, { field, onProgress, extraData, signal })
12
- * - download(fileId, filename?) — trigger descarga programática
13
- * - view(fileId) — abrir inline en otra pestaña
14
- * - viewUrl(fileId) / downloadUrl(fileId) — URLs absolutas a las rutas de innertia-laravel
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 (respeta permisos del backend). */
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 (respeta permisos). */
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
- /** Abre el file inline en una nueva pestaña (usa la auth de cookie). */
61
- const view = (fileId) => {
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
- window.open(viewUrl(fileId), '_blank', 'noopener,noreferrer')
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. Funciona con archivos auth/restricted
68
- * porque el browser envía cookies automáticamente.
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 = (fileId, filename) => {
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 = downloadUrl(fileId)
74
- if (filename) a.download = filename
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@innertia-solutions/innertia-nuxt",
3
- "version": "0.1.48",
3
+ "version": "0.1.50",
4
4
  "description": "Innertia Solutions — Nuxt layer unificada: core, auth, multitenancy, theme y app contexts",
5
5
  "keywords": [
6
6
  "nuxt",