Compare commits
2 Commits
08be5faf14
...
ff59510ade
| Author | SHA1 | Date |
|---|---|---|
|
|
ff59510ade | |
|
|
e331de5f66 |
|
|
@ -1,71 +1,69 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useFavoritesStore } from '~/stores/favorites'
|
import { useFavoritesStore } from '~/stores/favorites'
|
||||||
import { useHistoryStore } from '~/stores/history'
|
import { useHistoryStore } from '~/stores/history'
|
||||||
import type { SearchHit } from '~/types'
|
import type { SearchHit } from '~/types'
|
||||||
|
|
||||||
|
interface Study {
|
||||||
|
id?: number
|
||||||
|
title?: string
|
||||||
|
date?: string
|
||||||
|
place?: string
|
||||||
|
link?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface EntrelineaDoc {
|
interface EntrelineaDoc {
|
||||||
id?: string
|
id?: string
|
||||||
image?: string
|
image?: string
|
||||||
link?: string
|
link?: string
|
||||||
locale?: string
|
locale?: string
|
||||||
|
origin?: string
|
||||||
page?: number | string
|
page?: number | string
|
||||||
text?: string
|
text?: string
|
||||||
|
studies?: Study[]
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
document: EntrelineaDoc
|
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
|
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
|
highlightedText?: string | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emits = defineEmits<{ close: [] }>()
|
const emits = defineEmits<{ close: [] }>()
|
||||||
|
|
||||||
/** URL de ImageKit con preset `detail` (1800px, q90). La construcción
|
const showStudies = ref(false)
|
||||||
* centralizada vive en `~/utils/entrelineaImage` para no duplicarla. */
|
|
||||||
const imageUrl = computed<string | null>(() => {
|
|
||||||
return getEntrelineaImageUrl(props.document?.image, 'detail')
|
|
||||||
})
|
|
||||||
|
|
||||||
const title = computed(() => {
|
// Volver al detalle cuando cambia el documento
|
||||||
return (props.document?.id as string) || 'Entrelínea'
|
watch(() => props.document?.id, () => { showStudies.value = false })
|
||||||
})
|
|
||||||
|
|
||||||
const bodyHtml = computed<string>(() => {
|
const imageUrl = computed<string | null>(() =>
|
||||||
return props.highlightedText || props.document?.text || ''
|
getEntrelineaImageUrl(props.document?.image, 'detail')
|
||||||
})
|
)
|
||||||
|
|
||||||
|
const origin = computed(() => (props.document?.origin as string) || '')
|
||||||
|
|
||||||
|
const title = computed(() =>
|
||||||
|
origin.value || (props.document?.id as string) || 'Entrelínea'
|
||||||
|
)
|
||||||
|
|
||||||
|
const bodyHtml = computed<string>(() =>
|
||||||
|
props.highlightedText || props.document?.text || ''
|
||||||
|
)
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
/* Favoritos */
|
/* Favoritos / Historial */
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
const favorites = useFavoritesStore()
|
const favorites = useFavoritesStore()
|
||||||
const history = useHistoryStore()
|
const history = useHistoryStore()
|
||||||
const toast = useToast()
|
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 {
|
function toSearchHit(doc: EntrelineaDoc): SearchHit {
|
||||||
const id = doc.id || ''
|
const id = doc.id || ''
|
||||||
return {
|
return { _id: id, id, title: id || 'Entrelínea', body: doc.text, ...doc }
|
||||||
_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(
|
watch(
|
||||||
() => [props.collection, props.document?.id] as const,
|
() => [props.collection, props.document?.id] as const,
|
||||||
([collection, id]) => {
|
([collection, id]) => {
|
||||||
|
|
@ -96,119 +94,221 @@ function onToggleFavorite() {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UDashboardPanel id="entrelinea-detail">
|
<UDashboardPanel id="entrelinea-detail">
|
||||||
<UDashboardNavbar :title="document.studies?.[0]?.title" :toggle="false">
|
<UDashboardNavbar
|
||||||
|
:toggle="false"
|
||||||
|
:ui="{
|
||||||
|
root: 'h-auto min-h-(--ui-header-height) py-2 items-start sm:items-center',
|
||||||
|
left: 'flex-1 min-w-0 py-1',
|
||||||
|
title: 'flex items-center gap-1.5 font-semibold text-highlighted min-w-0 whitespace-normal'
|
||||||
|
}"
|
||||||
|
>
|
||||||
<template #leading>
|
<template #leading>
|
||||||
|
<!-- En sub-pantalla de estudios: volver al detalle -->
|
||||||
<UButton
|
<UButton
|
||||||
icon="i-lucide-x"
|
v-if="showStudies"
|
||||||
|
icon="i-lucide-arrow-left"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
class="-ms-1.5"
|
||||||
|
@click="showStudies = false"
|
||||||
|
/>
|
||||||
|
<!-- En detalle: cerrar el panel -->
|
||||||
|
<UButton
|
||||||
|
v-else
|
||||||
|
icon="i-lucide-arrow-left"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="-ms-1.5"
|
class="-ms-1.5"
|
||||||
@click="emits('close')"
|
@click="emits('close')"
|
||||||
/>
|
/>
|
||||||
<UBadge
|
</template>
|
||||||
v-if="document.studies?.[0]?.date"
|
|
||||||
:label="String(document.studies[0].date).toUpperCase()"
|
<template #title>
|
||||||
color="primary"
|
<span
|
||||||
variant="subtle"
|
v-if="showStudies"
|
||||||
/>
|
class="font-semibold text-sm block"
|
||||||
|
>
|
||||||
|
Estudios relacionados
|
||||||
|
<span class="text-muted font-normal">({{ document.studies?.length }})</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="font-semibold text-sm min-w-0 max-w-full block whitespace-normal leading-snug pr-1 [overflow-wrap:anywhere]"
|
||||||
|
:title="title"
|
||||||
|
>
|
||||||
|
{{ title }}
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #right>
|
<template #right>
|
||||||
<UBadge
|
<template v-if="!showStudies">
|
||||||
v-if="document.page"
|
<UTooltip
|
||||||
:label="`Página ${document.page}`"
|
v-if="document.studies?.length"
|
||||||
color="error"
|
:text="`Estudios relacionados (${document.studies.length})`"
|
||||||
variant="subtle"
|
>
|
||||||
size="sm"
|
<UChip
|
||||||
/>
|
:text="document.studies.length"
|
||||||
<UTooltip :text="isFav ? 'Quitar de mi lista' : 'Guardar en mi lista'">
|
color="primary"
|
||||||
<UButton
|
size="sm"
|
||||||
:icon="isFav ? 'i-lucide-bookmark-check' : 'i-lucide-bookmark-plus'"
|
:show="!!document.studies?.length"
|
||||||
:color="isFav ? 'primary' : 'neutral'"
|
>
|
||||||
variant="ghost"
|
<UButton
|
||||||
:aria-label="isFav ? 'Quitar de mi lista' : 'Guardar en mi lista'"
|
icon="i-lucide-book-open"
|
||||||
@click="onToggleFavorite"
|
color="neutral"
|
||||||
/>
|
variant="ghost"
|
||||||
</UTooltip>
|
aria-label="Estudios relacionados"
|
||||||
|
@click="showStudies = true"
|
||||||
|
/>
|
||||||
|
</UChip>
|
||||||
|
</UTooltip>
|
||||||
|
<UTooltip v-if="collection" :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>
|
||||||
</template>
|
</template>
|
||||||
</UDashboardNavbar>
|
</UDashboardNavbar>
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row justify-between gap-2 p-4 sm:px-6 border-b border-default">
|
<div
|
||||||
<div class="min-w-0 flex items-center gap-2 text-xs text-muted">
|
v-if="!showStudies"
|
||||||
|
class="flex items-start justify-between gap-3 px-4 sm:px-6 py-2.5 border-b border-default bg-elevated/40 min-w-0 w-full"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 min-w-0 flex-1">
|
||||||
<UBadge
|
<UBadge
|
||||||
v-if="document.filter"
|
v-if="document.locale"
|
||||||
:label="String(document.filter).toUpperCase()"
|
:label="String(document.locale).toUpperCase()"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
class="shrink-0"
|
||||||
/>
|
/>
|
||||||
<UBadge
|
<span
|
||||||
v-if="document.type"
|
v-if="origin && origin !== document.id"
|
||||||
:label="String(document.type).toUpperCase()"
|
class="inline-flex items-start rounded-md bg-primary/10 text-primary text-xs font-medium px-2 py-0.5 min-w-0 max-w-full whitespace-normal leading-snug [overflow-wrap:anywhere]"
|
||||||
color="neutral"
|
:title="origin"
|
||||||
variant="subtle"
|
>
|
||||||
/>
|
{{ origin }}
|
||||||
<UBadge
|
</span>
|
||||||
v-if="document.origin"
|
<span v-if="document.page != null" class="text-xs text-muted flex items-center gap-1 shrink-0">
|
||||||
:label="String(document.origin).toUpperCase()"
|
<UIcon name="i-lucide-file-text" class="size-3" />
|
||||||
color="neutral"
|
Pág. {{ document.page }}
|
||||||
variant="soft"
|
</span>
|
||||||
/>
|
<span
|
||||||
|
v-if="document.id && document.id !== origin"
|
||||||
|
class="text-xs text-dimmed font-mono truncate max-w-[160px] shrink-0"
|
||||||
|
:title="String(document.id)"
|
||||||
|
>
|
||||||
|
{{ document.id }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<UButton
|
||||||
|
v-if="document.link"
|
||||||
|
:to="document.link"
|
||||||
|
target="_blank"
|
||||||
|
icon="i-lucide-external-link"
|
||||||
|
label="Ver en sitio"
|
||||||
|
color="primary"
|
||||||
|
variant="soft"
|
||||||
|
size="xs"
|
||||||
|
class="shrink-0"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto relative bg-gray-100">
|
<!-- ------------------------------------------------------------------ -->
|
||||||
<!--
|
<!-- VISTA PRINCIPAL: imagen + texto + accesos -->
|
||||||
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
|
<div v-if="!showStudies" class="flex-1 overflow-y-auto">
|
||||||
reserva una "zona muerta" en la esquina inferior derecha donde el
|
<div class="p-4 sm:p-6 pb-10 flex flex-col gap-6">
|
||||||
botón puede flotar sin tapar texto ni la imagen.
|
|
||||||
-->
|
<!-- 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
|
<div
|
||||||
v-if="imageUrl"
|
v-if="imageUrl"
|
||||||
class="rounded-lg overflow-hidden border border-default bg-elevated flex items-center justify-center mb-2"
|
class="rounded-xl overflow-hidden border border-default shadow-sm bg-elevated flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<img
|
<img :src="imageUrl" :alt="title" loading="lazy" class="max-w-full h-auto">
|
||||||
:src="imageUrl"
|
|
||||||
:alt="title"
|
|
||||||
loading="lazy"
|
|
||||||
class="max-w-full h-auto"
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="rounded-lg border border-dashed border-default p-8 text-center text-xs text-dimmed"
|
class="rounded-xl 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" />
|
<UIcon name="i-lucide-image-off" class="size-6 mb-1 mx-auto" />
|
||||||
<p>Sin imagen disponible</p>
|
<p>Sin imagen disponible</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Entrelínea (text) — usa la versión resaltada si Typesense la
|
<!-- Texto -->
|
||||||
devolvió, si no el HTML crudo del documento. -->
|
<div v-if="bodyHtml" class="rounded-xl border border-default bg-white dark:bg-neutral-900 shadow-sm p-4 sm:p-6">
|
||||||
<article
|
<article
|
||||||
v-if="bodyHtml"
|
class="prose prose-sm max-w-none dark:prose-invert leading-relaxed"
|
||||||
>
|
v-html="bodyHtml"
|
||||||
<div
|
/>
|
||||||
v-html="bodyHtml"
|
</div>
|
||||||
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">
|
<p v-else class="text-sm text-muted">
|
||||||
No hay entrelínea para esta coincidencia.
|
No hay entrelínea para esta coincidencia.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!--
|
<!-- Link al sitio -->
|
||||||
FAB de favorito — mismo patrón que en InboxActivity: contenedor
|
<a
|
||||||
`sticky bottom-0 h-0` con `pointer-events-none` para que ocupe cero
|
v-if="document.link"
|
||||||
altura en el flujo y el botón quede anclado al borde inferior
|
:href="document.link"
|
||||||
visible mientras el usuario hace scroll. El padding `pe-16/pb-24`
|
target="_blank"
|
||||||
del contenedor de contenido garantiza que NUNCA tape texto ni imagen.
|
rel="noopener noreferrer"
|
||||||
-->
|
class="flex items-center gap-3 rounded-xl border border-primary/30 bg-primary/5 hover:bg-primary/10 transition-colors px-4 py-3 group"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-external-link" class="size-5 text-primary shrink-0" />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium text-primary">Ver en el sitio</p>
|
||||||
|
<p class="text-xs text-muted truncate">{{ document.link }}</p>
|
||||||
|
</div>
|
||||||
|
<UIcon name="i-lucide-chevron-right" class="size-4 text-primary/60 shrink-0 group-hover:translate-x-0.5 transition-transform" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ------------------------------------------------------------------ -->
|
||||||
|
<!-- SUB-PANTALLA: lista de estudios relacionados -->
|
||||||
|
<!-- ------------------------------------------------------------------ -->
|
||||||
|
<div v-else class="flex-1 overflow-y-auto divide-y divide-default">
|
||||||
|
<a
|
||||||
|
v-for="study in document.studies"
|
||||||
|
:key="study.id"
|
||||||
|
:href="study.link"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
:title="study.title"
|
||||||
|
class="flex items-center gap-3 px-4 sm:px-6 py-3.5 hover:bg-primary/5 transition-colors group"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col justify-center shrink-0 w-14 text-center">
|
||||||
|
<template v-if="study.date">
|
||||||
|
<span class="text-base font-bold text-highlighted tabular-nums leading-none">
|
||||||
|
{{ new Date(study.date + 'T00:00:00').getUTCDate() }}
|
||||||
|
</span>
|
||||||
|
<span class="text-[10px] uppercase tracking-wide text-muted leading-tight mt-0.5">
|
||||||
|
{{ new Date(study.date + 'T00:00:00').toLocaleDateString('es', { month: 'short', timeZone: 'UTC' }) }}
|
||||||
|
{{ new Date(study.date + 'T00:00:00').getUTCFullYear() }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<UIcon v-else name="i-lucide-calendar-x" class="size-4 text-dimmed mx-auto" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-px self-stretch bg-default shrink-0" />
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-highlighted leading-snug group-hover:text-primary transition-colors line-clamp-2">
|
||||||
|
{{ study.title }}
|
||||||
|
</p>
|
||||||
|
<p v-if="study.place" class="text-xs text-muted truncate mt-0.5">
|
||||||
|
{{ study.place }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UIcon name="i-lucide-arrow-up-right" class="size-4 text-muted shrink-0 group-hover:text-primary transition-colors" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
</UDashboardPanel>
|
</UDashboardPanel>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -369,10 +369,10 @@ function onInputKey(e: KeyboardEvent) {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UDashboardPanel id="activity-detail">
|
<UDashboardPanel id="activity-detail">
|
||||||
<UDashboardNavbar :title="activity.title" :toggle="false">
|
<UDashboardNavbar :toggle="false">
|
||||||
<template #leading>
|
<template #leading>
|
||||||
<UButton
|
<UButton
|
||||||
icon="i-lucide-x"
|
icon="i-lucide-arrow-left"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="-ms-1.5"
|
class="-ms-1.5"
|
||||||
|
|
@ -380,11 +380,14 @@ function onInputKey(e: KeyboardEvent) {
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #title>
|
||||||
|
<span class="truncate font-semibold text-sm min-w-0 block" :title="activity.title">
|
||||||
|
{{ activity.title }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #right>
|
<template #right>
|
||||||
<!-- El botón de favorito ya no vive aquí: ahora es un FAB flotante
|
<UTooltip :text="isFav ? 'Quitar de mi lista' : 'Guardar en mi lista'">
|
||||||
en la esquina inferior derecha del panel para que siga visible
|
|
||||||
mientras se lee. Ver más abajo en el scroll container. -->
|
|
||||||
<UTooltip :text="isFav ? 'Quitar de mi lista' : 'Guardar en mi lista'">
|
|
||||||
<UButton
|
<UButton
|
||||||
:icon="isFav ? 'i-lucide-bookmark-check' : 'i-lucide-bookmark-plus'"
|
:icon="isFav ? 'i-lucide-bookmark-check' : 'i-lucide-bookmark-plus'"
|
||||||
:color="isFav ? 'primary' : 'neutral'"
|
:color="isFav ? 'primary' : 'neutral'"
|
||||||
|
|
@ -393,96 +396,110 @@ function onInputKey(e: KeyboardEvent) {
|
||||||
@click="onToggleFavorite"
|
@click="onToggleFavorite"
|
||||||
/>
|
/>
|
||||||
</UTooltip>
|
</UTooltip>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
</UDashboardNavbar>
|
</UDashboardNavbar>
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row justify-between gap-2 p-4 sm:px-6 border-b border-default shadow-lg z-10">
|
<!-- Metadata + controles -->
|
||||||
<div class="min-w-0 flex gap-1 sm:gap-4 sm:items-center flex-col sm:flex-row">
|
<div class="flex flex-col gap-3 p-4 sm:px-6 border-b border-default shadow-sm z-10">
|
||||||
<p class=" text-sm text-highlighted flex items-center gap-1 mb-0.5">
|
<!-- Fila 1: fecha, lugar y enlace externo -->
|
||||||
<UIcon name="ph:calendar" class="size-5 text-green-600" />
|
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||||
{{ safeDate(activity) }}
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 min-w-0">
|
||||||
</p>
|
<p v-if="safeDate(activity)" class="text-sm text-highlighted flex items-center gap-1.5 shrink-0">
|
||||||
<p class="text-sm truncate flex items-center gap-1">
|
<UIcon name="ph:calendar" class="size-4 text-green-600" />
|
||||||
<UIcon name="ph:map-pin" class="size-5 text-green-600" />
|
{{ safeDate(activity) }}
|
||||||
{{ formatLocation(activity) }}
|
</p>
|
||||||
</p>
|
<p v-if="formatLocation(activity)" class="text-sm flex items-center gap-1.5 text-muted min-w-0">
|
||||||
|
<UIcon name="ph:map-pin" class="size-4 text-green-600 shrink-0" />
|
||||||
|
<span class="truncate max-w-[220px]">{{ formatLocation(activity) }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
v-if="matchUrl"
|
||||||
|
:to="matchUrl"
|
||||||
|
target="_blank"
|
||||||
|
icon="i-lucide-external-link"
|
||||||
|
label="Ver en sitio"
|
||||||
|
color="primary"
|
||||||
|
variant="soft"
|
||||||
|
size="xs"
|
||||||
|
class="shrink-0"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Fila 2: archivos adjuntos -->
|
||||||
|
<div v-if="fileLinks.length" class="flex flex-wrap gap-2">
|
||||||
|
<UButton
|
||||||
|
v-for="(f, idx) in fileLinks"
|
||||||
|
:key="idx"
|
||||||
|
:to="f.to"
|
||||||
|
:target="f.target"
|
||||||
|
:icon="f.icon!"
|
||||||
|
:label="f.label"
|
||||||
|
color="neutral"
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
external
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fila 3: búsqueda local en el documento -->
|
||||||
<div
|
<div
|
||||||
v-if="activity?.body"
|
v-if="activity?.body"
|
||||||
class="flex items-center gap-2"
|
class="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<UFieldGroup>
|
<UInput
|
||||||
<UInput
|
v-model="localQuery"
|
||||||
v-model="localQuery"
|
icon="i-lucide-search"
|
||||||
icon="i-lucide-search"
|
placeholder="Buscar en este documento..."
|
||||||
placeholder="Buscar frase exacta en este documento..."
|
class="flex-1 min-w-0"
|
||||||
class="w-full"
|
:ui="{ trailing: 'pe-1' }"
|
||||||
:ui="{ trailing: 'pe-1' }"
|
@keydown="onInputKey"
|
||||||
@keydown="onInputKey"
|
|
||||||
>
|
|
||||||
<template #trailing>
|
|
||||||
<UButton
|
|
||||||
v-if="localQuery"
|
|
||||||
icon="i-lucide-x"
|
|
||||||
variant="link"
|
|
||||||
aria-label="Limpiar"
|
|
||||||
@click="clearLocalQuery"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</UInput>
|
|
||||||
|
|
||||||
<UBadge v-if="localQuery" class="bg-gray-400 text-white">
|
|
||||||
<span
|
|
||||||
class="text-xs tabular-nums whitespace-nowrap min-w-[3.5rem] text-center text-white"
|
|
||||||
:class="totalMatches ? 'text-toned font-medium' : 'text-dimmed'"
|
|
||||||
>
|
|
||||||
<template v-if="localQuery">
|
|
||||||
{{ totalMatches ? `${currentIdx + 1} / ${totalMatches}` : '0 / 0' }}
|
|
||||||
</template>
|
|
||||||
</span>
|
|
||||||
</UBadge>
|
|
||||||
|
|
||||||
<UTooltip text="Anterior (Shift+Enter)">
|
|
||||||
<UButton
|
|
||||||
icon="i-lucide-chevron-up"
|
|
||||||
:disabled="!totalMatches"
|
|
||||||
aria-label="Coincidencia anterior"
|
|
||||||
class="bg-gray-400"
|
|
||||||
color="neutral"
|
|
||||||
@click="prevMatch"
|
|
||||||
/>
|
|
||||||
</UTooltip>
|
|
||||||
<UTooltip text="Siguiente (Enter)">
|
|
||||||
<UButton
|
|
||||||
icon="i-lucide-chevron-down"
|
|
||||||
:disabled="!totalMatches"
|
|
||||||
aria-label="Coincidencia siguiente"
|
|
||||||
class="bg-gray-400"
|
|
||||||
color="neutral"
|
|
||||||
@click="nextMatch"
|
|
||||||
/>
|
|
||||||
</UTooltip>
|
|
||||||
</UFieldGroup>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- <ul v-if="fileLinks.length" class="flex flex-wrap gap-3 text-xs text-muted">
|
|
||||||
<li
|
|
||||||
v-for="(f, idx) in fileLinks"
|
|
||||||
:key="idx"
|
|
||||||
class="flex items-center"
|
|
||||||
>
|
>
|
||||||
<ULink
|
<template #trailing>
|
||||||
:to="f.to"
|
<UButton
|
||||||
:target="f.target"
|
v-if="localQuery"
|
||||||
class="flex items-center gap-1 hover:text-primary"
|
icon="i-lucide-x"
|
||||||
|
variant="link"
|
||||||
|
aria-label="Limpiar"
|
||||||
|
@click="clearLocalQuery"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</UInput>
|
||||||
|
|
||||||
|
<template v-if="localQuery">
|
||||||
|
<span
|
||||||
|
class="text-xs tabular-nums whitespace-nowrap shrink-0 px-2 py-1 rounded-md bg-elevated border border-default"
|
||||||
|
:class="totalMatches ? 'text-toned font-medium' : 'text-dimmed'"
|
||||||
>
|
>
|
||||||
<UIcon :name="f.icon!" class="size-4" />
|
{{ totalMatches ? `${currentIdx + 1} / ${totalMatches}` : '0 / 0' }}
|
||||||
<span>{{ f.label }}</span>
|
</span>
|
||||||
</ULink>
|
|
||||||
</li>
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
</ul> -->
|
<UTooltip text="Anterior (Shift+Enter)">
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-chevron-up"
|
||||||
|
:disabled="!totalMatches"
|
||||||
|
aria-label="Coincidencia anterior"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
@click="prevMatch"
|
||||||
|
/>
|
||||||
|
</UTooltip>
|
||||||
|
<UTooltip text="Siguiente (Enter)">
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-chevron-down"
|
||||||
|
:disabled="!totalMatches"
|
||||||
|
aria-label="Coincidencia siguiente"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
@click="nextMatch"
|
||||||
|
/>
|
||||||
|
</UTooltip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref="scrollContainer" class="flex-1 overflow-y-auto relative bg-gray-100">
|
<div ref="scrollContainer" class="flex-1 overflow-y-auto relative bg-gray-100">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { watch, onMounted, onBeforeUnmount, toValue } from 'vue'
|
||||||
|
import type { Ref, WritableComputedRef } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercepts the mobile hardware back button when a detail panel is open,
|
||||||
|
* closing the panel instead of navigating away from the page.
|
||||||
|
*
|
||||||
|
* Strategy: when the panel opens on mobile, push a history entry with the
|
||||||
|
* same URL. When the user presses back, popstate fires and we close the
|
||||||
|
* panel. When the user closes via the X button, we programmatically pop
|
||||||
|
* the entry we pushed so the stack stays clean.
|
||||||
|
*/
|
||||||
|
export function useDetailHistory(
|
||||||
|
isOpen: WritableComputedRef<boolean> | Ref<boolean>,
|
||||||
|
isMobile: Ref<boolean>
|
||||||
|
) {
|
||||||
|
let pushed = false
|
||||||
|
let fromPopState = false
|
||||||
|
let suppressNext = false
|
||||||
|
|
||||||
|
function handlePopState() {
|
||||||
|
if (suppressNext) {
|
||||||
|
suppressNext = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (pushed && toValue(isMobile)) {
|
||||||
|
fromPopState = true
|
||||||
|
pushed = false
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(isOpen, (open, wasOpen) => {
|
||||||
|
if (!toValue(isMobile)) return
|
||||||
|
|
||||||
|
if (open && !wasOpen) {
|
||||||
|
history.pushState({ detailPanel: true }, '')
|
||||||
|
pushed = true
|
||||||
|
} else if (!open && wasOpen) {
|
||||||
|
if (fromPopState) {
|
||||||
|
// Back button closed the panel — state already popped by the browser.
|
||||||
|
fromPopState = false
|
||||||
|
} else if (pushed) {
|
||||||
|
// X button (or any programmatic close) — pop the entry we pushed.
|
||||||
|
pushed = false
|
||||||
|
suppressNext = true
|
||||||
|
history.go(-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (import.meta.client) {
|
||||||
|
onMounted(() => window.addEventListener('popstate', handlePopState))
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('popstate', handlePopState))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -109,6 +109,8 @@ watch(hits, () => {
|
||||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||||
const isMobile = breakpoints.smaller('lg')
|
const isMobile = breakpoints.smaller('lg')
|
||||||
|
|
||||||
|
useDetailHistory(isActivityPanelOpen, isMobile)
|
||||||
|
|
||||||
function retry() {
|
function retry() {
|
||||||
runSearch(query.value, false)
|
runSearch(query.value, false)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,8 @@ watch(hits, () => {
|
||||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||||
const isMobile = breakpoints.smaller('lg')
|
const isMobile = breakpoints.smaller('lg')
|
||||||
|
|
||||||
|
useDetailHistory(isPanelOpen, isMobile)
|
||||||
|
|
||||||
function retry() {
|
function retry() {
|
||||||
runSearch(query.value, false)
|
runSearch(query.value, false)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,13 +63,23 @@ const loading = ref(false)
|
||||||
const loadingMore = ref(false)
|
const loadingMore = ref(false)
|
||||||
const errorMsg = ref<string | null>(null)
|
const errorMsg = ref<string | null>(null)
|
||||||
|
|
||||||
|
interface Study {
|
||||||
|
id?: number
|
||||||
|
title?: string
|
||||||
|
date?: string
|
||||||
|
place?: string
|
||||||
|
link?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface EntrelineaDoc {
|
interface EntrelineaDoc {
|
||||||
id?: string
|
id?: string
|
||||||
image?: string
|
image?: string
|
||||||
link?: string
|
link?: string
|
||||||
locale?: string
|
locale?: string
|
||||||
|
origin?: string
|
||||||
page?: number | string
|
page?: number | string
|
||||||
text?: string
|
text?: string
|
||||||
|
studies?: Study[]
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -164,6 +174,7 @@ async function runSearch(q: string, append = false) {
|
||||||
|
|
||||||
const res = (multi?.results?.[0] ?? {}) as TypesenseSearchResponse
|
const res = (multi?.results?.[0] ?? {}) as TypesenseSearchResponse
|
||||||
const newHits = res?.hits ?? []
|
const newHits = res?.hits ?? []
|
||||||
|
|
||||||
hits.value = append ? hits.value.concat(newHits) : newHits
|
hits.value = append ? hits.value.concat(newHits) : newHits
|
||||||
total.value = res?.found ?? hits.value.length
|
total.value = res?.found ?? hits.value.length
|
||||||
currentPage.value = page
|
currentPage.value = page
|
||||||
|
|
@ -227,6 +238,8 @@ watch(hits, () => {
|
||||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||||
const isMobile = breakpoints.smaller('lg')
|
const isMobile = breakpoints.smaller('lg')
|
||||||
|
|
||||||
|
useDetailHistory(isPanelOpen, isMobile)
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
/* Favoritos */
|
/* Favoritos */
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
@ -320,7 +333,7 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
|
||||||
·
|
·
|
||||||
<code class="text-toned">{{ QUERY_BY }}</code>
|
<code class="text-toned">{{ QUERY_BY }}</code>
|
||||||
·
|
·
|
||||||
<code class="text-toned">{{ FILTER_BY }}</code>
|
<code class="text-toned">{{ filterBy }}</code>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue