search/app/components/entrelineas/EntrelineaDetail.vue

203 lines
6.8 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="title" :toggle="false">
<template #leading>
<UButton
icon="i-lucide-x"
color="neutral"
variant="ghost"
class="-ms-1.5"
@click="emits('close')"
/>
</template>
<template #right>
<UButton
v-if="document.link"
:to="document.link"
target="_blank"
icon="i-lucide-external-link"
label="Ver en sitio"
color="primary"
variant="solid"
size="sm"
/>
</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.locale"
:label="String(document.locale).toUpperCase()"
color="neutral"
variant="subtle"
size="xs"
/>
<span v-if="document.page != null">Página {{ document.page }}</span>
</div>
</div>
<div class="flex-1 overflow-y-auto relative">
<!--
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-4 sm:p-6 pe-16 sm:pe-20 pb-24 flex flex-col gap-6">
<!-- Imagen desde ImageKit -->
<div
v-if="imageUrl"
class="rounded-lg overflow-hidden border border-default bg-elevated flex items-center justify-center"
>
<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"
class="prose prose-sm max-w-none dark:prose-invert"
v-html="bodyHtml"
/>
<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
v-if="collection"
class="sticky bottom-0 inset-x-0 h-0 z-20 pointer-events-none"
>
<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="solid"
size="xl"
:aria-label="isFav ? 'Quitar de mi lista' : 'Guardar en mi lista'"
class="absolute bottom-4 end-4 sm:bottom-6 sm:end-6 rounded-full shadow-lg shadow-black/15 dark:shadow-black/40 ring-1 ring-default pointer-events-auto transition-transform hover:scale-105 active:scale-95"
@click="onToggleFavorite"
/>
</UTooltip>
</div>
</div>
</UDashboardPanel>
</template>