800 lines
28 KiB
Vue
Executable File
800 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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
}
|
|
|
|
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(/ /g, ' ')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/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(/ /g, ' ')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/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="p-4 sm:px-6 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="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 items-center gap-2 text-xs text-muted mb-1">
|
|
<span v-if="hasDate(row.activity)">{{ safeDate(row.activity) }}</span>
|
|
<USeparator v-if="hasDate(row.activity)" orientation="vertical" class="h-3" />
|
|
<span class="truncate">{{ formatLocation(row.activity) }}</span>
|
|
</p>
|
|
|
|
<div v-if="row.body">
|
|
<div
|
|
v-if="row.bodyApproximate"
|
|
class="text-[11px] text-warning flex items-center gap-1 mb-0.5"
|
|
>
|
|
<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="snippet-html text-dimmed line-clamp-3 text-xs"
|
|
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 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>
|