215 lines
6.9 KiB
Vue
215 lines
6.9 KiB
Vue
<script setup lang="ts">
|
|
import { computed, watch } from 'vue'
|
|
import { useFavoritesStore } from '~/stores/favorites'
|
|
import { useHistoryStore } from '~/stores/history'
|
|
import type { SearchHit } from '~/types'
|
|
|
|
interface EntrelineaDoc {
|
|
id?: string
|
|
image?: string
|
|
link?: string
|
|
locale?: string
|
|
page?: number | string
|
|
text?: string
|
|
[key: string]: unknown
|
|
}
|
|
|
|
const props = defineProps<{
|
|
document: EntrelineaDoc
|
|
/** Identificador de la colección dentro del store de favoritos.
|
|
* Si no se pasa, no se renderiza el botón de favorito. */
|
|
collection?: string
|
|
/** Versión del campo `text` con las coincidencias ya envueltas en
|
|
* <mark class="search-match"> por Typesense. Si viene, se usa para
|
|
* mostrar el cuerpo con resaltado; si no, cae al `document.text`. */
|
|
highlightedText?: string | null
|
|
}>()
|
|
|
|
const emits = defineEmits<{ close: [] }>()
|
|
|
|
/** URL de ImageKit con preset `detail` (1800px, q90). La construcción
|
|
* centralizada vive en `~/utils/entrelineaImage` para no duplicarla. */
|
|
const imageUrl = computed<string | null>(() => {
|
|
return getEntrelineaImageUrl(props.document?.image, 'detail')
|
|
})
|
|
|
|
const title = computed(() => {
|
|
return (props.document?.id as string) || 'Entrelínea'
|
|
})
|
|
|
|
const bodyHtml = computed<string>(() => {
|
|
return props.highlightedText || props.document?.text || ''
|
|
})
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Favoritos */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
const favorites = useFavoritesStore()
|
|
const history = useHistoryStore()
|
|
const toast = useToast()
|
|
|
|
/** Adapta el documento de Typesense al shape SearchHit que pide el store
|
|
* de favoritos/historial (que se reusa entre todos los buscadores). */
|
|
function toSearchHit(doc: EntrelineaDoc): SearchHit {
|
|
const id = doc.id || ''
|
|
return {
|
|
_id: id,
|
|
id,
|
|
title: id || 'Entrelínea',
|
|
body: doc.text,
|
|
...doc
|
|
}
|
|
}
|
|
|
|
// Registrar la visita en el historial cada vez que cambia el documento mostrado.
|
|
// El store deduplica por (collection, id): si el usuario reabre la misma
|
|
// entrelínea, el item salta arriba y se actualiza visitedAt. No registramos
|
|
// si no nos pasaron `collection` o si el documento aún no tiene id.
|
|
watch(
|
|
() => [props.collection, props.document?.id] as const,
|
|
([collection, id]) => {
|
|
if (!collection || !id) return
|
|
history.visit(collection, toSearchHit(props.document))
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
const isFav = computed(() => {
|
|
if (!props.collection || !props.document?.id) return false
|
|
return favorites.isFavorite(props.collection, props.document.id)
|
|
})
|
|
|
|
function onToggleFavorite() {
|
|
if (!props.collection || !props.document?.id) return
|
|
const wasFav = isFav.value
|
|
favorites.toggle(props.collection, toSearchHit(props.document))
|
|
toast.add({
|
|
title: wasFav ? 'Eliminado de tu lista' : 'Guardado en tu lista',
|
|
description: props.document.id,
|
|
icon: wasFav ? 'i-lucide-bookmark-x' : 'i-lucide-bookmark-check',
|
|
color: wasFav ? 'neutral' : 'primary',
|
|
duration: 1800
|
|
})
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<UDashboardPanel id="entrelinea-detail">
|
|
<UDashboardNavbar :title="document.studies?.[0]?.title" :toggle="false">
|
|
<template #leading>
|
|
<UButton
|
|
icon="i-lucide-x"
|
|
color="neutral"
|
|
variant="ghost"
|
|
class="-ms-1.5"
|
|
@click="emits('close')"
|
|
/>
|
|
<UBadge
|
|
v-if="document.studies?.[0]?.date"
|
|
:label="String(document.studies[0].date).toUpperCase()"
|
|
color="primary"
|
|
variant="subtle"
|
|
/>
|
|
</template>
|
|
|
|
<template #right>
|
|
<UBadge
|
|
v-if="document.page"
|
|
:label="`Página ${document.page}`"
|
|
color="error"
|
|
variant="subtle"
|
|
size="sm"
|
|
/>
|
|
<UTooltip :text="isFav ? 'Quitar de mi lista' : 'Guardar en mi lista'">
|
|
<UButton
|
|
:icon="isFav ? 'i-lucide-bookmark-check' : 'i-lucide-bookmark-plus'"
|
|
:color="isFav ? 'primary' : 'neutral'"
|
|
variant="ghost"
|
|
:aria-label="isFav ? 'Quitar de mi lista' : 'Guardar en mi lista'"
|
|
@click="onToggleFavorite"
|
|
/>
|
|
</UTooltip>
|
|
</template>
|
|
</UDashboardNavbar>
|
|
|
|
<div class="flex flex-col sm:flex-row justify-between gap-2 p-4 sm:px-6 border-b border-default">
|
|
<div class="min-w-0 flex items-center gap-2 text-xs text-muted">
|
|
<UBadge
|
|
v-if="document.filter"
|
|
:label="String(document.filter).toUpperCase()"
|
|
color="neutral"
|
|
variant="subtle"
|
|
/>
|
|
<UBadge
|
|
v-if="document.type"
|
|
:label="String(document.type).toUpperCase()"
|
|
color="neutral"
|
|
variant="subtle"
|
|
/>
|
|
<UBadge
|
|
v-if="document.origin"
|
|
:label="String(document.origin).toUpperCase()"
|
|
color="neutral"
|
|
variant="soft"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex-1 overflow-y-auto relative bg-gray-100">
|
|
<!--
|
|
Padding extra a la derecha (pe-16/20) y al fondo (pb-24) para que el
|
|
FAB sticky de favorito NUNCA se superponga al contenido: el contenedor
|
|
reserva una "zona muerta" en la esquina inferior derecha donde el
|
|
botón puede flotar sin tapar texto ni la imagen.
|
|
-->
|
|
<div class="p-1 sm:p-4 bg-white rounded-lg shadow-md max-w-4xl m-4 sm:mx-auto sm:my-6">
|
|
<!-- Imagen desde ImageKit -->
|
|
<div
|
|
v-if="imageUrl"
|
|
class="rounded-lg overflow-hidden border border-default bg-elevated flex items-center justify-center mb-2"
|
|
>
|
|
<img
|
|
:src="imageUrl"
|
|
:alt="title"
|
|
loading="lazy"
|
|
class="max-w-full h-auto"
|
|
>
|
|
</div>
|
|
<div
|
|
v-else
|
|
class="rounded-lg border border-dashed border-default p-8 text-center text-xs text-dimmed"
|
|
>
|
|
<UIcon name="i-lucide-image-off" class="size-6 mb-1 mx-auto" />
|
|
<p>Sin imagen disponible</p>
|
|
</div>
|
|
|
|
<!-- Entrelínea (text) — usa la versión resaltada si Typesense la
|
|
devolvió, si no el HTML crudo del documento. -->
|
|
<article
|
|
v-if="bodyHtml"
|
|
>
|
|
<div
|
|
v-html="bodyHtml"
|
|
class="prose prose-sm max-w-none"
|
|
/>
|
|
<USeparator class="my-4" />
|
|
<p class="font-semibold text-sm">{{ document.origin }}</p>
|
|
</article>
|
|
<p v-else class="text-sm text-muted">
|
|
No hay entrelínea para esta coincidencia.
|
|
</p>
|
|
</div>
|
|
|
|
<!--
|
|
FAB de favorito — mismo patrón que en InboxActivity: contenedor
|
|
`sticky bottom-0 h-0` con `pointer-events-none` para que ocupe cero
|
|
altura en el flujo y el botón quede anclado al borde inferior
|
|
visible mientras el usuario hace scroll. El padding `pe-16/pb-24`
|
|
del contenedor de contenido garantiza que NUNCA tape texto ni imagen.
|
|
-->
|
|
|
|
</div>
|
|
</UDashboardPanel>
|
|
</template>
|