search/app/components/inbox/InboxActivity.vue

519 lines
18 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'
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 toast = useToast()
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. -->
<UButton
v-if="matchUrl"
:to="matchUrl"
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">
<p class="font-semibold text-sm text-highlighted">
{{ safeDate(activity) }}
</p>
<p class="text-muted text-sm truncate">
{{ formatLocation(activity) }}
</p>
</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">
<!-- Find-in-document toolbar: sticky at the top of the scroll area so
the user can always reach it while reading. -->
<div
v-if="activity?.body"
class="sticky top-0 z-10 bg-default/95 backdrop-blur-sm border-b border-default px-4 sm:px-6 py-2 flex items-center gap-2"
>
<div class="relative flex-1 min-w-0 max-w-md">
<UInput
v-model="localQuery"
icon="i-lucide-search"
placeholder="Buscar frase exacta en este documento..."
size="sm"
class="w-full"
:ui="{ trailing: 'pe-1' }"
@keydown="onInputKey"
>
<template #trailing>
<UButton
v-if="localQuery"
icon="i-lucide-x"
color="neutral"
variant="link"
size="xs"
aria-label="Limpiar"
@click="clearLocalQuery"
/>
</template>
</UInput>
</div>
<span
class="text-xs tabular-nums whitespace-nowrap min-w-[3.5rem] text-center"
:class="totalMatches ? 'text-toned font-medium' : 'text-dimmed'"
>
<template v-if="localQuery">
{{ totalMatches ? `${currentIdx + 1} / ${totalMatches}` : '0 / 0' }}
</template>
</span>
<UTooltip text="Anterior (Shift+Enter)">
<UButton
icon="i-lucide-chevron-up"
color="neutral"
variant="ghost"
size="sm"
:disabled="!totalMatches"
aria-label="Coincidencia anterior"
@click="prevMatch"
/>
</UTooltip>
<UTooltip text="Siguiente (Enter)">
<UButton
icon="i-lucide-chevron-down"
color="neutral"
variant="ghost"
size="sm"
:disabled="!totalMatches"
aria-label="Coincidencia siguiente"
@click="nextMatch"
/>
</UTooltip>
</div>
<article
v-if="activity?.body"
ref="bodyContainer"
class="prose prose-sm max-w-none dark:prose-invert p-4 sm:p-6 pb-24"
/>
<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>
<!--
Botón flotante (FAB) para guardar/quitar de favoritos.
- Vive dentro del contenedor de scroll para anclarse al panel y no
interferir con otros layouts (e.g. layouts multi-panel).
- Truco: el wrapper es `sticky bottom-0 h-0` con `pointer-events-none`,
de forma que ocupa cero altura en el flujo del documento pero
mantiene al hijo absoluto pegado al borde inferior visible mientras
el usuario hace scroll. Así el botón siempre está a un toque,
igual en escritorio y en móvil.
- El padding-bottom extra del article (`pb-24`) evita que el FAB
tape el final del texto cuando se llega al final del documento.
-->
<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>