search/app/components/inbox/InboxList.vue

801 lines
28 KiB
Vue
Executable File

<script setup lang="ts">
import { useIntersectionObserver } from '@vueuse/core'
import type { SearchHit } from '~/types'
import { useFavoritesStore } from '~/stores/favorites'
const props = defineProps<{
activities: SearchHit[]
query?: string
hasMore?: boolean
loading?: boolean
loadingMore?: boolean
/** Identificador de la colección a la que pertenecen estos resultados.
* Se propaga al sistema de favoritos para distinguir entre tipos de
* contenido (actividades, conferencias, etc.). */
collection?: string
}>()
const favorites = useFavoritesStore()
const toast = useToast()
function onToggleFavorite(hit: SearchHit, ev: Event) {
// Evita que el click en la estrella abra el detalle.
ev.stopPropagation()
if (!props.collection) return
const wasFav = favorites.isFavorite(props.collection, hit._id)
favorites.toggle(props.collection, hit)
toast.add({
title: wasFav ? 'Eliminado de tu lista' : 'Guardado en tu lista',
description: hit.title,
icon: wasFav ? 'i-lucide-bookmark-x' : 'i-lucide-bookmark-check',
color: wasFav ? 'neutral' : 'primary',
duration: 1800
})
}
const emits = defineEmits<{
loadMore: []
}>()
const activitiesRefs = ref<Record<string | number, Element | null>>({})
const selectedActivity = defineModel<SearchHit | null>()
watch(selectedActivity, () => {
if (!selectedActivity.value) return
const el = activitiesRefs.value[selectedActivity.value._id]
if (el) el.scrollIntoView({ block: 'nearest' })
})
defineShortcuts({
arrowdown: () => {
const list = props.activities
if (!list.length) return
const idx = list.findIndex(a => a._id === selectedActivity.value?._id)
if (idx === -1) selectedActivity.value = list[0]
else if (idx < list.length - 1) selectedActivity.value = list[idx + 1]
},
arrowup: () => {
const list = props.activities
if (!list.length) return
const idx = list.findIndex(a => a._id === selectedActivity.value?._id)
if (idx === -1) selectedActivity.value = list[list.length - 1]
else if (idx > 0) selectedActivity.value = list[idx - 1]
}
})
function safeDate(activity: SearchHit) {
const d = activity.date ?? activity.isodate
if (!d) return ''
const ts = typeof d === 'string' ? new Date(d).getTime() : (d as number) * 1000
if (!Number.isFinite(ts)) return ''
return formatDate(ts / 1000)
}
function hasDate(activity: SearchHit) {
return activity.date != null || activity.isodate != null
}
const HIDDEN_FIELDS = new Set([
'_id', 'id', 'slug', 'date', 'isodate', 'type', 'thumbnail', 'files',
'place', 'city', 'state', 'country',
'title', 'body'
])
const FIELD_LABELS: Record<string, string> = {
speaker: 'orador',
speakers: 'oradores',
preacher: 'predicador',
author: 'autor',
authors: 'autores',
tags: 'etiquetas',
keywords: 'palabras clave',
categories: 'categorías',
category: 'categoría',
transcript: 'transcripción',
description: 'descripción',
excerpt: 'extracto',
summary: 'resumen',
notes: 'notas',
activity: 'actividad',
conference: 'conferencia'
}
function fieldLabel(key: string) {
return FIELD_LABELS[key] || key
}
// ---- Text helpers ------------------------------------------------------
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/** Strip diacritics + lowercase. Matches Meilisearch's default normalization
* so the user can type without accents and still find accented text. */
function normalize(s: string): string {
return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
}
/** Build a position-preserving normalized version of `text` plus a map from
* normalized index -> original index. Combining marks decompose into 0-length
* contributions, so the original index can drift; the map handles that. */
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 }
}
/** Compile a term into a regex source that matches the same phrase even when
* the whitespace differs (single space vs newline, double space, etc.). */
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+')
}
/** Find all literal occurrences of any term in `text`, accent-insensitive and
* whitespace-flexible. Returns spans of the ORIGINAL text. */
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
}
function stripHtml(s: string): string {
return s
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ' ')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/\s+/g, ' ')
.trim()
}
/** Like stripHtml but also drops the *content* of inline citation markers
* (superscripts, subscripts, footnote refs). Useful when a phrase like
* "los países que" gets interrupted in the source by `<sup>1</sup>` between
* words — the standard strip leaves "los 1 países que" and breaks the match. */
function stripHtmlAggressive(s: string): string {
return s
.replace(/<sup[^>]*>[\s\S]*?<\/sup>/gi, ' ')
.replace(/<sub[^>]*>[\s\S]*?<\/sub>/gi, ' ')
.replace(/<a\b[^>]*class="[^"]*(?:footnote|fn-ref|ref)[^"]*"[^>]*>[\s\S]*?<\/a>/gi, ' ')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ' ')
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/\s+/g, ' ')
.trim()
}
/**
* Tokenize the user's query, respecting double-quoted phrases.
* los paises "que vean" otra
* -> ['los', 'paises', 'que vean', 'otra']
* Quoted phrases are kept intact so they're matched and highlighted as a unit;
* standalone words must be at least 2 chars to avoid noisy "a", "y" matches.
*/
function buildTerms(query: string): string[] {
if (!query) return []
const out: string[] = []
const re = /"([^"]+)"|(\S+)/g
let m: RegExpExecArray | null
while ((m = re.exec(query)) !== null) {
if (m[1] !== undefined) {
const phrase = m[1].trim()
if (phrase.length > 0) out.push(phrase)
} else if (m[2] !== undefined) {
const word = m[2].trim()
if (word.length > 1) out.push(word)
}
}
return out
}
/** Wrap each accent-insensitive occurrence of any term in <mark>.
* The marked content keeps the ORIGINAL characters (with accents), so the
* display preserves the text the user is reading, not the normalized form. */
function highlightPlain(text: string, terms: string[]): string {
if (!text) return ''
if (!terms.length) return escapeHtml(text)
const matches = findMatchesInText(text, terms)
if (!matches.length) return escapeHtml(text)
let out = ''
let cursor = 0
for (const { start, end } of matches) {
if (start > cursor) out += escapeHtml(text.slice(cursor, start))
out += '<mark class="search-match">' + escapeHtml(text.slice(start, end)) + '</mark>'
cursor = end
}
if (cursor < text.length) out += escapeHtml(text.slice(cursor))
return out
}
function findInPlain(text: string, terms: string[]): boolean {
if (!text || !terms.length) return false
if (findMatchesInText(stripHtml(text), terms).length > 0) return true
// Try once more with aggressive stripping (drops <sup>/<sub> content) so
// phrases interrupted by footnote/verse markers still register as matches.
return findMatchesInText(stripHtmlAggressive(text), terms).length > 0
}
/** Strip *block-level* tags so the snippet renders cleanly inline (with line-clamp).
* Inline tags (b, strong, em, i, u, sup, sub, a, span, mark, br, etc.) are kept. */
function flattenBlocksToInline(html: string): string {
const blockTags = 'p|div|li|ul|ol|h[1-6]|blockquote|tr|td|th|article|section|aside|header|footer|nav|main|figure|figcaption|pre|table|thead|tbody|tfoot'
const re = new RegExp(`</?(?:${blockTags})\\b[^>]*>`, 'gi')
return html.replace(re, ' ').replace(/\s+/g, ' ').trim()
}
/** Walk all text nodes, build the FLAT text content of `root`, find matches in
* that flat text, then for each text node mark whichever portion of the match
* falls inside it. This handles phrases split across HTML tags (e.g.
* "los <em>países</em> que está") — they become several adjacent <mark>s. */
function applyHighlightsToContainer(root: Element, terms: string[]): Element[] {
if (!terms.length) return []
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 []
const matches = findMatchesInText(flat, terms)
if (!matches.length) return []
const marks: Element[] = []
// Process text nodes from last to first so DOM modifications (which only
// affect the node currently being replaced) don't invalidate references to
// earlier nodes. Each node gets replaced once with a fragment containing
// its 0..N match segments wrapped in <mark>.
for (let i = infos.length - 1; i >= 0; i--) {
const info = infos[i]
const nodeText = info.node.nodeValue || ''
const segments: { start: number, end: 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 })
}
if (!segments.length) continue
segments.sort((a, b) => a.start - b.start)
const frag = document.createDocumentFragment()
let cursor = 0
for (const { start, end } of segments) {
if (start > cursor) frag.appendChild(document.createTextNode(nodeText.slice(cursor, start)))
const mark = document.createElement('mark')
mark.className = 'search-match'
mark.textContent = nodeText.slice(start, end)
frag.appendChild(mark)
marks.push(mark)
cursor = end
}
if (cursor < nodeText.length) frag.appendChild(document.createTextNode(nodeText.slice(cursor)))
info.node.parentNode?.replaceChild(frag, info.node)
}
marks.reverse() // restore document order
return marks
}
/** Build an HTML snippet centered on the first match, preserving inline
* formatting from the source (bold, italic, sup, links, etc.). Uses Range API
* so any tag opened inside the cropped region is automatically closed. */
function snippetHtmlAroundMatch(rawHtml: string, terms: string[], ctx = 120): string | null {
if (!rawHtml) return null
if (typeof document === 'undefined') return null
if (!terms.length) return null
const container = document.createElement('div')
container.innerHTML = (typeof fixLink === 'function' ? fixLink(rawHtml) || '' : rawHtml)
const marks = applyHighlightsToContainer(container, terms)
if (!marks.length) return null
// Collect all text nodes in document order *after* highlighting.
const allText: Text[] = []
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null)
let n: Node | null
while ((n = walker.nextNode())) allText.push(n as Text)
if (!allText.length) return null
// Find text-node boundaries of the first <mark>.
const firstMark = marks[0]
const markText: Text[] = []
const mw = document.createTreeWalker(firstMark, NodeFilter.SHOW_TEXT, null)
while ((n = mw.nextNode())) markText.push(n as Text)
if (!markText.length) return null
const matchStartIdx = allText.indexOf(markText[0])
const matchEndIdx = allText.indexOf(markText[markText.length - 1])
if (matchStartIdx < 0 || matchEndIdx < 0) return null
// Walk back to gather ~ctx chars before the match.
let startNode: Text = allText[matchStartIdx]
let startOffset = 0
let before = ctx
for (let i = matchStartIdx - 1; i >= 0 && before > 0; i--) {
const len = allText[i].nodeValue?.length || 0
if (len >= before) {
startNode = allText[i]
startOffset = Math.max(0, len - before)
before = 0
} else {
before -= len
startNode = allText[i]
startOffset = 0
}
}
// Walk forward to gather ~ctx chars after the match.
let endNode: Text = allText[matchEndIdx]
let endOffset = endNode.nodeValue?.length || 0
let after = ctx
for (let i = matchEndIdx + 1; i < allText.length && after > 0; i++) {
const len = allText[i].nodeValue?.length || 0
if (len >= after) {
endNode = allText[i]
endOffset = after
after = 0
} else {
after -= len
endNode = allText[i]
endOffset = len
}
}
// Snap boundaries to nearest word breaks for cleaner cuts.
const startText = startNode.nodeValue || ''
if (startOffset > 0 && startOffset < startText.length) {
const sp = startText.indexOf(' ', startOffset)
if (sp !== -1 && sp - startOffset < 25) startOffset = sp + 1
}
const endText = endNode.nodeValue || ''
if (endOffset > 0 && endOffset < endText.length) {
const sp = endText.lastIndexOf(' ', endOffset)
if (sp !== -1 && endOffset - sp < 25) endOffset = sp
}
const range = document.createRange()
range.setStart(startNode, startOffset)
range.setEnd(endNode, endOffset)
const tmp = document.createElement('div')
tmp.appendChild(range.cloneContents())
let html = flattenBlocksToInline(tmp.innerHTML)
// Ellipsis if we cut anything off.
const truncatedStart = startNode !== allText[0] || startOffset > 0
const lastNode = allText[allText.length - 1]
const lastLen = lastNode.nodeValue?.length || 0
const truncatedEnd = endNode !== lastNode || endOffset < lastLen
if (truncatedStart) html = '… ' + html
if (truncatedEnd) html = html + ' …'
return html
}
/** No-query case: take a short HTML chunk from the start of the body. */
function startHtmlChunk(rawHtml: string, maxChars = 180): string | null {
if (!rawHtml) return null
if (typeof document === 'undefined') {
const plain = stripHtml(rawHtml)
return plain ? escapeHtml(plain.length > maxChars ? plain.slice(0, maxChars) + '…' : plain) : null
}
const container = document.createElement('div')
container.innerHTML = (typeof fixLink === 'function' ? fixLink(rawHtml) || '' : rawHtml)
const allText: Text[] = []
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null)
let n: Node | null
while ((n = walker.nextNode())) allText.push(n as Text)
if (!allText.length) return null
let endNode: Text = allText[0]
let endOffset = 0
let remaining = maxChars
for (let i = 0; i < allText.length && remaining > 0; i++) {
const len = allText[i].nodeValue?.length || 0
if (len >= remaining) {
endNode = allText[i]
endOffset = remaining
remaining = 0
} else {
remaining -= len
endNode = allText[i]
endOffset = len
}
}
const range = document.createRange()
range.setStart(allText[0], 0)
range.setEnd(endNode, endOffset)
const tmp = document.createElement('div')
tmp.appendChild(range.cloneContents())
let html = flattenBlocksToInline(tmp.innerHTML)
const lastNode = allText[allText.length - 1]
const lastLen = lastNode.nodeValue?.length || 0
if (endNode !== lastNode || endOffset < lastLen) html = html + ' …'
return html
}
/** Plain-text fallback when the HTML-preserving snippet can't find the phrase
* (because tags get in the way). We strip HTML, find the phrase in the
* resulting text, and build a snippet around it. Loses inline formatting but
* guarantees a snippet exists for every result Meilisearch returned. */
function snippetPlainAroundMatch(rawHtml: string, terms: string[], ctx = 120, aggressive = false): string | null {
const plain = aggressive ? stripHtmlAggressive(rawHtml) : stripHtml(rawHtml)
if (!plain) return null
const matches = findMatchesInText(plain, terms)
if (!matches.length) return null
const first = matches[0]
let start = Math.max(0, first.start - ctx)
let end = Math.min(plain.length, first.end + ctx)
if (start > 0) {
const sp = plain.indexOf(' ', start)
if (sp !== -1 && sp - start < 25) start = sp + 1
}
if (end < plain.length) {
const sp = plain.lastIndexOf(' ', end)
if (sp !== -1 && end - sp < 25) end = sp
}
// Highlight every match that falls inside the snippet window.
const winMatches = matches.filter(m => m.start >= start && m.end <= end)
let html = ''
let cursor = start
for (const m of winMatches) {
if (m.start > cursor) html += escapeHtml(plain.slice(cursor, m.start))
html += '<mark class="search-match">' + escapeHtml(plain.slice(m.start, m.end)) + '</mark>'
cursor = m.end
}
if (cursor < end) html += escapeHtml(plain.slice(cursor, end))
if (start > 0) html = '… ' + html
if (end < plain.length) html = html + ' …'
return html
}
interface BodyPreview {
html: string
/** True if we couldn't pinpoint the phrase position in the body and are
* just showing the start of the document as fallback context. */
approximate: boolean
}
function bodyPreview(rawHtml: string, terms: string[], meiliMatchedBody = false): BodyPreview | null {
if (!rawHtml) return null
if (terms.length) {
// 1. HTML-preserving snippet (keeps bold/italic/sup formatting).
const htmlSnippet = snippetHtmlAroundMatch(rawHtml, terms, 120)
if (htmlSnippet) return { html: htmlSnippet, approximate: false }
// 2. Plain-text snippet (handles phrases split across simple inline tags).
const plainSnippet = snippetPlainAroundMatch(rawHtml, terms, 120, false)
if (plainSnippet) return { html: plainSnippet, approximate: false }
// 3. Aggressive plain-text snippet — drops <sup>/<sub> content (footnote
// numbers, verse markers) that often interrupt phrases between words.
const aggressiveSnippet = snippetPlainAroundMatch(rawHtml, terms, 120, true)
if (aggressiveSnippet) return { html: aggressiveSnippet, approximate: false }
// 4. Meilisearch insists the body matched but we can't pinpoint where —
// show the start of the body as fallback context, and flag the row.
if (meiliMatchedBody) {
const start = startHtmlChunk(rawHtml, 180)
if (start) return { html: start, approximate: true }
}
return null
}
const start = startHtmlChunk(rawHtml, 200)
return start ? { html: start, approximate: false } : null
}
// ---- Row view-model ----------------------------------------------------
interface RowVm {
activity: SearchHit
title: string
body: string | null
bodyApproximate: boolean
extraSnippets: { field: string, snippet: string }[]
extraFields: string[]
}
const rows = computed<RowVm[]>(() => {
const terms = buildTerms(props.query || '')
const hasQuery = terms.length > 0
const result = props.activities.map((activity) => {
const titleText = activity.title || ''
const titleHtml = highlightPlain(titleText, terms)
const meiliMatchedBody = !!activity._matchesPosition?.body
let bodyResult = bodyPreview(activity.body || '', terms, meiliMatchedBody)
const titleMatched = hasQuery && findInPlain(titleText, terms)
const bodyMatched = hasQuery && !!bodyResult && !bodyResult.approximate
&& bodyResult.html.includes('<mark')
const showFallback = hasQuery && !titleMatched && !bodyMatched
const extraSnippets: RowVm['extraSnippets'] = []
const extraFields: string[] = []
if (showFallback) {
for (const key of Object.keys(activity)) {
if (HIDDEN_FIELDS.has(key) || key.startsWith('_')) continue
const val = (activity as Record<string, unknown>)[key]
if (typeof val !== 'string') continue
if (!findInPlain(val, terms)) continue
if (!extraFields.includes(key)) extraFields.push(key)
if (extraSnippets.length < 2) {
const snip = snippetHtmlAroundMatch(val, terms, 90)
|| snippetPlainAroundMatch(val, terms, 90, false)
|| snippetPlainAroundMatch(val, terms, 90, true)
if (snip) extraSnippets.push({ field: key, snippet: snip })
}
}
if (!extraFields.length && activity._matchesPosition) {
for (const key of Object.keys(activity._matchesPosition)) {
if (!HIDDEN_FIELDS.has(key)) extraFields.push(key)
}
}
}
// Universal safety net: if there's a query but we still have NOTHING to
// show below the title (no body snippet, no extras, no "matched in" hint),
// surface at least the start of the body so the user knows what document
// they're looking at and that Meilisearch saw a match somewhere.
if (
hasQuery
&& !bodyResult
&& !extraSnippets.length
&& !extraFields.length
&& activity.body
) {
const start = startHtmlChunk(activity.body, 180)
if (start) bodyResult = { html: start, approximate: true }
}
return {
activity,
title: titleHtml,
body: bodyResult?.html ?? null,
bodyApproximate: bodyResult?.approximate ?? false,
extraSnippets,
extraFields
}
})
if (hasQuery) {
return result.filter(row => {
const titleHasMatch = row.title.includes('<mark')
const hasBodyMatch = row.body && !row.bodyApproximate
const hasExtraSnippets = row.extraSnippets.length > 0
return titleHasMatch || hasBodyMatch || hasExtraSnippets
})
}
return result
})
// ---- Infinite scroll ---------------------------------------------------
const sentinel = ref<HTMLElement | null>(null)
useIntersectionObserver(
sentinel,
([entry]) => {
if (!entry?.isIntersecting) return
if (props.hasMore && !props.loadingMore && !props.loading) {
emits('loadMore')
}
},
{ threshold: 0 }
)
</script>
<template>
<div class="overflow-y-auto divide-y divide-default flex-1">
<div
v-if="loading && !rows.length"
class="flex items-center justify-center gap-2 py-16 text-sm text-muted"
>
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
Buscando...
</div>
<div
v-else-if="!rows.length"
class="flex flex-col items-center justify-center gap-2 py-16 text-dimmed text-sm"
>
<UIcon name="i-lucide-inbox" class="size-10" />
<p>{{ query ? `Sin coincidencias para "${query}"` : 'No hay actividades' }}</p>
</div>
<div
v-for="(row, index) in rows"
:key="row.activity._id ?? index"
:ref="(el) => { activitiesRefs[row.activity._id] = el as Element | null }"
>
<div
class="bg-gray-100 p-4 sm:px-4 text-sm cursor-pointer border-l-2 transition-colors"
:class="[
row.activity.unread ? 'text-highlighted' : 'text-toned',
selectedActivity && selectedActivity._id === row.activity._id
? 'border-primary bg-primary/10'
: 'border-transparent hover:border-primary hover:bg-primary/5'
]"
@click="selectedActivity = row.activity"
>
<div class="">
<div class="flex items-start justify-between gap-2 mb-1 ">
<div class="text-sm font-semibold line-clamp-2" v-html="row.title" />
<div class="flex items-center gap-1 shrink-0">
<UChip v-if="row.activity.unread" />
<UTooltip
v-if="collection"
:text="favorites.isFavorite(collection, row.activity._id) ? 'Quitar de mi lista' : 'Guardar en mi lista'"
>
<UButton
:icon="favorites.isFavorite(collection, row.activity._id) ? 'i-lucide-bookmark-check' : 'i-lucide-bookmark-plus'"
:color="favorites.isFavorite(collection, row.activity._id) ? 'primary' : 'neutral'"
variant="ghost"
size="xs"
:aria-label="favorites.isFavorite(collection, row.activity._id) ? 'Quitar de mi lista' : 'Guardar en mi lista'"
@click="(ev: MouseEvent) => onToggleFavorite(row.activity, ev)"
/>
</UTooltip>
</div>
</div>
<p class="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 text-sm sm:text-xs mb-1 justify-between ">
<span v-if="hasDate(row.activity)" class="flex items-center gap-1"><UIcon name="ph:calendar" class="size-5 text-green-600" />{{ safeDate(row.activity) }}</span>
<span class="truncate flex items-center"><UIcon name="ph:map-pin" class="size-5 text-green-600" />{{ formatLocation(row.activity) }}</span>
</p>
<div class="mt-2 !text-sm" v-if="row.body">
<div
v-if="row.bodyApproximate"
class="text-[11px] text-warning flex items-center gap-1 mb-0.5 bg-white p-2 rounded-xl"
>
<UIcon name="i-lucide-info" class="size-3" />
<span>Coincidencia en el documento (abre para ubicarla con la búsqueda interna)</span>
</div>
<div
class="mt-1 snippet-html text-dimmed text-sm sm:text-xs bg-white p-3 rounded-lg"
v-html="row.body"
/>
</div>
<div
v-for="extra in row.extraSnippets"
:key="extra.field"
class="mt-1.5 text-xs text-dimmed flex items-start gap-1.5"
>
<UBadge
:label="fieldLabel(extra.field)"
size="xs"
color="warning"
variant="subtle"
class="mt-0.5 shrink-0 capitalize"
/>
<span class="snippet-html line-clamp-2" v-html="extra.snippet" />
</div>
<div
v-if="!row.extraSnippets.length && row.extraFields.length"
class="mt-1.5 text-xs text-muted flex items-center gap-1.5 "
>
<UIcon name="i-lucide-info" class="size-3.5" />
<span>
Coincide en:
<span class="font-medium capitalize">{{ row.extraFields.map(fieldLabel).join(', ') }}</span>
</span>
</div>
</div>
</div>
</div>
<div ref="sentinel" class="h-12 -mt-12 pointer-events-none" aria-hidden="true" />
<div
v-if="loadingMore"
class="py-3 flex items-center justify-center gap-2 text-xs text-muted"
>
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
Cargando más...
</div>
<div
v-else-if="rows.length && !hasMore && !loading"
class="py-3 text-center text-xs text-dimmed"
>
No hay más resultados
</div>
</div>
</template>
<style scoped>
/* Keep snippet HTML readable but compact. We render arbitrary inline formatting
from the source (bold, italic, sup, links, mark) — strip default link colors
so the row stays readable, and prevent inline elements from breaking layout. */
.snippet-html :deep(a) {
color: inherit;
text-decoration: none;
pointer-events: none;
font-weight: inherit;
}
.snippet-html :deep(strong),
.snippet-html :deep(b) {
font-weight: 600;
color: var(--ui-color-toned, inherit);
}
.snippet-html :deep(em),
.snippet-html :deep(i) {
font-style: italic;
}
.snippet-html :deep(sup) {
font-size: 0.7em;
vertical-align: super;
}
.snippet-html :deep(br) {
display: none;
}
</style>