search/app/components/entrelineas/EntrelineaDetail.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>