@innertia-solutions/innertia-nuxt 0.1.12 → 0.1.13

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.
Files changed (51) hide show
  1. package/components/Attention.vue +269 -0
  2. package/components/Chart/index.vue +193 -0
  3. package/components/Container.vue +116 -0
  4. package/components/CopyButton.vue +98 -0
  5. package/components/Files/Card.vue +215 -0
  6. package/components/Files/Preview.vue +675 -0
  7. package/components/Files/Upload.vue +408 -0
  8. package/components/Files/UploadModal.vue +83 -0
  9. package/components/Forms/InputPin.vue +162 -0
  10. package/components/InfoToolbar.vue +54 -0
  11. package/components/Kanban/Standard.vue +588 -0
  12. package/components/Loader.vue +358 -0
  13. package/components/Modal/Base.vue +6 -0
  14. package/components/Modal/Confirm.vue +89 -0
  15. package/components/Modal/DeleteConfirm.vue +14 -31
  16. package/components/Modal/Form.vue +76 -0
  17. package/components/Modal/Host.vue +58 -0
  18. package/components/Modal/Prompt.vue +74 -0
  19. package/components/Modal.vue +119 -34
  20. package/components/Table/Standard.vue +198 -25
  21. package/components/Table/index.vue +169 -369
  22. package/components/Table/views/ViewCards.vue +74 -0
  23. package/components/Table/views/ViewDatabase.vue +236 -0
  24. package/components/Table/views/ViewTable.vue +299 -0
  25. package/components/Toast/Alert.vue +16 -111
  26. package/components/Toast/Container.vue +4 -29
  27. package/components/Toast/Notification.vue +8 -43
  28. package/components/Toast/Process.vue +8 -86
  29. package/components/Toast/Stack.vue +59 -0
  30. package/components/Toast/Standard.vue +115 -0
  31. package/components/Tree/Standard.vue +49 -4
  32. package/components/Workflow/Board.vue +356 -0
  33. package/components/Workflow/Builder.vue +454 -0
  34. package/components/Workflow/Form.vue +526 -0
  35. package/components/Workflow/Viewer.vue +225 -0
  36. package/components/Workflow/edges/TransitionEdge.vue +84 -0
  37. package/components/Workflow/nodes/StepNode.vue +88 -0
  38. package/composables/useClipboard.js +79 -0
  39. package/composables/useConfetti.js +194 -0
  40. package/composables/useFile.js +189 -0
  41. package/composables/useModal.js +71 -0
  42. package/composables/useToast.js +38 -65
  43. package/composables/useWorkflow/form.js +162 -0
  44. package/composables/useWorkflow/index.js +18 -0
  45. package/composables/useWorkflow/view.js +220 -0
  46. package/composables/useWorkflowLayout.js +162 -0
  47. package/css/tokens.css +10 -10
  48. package/nuxt.config.ts +1 -1
  49. package/package.json +13 -8
  50. package/stores/modal.js +48 -0
  51. package/stores/toast.js +156 -117
