690 lines
22 KiB
Vue
690 lines
22 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref, watch } from 'vue'
|
|
import { storeToRefs } from 'pinia'
|
|
import { breakpointsTailwind } from '@vueuse/core'
|
|
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
import type { SearchHit } from '~/types'
|
|
import { useHistoryStore, type HistoryItem, HISTORY_LIMIT } from '~/stores/history'
|
|
import InboxActivity from '~/components/inbox/InboxActivity.vue'
|
|
import EntrelineaDetail from '~/components/entrelineas/EntrelineaDetail.vue'
|
|
|
|
/**
|
|
* Pantalla de historial — gemela de `pages/favoritos.vue`.
|
|
*
|
|
* Por qué prácticamente idéntica a Mi lista: el usuario espera la misma
|
|
* ergonomía (lista a la izquierda, panel de detalle a la derecha, búsqueda
|
|
* arriba, mismas acciones de export/import). Si las dos pantallas divergen
|
|
* en patrones, la curva de aprendizaje se duplica sin razón.
|
|
*
|
|
* Diferencias respecto a Mi lista:
|
|
* - El store es `useHistoryStore` y los items tienen `visitedAt` (no `addedAt`).
|
|
* - Cada fila muestra "Visto: <fecha>" para que se entienda que el orden
|
|
* de la lista es por última visita, no por fecha de creación.
|
|
* - El registro lo hace el detalle al abrirse (ver `InboxActivity.vue` y
|
|
* `EntrelineaDetail.vue` — ambos llaman a `history.visit(...)`).
|
|
*/
|
|
|
|
const history = useHistoryStore()
|
|
const { items: histItems, total: histTotal, collections: histCollections } = storeToRefs(history)
|
|
const toast = useToast()
|
|
|
|
const ENTRELINEAS_COLLECTION = 'entrelineas'
|
|
|
|
const COLLECTION_LABELS: Record<string, string> = {
|
|
activities: 'Actividades',
|
|
conferences: 'Conferencias',
|
|
entrelineas: 'Entre Líneas'
|
|
}
|
|
|
|
function labelFor(c: string): string {
|
|
return COLLECTION_LABELS[c] || c.charAt(0).toUpperCase() + c.slice(1)
|
|
}
|
|
|
|
// Filtros: pestaña por colección o "todos".
|
|
const activeCollection = ref<string>('all')
|
|
|
|
// Reset del filtro si la colección activa se queda sin elementos.
|
|
watch(histCollections, (cols) => {
|
|
if (activeCollection.value !== 'all' && !cols.includes(activeCollection.value)) {
|
|
activeCollection.value = 'all'
|
|
}
|
|
})
|
|
|
|
const tabs = computed(() => {
|
|
const items = [{ value: 'all', label: `Todos (${histTotal.value})`, icon: 'i-lucide-history' }]
|
|
for (const c of histCollections.value) {
|
|
const count = histItems.value.filter(it => it.collection === c).length
|
|
items.push({
|
|
value: c,
|
|
label: `${labelFor(c)} (${count})`,
|
|
icon: c === 'activities'
|
|
? 'i-lucide-calendar-days'
|
|
: c === 'conferences'
|
|
? 'i-lucide-mic'
|
|
: c === 'entrelineas'
|
|
? 'i-lucide-book-open'
|
|
: 'i-lucide-folder'
|
|
})
|
|
}
|
|
return items
|
|
})
|
|
|
|
const localQuery = ref('')
|
|
|
|
const filteredItems = computed<HistoryItem[]>(() => {
|
|
let list = histItems.value
|
|
if (activeCollection.value !== 'all') {
|
|
list = list.filter(it => it.collection === activeCollection.value)
|
|
}
|
|
const q = localQuery.value.trim().toLowerCase()
|
|
if (q) {
|
|
list = list.filter((it) => {
|
|
const t = (it.hit?.title || '').toLowerCase()
|
|
return t.includes(q)
|
|
})
|
|
}
|
|
return list
|
|
})
|
|
|
|
// ---- Selección y panel de detalle --------------------------------------
|
|
|
|
const selected = ref<HistoryItem | null>(null)
|
|
|
|
const selectedHit = computed<SearchHit | null>(() => selected.value?.hit ?? null)
|
|
const selectedCollection = computed<string | undefined>(() => selected.value?.collection)
|
|
|
|
const isEntrelinea = computed(() => selectedCollection.value === ENTRELINEAS_COLLECTION)
|
|
|
|
/** El SearchHit guardado de una entrelínea conserva todos los campos del
|
|
* documento original. Lo casteamos a un objeto plano para EntrelineaDetail. */
|
|
const selectedEntrelineaDoc = computed<Record<string, unknown> | null>(() => {
|
|
if (!isEntrelinea.value || !selectedHit.value) return null
|
|
return selectedHit.value as unknown as Record<string, unknown>
|
|
})
|
|
|
|
const isPanelOpen = computed({
|
|
get() { return !!selected.value },
|
|
set(v: boolean) { if (!v) selected.value = null }
|
|
})
|
|
|
|
// Si el item seleccionado desaparece del historial (eliminado desde otra
|
|
// pestaña o desde aquí mismo), cerramos el panel de detalle.
|
|
watch(histItems, (items) => {
|
|
if (!selected.value) return
|
|
const stillThere = items.find(it =>
|
|
it.collection === selected.value!.collection
|
|
&& String(it._id) === String(selected.value!._id))
|
|
if (!stillThere) selected.value = null
|
|
})
|
|
|
|
const breakpoints = useBreakpoints(breakpointsTailwind)
|
|
const isMobile = breakpoints.smaller('lg')
|
|
|
|
// ---- Helpers de fila ---------------------------------------------------
|
|
|
|
function safeDate(hit: SearchHit) {
|
|
const d = hit.date ?? hit.isodate
|
|
if (!d) return ''
|
|
const ts = typeof d === 'string' ? new Date(d).getTime() : (d as number) * 1000
|
|
if (!Number.isFinite(ts)) return ''
|
|
return formatDate(ts / 1000)
|
|
}
|
|
|
|
function hasDate(hit: SearchHit) {
|
|
return hit.date != null || hit.isodate != null
|
|
}
|
|
|
|
/** Etiqueta corta y humana para `visitedAt`. Tres tramos:
|
|
* - menos de un minuto: "ahora"
|
|
* - mismo día: "hoy HH:mm"
|
|
* - cualquier otro: fecha completa
|
|
* Mejor que "hace 3 horas" porque a) no requiere recomputar cada minuto,
|
|
* b) deja claro de un vistazo si la entrada es del día o de antes. */
|
|
function formatVisited(ts: number): string {
|
|
if (!Number.isFinite(ts)) return ''
|
|
const now = Date.now()
|
|
const diffMs = now - ts
|
|
if (diffMs < 60_000) return 'hace un momento'
|
|
|
|
const d = new Date(ts)
|
|
const today = new Date()
|
|
const sameDay = d.getFullYear() === today.getFullYear()
|
|
&& d.getMonth() === today.getMonth()
|
|
&& d.getDate() === today.getDate()
|
|
|
|
const hh = String(d.getHours()).padStart(2, '0')
|
|
const mm = String(d.getMinutes()).padStart(2, '0')
|
|
if (sameDay) return `hoy ${hh}:${mm}`
|
|
|
|
const yesterday = new Date(today)
|
|
yesterday.setDate(yesterday.getDate() - 1)
|
|
const sameYesterday = d.getFullYear() === yesterday.getFullYear()
|
|
&& d.getMonth() === yesterday.getMonth()
|
|
&& d.getDate() === yesterday.getDate()
|
|
if (sameYesterday) return `ayer ${hh}:${mm}`
|
|
|
|
const day = String(d.getDate()).padStart(2, '0')
|
|
const month = String(d.getMonth() + 1).padStart(2, '0')
|
|
return `${day}/${month}/${d.getFullYear()} ${hh}:${mm}`
|
|
}
|
|
|
|
function removeItem(it: HistoryItem, ev: Event) {
|
|
ev.stopPropagation()
|
|
history.remove(it.collection, it._id)
|
|
toast.add({
|
|
title: 'Eliminado del historial',
|
|
description: it.hit?.title,
|
|
icon: 'i-lucide-trash-2',
|
|
color: 'neutral',
|
|
duration: 1500
|
|
})
|
|
}
|
|
|
|
// ---- Exportar / Compartir ----------------------------------------------
|
|
|
|
async function copyHistory() {
|
|
if (!histTotal.value) return
|
|
const ok = await history.copyJsonToClipboard()
|
|
toast.add({
|
|
title: ok ? 'Historial copiado al portapapeles' : 'No se pudo copiar',
|
|
description: ok ? 'Pégalo en otro lugar para guardarlo.' : 'Tu navegador bloqueó el acceso al portapapeles.',
|
|
icon: ok ? 'i-lucide-clipboard-check' : 'i-lucide-triangle-alert',
|
|
color: ok ? 'primary' : 'error',
|
|
duration: 2400
|
|
})
|
|
}
|
|
|
|
function downloadHistory() {
|
|
if (!histTotal.value) return
|
|
history.downloadJson()
|
|
toast.add({
|
|
title: 'Descargando tu historial',
|
|
icon: 'i-lucide-download',
|
|
color: 'primary',
|
|
duration: 1500
|
|
})
|
|
}
|
|
|
|
const showClearConfirm = ref(false)
|
|
function confirmClear() {
|
|
history.clear()
|
|
showClearConfirm.value = false
|
|
toast.add({
|
|
title: 'Historial vaciado',
|
|
icon: 'i-lucide-trash-2',
|
|
color: 'neutral',
|
|
duration: 1500
|
|
})
|
|
}
|
|
|
|
// ---- Importar ----------------------------------------------------------
|
|
|
|
const showImport = ref(false)
|
|
const importMode = ref<'merge' | 'replace'>('merge')
|
|
const importText = ref('')
|
|
const importFileInput = ref<HTMLInputElement | null>(null)
|
|
const importing = ref(false)
|
|
|
|
function openImport() {
|
|
importText.value = ''
|
|
importMode.value = 'merge'
|
|
showImport.value = true
|
|
}
|
|
|
|
function pickImportFile() {
|
|
importFileInput.value?.click()
|
|
}
|
|
|
|
async function onImportFile(ev: Event) {
|
|
const input = ev.target as HTMLInputElement
|
|
const file = input.files?.[0]
|
|
if (!file) return
|
|
importing.value = true
|
|
try {
|
|
const result = await history.importFromFile(file, importMode.value)
|
|
toast.add({
|
|
title: `Importados ${result.added}`,
|
|
description: result.skipped
|
|
? `${result.skipped} ya estaban en tu historial${result.invalid ? `, ${result.invalid} inválidos` : ''}.`
|
|
: result.invalid
|
|
? `${result.invalid} inválidos.`
|
|
: 'Tu historial se actualizó.',
|
|
icon: 'i-lucide-history',
|
|
color: 'primary',
|
|
duration: 2800
|
|
})
|
|
showImport.value = false
|
|
} catch (e) {
|
|
toast.add({
|
|
title: 'No se pudo importar',
|
|
description: (e as Error)?.message || 'Archivo inválido.',
|
|
icon: 'i-lucide-triangle-alert',
|
|
color: 'error',
|
|
duration: 3200
|
|
})
|
|
} finally {
|
|
importing.value = false
|
|
input.value = ''
|
|
}
|
|
}
|
|
|
|
function applyTextImport() {
|
|
if (!importText.value.trim()) return
|
|
importing.value = true
|
|
try {
|
|
const result = history.importFromJson(importText.value, importMode.value)
|
|
toast.add({
|
|
title: `Importados ${result.added}`,
|
|
description: result.skipped
|
|
? `${result.skipped} ya estaban en tu historial${result.invalid ? `, ${result.invalid} inválidos` : ''}.`
|
|
: result.invalid
|
|
? `${result.invalid} inválidos.`
|
|
: 'Tu historial se actualizó.',
|
|
icon: 'i-lucide-history',
|
|
color: 'primary',
|
|
duration: 2800
|
|
})
|
|
showImport.value = false
|
|
} catch (e) {
|
|
toast.add({
|
|
title: 'No se pudo importar',
|
|
description: (e as Error)?.message || 'JSON inválido.',
|
|
icon: 'i-lucide-triangle-alert',
|
|
color: 'error',
|
|
duration: 3200
|
|
})
|
|
} finally {
|
|
importing.value = false
|
|
}
|
|
}
|
|
|
|
// Menú compacto para móvil — los iconos sueltos del navbar son ambiguos sin
|
|
// tooltip (que no aparece en touch), así que en pantallas chicas los
|
|
// agrupamos en un dropdown con etiquetas explícitas.
|
|
const mobileActions = computed<DropdownMenuItem[][]>(() => [[
|
|
{
|
|
label: 'Importar historial',
|
|
icon: 'i-lucide-upload',
|
|
onSelect: () => openImport()
|
|
},
|
|
{
|
|
label: 'Copiar al portapapeles',
|
|
icon: 'i-lucide-clipboard-copy',
|
|
disabled: !histTotal.value,
|
|
onSelect: () => copyHistory()
|
|
},
|
|
{
|
|
label: 'Descargar (.json)',
|
|
icon: 'i-lucide-download',
|
|
disabled: !histTotal.value,
|
|
onSelect: () => downloadHistory()
|
|
}
|
|
], [
|
|
{
|
|
label: 'Vaciar historial',
|
|
icon: 'i-lucide-trash-2',
|
|
color: 'error',
|
|
disabled: !histTotal.value,
|
|
onSelect: () => { showClearConfirm.value = true }
|
|
}
|
|
]])
|
|
|
|
/** Aviso de cercanía al tope. El store recorta a `HISTORY_LIMIT`; mostramos
|
|
* un hint discreto cuando estamos a menos del 10% del tope para que el
|
|
* usuario sepa por qué las entradas antiguas pueden desaparecer. */
|
|
const nearLimit = computed(() => histTotal.value >= Math.floor(HISTORY_LIMIT * 0.9))
|
|
</script>
|
|
|
|
<template>
|
|
<UDashboardPanel
|
|
id="history-list"
|
|
:default-size="32"
|
|
:min-size="24"
|
|
:max-size="44"
|
|
resizable
|
|
>
|
|
<UDashboardNavbar title="Historial">
|
|
<template #leading>
|
|
<UDashboardSidebarCollapse />
|
|
</template>
|
|
|
|
<template #trailing>
|
|
<UBadge v-if="histTotal" :label="histTotal" variant="subtle" />
|
|
</template>
|
|
|
|
<template #right>
|
|
<!-- Desktop: iconos individuales con tooltip -->
|
|
<div class="hidden sm:flex items-center gap-0.5">
|
|
<UTooltip text="Importar historial">
|
|
<UButton
|
|
icon="i-lucide-upload"
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="sm"
|
|
aria-label="Importar historial"
|
|
@click="openImport"
|
|
/>
|
|
</UTooltip>
|
|
<UTooltip text="Copiar historial al portapapeles">
|
|
<UButton
|
|
icon="i-lucide-clipboard-copy"
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="sm"
|
|
:disabled="!histTotal"
|
|
aria-label="Copiar historial"
|
|
@click="copyHistory"
|
|
/>
|
|
</UTooltip>
|
|
<UTooltip text="Descargar historial (.json)">
|
|
<UButton
|
|
icon="i-lucide-download"
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="sm"
|
|
:disabled="!histTotal"
|
|
aria-label="Descargar historial"
|
|
@click="downloadHistory"
|
|
/>
|
|
</UTooltip>
|
|
<UTooltip text="Vaciar historial">
|
|
<UButton
|
|
icon="i-lucide-trash-2"
|
|
color="error"
|
|
variant="ghost"
|
|
size="sm"
|
|
:disabled="!histTotal"
|
|
aria-label="Vaciar historial"
|
|
@click="showClearConfirm = true"
|
|
/>
|
|
</UTooltip>
|
|
</div>
|
|
|
|
<!-- Móvil: un único botón con menú etiquetado -->
|
|
<UDropdownMenu :items="mobileActions" :content="{ align: 'end' }" class="sm:hidden">
|
|
<UButton
|
|
icon="i-lucide-ellipsis-vertical"
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="sm"
|
|
aria-label="Más acciones"
|
|
/>
|
|
</UDropdownMenu>
|
|
</template>
|
|
</UDashboardNavbar>
|
|
|
|
<div class="px-4 sm:px-6 py-3 border-b border-default flex flex-col gap-2.5">
|
|
<UInput
|
|
v-model="localQuery"
|
|
icon="i-lucide-search"
|
|
placeholder="Filtrar historial por título..."
|
|
size="md"
|
|
class="w-full"
|
|
/>
|
|
<div class="-mx-4 sm:mx-0 px-4 sm:px-0 flex gap-1.5 overflow-x-auto pb-0.5 scrollbar-thin">
|
|
<UButton
|
|
v-for="t in tabs"
|
|
:key="t.value"
|
|
:icon="t.icon"
|
|
:label="t.label"
|
|
size="xs"
|
|
class="shrink-0"
|
|
:color="activeCollection === t.value ? 'primary' : 'neutral'"
|
|
:variant="activeCollection === t.value ? 'soft' : 'ghost'"
|
|
@click="activeCollection = t.value"
|
|
/>
|
|
</div>
|
|
<p
|
|
v-if="nearLimit"
|
|
class="flex items-center gap-1 text-[11px] text-warning"
|
|
>
|
|
<UIcon name="i-lucide-info" class="size-3" />
|
|
El historial guarda hasta {{ HISTORY_LIMIT }} entradas. Al superarlo, las más antiguas se eliminan automáticamente.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="overflow-y-auto divide-y divide-default flex-1">
|
|
<div
|
|
v-if="!filteredItems.length"
|
|
class="flex flex-col items-center justify-center gap-3 py-12 sm:py-16 text-dimmed text-sm px-6 text-center"
|
|
>
|
|
<UIcon name="i-lucide-history" class="size-10" />
|
|
<template v-if="!histTotal">
|
|
<p class="font-medium text-toned">
|
|
Tu historial está vacío
|
|
</p>
|
|
<p class="max-w-xs">
|
|
Cada vez que abras el detalle de un resultado, se guardará aquí
|
|
automáticamente.
|
|
</p>
|
|
<UButton
|
|
icon="i-lucide-upload"
|
|
label="Importar un historial compartido"
|
|
variant="soft"
|
|
size="sm"
|
|
class="mt-1"
|
|
@click="openImport"
|
|
/>
|
|
</template>
|
|
<p v-else>
|
|
No hay coincidencias en tu historial para este filtro.
|
|
</p>
|
|
</div>
|
|
|
|
<div
|
|
v-for="(it, index) in filteredItems"
|
|
:key="`${it.collection}-${it._id}-${index}`"
|
|
>
|
|
<div
|
|
class="p-4 sm:px-6 text-sm cursor-pointer border-l-2 transition-colors text-toned"
|
|
:class="selected && selected.collection === it.collection && String(selected._id) === String(it._id)
|
|
? 'border-primary bg-primary/10'
|
|
: 'border-transparent hover:border-primary hover:bg-primary/5'"
|
|
@click="selected = it"
|
|
>
|
|
<div class="flex items-start justify-between gap-2 mb-1">
|
|
<div class="min-w-0 flex-1">
|
|
<UBadge
|
|
:label="labelFor(it.collection)"
|
|
size="xs"
|
|
variant="subtle"
|
|
color="neutral"
|
|
class="mb-1 capitalize"
|
|
/>
|
|
<div class="text-sm font-semibold line-clamp-2">
|
|
{{ it.hit?.title || 'Sin título' }}
|
|
</div>
|
|
</div>
|
|
<UButton
|
|
icon="i-lucide-trash-2"
|
|
color="neutral"
|
|
variant="ghost"
|
|
size="sm"
|
|
class="-mt-1 -me-1 shrink-0"
|
|
aria-label="Eliminar del historial"
|
|
@click="(ev: MouseEvent) => removeItem(it, ev)"
|
|
/>
|
|
</div>
|
|
|
|
<p class="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-muted">
|
|
<span class="inline-flex items-center gap-1 text-[11px] text-dimmed">
|
|
<UIcon name="i-lucide-clock" class="size-3" />
|
|
Visto: {{ formatVisited(it.visitedAt) }}
|
|
</span>
|
|
<USeparator v-if="hasDate(it.hit)" orientation="vertical" class="h-3 hidden sm:block" />
|
|
<span v-if="hasDate(it.hit)">{{ safeDate(it.hit) }}</span>
|
|
<USeparator v-if="formatLocation(it.hit)" orientation="vertical" class="h-3 hidden sm:block" />
|
|
<span class="truncate">{{ formatLocation(it.hit) }}</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</UDashboardPanel>
|
|
|
|
<!-- Entrelíneas → componente propio (imagen ImageKit + texto). -->
|
|
<EntrelineaDetail
|
|
v-if="selected && !isMobile && isEntrelinea"
|
|
:document="selectedEntrelineaDoc!"
|
|
:collection="selectedCollection"
|
|
@close="selected = null"
|
|
/>
|
|
<!-- Resto (actividades, conferencias) → InboxActivity. -->
|
|
<InboxActivity
|
|
v-else-if="selected && !isMobile"
|
|
:activity="selectedHit!"
|
|
:collection="selectedCollection"
|
|
@close="selected = null"
|
|
/>
|
|
<div v-else-if="!selected" class="hidden lg:flex flex-1 items-center justify-center">
|
|
<div class="flex flex-col items-center gap-2 text-dimmed">
|
|
<UIcon name="i-lucide-history" class="size-16" />
|
|
<p class="text-sm">
|
|
Selecciona un elemento para ver el detalle
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<ClientOnly>
|
|
<USlideover v-if="isMobile" v-model:open="isPanelOpen">
|
|
<template #content>
|
|
<EntrelineaDetail
|
|
v-if="selected && isEntrelinea"
|
|
:document="selectedEntrelineaDoc!"
|
|
:collection="selectedCollection"
|
|
@close="selected = null"
|
|
/>
|
|
<InboxActivity
|
|
v-else-if="selected"
|
|
:activity="selectedHit!"
|
|
:collection="selectedCollection"
|
|
@close="selected = null"
|
|
/>
|
|
</template>
|
|
</USlideover>
|
|
</ClientOnly>
|
|
|
|
<!-- Confirmación de vaciado ------------------------------------------- -->
|
|
<UModal v-model:open="showClearConfirm" title="Vaciar historial">
|
|
<template #body>
|
|
<p class="text-sm text-toned">
|
|
Esto eliminará las <strong>{{ histTotal }}</strong> entradas guardadas en este dispositivo.
|
|
La acción no se puede deshacer (a menos que tengas un export previo).
|
|
Tu lista de favoritos no se ve afectada.
|
|
</p>
|
|
</template>
|
|
<template #footer>
|
|
<div class="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 w-full">
|
|
<UButton
|
|
color="neutral"
|
|
variant="ghost"
|
|
label="Cancelar"
|
|
block
|
|
class="sm:w-auto"
|
|
@click="showClearConfirm = false"
|
|
/>
|
|
<UButton
|
|
color="error"
|
|
label="Vaciar historial"
|
|
icon="i-lucide-trash-2"
|
|
block
|
|
class="sm:w-auto"
|
|
@click="confirmClear"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</UModal>
|
|
|
|
<!-- Importar ---------------------------------------------------------- -->
|
|
<UModal v-model:open="showImport" title="Importar historial" :ui="{ footer: 'justify-end' }">
|
|
<template #body>
|
|
<div class="flex flex-col gap-4">
|
|
<p class="text-sm text-muted">
|
|
Carga un historial exportado desde un archivo
|
|
<code class="px-1 rounded bg-elevated text-toned font-mono text-xs">.json</code>
|
|
o pega su contenido.
|
|
</p>
|
|
|
|
<UButton
|
|
color="primary"
|
|
variant="soft"
|
|
icon="i-lucide-file-up"
|
|
label="Subir archivo .json"
|
|
size="md"
|
|
block
|
|
@click="pickImportFile"
|
|
/>
|
|
|
|
<USeparator label="o pega el contenido" />
|
|
|
|
<div>
|
|
<p class="text-xs font-medium text-toned mb-1.5">
|
|
¿Qué hacer si ya tienes historial?
|
|
</p>
|
|
<div class="grid grid-cols-2 gap-1.5">
|
|
<UButton
|
|
label="Combinar"
|
|
icon="i-lucide-merge"
|
|
size="sm"
|
|
block
|
|
:color="importMode === 'merge' ? 'primary' : 'neutral'"
|
|
:variant="importMode === 'merge' ? 'soft' : 'outline'"
|
|
@click="importMode = 'merge'"
|
|
/>
|
|
<UButton
|
|
label="Reemplazar"
|
|
icon="i-lucide-replace"
|
|
size="sm"
|
|
block
|
|
:color="importMode === 'replace' ? 'primary' : 'neutral'"
|
|
:variant="importMode === 'replace' ? 'soft' : 'outline'"
|
|
@click="importMode = 'replace'"
|
|
/>
|
|
</div>
|
|
<p class="text-xs text-dimmed mt-1.5">
|
|
{{ importMode === 'merge'
|
|
? 'Conserva el historial actual y añade las entradas nuevas.'
|
|
: 'Borra el historial actual y lo sustituye por el importado.' }}
|
|
</p>
|
|
</div>
|
|
|
|
<UTextarea
|
|
v-model="importText"
|
|
:rows="6"
|
|
placeholder='{"version":1,"items":[ ... ]}'
|
|
class="w-full font-mono text-xs"
|
|
/>
|
|
|
|
<input
|
|
ref="importFileInput"
|
|
type="file"
|
|
accept="application/json,.json"
|
|
class="hidden"
|
|
@change="onImportFile"
|
|
>
|
|
</div>
|
|
</template>
|
|
<template #footer>
|
|
<div class="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 w-full">
|
|
<UButton
|
|
color="neutral"
|
|
variant="ghost"
|
|
label="Cancelar"
|
|
block
|
|
class="sm:w-auto"
|
|
@click="showImport = false"
|
|
/>
|
|
<UButton
|
|
color="primary"
|
|
icon="i-lucide-history"
|
|
label="Importar desde texto"
|
|
:loading="importing"
|
|
:disabled="!importText.trim() || importing"
|
|
block
|
|
class="sm:w-auto"
|
|
@click="applyTextImport"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</UModal>
|
|
</template>
|