506 lines
17 KiB
Vue
Executable File
506 lines
17 KiB
Vue
Executable File
<script setup lang="ts">
|
|
import dayjs from 'dayjs'
|
|
import { useDebounce } from '@vueuse/core'
|
|
import type { SearchHit } from '~/types'
|
|
import { useFavoritesStore } from '~/stores/favorites'
|
|
import { useHistoryStore } from '~/stores/history'
|
|
|
|
const props = defineProps<{
|
|
activity: SearchHit
|
|
/** Permitir cualquier nombre de colección para que el detalle siga siendo
|
|
* reusable cuando se añadan otros buscadores en el futuro. */
|
|
collection?: string
|
|
query?: string
|
|
}>()
|
|
|
|
const emits = defineEmits(['close'])
|
|
|
|
const { locale } = useI18n()
|
|
|
|
const favorites = useFavoritesStore()
|
|
const history = useHistoryStore()
|
|
const toast = useToast()
|
|
|
|
// Registrar la visita en el historial cada vez que el componente recibe una
|
|
// actividad distinta (o se monta con la primera). El store deduplica por
|
|
// (collection, _id) moviendo la entrada al inicio y actualizando la fecha,
|
|
// así que disparar esto de más es seguro. No registramos si no hay
|
|
// `collection` (uso fuera de un buscador concreto) ni si el hit aún no
|
|
// tiene `_id` resoluble.
|
|
watch(
|
|
() => [props.collection, props.activity?._id] as const,
|
|
([collection, id]) => {
|
|
if (!collection || id == null || id === '') return
|
|
history.visit(collection, props.activity)
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
const isFav = computed(() => {
|
|
if (!props.collection || !props.activity?._id) return false
|
|
return favorites.isFavorite(props.collection, props.activity._id)
|
|
})
|
|
|
|
function onToggleFavorite() {
|
|
if (!props.collection || !props.activity) return
|
|
const wasFav = isFav.value
|
|
favorites.toggle(props.collection, props.activity)
|
|
toast.add({
|
|
title: wasFav ? 'Eliminado de tu lista' : 'Guardado en tu lista',
|
|
description: props.activity.title,
|
|
icon: wasFav ? 'i-lucide-bookmark-x' : 'i-lucide-bookmark-check',
|
|
color: wasFav ? 'neutral' : 'primary',
|
|
duration: 1800
|
|
})
|
|
}
|
|
|
|
// ---- Match URL & metadata ----------------------------------------------
|
|
|
|
function getTimestamp(i: SearchHit): number | null {
|
|
const d = i.date ?? i.isodate
|
|
if (d == null) return null
|
|
const ts = typeof d === 'string' ? new Date(d).getTime() : (d as number) * 1000
|
|
return Number.isFinite(ts) ? ts : null
|
|
}
|
|
|
|
const matchUrl = computed(() => {
|
|
const i = props.activity
|
|
if (!i?.type) return null
|
|
const ts = getTimestamp(i)
|
|
if (ts == null) return null
|
|
const d = dayjs(ts)
|
|
const month = (d.month() + 1).toString().padStart(2, '0')
|
|
const slug = i.slug && i.slug !== 'undefined' ? i.slug : i.id ?? i._id
|
|
return `https://www.carpa.com/${locale.value}/${i.type}/${d.year()}/${month}/${slug}`
|
|
})
|
|
|
|
const fileLinks = computed(() => formatFiles(props.activity.files || {}))
|
|
|
|
function safeDate(i: SearchHit) {
|
|
const ts = getTimestamp(i)
|
|
return ts == null ? '' : formatDate(ts / 1000)
|
|
}
|
|
|
|
// ---- Find-in-document --------------------------------------------------
|
|
|
|
const bodyContainer = ref<HTMLElement | null>(null)
|
|
const scrollContainer = ref<HTMLElement | null>(null)
|
|
|
|
function stripOuterQuotes(s: string): string {
|
|
return s.trim().replace(/^"+|"+$/g, '').trim()
|
|
}
|
|
|
|
const localQuery = ref(stripOuterQuotes(props.query || ''))
|
|
const debouncedLocalQuery = useDebounce(localQuery, 200)
|
|
|
|
const currentIdx = ref(0)
|
|
const totalMatches = ref(0)
|
|
|
|
// Reset local query and counters when the user opens a different activity.
|
|
watch(
|
|
() => props.activity?._id,
|
|
() => {
|
|
localQuery.value = stripOuterQuotes(props.query || '')
|
|
currentIdx.value = 0
|
|
totalMatches.value = 0
|
|
}
|
|
)
|
|
|
|
// Keep local query in sync if the parent search changes while the same
|
|
// activity stays open.
|
|
watch(() => props.query, (q) => {
|
|
const stripped = stripOuterQuotes(q || '')
|
|
if (stripped !== localQuery.value) localQuery.value = stripped
|
|
})
|
|
|
|
function escapeRegex(s: string) {
|
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
}
|
|
|
|
function normalize(s: string): string {
|
|
return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
|
}
|
|
|
|
function normalizeWithMap(text: string): { normalized: string, map: number[] } {
|
|
let normalized = ''
|
|
const map: number[] = []
|
|
for (let i = 0; i < text.length; i++) {
|
|
const norm = text[i].normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
|
for (let j = 0; j < norm.length; j++) {
|
|
normalized += norm[j]
|
|
map.push(i)
|
|
}
|
|
}
|
|
return { normalized, map }
|
|
}
|
|
|
|
function findMatchesInText(text: string, terms: string[]): Array<{ start: number, end: number }> {
|
|
if (!text || !terms.length) return []
|
|
const sources = terms.map(t => termToRegexSource(normalize(t))).filter(s => s.length > 0)
|
|
if (!sources.length) return []
|
|
|
|
const { normalized, map } = normalizeWithMap(text)
|
|
const re = new RegExp(`(${sources.join('|')})`, 'g')
|
|
const out: Array<{ start: number, end: number }> = []
|
|
let m: RegExpExecArray | null
|
|
while ((m = re.exec(normalized)) !== null) {
|
|
if (m[0].length === 0) { re.lastIndex++; continue }
|
|
const start = map[m.index] ?? text.length
|
|
const endNormIdx = m.index + m[0].length
|
|
const end = endNormIdx < map.length ? map[endNormIdx] : text.length
|
|
out.push({ start, end })
|
|
}
|
|
return out
|
|
}
|
|
|
|
// "los paises" -> ['los paises'] | los paises -> ['los', 'paises']
|
|
// Palabras de 1 caracter se descartan (ruido); usa comillas si las necesitas.
|
|
function parseTerms(query: string): string[] {
|
|
if (!query) return []
|
|
const tokens: string[] = []
|
|
const tokenRe = /"([^"]+)"|(\S+)/g
|
|
for (const match of query.matchAll(tokenRe)) {
|
|
const [, phrase, word] = match
|
|
if (phrase) tokens.push(phrase.trim())
|
|
else if (word && word.length > 1) tokens.push(word)
|
|
}
|
|
return tokens.filter(Boolean)
|
|
}
|
|
|
|
/** Decide cómo trocear la query antes de buscarla en el cuerpo del detalle.
|
|
*
|
|
* - Si la query coincide con la que viene del buscador padre (`props.query`),
|
|
* se tokeniza igual que en el listado: cada palabra se resalta por separado,
|
|
* para que el usuario vea TODOS los términos del search global.
|
|
* - Si el usuario ha modificado el input local (escribió algo distinto),
|
|
* tratamos toda la cadena como una FRASE EXACTA — sin tokenizar — porque
|
|
* cuando alguien escribe "ley de extranjeria" dentro del detalle quiere
|
|
* encontrar exactamente esa secuencia, no las tres palabras sueltas. */
|
|
function termsFor(query: string): string[] {
|
|
if (!query) return []
|
|
if (query !== (props.query || '')) {
|
|
const stripped = stripOuterQuotes(query)
|
|
return stripped ? [stripped] : []
|
|
}
|
|
return parseTerms(query)
|
|
}
|
|
|
|
function termToRegexSource(term: string): string {
|
|
const parts = term.split(/\s+/).filter(Boolean)
|
|
if (parts.length === 0) return ''
|
|
if (parts.length === 1) return escapeRegex(parts[0])
|
|
return parts.map(escapeRegex).join('\\s+')
|
|
}
|
|
|
|
/** Walk text nodes, build the FLAT text content of `root`, find phrase
|
|
* positions in that flat text, then mark whichever portion of each match
|
|
* falls inside each text node. Cross-node phrases (split by HTML tags) get
|
|
* highlighted as several adjacent <mark>s.
|
|
* Returns the number of distinct matches (not <mark> elements) so the
|
|
* navigation counter ("3 / N") reflects unique phrase occurrences. */
|
|
function highlightTextNodes(root: HTMLElement, terms: string[]): number {
|
|
if (!terms.length) return 0
|
|
|
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null)
|
|
const infos: { node: Text, start: number, end: number }[] = []
|
|
let flat = ''
|
|
let n: Node | null
|
|
while ((n = walker.nextNode())) {
|
|
const node = n as Text
|
|
const text = node.nodeValue || ''
|
|
infos.push({ node, start: flat.length, end: flat.length + text.length })
|
|
flat += text
|
|
}
|
|
if (!infos.length) return 0
|
|
|
|
const matches = findMatchesInText(flat, terms)
|
|
if (!matches.length) return 0
|
|
|
|
// Tag the first <mark> of each logical match so the navigator can step
|
|
// between distinct occurrences (not between fragments of one phrase).
|
|
const matchStartByPos = new Set(matches.map(m => m.start))
|
|
|
|
for (let i = infos.length - 1; i >= 0; i--) {
|
|
const info = infos[i]
|
|
const nodeText = info.node.nodeValue || ''
|
|
|
|
const segments: { start: number, end: number, matchStart: number }[] = []
|
|
for (const match of matches) {
|
|
const segStart = Math.max(match.start, info.start) - info.start
|
|
const segEnd = Math.min(match.end, info.end) - info.start
|
|
if (segStart < segEnd) segments.push({ start: segStart, end: segEnd, matchStart: match.start })
|
|
}
|
|
if (!segments.length) continue
|
|
segments.sort((a, b) => a.start - b.start)
|
|
|
|
const frag = document.createDocumentFragment()
|
|
let cursor = 0
|
|
for (const seg of segments) {
|
|
if (seg.start > cursor) frag.appendChild(document.createTextNode(nodeText.slice(cursor, seg.start)))
|
|
const mark = document.createElement('mark')
|
|
mark.className = 'search-match'
|
|
// First fragment of a logical match gets a marker class so we can find
|
|
// distinct occurrences for the up/down navigation buttons.
|
|
if (matchStartByPos.has(seg.matchStart) && info.start + seg.start === seg.matchStart) {
|
|
mark.classList.add('match-start')
|
|
}
|
|
mark.textContent = nodeText.slice(seg.start, seg.end)
|
|
frag.appendChild(mark)
|
|
cursor = seg.end
|
|
}
|
|
if (cursor < nodeText.length) frag.appendChild(document.createTextNode(nodeText.slice(cursor)))
|
|
info.node.parentNode?.replaceChild(frag, info.node)
|
|
}
|
|
|
|
return matches.length
|
|
}
|
|
|
|
function getMarks(): HTMLElement[] {
|
|
const el = bodyContainer.value
|
|
if (!el) return []
|
|
return Array.from(el.querySelectorAll('mark.search-match')) as HTMLElement[]
|
|
}
|
|
|
|
/** Distinct match starts — for cross-node phrases, only the FIRST <mark>
|
|
* fragment of each phrase carries the .match-start class. */
|
|
function getMatchStarts(): HTMLElement[] {
|
|
const el = bodyContainer.value
|
|
if (!el) return []
|
|
return Array.from(el.querySelectorAll('mark.search-match.match-start')) as HTMLElement[]
|
|
}
|
|
|
|
function applyCurrent(scroll = true) {
|
|
const allMarks = getMarks()
|
|
allMarks.forEach(m => m.classList.remove('is-current'))
|
|
const starts = getMatchStarts()
|
|
if (!starts.length) return
|
|
const idx = Math.min(Math.max(currentIdx.value, 0), starts.length - 1)
|
|
const target = starts[idx]
|
|
if (!target) return
|
|
target.classList.remove('is-current')
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
void target.offsetWidth
|
|
target.classList.add('is-current')
|
|
if (scroll) target.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
}
|
|
|
|
function nextMatch() {
|
|
if (!totalMatches.value) return
|
|
currentIdx.value = (currentIdx.value + 1) % totalMatches.value
|
|
applyCurrent()
|
|
}
|
|
|
|
function prevMatch() {
|
|
if (!totalMatches.value) return
|
|
currentIdx.value = (currentIdx.value - 1 + totalMatches.value) % totalMatches.value
|
|
applyCurrent()
|
|
}
|
|
|
|
function clearLocalQuery() {
|
|
localQuery.value = ''
|
|
}
|
|
|
|
async function renderBody() {
|
|
await nextTick()
|
|
const el = bodyContainer.value
|
|
if (!el) return
|
|
|
|
const raw = props.activity?.body
|
|
el.innerHTML = raw ? ((fixLink(raw) as string) || '') : ''
|
|
|
|
if (scrollContainer.value) scrollContainer.value.scrollTop = 0
|
|
|
|
// Importante: usamos `localQuery.value` directamente (no la versión
|
|
// debounced) para que el primer scroll-a-coincidencia tras cambiar de
|
|
// detalle refleje la query recién reseteada. Si leyéramos del debounce,
|
|
// tendría aún el valor antiguo del input local (200ms de retraso) y
|
|
// buscaríamos el término viejo en el cuerpo nuevo → sin coincidencias →
|
|
// sin scroll. Bug clásico de carrera entre el reset síncrono y el debounce.
|
|
applyHighlights({ query: localQuery.value, scroll: true })
|
|
}
|
|
|
|
function applyHighlights({ query, scroll }: { query: string, scroll: boolean }) {
|
|
const el = bodyContainer.value
|
|
if (!el) return
|
|
|
|
// Wipe any previous <mark> wrappers by replacing them with text nodes.
|
|
const previous = el.querySelectorAll('mark.search-match')
|
|
previous.forEach((m) => {
|
|
const text = document.createTextNode(m.textContent || '')
|
|
m.parentNode?.replaceChild(text, m)
|
|
})
|
|
// Merge adjacent text nodes so the next regex pass works cleanly.
|
|
el.normalize()
|
|
|
|
const terms = termsFor(query)
|
|
|
|
const count = terms.length ? highlightTextNodes(el, terms) : 0
|
|
totalMatches.value = count
|
|
currentIdx.value = 0
|
|
|
|
if (count > 0) {
|
|
nextTick(() => applyCurrent(scroll))
|
|
}
|
|
}
|
|
|
|
// Re-render entire body when the activity changes.
|
|
watch(() => props.activity?._id, renderBody, { immediate: true })
|
|
|
|
// Re-apply highlights when the user edits the local search query.
|
|
// `scroll: false` porque mientras teclea no queremos saltar en cada pulsación;
|
|
// el salto inicial a la primera coincidencia ya lo hace `renderBody`.
|
|
watch(debouncedLocalQuery, (q) => {
|
|
applyHighlights({ query: q, scroll: false })
|
|
})
|
|
|
|
onMounted(renderBody)
|
|
|
|
// Keyboard helpers when the input is focused.
|
|
function onInputKey(e: KeyboardEvent) {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
if (e.shiftKey) prevMatch()
|
|
else nextMatch()
|
|
} else if (e.key === 'Escape') {
|
|
clearLocalQuery()
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<UDashboardPanel id="activity-detail">
|
|
<UDashboardNavbar :title="activity.title" :toggle="false">
|
|
<template #leading>
|
|
<UButton
|
|
icon="i-lucide-x"
|
|
color="neutral"
|
|
variant="ghost"
|
|
class="-ms-1.5"
|
|
@click="emits('close')"
|
|
/>
|
|
</template>
|
|
|
|
<template #right>
|
|
<!-- El botón de favorito ya no vive aquí: ahora es un FAB flotante
|
|
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
|
|
: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 shadow-lg z-10">
|
|
<div class="min-w-0 flex gap-1 sm:gap-4 sm:items-center flex-col sm:flex-row">
|
|
<p class=" text-sm text-highlighted flex items-center gap-1 mb-0.5">
|
|
<UIcon name="ph:calendar" class="size-5 text-green-600" />
|
|
{{ safeDate(activity) }}
|
|
</p>
|
|
<p class="text-sm truncate flex items-center gap-1">
|
|
<UIcon name="ph:map-pin" class="size-5 text-green-600" />
|
|
{{ formatLocation(activity) }}
|
|
</p>
|
|
</div>
|
|
|
|
<div
|
|
v-if="activity?.body"
|
|
class="flex items-center gap-2"
|
|
>
|
|
<UFieldGroup>
|
|
<UInput
|
|
v-model="localQuery"
|
|
icon="i-lucide-search"
|
|
placeholder="Buscar frase exacta en este documento..."
|
|
class="w-full"
|
|
:ui="{ trailing: 'pe-1' }"
|
|
@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
|
|
:to="f.to"
|
|
:target="f.target"
|
|
class="flex items-center gap-1 hover:text-primary"
|
|
>
|
|
<UIcon :name="f.icon!" class="size-4" />
|
|
<span>{{ f.label }}</span>
|
|
</ULink>
|
|
</li>
|
|
</ul> -->
|
|
</div>
|
|
|
|
<div ref="scrollContainer" class="flex-1 overflow-y-auto relative bg-gray-100">
|
|
<div class="p-1 sm:p-4 bg-white rounded-lg shadow-md max-w-4xl m-4 sm:mx-auto sm:my-6">
|
|
<article
|
|
v-if="activity?.body"
|
|
ref="bodyContainer"
|
|
class="prose prose-sm max-w-none dark:prose-invert p-4 sm:p-6 pb-24 prose-p:mb-2 text-base/7 prose:text-base/7"
|
|
/>
|
|
<p v-else class="p-4 sm:p-6 text-sm text-muted">
|
|
No hay contenido disponible para esta coincidencia.
|
|
<span v-if="matchUrl">
|
|
Puedes
|
|
<ULink :to="matchUrl" target="_blank" class="text-primary">verla en el sitio</ULink>.
|
|
</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</UDashboardPanel>
|
|
</template>
|