@@ -0,0 +1,675 @@
1
+ <script setup>
2
+ import { IconDownload, IconExternalLink, IconFileX, IconLoader2 } from '@tabler/icons-vue'
3
+
4
+ /**
5
+ * <FilesPreview> — Previsualizador universal de archivos. Detecta el tipo
6
+ * automáticamente y dispatcha al viewer correspondiente.
7
+ *
8
+ * Soporta sin deps:
9
+ * - PDF (iframe nativo del browser)
10
+ * - Imágenes (jpg, png, gif, webp, svg, etc.)
11
+ * - Video / Audio
12
+ * - Texto plano, JSON, código fuente
13
+ * - Markdown (parser inline sin deps)
14
+ * - CSV / TSV (tabla nativa)
15
+ *
16
+ * Soporta con import dinámico (consumer debe instalar la dep):
17
+ * - .xlsx / .xls → `xlsx` (SheetJS)
18
+ * - .docx → `mammoth`
19
+ *
20
+ * Para cualquier otro tipo: fallback con metadata + botón de descarga.
21
+ *
22
+ * Sigue los tokens del DS: bordered, padding, size, rounded-card, border-card-line.
23
+ */
24
+
25
+ const props = defineProps({
26
+ /** File del backend innertia-laravel: { id, original_name, mime_type, extension, size } */
27
+ file: { type: Object, required: true },
28
+
29
+ /** 'sm' | 'md' | 'lg' (default) | 'fit' — controla min/max-height del body
30
+ * Default 'lg' porque previsualizar archivos (PDF, docx, etc.) necesita altura */
31
+ size: { type: String, default: 'lg' },
32
+
33
+ /** Wrapper con border + rounded-card. */
34
+ bordered: { type: Boolean, default: true },
35
+
36
+ /** Muestra toolbar superior con filename + botones (download / abrir). */
37
+ showToolbar: { type: Boolean, default: true },
38
+ })
39
+
40
+ const { viewUrl, download, view, formatSize, iconFor } = useFile()
41
+
42
+ const fileIcon = computed(() => iconFor(props.file?.mime_type))
43
+
44
+ // ─── Tipo de viewer ──────────────────────────────────────────────────────────
45
+ const viewerType = computed(() => {
46
+ const mime = (props.file?.mime_type || '').toLowerCase()
47
+ const ext = (props.file?.extension || '').toLowerCase()
48
+
49
+ if (mime.startsWith('image/')) return 'image'
50
+ if (mime === 'application/pdf' || ext === 'pdf') return 'pdf'
51
+ if (mime.startsWith('video/')) return 'video'
52
+ if (mime.startsWith('audio/')) return 'audio'
53
+
54
+ if (mime === 'application/json' || ext === 'json') return 'json'
55
+ if (mime === 'text/markdown' || ['md', 'markdown'].includes(ext)) return 'markdown'
56
+ if (mime === 'text/csv' || ['csv', 'tsv'].includes(ext)) return 'csv'
57
+
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'
59
+
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'
62
+
63
+ return 'unknown'
64
+ })
65
+
66
+ // ─── Sizes — altura fija basada en vh para que el iframe/video tengan altura
67
+ // concreta del parent y no colapsen por flex sizing.
68
+ const sizeStyle = computed(() => {
69
+ switch (props.size) {
70
+ case 'sm': return { height: '32rem' } // ~512px fijo
71
+ case 'lg': return { height: '85vh' } // 85% del viewport
72
+ case 'fit': return {}
73
+ case 'md':
74
+ default: return { height: '60vh' } // 60% del viewport
75
+ }
76
+ })
77
+
78
+ // ─── Carga de contenido según tipo ───────────────────────────────────────────
79
+ const textContent = ref(null)
80
+ const textError = ref(null)
81
+ const isLoadingTxt = ref(false)
82
+
83
+ const xlsxWorkbook = ref(null) // { sheets: [{ name, header, rows }], hasImages: bool }
84
+ const xlsxActiveIdx = ref(0)
85
+ const xlsxError = ref(null)
86
+ const isLoadingXlsx = ref(false)
87
+
88
+ const docxHtml = ref(null)
89
+ const docxError = ref(null)
90
+ const isLoadingDocx = ref(false)
91
+
92
+ const fetchText = async () => {
93
+ if (!['markdown', 'csv', 'json', 'text'].includes(viewerType.value)) return
94
+ isLoadingTxt.value = true
95
+ textError.value = null
96
+ try {
97
+ const res = await fetch(viewUrl(props.file.id), { credentials: 'include' })
98
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
99
+ textContent.value = await res.text()
100
+ } catch (e) {
101
+ textError.value = e.message
102
+ } finally {
103
+ isLoadingTxt.value = false
104
+ }
105
+ }
106
+
107
+ /** Helper para módulos CJS lazy-importados.
108
+ * Vite expone CJS como `{ default: exports }` pero también auto-spreads
109
+ * top-level keys cuando puede. Buscamos en este orden:
110
+ * 1. mod.<fn> (top-level, ESM puro o spread bueno)
111
+ * 2. mod.default.<fn> (CJS clásico via interop)
112
+ * 3. mod.default (puede ser el módulo mismo, no las funciones)
113
+ */
114
+ function pickModule(mod, sentinel) {
115
+ if (!mod) return null
116
+ if (typeof mod[sentinel] === 'function') return mod
117
+ if (mod.default && typeof mod.default[sentinel] === 'function') return mod.default
118
+ if (mod.default?.default && typeof mod.default.default[sentinel] === 'function') return mod.default.default
119
+ return null
120
+ }
121
+
122
+ /** Convierte un índice de columna 0-based a letra Excel (0=A, 25=Z, 26=AA, ...). */
123
+ function colLetter(idx) {
124
+ let s = ''
125
+ let n = idx + 1
126
+ while (n > 0) {
127
+ const r = (n - 1) % 26
128
+ s = String.fromCharCode(65 + r) + s
129
+ n = Math.floor((n - 1) / 26)
130
+ }
131
+ return s
132
+ }
133
+
134
+ /** Buffer/Uint8Array → data URL para renderizar imágenes embebidas en <img>. */
135
+ function bufferToDataUrl(buf, ext) {
136
+ const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf)
137
+ let binary = ''
138
+ const chunkSize = 0x8000
139
+ for (let i = 0; i < bytes.length; i += chunkSize) {
140
+ binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize))
141
+ }
142
+ const base64 = btoa(binary)
143
+ const mime = ext === 'png' ? 'image/png'
144
+ : ext === 'gif' ? 'image/gif'
145
+ : ext === 'svg' ? 'image/svg+xml'
146
+ : ext === 'webp' ? 'image/webp'
147
+ : 'image/jpeg'
148
+ return `data:${mime};base64,${base64}`
149
+ }
150
+
151
+ /** Renderiza una celda a string display + alineación, manejando todos los tipos
152
+ * que ExcelJS puede entregar: number, string, Date, formula{result}, richText, hyperlink, etc. */
153
+ function renderCell(cell) {
154
+ const val = cell?.value
155
+ if (val == null || val === '') return { v: '', align: 'left', t: 'empty' }
156
+
157
+ if (typeof val === 'number') {
158
+ return { v: cell.text || String(val), align: 'right', t: 'number' }
159
+ }
160
+ if (val instanceof Date) {
161
+ return { v: cell.text || val.toLocaleDateString('es-CL'), align: 'right', t: 'date' }
162
+ }
163
+ if (typeof val === 'boolean') {
164
+ return { v: val ? 'TRUE' : 'FALSE', align: 'center', t: 'bool' }
165
+ }
166
+ if (typeof val === 'object') {
167
+ if (val.formula !== undefined) {
168
+ const r = val.result
169
+ if (r == null) return { v: '', align: 'left', t: 'formula' }
170
+ if (typeof r === 'number') return { v: cell.text || String(r), align: 'right', t: 'formula' }
171
+ if (r instanceof Date) return { v: cell.text || r.toLocaleDateString('es-CL'), align: 'right', t: 'formula' }
172
+ return { v: String(r), align: 'left', t: 'formula' }
173
+ }
174
+ if (Array.isArray(val.richText)) {
175
+ return { v: val.richText.map(rt => rt.text).join(''), align: 'left', t: 'rich' }
176
+ }
177
+ if (val.text != null) {
178
+ // Hyperlink: { text, hyperlink }
179
+ return { v: String(val.text), align: 'left', t: 'link' }
180
+ }
181
+ if (val.error) return { v: val.error, align: 'left', t: 'error' }
182
+ }
183
+ return { v: String(val), align: 'left', t: 'string' }
184
+ }
185
+
186
+ /** Letras Excel ('A','AB',...) → índice 0-based. */
187
+ function letterToIdx(letters) {
188
+ let n = 0
189
+ for (let i = 0; i < letters.length; i++) n = n * 26 + (letters.charCodeAt(i) - 64)
190
+ return n - 1
191
+ }
192
+
193
+ /** Parsea worksheet.model.merges ('A1:B2', ...) → { anchors, skip }
194
+ * - anchors[address] = { colSpan, rowSpan } para la celda top-left del merge
195
+ * - skip = Set de addresses que deben NO renderearse (cubiertas por un merge) */
196
+ function parseMerges(ws) {
197
+ const anchors = {}
198
+ const skip = new Set()
199
+ const merges = ws.model?.merges || []
200
+ for (const m of merges) {
201
+ const match = m.match(/^([A-Z]+)(\d+):([A-Z]+)(\d+)$/)
202
+ if (!match) continue
203
+ const sCol = letterToIdx(match[1])
204
+ const sRow = parseInt(match[2], 10)
205
+ const eCol = letterToIdx(match[3])
206
+ const eRow = parseInt(match[4], 10)
207
+ const anchor = colLetter(sCol) + sRow
208
+ anchors[anchor] = { colSpan: eCol - sCol + 1, rowSpan: eRow - sRow + 1 }
209
+ for (let r = sRow; r <= eRow; r++) {
210
+ for (let c = sCol; c <= eCol; c++) {
211
+ const addr = colLetter(c) + r
212
+ if (addr !== anchor) skip.add(addr)
213
+ }
214
+ }
215
+ }
216
+ return { anchors, skip }
217
+ }
218
+
219
+ /** Construye una sheet con grilla + imágenes embebidas + merged cells. */
220
+ function buildSheet(ws, workbook) {
221
+ const colCount = Math.max(ws.actualColumnCount, ws.columnCount, 0)
222
+ const rowCount = Math.max(ws.actualRowCount, ws.rowCount, 0)
223
+
224
+ const cols = []
225
+ for (let c = 0; c < colCount; c++) cols.push(colLetter(c))
226
+
227
+ const { anchors, skip } = parseMerges(ws)
228
+
229
+ const rows = []
230
+ for (let r = 1; r <= rowCount; r++) {
231
+ const row = ws.getRow(r)
232
+ const rowData = []
233
+ for (let c = 1; c <= colCount; c++) {
234
+ const address = colLetter(c - 1) + r
235
+ // Celdas cubiertas por un merge (que no son la top-left) se marcan para skip
236
+ if (skip.has(address)) {
237
+ rowData.push({ skip: true, address })
238
+ continue
239
+ }
240
+ const cell = row.getCell(c)
241
+ const rendered = renderCell(cell)
242
+ rendered.address = address
243
+ if (anchors[address]) {
244
+ rendered.colSpan = anchors[address].colSpan
245
+ rendered.rowSpan = anchors[address].rowSpan
246
+ }
247
+ rowData.push(rendered)
248
+ }
249
+ rows.push(rowData)
250
+ }
251
+
252
+ // Imágenes embebidas — ExcelJS expone getImages() + workbook.model.media
253
+ const imageMap = {}
254
+ const images = (typeof ws.getImages === 'function' ? ws.getImages() : []) || []
255
+ for (const img of images) {
256
+ const media = workbook.model.media?.find(m => String(m.index) === String(img.imageId))
257
+ if (!media || !media.buffer) continue
258
+ const dataUrl = bufferToDataUrl(media.buffer, media.extension)
259
+ const tlCol = Math.floor(img.range?.tl?.col ?? 0)
260
+ const tlRow = Math.floor(img.range?.tl?.row ?? 0)
261
+ const address = colLetter(tlCol) + (tlRow + 1)
262
+ if (!imageMap[address]) imageMap[address] = []
263
+ imageMap[address].push({ dataUrl, name: media.name })
264
+ }
265
+
266
+ return { name: ws.name, cols, rows, firstRow: 1, imageMap }
267
+ }
268
+
269
+ const loadXlsx = async () => {
270
+ if (viewerType.value !== 'xlsx') return
271
+ isLoadingXlsx.value = true
272
+ xlsxError.value = null
273
+ xlsxActiveIdx.value = 0
274
+ try {
275
+ const mod = await import('exceljs').catch(() => null)
276
+ const ExcelJS = pickModule(mod, 'Workbook')
277
+ ?? (mod?.default?.Workbook ? mod.default : null)
278
+ ?? mod
279
+ if (!ExcelJS?.Workbook) {
280
+ console.warn('[FilesPreview] exceljs module shape:', mod)
281
+ throw new Error('Para previsualizar .xlsx instalá la dep: pnpm add exceljs')
282
+ }
283
+ const res = await fetch(viewUrl(props.file.id), { credentials: 'include' })
284
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
285
+ const buf = await res.arrayBuffer()
286
+ const wb = new ExcelJS.Workbook()
287
+ await wb.xlsx.load(buf)
288
+ const sheets = wb.worksheets.map(ws => buildSheet(ws, wb))
289
+ xlsxWorkbook.value = { sheets }
290
+ } catch (e) {
291
+ console.error('[FilesPreview] xlsx error', e)
292
+ xlsxError.value = e.message
293
+ } finally {
294
+ isLoadingXlsx.value = false
295
+ }
296
+ }
297
+
298
+ const xlsxActiveSheet = computed(() => xlsxWorkbook.value?.sheets?.[xlsxActiveIdx.value] ?? null)
299
+
300
+ const loadDocx = async () => {
301
+ if (viewerType.value !== 'docx') return
302
+ isLoadingDocx.value = true
303
+ docxError.value = null
304
+ try {
305
+ const mod = await import('mammoth').catch(() => null)
306
+ const mammoth = pickModule(mod, 'convertToHtml')
307
+ if (!mammoth) {
308
+ console.warn('[FilesPreview] mammoth module shape:', mod)
309
+ throw new Error('Para previsualizar .docx instalá la dep: pnpm add mammoth')
310
+ }
311
+ const res = await fetch(viewUrl(props.file.id), { credentials: 'include' })
312
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
313
+ const buf = await res.arrayBuffer()
314
+ const result = await mammoth.convertToHtml({ arrayBuffer: buf })
315
+ docxHtml.value = result.value
316
+ } catch (e) {
317
+ docxError.value = e.message
318
+ } finally {
319
+ isLoadingDocx.value = false
320
+ }
321
+ }
322
+
323
+ watch(() => props.file?.id, () => {
324
+ textContent.value = null
325
+ xlsxWorkbook.value = null
326
+ xlsxActiveIdx.value = 0
327
+ docxHtml.value = null
328
+ textError.value = null
329
+ xlsxError.value = null
330
+ docxError.value = null
331
+ fetchText()
332
+ loadXlsx()
333
+ loadDocx()
334
+ }, { immediate: true })
335
+
336
+ // ─── Markdown parser inline ──────────────────────────────────────────────────
337
+ function renderMarkdown(md) {
338
+ if (!md) return ''
339
+ let html = md.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
340
+
341
+ // Fenced code blocks
342
+ html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, _lang, code) =>
343
+ `<pre class="bg-muted rounded-control p-3 overflow-x-auto my-3"><code class="text-xs font-mono text-foreground">${code.trim()}</code></pre>`
344
+ )
345
+
346
+ // Headers
347
+ html = html.replace(/^### (.*$)/gm, '<h3 class="text-base font-semibold text-foreground mt-5 mb-2">$1</h3>')
348
+ html = html.replace(/^## (.*$)/gm, '<h2 class="text-lg font-semibold text-foreground mt-6 mb-3">$1</h2>')
349
+ html = html.replace(/^# (.*$)/gm, '<h1 class="text-xl font-bold text-foreground mt-6 mb-3">$1</h1>')
350
+
351
+ // Bold + italic (orden importa)
352
+ html = html.replace(/\*\*(.+?)\*\*/g, '<strong class="font-semibold text-foreground">$1</strong>')
353
+ html = html.replace(/(?<![\w*])\*([^*\n]+)\*(?![\w*])/g, '<em>$1</em>')
354
+
355
+ // Inline code
356
+ html = html.replace(/`([^`\n]+)`/g, '<code class="bg-muted text-foreground rounded text-[0.85em] font-mono px-1 py-0.5">$1</code>')
357
+
358
+ // Images (antes que links)
359
+ html = html.replace(/!\[(.*?)\]\((.*?)\)/g, '<img alt="$1" src="$2" class="max-w-full rounded-card my-3" />')
360
+ // Links
361
+ html = html.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2" target="_blank" rel="noreferrer" class="text-primary underline hover:text-primary-hover">$1</a>')
362
+
363
+ // Horizontal rules
364
+ html = html.replace(/^---+$/gm, '<hr class="my-5 border-card-line" />')
365
+
366
+ // Lists (linewise)
367
+ html = html.replace(/^(?:- |\* |\+ )(.*$)/gm, '<li class="list-disc ml-5 my-1">$1</li>')
368
+ html = html.replace(/^\d+\. (.*$)/gm, '<li class="list-decimal ml-5 my-1">$1</li>')
369
+
370
+ // Blockquotes
371
+ html = html.replace(/^> (.*$)/gm, '<blockquote class="border-l-2 border-card-line pl-3 text-muted-foreground italic my-2">$1</blockquote>')
372
+
373
+ // Paragraphs (split por dobles saltos)
374
+ html = html.split(/\n{2,}/).map(p => {
375
+ p = p.trim()
376
+ if (!p) return ''
377
+ if (/^<(h\d|pre|li|blockquote|hr|img)/i.test(p)) return p
378
+ return `<p class="my-2 text-foreground leading-relaxed">${p.replace(/\n/g, '<br>')}</p>`
379
+ }).join('\n')
380
+
381
+ return html
382
+ }
383
+
384
+ const markdownHtml = computed(() => renderMarkdown(textContent.value ?? ''))
385
+
386
+ // ─── CSV parser ──────────────────────────────────────────────────────────────
387
+ function parseCsv(text) {
388
+ const rows = []
389
+ let row = []
390
+ let cell = ''
391
+ let inQuotes = false
392
+ for (let i = 0; i < text.length; i++) {
393
+ const c = text[i]
394
+ if (inQuotes) {
395
+ if (c === '"') {
396
+ if (text[i + 1] === '"') { cell += '"'; i++ }
397
+ else inQuotes = false
398
+ } else cell += c
399
+ } else {
400
+ if (c === '"') inQuotes = true
401
+ else if (c === ',') { row.push(cell); cell = '' }
402
+ else if (c === '\n') { row.push(cell); rows.push(row); row = []; cell = '' }
403
+ else if (c === '\r') continue
404
+ else cell += c
405
+ }
406
+ }
407
+ if (cell || row.length) { row.push(cell); rows.push(row) }
408
+ return rows
409
+ }
410
+
411
+ const csvRows = computed(() => textContent.value ? parseCsv(textContent.value) : [])
412
+ const csvHeader = computed(() => csvRows.value[0] ?? [])
413
+ const csvBody = computed(() => csvRows.value.slice(1))
414
+
415
+ // ─── JSON pretty ─────────────────────────────────────────────────────────────
416
+ const jsonPretty = computed(() => {
417
+ if (!textContent.value) return ''
418
+ try { return JSON.stringify(JSON.parse(textContent.value), null, 2) }
419
+ catch { return textContent.value }
420
+ })
421
+ </script>
422
+
423
+ <template>
424
+ <div
425
+ :class="[
426
+ 'flex flex-col bg-card',
427
+ bordered ? 'border border-card-line rounded-card overflow-hidden' : '',
428
+ ]"
429
+ :style="sizeStyle"
430
+ >
431
+ <!-- Toolbar superior -->
432
+ <div
433
+ v-if="showToolbar"
434
+ :class="[
435
+ 'shrink-0 flex items-center gap-3 px-4 py-3 bg-card',
436
+ bordered ? 'border-b border-card-line' : '',
437
+ ]"
438
+ >
439
+ <component :is="fileIcon" :size="20" :stroke="1.75" class="shrink-0 text-muted-foreground" />
440
+ <div class="flex-1 min-w-0">
441
+ <p class="text-sm font-medium text-foreground truncate">{{ file.original_name }}</p>
442
+ <p class="text-xs text-muted-foreground flex items-center gap-2 mt-0.5">
443
+ <span v-if="file.extension" class="uppercase font-medium">{{ file.extension }}</span>
444
+ <span v-if="file.size">{{ formatSize(file.size) }}</span>
445
+ </p>
446
+ </div>
447
+ <button
448
+ type="button"
449
+ title="Abrir en pestaña nueva"
450
+ 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)"
452
+ >
453
+ <IconExternalLink class="size-4" />
454
+ </button>
455
+ <button
456
+ type="button"
457
+ title="Descargar"
458
+ 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)"
460
+ >
461
+ <IconDownload class="size-4" />
462
+ </button>
463
+ </div>
464
+
465
+ <!-- Body — varía por tipo. `relative` para que iframe/video/img puedan
466
+ hacer absolute-fill sin depender del flex sizing del padre. -->
467
+ <div class="flex-1 min-h-0 overflow-auto relative">
468
+
469
+ <!-- PDF — absolute fill (iframe + flex sizing es problemático)
470
+ Params: navpanes=0 oculta el sidebar de thumbnails (Chrome/Chromium).
471
+ view=FitH ajusta al ancho. -->
472
+ <iframe
473
+ v-if="viewerType === 'pdf'"
474
+ :src="`${viewUrl(file.id)}#navpanes=0&view=FitH`"
475
+ class="absolute inset-0 w-full h-full border-0"
476
+ :title="file.original_name"
477
+ />
478
+
479
+ <!-- Image -->
480
+ <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" />
482
+ </div>
483
+
484
+ <!-- Video -->
485
+ <video
486
+ v-else-if="viewerType === 'video'"
487
+ :src="viewUrl(file.id)"
488
+ controls
489
+ class="absolute inset-0 w-full h-full bg-black"
490
+ />
491
+
492
+ <!-- Audio -->
493
+ <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" />
495
+ </div>
496
+
497
+ <!-- Markdown -->
498
+ <div v-else-if="viewerType === 'markdown'" class="p-5">
499
+ <div v-if="isLoadingTxt" class="text-sm text-muted-foreground inline-flex items-center gap-2"><IconLoader2 class="size-4 animate-spin" /> Cargando…</div>
500
+ <p v-else-if="textError" class="text-sm text-red-500">No se pudo cargar: {{ textError }}</p>
501
+ <div v-else class="max-w-none" v-html="markdownHtml" />
502
+ </div>
503
+
504
+ <!-- JSON -->
505
+ <div v-else-if="viewerType === 'json'" class="p-3">
506
+ <div v-if="isLoadingTxt" class="text-sm text-muted-foreground inline-flex items-center gap-2"><IconLoader2 class="size-4 animate-spin" /> Cargando…</div>
507
+ <p v-else-if="textError" class="text-sm text-red-500">No se pudo cargar: {{ textError }}</p>
508
+ <pre v-else class="text-xs font-mono text-foreground whitespace-pre-wrap break-words bg-muted/30 rounded-control p-3">{{ jsonPretty }}</pre>
509
+ </div>
510
+
511
+ <!-- Plain text / code -->
512
+ <div v-else-if="viewerType === 'text'" class="p-3">
513
+ <div v-if="isLoadingTxt" class="text-sm text-muted-foreground inline-flex items-center gap-2"><IconLoader2 class="size-4 animate-spin" /> Cargando…</div>
514
+ <p v-else-if="textError" class="text-sm text-red-500">No se pudo cargar: {{ textError }}</p>
515
+ <pre v-else class="text-xs font-mono text-foreground whitespace-pre-wrap break-words bg-muted/30 rounded-control p-3">{{ textContent }}</pre>
516
+ </div>
517
+
518
+ <!-- CSV -->
519
+ <div v-else-if="viewerType === 'csv'" class="overflow-auto">
520
+ <div v-if="isLoadingTxt" class="p-3 text-sm text-muted-foreground inline-flex items-center gap-2"><IconLoader2 class="size-4 animate-spin" /> Cargando…</div>
521
+ <p v-else-if="textError" class="p-3 text-sm text-red-500">No se pudo cargar: {{ textError }}</p>
522
+ <table v-else class="text-xs font-mono w-full">
523
+ <thead class="sticky top-0 bg-card border-b-2 border-card-line z-10">
524
+ <tr>
525
+ <th v-for="(h, i) in csvHeader" :key="i" class="text-left px-3 py-2 font-semibold text-foreground whitespace-nowrap">{{ h }}</th>
526
+ </tr>
527
+ </thead>
528
+ <tbody>
529
+ <tr v-for="(row, ri) in csvBody" :key="ri" class="border-b border-card-line hover:bg-muted-hover/30">
530
+ <td v-for="(cell, ci) in row" :key="ci" class="px-3 py-1.5 text-muted-foreground-1 whitespace-nowrap">{{ cell }}</td>
531
+ </tr>
532
+ </tbody>
533
+ </table>
534
+ </div>
535
+
536
+ <!-- XLSX — custom renderer con tabs, row/col headers, type-aware -->
537
+ <div v-else-if="viewerType === 'xlsx'" class="absolute inset-0 flex flex-col">
538
+ <div v-if="isLoadingXlsx" class="p-3 text-sm text-muted-foreground inline-flex items-center gap-2">
539
+ <IconLoader2 class="size-4 animate-spin" /> Procesando hoja de cálculo…
540
+ </div>
541
+ <p v-else-if="xlsxError" class="p-3 text-sm text-red-500">{{ xlsxError }}</p>
542
+
543
+ <template v-else-if="xlsxActiveSheet">
544
+ <!-- Grid -->
545
+ <div class="flex-1 min-h-0 overflow-auto bg-card">
546
+ <table class="border-collapse text-[11px] font-mono">
547
+ <thead class="sticky top-0 z-20">
548
+ <tr>
549
+ <!-- corner -->
550
+ <th class="sticky left-0 z-30 w-10 h-7 bg-muted border-b border-r border-card-line" />
551
+ <!-- column letters A, B, C, ... -->
552
+ <th
553
+ v-for="col in xlsxActiveSheet.cols"
554
+ :key="col"
555
+ class="min-w-[6rem] h-7 px-2 bg-muted text-muted-foreground font-semibold text-center border-b border-r border-card-line"
556
+ >
557
+ {{ col }}
558
+ </th>
559
+ </tr>
560
+ </thead>
561
+ <tbody>
562
+ <tr
563
+ v-for="(row, ri) in xlsxActiveSheet.rows"
564
+ :key="ri"
565
+ class="hover:bg-muted/30"
566
+ >
567
+ <!-- row number sticky a la izquierda -->
568
+ <th class="sticky left-0 z-10 w-10 bg-muted text-muted-foreground font-semibold text-center border-b border-r border-card-line align-middle">
569
+ {{ xlsxActiveSheet.firstRow + ri }}
570
+ </th>
571
+ <template v-for="(cell, ci) in row" :key="ci">
572
+ <td
573
+ v-if="!cell.skip"
574
+ :colspan="cell.colSpan || null"
575
+ :rowspan="cell.rowSpan || null"
576
+ class="px-2 border-b border-r border-card-line text-foreground whitespace-nowrap align-middle"
577
+ :class="{
578
+ 'text-right': cell.align === 'right',
579
+ 'text-center': cell.align === 'center',
580
+ 'text-left': cell.align === 'left',
581
+ 'text-muted-foreground-2 italic': cell.t === 'error',
582
+ 'text-primary underline': cell.t === 'link',
583
+ 'h-auto py-1': xlsxActiveSheet.imageMap[cell.address],
584
+ 'bg-muted/20': cell.colSpan > 1 || cell.rowSpan > 1,
585
+ }"
586
+ :style="!xlsxActiveSheet.imageMap[cell.address] ? { height: '1.75rem' } : {}"
587
+ >
588
+ <!-- Imágenes embebidas en la celda -->
589
+ <div v-if="xlsxActiveSheet.imageMap[cell.address]" class="flex flex-col gap-1">
590
+ <img
591
+ v-for="(img, ii) in xlsxActiveSheet.imageMap[cell.address]"
592
+ :key="ii"
593
+ :src="img.dataUrl"
594
+ :alt="img.name"
595
+ class="max-w-[240px] max-h-[160px] object-contain rounded-control border border-card-line bg-card"
596
+ />
597
+ <span v-if="cell.v" class="text-[10px] text-muted-foreground">{{ cell.v }}</span>
598
+ </div>
599
+ <template v-else>{{ cell.v }}</template>
600
+ </td>
601
+ </template>
602
+ </tr>
603
+ </tbody>
604
+ </table>
605
+ </div>
606
+
607
+ <!-- Tabs de hojas — abajo, estilo Excel -->
608
+ <div
609
+ v-if="xlsxWorkbook && xlsxWorkbook.sheets.length > 1"
610
+ class="shrink-0 flex items-center gap-0.5 px-2 py-1.5 bg-muted border-t border-card-line overflow-x-auto"
611
+ >
612
+ <button
613
+ v-for="(sheet, idx) in xlsxWorkbook.sheets"
614
+ :key="sheet.name"
615
+ type="button"
616
+ @click="xlsxActiveIdx = idx"
617
+ :class="[
618
+ 'px-3 py-1 text-xs font-medium rounded-control transition-colors whitespace-nowrap',
619
+ xlsxActiveIdx === idx
620
+ ? 'bg-card text-foreground shadow-sm border border-card-line'
621
+ : 'text-muted-foreground hover:bg-card/60 hover:text-foreground',
622
+ ]"
623
+ >
624
+ {{ sheet.name }}
625
+ </button>
626
+ </div>
627
+ </template>
628
+ </div>
629
+
630
+ <!-- DOCX -->
631
+ <div v-else-if="viewerType === 'docx'" class="p-5">
632
+ <div v-if="isLoadingDocx" class="text-sm text-muted-foreground inline-flex items-center gap-2"><IconLoader2 class="size-4 animate-spin" /> Procesando documento…</div>
633
+ <p v-else-if="docxError" class="text-sm text-red-500">{{ docxError }}</p>
634
+ <div v-else class="docx-preview max-w-none text-foreground" v-html="docxHtml" />
635
+ </div>
636
+
637
+ <!-- Fallback -->
638
+ <div v-else class="size-full flex flex-col items-center justify-center gap-3 p-8 text-center">
639
+ <div class="size-16 rounded-card bg-muted flex items-center justify-center text-muted-foreground">
640
+ <IconFileX :size="32" :stroke="1.25" />
641
+ </div>
642
+ <div>
643
+ <p class="text-sm font-medium text-foreground">Vista previa no disponible</p>
644
+ <p class="text-xs text-muted-foreground mt-1">No tenemos un visor para este tipo de archivo.</p>
645
+ <p v-if="file.extension || file.mime_type" class="text-[10px] text-muted-foreground-2 mt-1 font-mono">{{ file.extension }} · {{ file.mime_type }}</p>
646
+ </div>
647
+ <button
648
+ type="button"
649
+ @click="download(file.id, file.original_name)"
650
+ 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
+ >
652
+ <IconDownload :size="14" /> Descargar
653
+ </button>
654
+ </div>
655
+ </div>
656
+ </div>
657
+ </template>
658
+
659
+ <style scoped>
660
+ /* Estilos del HTML inyectado por mammoth (docx). xlsx ahora usa renderer custom. */
661
+ .docx-preview :deep(h1) { font-size: 1.5rem; font-weight: 700; margin: 1.5rem 0 0.75rem; }
662
+ .docx-preview :deep(h2) { font-size: 1.25rem; font-weight: 600; margin: 1.25rem 0 0.5rem; }
663
+ .docx-preview :deep(h3) { font-size: 1.1rem; font-weight: 600; margin: 1rem 0 0.5rem; }
664
+ .docx-preview :deep(p) { margin: 0.5rem 0; line-height: 1.6; }
665
+ .docx-preview :deep(ul),
666
+ .docx-preview :deep(ol) { margin: 0.5rem 0 0.5rem 1.5rem; }
667
+ .docx-preview :deep(li) { margin: 0.25rem 0; }
668
+ .docx-preview :deep(strong) { font-weight: 600; }
669
+ .docx-preview :deep(a) { color: var(--color-primary); text-decoration: underline; }
670
+ .docx-preview :deep(table) { border-collapse: collapse; margin: 0.75rem 0; }
671
+ .docx-preview :deep(td), .docx-preview :deep(th) {
672
+ border: 1px solid var(--color-card-line);
673
+ padding: 4px 8px;
674
+ }
675
+ </style>