@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, 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()
@@ -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(viewUrl(props.file.id), { credentials: 'include' })
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(viewUrl(props.file.id), { credentials: 'include' })
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(viewUrl(props.file.id), { credentials: 'include' })
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.id)"
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.id, file.original_name)"
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="`${viewUrl(file.id)}#navpanes=0&view=FitH`"
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="viewUrl(file.id)" :alt="file.original_name" class="max-w-full max-h-full object-contain rounded-control shadow-sm" />
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="viewUrl(file.id)"
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="viewUrl(file.id)" controls class="w-full max-w-md" />
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.id, file.original_name)"
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
- <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.49",
4
4
  "description": "Innertia Solutions — Nuxt layer unificada: core, auth, multitenancy, theme y app contexts",
5
5
  "keywords": [
6
6
  "nuxt",