@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.
- package/components/Attention.vue +269 -0
- package/components/Chart/index.vue +193 -0
- package/components/Container.vue +116 -0
- package/components/CopyButton.vue +98 -0
- package/components/Files/Card.vue +215 -0
- package/components/Files/Preview.vue +675 -0
- package/components/Files/Upload.vue +408 -0
- package/components/Files/UploadModal.vue +83 -0
- package/components/Forms/InputPin.vue +162 -0
- package/components/InfoToolbar.vue +54 -0
- package/components/Kanban/Standard.vue +588 -0
- package/components/Loader.vue +358 -0
- package/components/Modal/Base.vue +6 -0
- package/components/Modal/Confirm.vue +89 -0
- package/components/Modal/DeleteConfirm.vue +14 -31
- package/components/Modal/Form.vue +76 -0
- package/components/Modal/Host.vue +58 -0
- package/components/Modal/Prompt.vue +74 -0
- package/components/Modal.vue +119 -34
- package/components/Table/Standard.vue +198 -25
- package/components/Table/index.vue +169 -369
- package/components/Table/views/ViewCards.vue +74 -0
- package/components/Table/views/ViewDatabase.vue +236 -0
- package/components/Table/views/ViewTable.vue +299 -0
- package/components/Toast/Alert.vue +16 -111
- package/components/Toast/Container.vue +4 -29
- package/components/Toast/Notification.vue +8 -43
- package/components/Toast/Process.vue +8 -86
- package/components/Toast/Stack.vue +59 -0
- package/components/Toast/Standard.vue +115 -0
- package/components/Tree/Standard.vue +49 -4
- package/components/Workflow/Board.vue +356 -0
- package/components/Workflow/Builder.vue +454 -0
- package/components/Workflow/Form.vue +526 -0
- package/components/Workflow/Viewer.vue +225 -0
- package/components/Workflow/edges/TransitionEdge.vue +84 -0
- package/components/Workflow/nodes/StepNode.vue +88 -0
- package/composables/useClipboard.js +79 -0
- package/composables/useConfetti.js +194 -0
- package/composables/useFile.js +189 -0
- package/composables/useModal.js +71 -0
- package/composables/useToast.js +38 -65
- package/composables/useWorkflow/form.js +162 -0
- package/composables/useWorkflow/index.js +18 -0
- package/composables/useWorkflow/view.js +220 -0
- package/composables/useWorkflowLayout.js +162 -0
- package/css/tokens.css +10 -10
- package/nuxt.config.ts +1 -1
- package/package.json +13 -8
- package/stores/modal.js +48 -0
- 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
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>
|