548 lines
18 KiB
Vue
548 lines
18 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
|
|
import { useFavoritesStore } from '~/stores/favorites'
|
|
import { useHistoryStore } from '~/stores/history'
|
|
import type { SearchHit } from '~/types'
|
|
|
|
interface Study {
|
|
id?: number
|
|
title?: string
|
|
date?: string
|
|
place?: string
|
|
link?: string
|
|
}
|
|
|
|
interface EntrelineaDoc {
|
|
id?: string
|
|
image?: string
|
|
link?: string
|
|
locale?: string
|
|
origin?: string
|
|
filter?: string
|
|
page?: number | string
|
|
text?: string
|
|
studies?: Study[]
|
|
[key: string]: unknown
|
|
}
|
|
|
|
const props = defineProps<{
|
|
document: EntrelineaDoc
|
|
collection?: string
|
|
highlightedText?: string | null
|
|
}>()
|
|
|
|
const emits = defineEmits<{ close: [] }>()
|
|
|
|
const showStudies = ref(false)
|
|
|
|
// Volver al detalle cuando cambia el documento
|
|
watch(() => props.document?.id, () => { showStudies.value = false })
|
|
|
|
const imageUrl = computed<string | null>(() =>
|
|
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'
|
|
)
|
|
|
|
function formatEntrelineaText(html: string): string {
|
|
return html;
|
|
if (!html) return ''
|
|
|
|
const sPrefix = "font-family:'Times New Roman',serif;font-style:italic;color:#c00000"
|
|
const sBracket = "font-family:'Times New Roman',serif;font-style:italic;color:#c00000"
|
|
const sInner = "font-family:'Times New Roman',serif;font-style:italic;color:#c00000;text-decoration:underline"
|
|
|
|
const wrapLine = (content: string, isFirst: boolean): string =>
|
|
(isFirst ? `<span style="${sPrefix}">[WSS] </span>` : '') +
|
|
`<span style="${sBracket}">«</span>` +
|
|
`<span style="${sInner}">${content}</span>` +
|
|
`<span style="${sBracket}">»</span>`
|
|
|
|
if (/<p[\s>]/i.test(html)) {
|
|
const lines = html
|
|
.split(/<p(?:[^>]*)>/i)
|
|
.map(part => part.replace(/<\/p>/gi, '').trim())
|
|
.filter(Boolean)
|
|
if (lines.length) return lines.map((l, i) => wrapLine(l, i === 0)).join('<br>')
|
|
return html
|
|
}
|
|
|
|
if (/<br/i.test(html)) {
|
|
const parts = html.split(/<br\s*\/?>/i).map(p => p.trim()).filter(Boolean)
|
|
if (parts.length) return parts.map((l, i) => wrapLine(l, i === 0)).join('<br>')
|
|
}
|
|
|
|
return wrapLine(html.trim(), true)
|
|
}
|
|
|
|
const bodyHtml = computed<string>(() =>
|
|
formatEntrelineaText(props.highlightedText || props.document?.text || '')
|
|
)
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Favoritos / Historial */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
const favorites = useFavoritesStore()
|
|
const history = useHistoryStore()
|
|
const toast = useToast()
|
|
|
|
function toSearchHit(doc: EntrelineaDoc): SearchHit {
|
|
const id = doc.id || ''
|
|
return { _id: id, id, title: id || 'Entrelínea', body: doc.text, ...doc }
|
|
}
|
|
|
|
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
|
|
})
|
|
}
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Desktop hover zoom (lente + panel magnificado) */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
const zoomBoxRef = ref<HTMLElement | null>(null)
|
|
const zoomImgRef = ref<HTMLImageElement | null>(null)
|
|
const zoomActive = ref(false)
|
|
|
|
const cursorX = ref(0)
|
|
const cursorY = ref(0)
|
|
const imgW = ref(0)
|
|
const imgH = ref(0)
|
|
const imgOffsetX = ref(0)
|
|
const imgOffsetY = ref(0)
|
|
const boxRight = ref(0)
|
|
const boxLeft = ref(0)
|
|
const boxTop = ref(0)
|
|
|
|
const ZOOM_LEVEL = 2.5
|
|
const PANEL_W = 400
|
|
const PANEL_H = 400
|
|
const LENS_W = PANEL_W / ZOOM_LEVEL
|
|
const LENS_H = PANEL_H / ZOOM_LEVEL
|
|
|
|
function refreshImageRect() {
|
|
if (!zoomImgRef.value || !zoomBoxRef.value) return
|
|
const ir = zoomImgRef.value.getBoundingClientRect()
|
|
const br = zoomBoxRef.value.getBoundingClientRect()
|
|
imgW.value = ir.width
|
|
imgH.value = ir.height
|
|
imgOffsetX.value = ir.left - br.left
|
|
imgOffsetY.value = ir.top - br.top
|
|
boxRight.value = br.right
|
|
boxLeft.value = br.left
|
|
boxTop.value = br.top
|
|
}
|
|
|
|
function onZoomEnter() {
|
|
refreshImageRect()
|
|
zoomActive.value = true
|
|
}
|
|
|
|
function onZoomLeave() {
|
|
zoomActive.value = false
|
|
}
|
|
|
|
function onZoomMove(e: MouseEvent) {
|
|
if (!zoomImgRef.value || !imgW.value) return
|
|
const r = zoomImgRef.value.getBoundingClientRect()
|
|
let x = e.clientX - r.left
|
|
let y = e.clientY - r.top
|
|
x = Math.max(LENS_W / 2, Math.min(imgW.value - LENS_W / 2, x))
|
|
y = Math.max(LENS_H / 2, Math.min(imgH.value - LENS_H / 2, y))
|
|
cursorX.value = x
|
|
cursorY.value = y
|
|
}
|
|
|
|
const lensStyle = computed(() => ({
|
|
left: `${imgOffsetX.value + cursorX.value - LENS_W / 2}px`,
|
|
top: `${imgOffsetY.value + cursorY.value - LENS_H / 2}px`,
|
|
width: `${LENS_W}px`,
|
|
height: `${LENS_H}px`,
|
|
}))
|
|
|
|
const zoomPanelStyle = computed(() => {
|
|
if (!imgW.value) return {}
|
|
const bgX = -(cursorX.value * ZOOM_LEVEL - PANEL_W / 2)
|
|
const bgY = -(cursorY.value * ZOOM_LEVEL - PANEL_H / 2)
|
|
const vw = typeof window !== 'undefined' ? window.innerWidth : 1600
|
|
const left = (boxRight.value + 16 + PANEL_W <= vw)
|
|
? boxRight.value + 16
|
|
: Math.max(8, boxLeft.value - 16 - PANEL_W)
|
|
return {
|
|
left: `${left}px`,
|
|
top: `${boxTop.value}px`,
|
|
width: `${PANEL_W}px`,
|
|
height: `${PANEL_H}px`,
|
|
backgroundImage: `url("${imageUrl.value}")`,
|
|
backgroundSize: `${imgW.value * ZOOM_LEVEL}px ${imgH.value * ZOOM_LEVEL}px`,
|
|
backgroundPosition: `${bgX}px ${bgY}px`,
|
|
backgroundRepeat: 'no-repeat',
|
|
}
|
|
})
|
|
|
|
watch(imageUrl, () => { setTimeout(refreshImageRect, 50) })
|
|
|
|
onMounted(() => {
|
|
window.addEventListener('resize', refreshImageRect)
|
|
window.addEventListener('scroll', refreshImageRect, { passive: true })
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('resize', refreshImageRect)
|
|
window.removeEventListener('scroll', refreshImageRect)
|
|
})
|
|
|
|
/* -------------------------------------------------------------------------- */
|
|
/* Mobile lightbox con pinch-to-zoom */
|
|
/* -------------------------------------------------------------------------- */
|
|
|
|
const lightboxOpen = ref(false)
|
|
const lightboxScale = ref(1)
|
|
const lbTranslateX = ref(0)
|
|
const lbTranslateY = ref(0)
|
|
|
|
let lbInitialDist = 0
|
|
let lbInitialScale = 1
|
|
let lbLastTouchX = 0
|
|
let lbLastTouchY = 0
|
|
let lbLastMidX = 0
|
|
let lbLastMidY = 0
|
|
|
|
const LB_MIN_SCALE = 1
|
|
const LB_MAX_SCALE = 5
|
|
|
|
function openLightbox() {
|
|
lightboxScale.value = LB_MIN_SCALE
|
|
lbTranslateX.value = 0
|
|
lbTranslateY.value = 0
|
|
lightboxOpen.value = true
|
|
}
|
|
|
|
function closeLightbox() {
|
|
lightboxOpen.value = false
|
|
}
|
|
|
|
function getTouchDist(t1: Touch, t2: Touch): number {
|
|
return Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY)
|
|
}
|
|
|
|
function onLightboxTouchStart(e: TouchEvent) {
|
|
if (e.touches.length === 1) {
|
|
lbLastTouchX = e.touches[0].clientX
|
|
lbLastTouchY = e.touches[0].clientY
|
|
} else if (e.touches.length === 2) {
|
|
lbInitialDist = getTouchDist(e.touches[0], e.touches[1])
|
|
lbInitialScale = lightboxScale.value
|
|
lbLastMidX = (e.touches[0].clientX + e.touches[1].clientX) / 2
|
|
lbLastMidY = (e.touches[0].clientY + e.touches[1].clientY) / 2
|
|
}
|
|
}
|
|
|
|
function onLightboxTouchMove(e: TouchEvent) {
|
|
// Prevent page scroll while interacting inside the lightbox
|
|
e.preventDefault()
|
|
if (e.touches.length === 1 && lightboxScale.value > 1) {
|
|
const dx = e.touches[0].clientX - lbLastTouchX
|
|
const dy = e.touches[0].clientY - lbLastTouchY
|
|
lbTranslateX.value += dx
|
|
lbTranslateY.value += dy
|
|
lbLastTouchX = e.touches[0].clientX
|
|
lbLastTouchY = e.touches[0].clientY
|
|
} else if (e.touches.length === 2) {
|
|
const dist = getTouchDist(e.touches[0], e.touches[1])
|
|
lightboxScale.value = Math.max(LB_MIN_SCALE, Math.min(LB_MAX_SCALE, lbInitialScale * (dist / lbInitialDist)))
|
|
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2
|
|
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2
|
|
lbTranslateX.value += midX - lbLastMidX
|
|
lbTranslateY.value += midY - lbLastMidY
|
|
lbLastMidX = midX
|
|
lbLastMidY = midY
|
|
if (lightboxScale.value <= LB_MIN_SCALE) {
|
|
lbTranslateX.value = 0
|
|
lbTranslateY.value = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
function onLightboxTouchEnd() {
|
|
if (lightboxScale.value <= LB_MIN_SCALE) {
|
|
lightboxScale.value = LB_MIN_SCALE
|
|
lbTranslateX.value = 0
|
|
lbTranslateY.value = 0
|
|
}
|
|
}
|
|
|
|
const lightboxImgStyle = computed(() => ({
|
|
transform: `translate(${lbTranslateX.value}px, ${lbTranslateY.value}px) scale(${lightboxScale.value})`,
|
|
transformOrigin: 'center center',
|
|
}))
|
|
</script>
|
|
|
|
<template>
|
|
<UDashboardPanel id="entrelinea-detail">
|
|
<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>
|
|
<!-- En sub-pantalla de estudios: volver al detalle -->
|
|
<UButton
|
|
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"
|
|
variant="ghost"
|
|
class="-ms-1.5"
|
|
@click="emits('close')"
|
|
/>
|
|
</template>
|
|
|
|
<template #right>
|
|
<template v-if="!showStudies">
|
|
<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>
|
|
</UDashboardNavbar>
|
|
|
|
<div
|
|
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
|
|
v-if="document.locale"
|
|
:label="String(document.locale).toUpperCase()"
|
|
color="neutral"
|
|
variant="subtle"
|
|
size="xs"
|
|
class="shrink-0"
|
|
/>
|
|
<UBadge
|
|
v-if="document.filter"
|
|
:label="String(document.filter)"
|
|
color="neutral"
|
|
variant="subtle"
|
|
size="xs"
|
|
class="shrink-0 uppercase"
|
|
/>
|
|
<span
|
|
v-if="origin"
|
|
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]"
|
|
:title="origin"
|
|
>
|
|
{{ origin }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ------------------------------------------------------------------ -->
|
|
<!-- VISTA PRINCIPAL: imagen + texto + accesos -->
|
|
<!-- ------------------------------------------------------------------ -->
|
|
<div v-if="!showStudies" class="flex-1 overflow-y-auto">
|
|
<div class="p-4 sm:p-6 pb-10 flex flex-col gap-6">
|
|
|
|
<!-- Imagen: mobile toca para abrir lightbox, desktop zoom al hover -->
|
|
<template v-if="imageUrl">
|
|
<!-- Mobile: imagen con indicador de zoom táctil -->
|
|
<div
|
|
class="lg:hidden rounded-xl overflow-hidden border border-default shadow-sm bg-elevated relative cursor-pointer"
|
|
@click="openLightbox"
|
|
>
|
|
<img :src="imageUrl" :alt="title" loading="lazy" class="w-full h-auto select-none">
|
|
<div class="absolute bottom-2 right-2 bg-black/50 rounded-full p-1.5 pointer-events-none">
|
|
<UIcon name="i-lucide-zoom-in" class="size-4 text-white" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Desktop: hover zoom con lente -->
|
|
<div
|
|
ref="zoomBoxRef"
|
|
class="hidden lg:flex rounded-xl bg-elevated border border-default shadow-sm p-4 items-center justify-center relative cursor-zoom-in select-none min-h-48"
|
|
@mouseenter="onZoomEnter"
|
|
@mouseleave="onZoomLeave"
|
|
@mousemove="onZoomMove"
|
|
>
|
|
<img
|
|
ref="zoomImgRef"
|
|
:src="imageUrl"
|
|
:alt="title"
|
|
loading="lazy"
|
|
class="max-h-full max-w-full object-contain drop-shadow-sm select-none"
|
|
draggable="false"
|
|
@load="refreshImageRect"
|
|
/>
|
|
<!-- Lente: marca el área magnificada -->
|
|
<div
|
|
v-show="zoomActive"
|
|
class="absolute pointer-events-none border-2 border-white shadow-lg rounded"
|
|
style="background-color: rgba(255,255,255,0.35);"
|
|
:style="lensStyle"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Sin imagen -->
|
|
<div
|
|
v-else
|
|
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" />
|
|
<p>Sin imagen disponible</p>
|
|
</div>
|
|
|
|
<!-- Texto -->
|
|
<div v-if="bodyHtml" class="rounded-xl border border-default bg-white dark:bg-neutral-900 shadow-sm p-4 sm:p-6">
|
|
<article
|
|
class="prose prose-sm max-w-none dark:prose-invert leading-relaxed"
|
|
v-html="bodyHtml"
|
|
/>
|
|
</div>
|
|
<p v-else class="text-sm text-muted">
|
|
No hay entrelínea para esta coincidencia.
|
|
</p>
|
|
|
|
<!-- Link al sitio -->
|
|
<a
|
|
v-if="document.link"
|
|
:href="document.link"
|
|
target="_blank"
|
|
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>
|
|
|
|
<!-- ------------------------------------------------------------------ -->
|
|
<!-- 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>
|
|
|
|
<!-- Panel de zoom desktop: Teleport a body para escapar del stacking context -->
|
|
<Teleport to="body">
|
|
<div
|
|
v-show="zoomActive"
|
|
class="hidden lg:block fixed pointer-events-none bg-white border border-gray-200 shadow-2xl rounded-lg overflow-hidden"
|
|
style="z-index: 9999;"
|
|
:style="zoomPanelStyle"
|
|
/>
|
|
</Teleport>
|
|
|
|
<!-- Lightbox mobile con pinch-to-zoom -->
|
|
<Teleport to="body">
|
|
<div
|
|
v-if="lightboxOpen"
|
|
class="lg:hidden fixed inset-0 bg-black z-[9999] flex items-center justify-center"
|
|
@touchstart.passive="onLightboxTouchStart"
|
|
@touchmove.prevent="onLightboxTouchMove"
|
|
@touchend="onLightboxTouchEnd"
|
|
@click.self="closeLightbox"
|
|
>
|
|
<button
|
|
class="absolute top-4 right-4 z-10 text-white bg-black/60 rounded-full p-2"
|
|
@click="closeLightbox"
|
|
>
|
|
<UIcon name="i-lucide-x" class="size-6" />
|
|
</button>
|
|
<img
|
|
:src="imageUrl!"
|
|
:alt="title"
|
|
class="max-w-full max-h-full object-contain select-none"
|
|
:style="lightboxImgStyle"
|
|
draggable="false"
|
|
/>
|
|
</div>
|
|
</Teleport>
|
|
|
|
</UDashboardPanel>
|
|
</template>
|