Compare commits
No commits in common. "756c62af86ce9719fbc07472f22421d82de7fa86" and "858b67b9caecefe5cd66cef1da17961563af90f0" have entirely different histories.
756c62af86
...
858b67b9ca
|
|
@ -75,20 +75,3 @@ mark.search-match.is-current {
|
||||||
70% { box-shadow: 0 0 0 14px rgba(249, 115, 22, 0), 0 0 0 2px #8cff32 inset; }
|
70% { box-shadow: 0 0 0 14px rgba(249, 115, 22, 0), 0 0 0 2px #8cff32 inset; }
|
||||||
100% { box-shadow: 0 0 0 0 rgba(249, 115, 22, 0), 0 0 0 2px #8cff32 inset; }
|
100% { box-shadow: 0 0 0 0 rgba(249, 115, 22, 0), 0 0 0 2px #8cff32 inset; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Span de coincidencia en la vista de detalle de estudios Typesense.
|
|
||||||
Marca el párrafo que el usuario clickeó desde los resultados de búsqueda. */
|
|
||||||
#bible-study-search-match {
|
|
||||||
background-color: #fdff32;
|
|
||||||
color: #000;
|
|
||||||
padding: 2px;
|
|
||||||
border-radius: 2px;
|
|
||||||
font-weight: 600;
|
|
||||||
box-shadow: 0 0 0 1px #e9ff32 inset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark #bible-study-search-match {
|
|
||||||
background-color: #fdff32;
|
|
||||||
color: #000;
|
|
||||||
box-shadow: 0 0 0 1px #e9ff32 inset;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,936 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import dayjs from 'dayjs'
|
|
||||||
import { useDebounce } from '@vueuse/core'
|
|
||||||
import { useFavoritesStore } from '~/stores/favorites'
|
|
||||||
import { useHistoryStore } from '~/stores/history'
|
|
||||||
import type { SearchHit } from '~/types'
|
|
||||||
|
|
||||||
interface TypesenseHighlight {
|
|
||||||
field?: string
|
|
||||||
snippet?: string
|
|
||||||
value?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ParagraphDoc {
|
|
||||||
id?: string
|
|
||||||
document_id: string
|
|
||||||
text: string
|
|
||||||
number: number
|
|
||||||
locale: string
|
|
||||||
type: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TypesenseParagraphHit {
|
|
||||||
document: ParagraphDoc
|
|
||||||
highlights?: TypesenseHighlight[]
|
|
||||||
highlight?: Record<string, { snippet?: string, value?: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DocumentDoc {
|
|
||||||
id?: string
|
|
||||||
code: string
|
|
||||||
locale: string
|
|
||||||
type: string
|
|
||||||
title: string
|
|
||||||
timestamp: number
|
|
||||||
date: string
|
|
||||||
place?: string
|
|
||||||
city?: string
|
|
||||||
state?: string
|
|
||||||
country?: string
|
|
||||||
thumbnail?: string
|
|
||||||
files?: {
|
|
||||||
youtube?: string
|
|
||||||
video?: string
|
|
||||||
audio?: string
|
|
||||||
booklet?: string
|
|
||||||
simple?: string
|
|
||||||
}
|
|
||||||
slug?: string
|
|
||||||
body?: string
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
document: DocumentDoc | null
|
|
||||||
documentLoading: boolean
|
|
||||||
paragraphs: TypesenseParagraphHit[]
|
|
||||||
paragraphsLoading: boolean
|
|
||||||
collection: string
|
|
||||||
query?: string
|
|
||||||
selectedHit?: TypesenseParagraphHit | null
|
|
||||||
selectedMatchingHits?: TypesenseParagraphHit[] | null
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emits = defineEmits(['close'])
|
|
||||||
|
|
||||||
const { locale } = useI18n()
|
|
||||||
const favorites = useFavoritesStore()
|
|
||||||
const history = useHistoryStore()
|
|
||||||
const toast = useToast()
|
|
||||||
|
|
||||||
function toSearchHit(doc: DocumentDoc): SearchHit {
|
|
||||||
return {
|
|
||||||
_id: doc.id || doc.code || '',
|
|
||||||
id: doc.id,
|
|
||||||
title: doc.title || '',
|
|
||||||
date: doc.date,
|
|
||||||
place: doc.place,
|
|
||||||
city: doc.city,
|
|
||||||
state: doc.state,
|
|
||||||
country: doc.country,
|
|
||||||
body: doc.body,
|
|
||||||
thumbnail: doc.thumbnail,
|
|
||||||
type: doc.type,
|
|
||||||
files: doc.files,
|
|
||||||
slug: doc.slug,
|
|
||||||
...doc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => [props.collection, props.document?.id] as const,
|
|
||||||
([collection, id]) => {
|
|
||||||
if (!collection || !id || !props.document) 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) 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.title,
|
|
||||||
icon: wasFav ? 'i-lucide-bookmark-x' : 'i-lucide-bookmark-check',
|
|
||||||
color: wasFav ? 'neutral' : 'primary',
|
|
||||||
duration: 1800
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchUrl = computed(() => {
|
|
||||||
const doc = props.document
|
|
||||||
if (!doc?.type) return null
|
|
||||||
const ts = doc.timestamp
|
|
||||||
? doc.timestamp * 1000
|
|
||||||
: doc.date ? new Date(doc.date).getTime() : null
|
|
||||||
if (!ts || !Number.isFinite(ts)) return null
|
|
||||||
const d = dayjs(ts)
|
|
||||||
const month = (d.month() + 1).toString().padStart(2, '0')
|
|
||||||
const slug = doc.slug && doc.slug !== 'undefined' ? doc.slug : doc.id ?? doc.code
|
|
||||||
return `https://www.carpa.com/${locale.value}/${doc.type}/${d.year()}/${month}/${slug}`
|
|
||||||
})
|
|
||||||
|
|
||||||
const fileLinks = computed(() => formatFiles(props.document?.files || {}))
|
|
||||||
|
|
||||||
function safeDate(): string {
|
|
||||||
const doc = props.document
|
|
||||||
if (!doc) return ''
|
|
||||||
const ts = doc.timestamp
|
|
||||||
? doc.timestamp
|
|
||||||
: doc.date ? Math.floor(new Date(doc.date).getTime() / 1000) : null
|
|
||||||
if (!ts) return doc.date || ''
|
|
||||||
return formatDate(ts)
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeLocation(): string {
|
|
||||||
const doc = props.document
|
|
||||||
if (!doc) return ''
|
|
||||||
return formatLocation({
|
|
||||||
id: doc.id || '',
|
|
||||||
date: doc.timestamp ?? 0,
|
|
||||||
slug: doc.slug ?? '',
|
|
||||||
type: doc.type ?? '',
|
|
||||||
place: doc.place ?? '',
|
|
||||||
city: doc.city ?? '',
|
|
||||||
state: doc.state ?? '',
|
|
||||||
country: doc.country ?? '',
|
|
||||||
thumbnail: ''
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Utilerías de highlight --------------------------------------------------
|
|
||||||
|
|
||||||
function highlightedFor(hit: TypesenseParagraphHit, field: string): string | null {
|
|
||||||
const fromArr = hit.highlights?.find(h => h.field === field)
|
|
||||||
if (fromArr?.snippet) return fromArr.snippet
|
|
||||||
if (fromArr?.value) return fromArr.value
|
|
||||||
const fromObj = hit.highlight?.[field]
|
|
||||||
if (fromObj?.snippet) return fromObj.snippet
|
|
||||||
if (fromObj?.value) return fromObj.value
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeEntities(s: string): string {
|
|
||||||
if (!s || s.indexOf('&') === -1) return s
|
|
||||||
if (typeof document === 'undefined') return s
|
|
||||||
const tmp = document.createElement('div')
|
|
||||||
tmp.innerHTML = s
|
|
||||||
return tmp.textContent || s
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Snippet parsing & precise mark application ----------------------------
|
|
||||||
|
|
||||||
interface SnippetSegment {
|
|
||||||
text: string
|
|
||||||
isMarked: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// Descompone el HTML del snippet en segmentos marcados y no marcados.
|
|
||||||
function parseSnippetHtml(html: string): SnippetSegment[] {
|
|
||||||
const segments: SnippetSegment[] = []
|
|
||||||
let lastIdx = 0
|
|
||||||
const markRe = /<mark[^>]*>([\s\S]*?)<\/mark>/gi
|
|
||||||
let m: RegExpExecArray | null
|
|
||||||
while ((m = markRe.exec(html)) !== null) {
|
|
||||||
if (m.index > lastIdx) {
|
|
||||||
const raw = html.slice(lastIdx, m.index).replace(/<[^>]*>/g, '')
|
|
||||||
if (raw) segments.push({ text: decodeEntities(raw), isMarked: false })
|
|
||||||
}
|
|
||||||
segments.push({ text: decodeEntities(m[1]), isMarked: true })
|
|
||||||
lastIdx = m.index + m[0].length
|
|
||||||
}
|
|
||||||
if (lastIdx < html.length) {
|
|
||||||
const raw = html.slice(lastIdx).replace(/<[^>]*>/g, '')
|
|
||||||
if (raw) segments.push({ text: decodeEntities(raw), isMarked: false })
|
|
||||||
}
|
|
||||||
return segments
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recolecta nodos de texto de un párrafo, excluyendo los que ya están dentro de marks.
|
|
||||||
function collectParaTextNodes(paraEl: HTMLElement): { flat: string, nmap: { node: Text, start: number, end: number }[] } {
|
|
||||||
const walker = document.createTreeWalker(paraEl, NodeFilter.SHOW_TEXT, {
|
|
||||||
acceptNode(node: Node) {
|
|
||||||
let p = node.parentNode
|
|
||||||
while (p && p !== paraEl) {
|
|
||||||
if (p instanceof HTMLElement && p.matches('mark.search-match')) return NodeFilter.FILTER_REJECT
|
|
||||||
p = p.parentNode
|
|
||||||
}
|
|
||||||
return NodeFilter.FILTER_ACCEPT
|
|
||||||
}
|
|
||||||
})
|
|
||||||
let flat = ''
|
|
||||||
const nmap: { node: Text, start: number, end: number }[] = []
|
|
||||||
let n: Node | null
|
|
||||||
while ((n = walker.nextNode())) {
|
|
||||||
const node = n as Text
|
|
||||||
const t = node.nodeValue || ''
|
|
||||||
nmap.push({ node, start: flat.length, end: flat.length + t.length })
|
|
||||||
flat += t
|
|
||||||
}
|
|
||||||
return { flat, nmap }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ubica el snippet completo (con contexto) en el párrafo y aplica un <mark> por cada
|
|
||||||
// segmento marcado del snippet. Retorna los marks creados en orden de documento.
|
|
||||||
//
|
|
||||||
// Algoritmo: aplica los marks de DERECHA A IZQUIERDA para que las posiciones
|
|
||||||
// computadas desde el flat text original sigan siendo válidas en cada iteración,
|
|
||||||
// ya que las mutaciones DOM a la derecha no desplazan los nodos a la izquierda.
|
|
||||||
function findAndApplySnippetMarks(paraEl: HTMLElement, snippetHtml: string): HTMLElement[] {
|
|
||||||
const segments = parseSnippetHtml(snippetHtml)
|
|
||||||
if (!segments.some(s => s.isMarked)) return []
|
|
||||||
|
|
||||||
const fullPlain = segments.map(s => s.text).join('')
|
|
||||||
|
|
||||||
// Eliminar SOLO marcadores de truncación de Typesense (… o ...), NO puntos de oración.
|
|
||||||
// Ejemplo correcto: "…texto aquí…" → "texto aquí"
|
|
||||||
// Ejemplo incorrecto anterior: "el Programa Divino." → "el Programa Divino" (perdía el punto)
|
|
||||||
const stripped = fullPlain
|
|
||||||
.replace(/^(?:…|\.\.\.)\s*/, '')
|
|
||||||
.replace(/\s*(?:…|\.\.\.)$/, '')
|
|
||||||
.trim()
|
|
||||||
if (!stripped) return []
|
|
||||||
|
|
||||||
// Ajustar leadingLen para el cálculo de offsets de marks
|
|
||||||
const leadingLen = fullPlain.length - fullPlain.replace(/^(?:…|\.\.\.)\s*/, '').length
|
|
||||||
|
|
||||||
// Flat text inicial del párrafo para calcular posiciones
|
|
||||||
const { flat: initFlat } = collectParaTextNodes(paraEl)
|
|
||||||
if (!initFlat) return []
|
|
||||||
|
|
||||||
// Mapeo posición-normalizada → posición-original
|
|
||||||
const { normalized: normFlat, map: normToOrig } = normalizeWithMap(initFlat)
|
|
||||||
const normStripped = normalize(stripped)
|
|
||||||
const normIdx = normFlat.indexOf(normStripped)
|
|
||||||
if (normIdx === -1) return []
|
|
||||||
|
|
||||||
// Calcular posición original de cada segmento marcado dentro del snippet
|
|
||||||
const markPositions: { origStart: number, origEnd: number }[] = []
|
|
||||||
let plainCursor = leadingLen
|
|
||||||
|
|
||||||
for (const seg of segments) {
|
|
||||||
const normOffset = normalize(fullPlain.slice(leadingLen, plainCursor)).length
|
|
||||||
if (seg.isMarked && normOffset <= normStripped.length) {
|
|
||||||
const normStart = normIdx + normOffset
|
|
||||||
const normEnd = normStart + normalize(seg.text).length
|
|
||||||
const origStart = normStart < normToOrig.length ? normToOrig[normStart] : initFlat.length
|
|
||||||
const origEnd = normEnd < normToOrig.length ? normToOrig[normEnd] : initFlat.length
|
|
||||||
if (origStart < origEnd) markPositions.push({ origStart, origEnd })
|
|
||||||
}
|
|
||||||
plainCursor += seg.text.length
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!markPositions.length) return []
|
|
||||||
|
|
||||||
// Aplicar marks de derecha a izquierda
|
|
||||||
markPositions.sort((a, b) => b.origStart - a.origStart)
|
|
||||||
const createdMarks: HTMLElement[] = []
|
|
||||||
|
|
||||||
for (const { origStart, origEnd } of markPositions) {
|
|
||||||
// Re-recolectar nodos: los ya marcados quedan excluidos del flat text.
|
|
||||||
// Las posiciones < origStart del último mark aplicado no se ven afectadas.
|
|
||||||
const { nmap } = collectParaTextNodes(paraEl)
|
|
||||||
|
|
||||||
for (let i = nmap.length - 1; i >= 0; i--) {
|
|
||||||
const info = nmap[i]
|
|
||||||
const segStart = Math.max(origStart, info.start) - info.start
|
|
||||||
const segEnd = Math.min(origEnd, info.end) - info.start
|
|
||||||
if (segStart >= segEnd) continue
|
|
||||||
|
|
||||||
const nodeText = info.node.nodeValue || ''
|
|
||||||
const frag = document.createDocumentFragment()
|
|
||||||
if (segStart > 0) frag.appendChild(document.createTextNode(nodeText.slice(0, segStart)))
|
|
||||||
const mark = document.createElement('mark')
|
|
||||||
mark.className = 'search-match match-start'
|
|
||||||
mark.textContent = nodeText.slice(segStart, segEnd)
|
|
||||||
frag.appendChild(mark)
|
|
||||||
createdMarks.push(mark)
|
|
||||||
if (segEnd < nodeText.length) frag.appendChild(document.createTextNode(nodeText.slice(segEnd)))
|
|
||||||
info.node.parentNode?.replaceChild(frag, info.node)
|
|
||||||
}
|
|
||||||
paraEl.normalize()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Los marks se crearon de derecha a izquierda; revertir para orden de documento
|
|
||||||
createdMarks.reverse()
|
|
||||||
return createdMarks
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalize(s: string): string {
|
|
||||||
// = non-breaking space ( ); treat as regular space so DOM text
|
|
||||||
// nodes (which keep as ) match Typesense snippets (regular spaces).
|
|
||||||
return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/ /g, ' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Refs de estado ---------------------------------------------------------
|
|
||||||
|
|
||||||
const paragraphsContainer = ref<HTMLElement | null>(null)
|
|
||||||
|
|
||||||
// 'typesense' = Estado 1 (server-driven), 'local' = Estado 2 (client-driven)
|
|
||||||
type SearchMode = 'typesense' | 'local'
|
|
||||||
const searchMode = ref<SearchMode>('typesense')
|
|
||||||
const matchElements = ref<HTMLElement[]>([])
|
|
||||||
const currentMatchIdx = ref(0)
|
|
||||||
|
|
||||||
// ---- Limpieza de marks del DOM ----------------------------------------------
|
|
||||||
|
|
||||||
function clearMatchMarks() {
|
|
||||||
const container = paragraphsContainer.value
|
|
||||||
if (!container) return
|
|
||||||
container.querySelectorAll('mark.search-match').forEach(m => {
|
|
||||||
const parent = m.parentNode
|
|
||||||
if (parent) {
|
|
||||||
parent.replaceChild(document.createTextNode(m.textContent || ''), m)
|
|
||||||
parent.normalize()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
matchElements.value = []
|
|
||||||
currentMatchIdx.value = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Envolver coincidencias de texto en un párrafo --------------------------
|
|
||||||
//
|
|
||||||
// Recorre los nodos de texto dentro de paraEl, encuentra la primera aparición
|
|
||||||
// de matchText (ignorando diacríticos y mayúsculas) y la envuelve en un <mark>.
|
|
||||||
// Repite hasta que no haya más apariciones. Devuelve todos los marks creados.
|
|
||||||
|
|
||||||
function wrapTextMatchesInParagraph(paraEl: HTMLElement, matchText: string): HTMLElement[] {
|
|
||||||
const normMatch = normalize(matchText)
|
|
||||||
if (!normMatch) return []
|
|
||||||
|
|
||||||
const marks: HTMLElement[] = []
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const walker = document.createTreeWalker(
|
|
||||||
paraEl,
|
|
||||||
NodeFilter.SHOW_TEXT,
|
|
||||||
{
|
|
||||||
acceptNode(node: Node) {
|
|
||||||
let p = node.parentNode
|
|
||||||
while (p && p !== paraEl) {
|
|
||||||
if (p instanceof HTMLElement && p.matches('mark.search-match')) {
|
|
||||||
return NodeFilter.FILTER_REJECT
|
|
||||||
}
|
|
||||||
p = p.parentNode
|
|
||||||
}
|
|
||||||
return NodeFilter.FILTER_ACCEPT
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const textNodes: Text[] = []
|
|
||||||
let n: Node | null
|
|
||||||
while ((n = walker.nextNode())) textNodes.push(n as Text)
|
|
||||||
|
|
||||||
if (!textNodes.length) break
|
|
||||||
|
|
||||||
let flatText = ''
|
|
||||||
const nodeMap: { node: Text, start: number, end: number }[] = []
|
|
||||||
for (const node of textNodes) {
|
|
||||||
const t = node.nodeValue || ''
|
|
||||||
nodeMap.push({ node, start: flatText.length, end: flatText.length + t.length })
|
|
||||||
flatText += t
|
|
||||||
}
|
|
||||||
|
|
||||||
const normFlat = normalize(flatText)
|
|
||||||
const startIdx = normFlat.indexOf(normMatch)
|
|
||||||
if (startIdx === -1) break
|
|
||||||
|
|
||||||
const endIdx = startIdx + normMatch.length
|
|
||||||
let applied = false
|
|
||||||
|
|
||||||
for (let i = nodeMap.length - 1; i >= 0; i--) {
|
|
||||||
const info = nodeMap[i]
|
|
||||||
const segStart = Math.max(startIdx, info.start) - info.start
|
|
||||||
const segEnd = Math.min(endIdx, info.end) - info.start
|
|
||||||
if (segStart >= segEnd) continue
|
|
||||||
|
|
||||||
const nodeText = info.node.nodeValue || ''
|
|
||||||
const frag = document.createDocumentFragment()
|
|
||||||
let cursor = 0
|
|
||||||
|
|
||||||
if (segStart > cursor) frag.appendChild(document.createTextNode(nodeText.slice(cursor, segStart)))
|
|
||||||
|
|
||||||
const mark = document.createElement('mark')
|
|
||||||
mark.className = 'search-match match-start'
|
|
||||||
mark.textContent = nodeText.slice(segStart, segEnd)
|
|
||||||
frag.appendChild(mark)
|
|
||||||
marks.push(mark)
|
|
||||||
cursor = segEnd
|
|
||||||
|
|
||||||
if (cursor < nodeText.length) frag.appendChild(document.createTextNode(nodeText.slice(cursor)))
|
|
||||||
|
|
||||||
info.node.parentNode?.replaceChild(frag, info.node)
|
|
||||||
applied = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!applied) break
|
|
||||||
|
|
||||||
paraEl.normalize()
|
|
||||||
}
|
|
||||||
|
|
||||||
return marks
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Estado 1: aplicar highlights de Typesense y scroll inicial -------------
|
|
||||||
|
|
||||||
async function applyTypesenseHighlights() {
|
|
||||||
await nextTick()
|
|
||||||
clearMatchMarks()
|
|
||||||
|
|
||||||
const container = paragraphsContainer.value
|
|
||||||
if (!container) return
|
|
||||||
|
|
||||||
searchMode.value = 'typesense'
|
|
||||||
|
|
||||||
const hasMatchingHits = !!(props.selectedMatchingHits?.length)
|
|
||||||
|
|
||||||
// Paso 1: marks del snippet del selectedHit (solo cuando hay hits de Typesense).
|
|
||||||
let snippetMarks: HTMLElement[] = []
|
|
||||||
|
|
||||||
if (hasMatchingHits && props.selectedHit) {
|
|
||||||
const snippet = highlightedFor(props.selectedHit, 'text')
|
|
||||||
const paraNumber = props.selectedHit.document.number
|
|
||||||
if (snippet && paraNumber !== undefined) {
|
|
||||||
const paraEl = container.querySelector(`[data-paragraph-number="${paraNumber}"]`) as HTMLElement | null
|
|
||||||
if (paraEl) snippetMarks = findAndApplySnippetMarks(paraEl, snippet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paso 2: marks de fondo con la frase EXACTA del query (siempre se aplican).
|
|
||||||
// Pre-check de texto plano para saltarse párrafos sin la frase → más rápido.
|
|
||||||
const rawPhrase = (props.query || '').replace(/^"+|"+$/g, '').trim()
|
|
||||||
|
|
||||||
if (rawPhrase) {
|
|
||||||
const normPhrase = normalize(rawPhrase)
|
|
||||||
const allParaEls = Array.from(
|
|
||||||
container.querySelectorAll('[data-paragraph-number]')
|
|
||||||
) as HTMLElement[]
|
|
||||||
for (const paraEl of allParaEls) {
|
|
||||||
if (!normalize(paraEl.textContent || '').includes(normPhrase)) continue
|
|
||||||
wrapTextMatchesInParagraph(paraEl, rawPhrase)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paso 3: recolectar todos los marks en orden DOM para navegación secuencial.
|
|
||||||
const domMarks = Array.from(
|
|
||||||
container.querySelectorAll('mark.search-match.match-start')
|
|
||||||
) as HTMLElement[]
|
|
||||||
matchElements.value = domMarks
|
|
||||||
currentMatchIdx.value = 0
|
|
||||||
|
|
||||||
// Paso 4: scroll — solo cuando hay hits de Typesense que ubican el párrafo correcto.
|
|
||||||
// Sin hits, los marks se muestran para que el usuario navegue, pero no se hace scroll.
|
|
||||||
if (!hasMatchingHits) return
|
|
||||||
|
|
||||||
let targetMark: HTMLElement | null = snippetMarks[0] ?? null
|
|
||||||
|
|
||||||
if (!targetMark && props.selectedHit) {
|
|
||||||
const paraNumber = props.selectedHit.document.number
|
|
||||||
if (paraNumber !== undefined) {
|
|
||||||
const paraEl = container.querySelector(`[data-paragraph-number="${paraNumber}"]`) as HTMLElement | null
|
|
||||||
if (paraEl) {
|
|
||||||
const marksInPara = Array.from(
|
|
||||||
paraEl.querySelectorAll('mark.search-match.match-start')
|
|
||||||
) as HTMLElement[]
|
|
||||||
if (marksInPara.length) {
|
|
||||||
targetMark = marksInPara[0]
|
|
||||||
} else {
|
|
||||||
await nextTick()
|
|
||||||
domMarks.forEach(m => m.classList.remove('is-current'))
|
|
||||||
paraEl.scrollIntoView({ block: 'start', behavior: 'smooth' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetMark) targetMark = domMarks[0] ?? null
|
|
||||||
|
|
||||||
if (targetMark) {
|
|
||||||
const idx = domMarks.indexOf(targetMark)
|
|
||||||
currentMatchIdx.value = idx !== -1 ? idx : 0
|
|
||||||
await nextTick()
|
|
||||||
domMarks.forEach(m => m.classList.remove('is-current'))
|
|
||||||
targetMark.classList.add('is-current')
|
|
||||||
targetMark.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Estado 2: búsqueda local -----------------------------------------------
|
|
||||||
|
|
||||||
function applyLocalHighlights(query: string) {
|
|
||||||
clearMatchMarks()
|
|
||||||
searchMode.value = 'local'
|
|
||||||
|
|
||||||
const el = paragraphsContainer.value
|
|
||||||
if (!el || !query) return
|
|
||||||
|
|
||||||
const terms = termsFor(query)
|
|
||||||
if (!terms.length) return
|
|
||||||
|
|
||||||
highlightTextNodes(el, terms)
|
|
||||||
|
|
||||||
matchElements.value = Array.from(el.querySelectorAll('mark.search-match.match-start')) as HTMLElement[]
|
|
||||||
currentMatchIdx.value = 0
|
|
||||||
|
|
||||||
if (matchElements.value.length) {
|
|
||||||
nextTick(() => {
|
|
||||||
matchElements.value[0].classList.add('is-current')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Navegación entre coincidencias -----------------------------------------
|
|
||||||
|
|
||||||
function navigateToCurrent() {
|
|
||||||
matchElements.value.forEach(m => m.classList.remove('is-current'))
|
|
||||||
const target = matchElements.value[currentMatchIdx.value]
|
|
||||||
if (!target) return
|
|
||||||
target.classList.add('is-current')
|
|
||||||
target.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextMatch() {
|
|
||||||
if (!matchElements.value.length) return
|
|
||||||
currentMatchIdx.value = (currentMatchIdx.value + 1) % matchElements.value.length
|
|
||||||
navigateToCurrent()
|
|
||||||
}
|
|
||||||
|
|
||||||
function prevMatch() {
|
|
||||||
if (!matchElements.value.length) return
|
|
||||||
currentMatchIdx.value = (currentMatchIdx.value - 1 + matchElements.value.length) % matchElements.value.length
|
|
||||||
navigateToCurrent()
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearLocalQuery() {
|
|
||||||
localQuery.value = ''
|
|
||||||
// Al limpiar el input local, volver a Estado 1 si hay hits de Typesense
|
|
||||||
if (props.selectedMatchingHits?.length) {
|
|
||||||
applyTypesenseHighlights()
|
|
||||||
} else {
|
|
||||||
clearMatchMarks()
|
|
||||||
searchMode.value = 'typesense'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Input de búsqueda local ------------------------------------------------
|
|
||||||
// Arranca vacío siempre: el Estado 1 (Typesense) es el modo inicial.
|
|
||||||
// Solo al escribir aquí se activa el Estado 2.
|
|
||||||
|
|
||||||
const localQuery = ref('')
|
|
||||||
const debouncedLocalQuery = useDebounce(localQuery, 200)
|
|
||||||
|
|
||||||
watch(debouncedLocalQuery, (q) => {
|
|
||||||
// Cualquier cambio en el input activa el Estado 2
|
|
||||||
applyLocalHighlights(q)
|
|
||||||
})
|
|
||||||
|
|
||||||
function onInputKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault()
|
|
||||||
if (e.shiftKey) prevMatch()
|
|
||||||
else nextMatch()
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
clearLocalQuery()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Inicialización y reactividad -------------------------------------------
|
|
||||||
|
|
||||||
// Al cambiar de documento, resetear todo el estado
|
|
||||||
watch(
|
|
||||||
() => props.document?.id,
|
|
||||||
() => {
|
|
||||||
localQuery.value = ''
|
|
||||||
matchElements.value = []
|
|
||||||
currentMatchIdx.value = 0
|
|
||||||
searchMode.value = 'typesense'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Cuando llegan los párrafos, aplicar highlights de Typesense (Estado 1)
|
|
||||||
watch(
|
|
||||||
() => props.paragraphs,
|
|
||||||
async (paragraphs) => {
|
|
||||||
if (paragraphs.length) {
|
|
||||||
// Resetear input local para garantizar Estado 1 al cargar nuevo documento
|
|
||||||
localQuery.value = ''
|
|
||||||
await applyTypesenseHighlights()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (props.paragraphs.length) {
|
|
||||||
await applyTypesenseHighlights()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---- Utilerías de búsqueda local --------------------------------------------
|
|
||||||
|
|
||||||
function escapeRegex(s: string) {
|
|
||||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeWithMap(text: string): { normalized: string, map: number[] } {
|
|
||||||
let normalized = ''
|
|
||||||
const map: number[] = []
|
|
||||||
for (let i = 0; i < text.length; i++) {
|
|
||||||
// Treat non-breaking space ( ) as regular space — same as normalize()
|
|
||||||
const char = text.charCodeAt(i) === 0xa0 ? ' ' : text[i]
|
|
||||||
const norm = char.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
|
||||||
for (let j = 0; j < norm.length; j++) {
|
|
||||||
normalized += norm[j]
|
|
||||||
map.push(i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { normalized, map }
|
|
||||||
}
|
|
||||||
|
|
||||||
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+')
|
|
||||||
}
|
|
||||||
|
|
||||||
function findMatchesInText(text: string, terms: string[]): Array<{ start: number, end: number }> {
|
|
||||||
if (!text || !terms.length) return []
|
|
||||||
const sources = terms
|
|
||||||
.map(t => ({ term: t, src: termToRegexSource(normalize(t)) }))
|
|
||||||
.filter(x => x.src.length > 0)
|
|
||||||
.sort((a, b) => b.term.length - a.term.length)
|
|
||||||
.map(x => x.src)
|
|
||||||
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 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
function termsFor(query: string): string[] {
|
|
||||||
if (!query) return []
|
|
||||||
if (query.includes('"')) return parseTerms(query)
|
|
||||||
const stripped = query.replace(/^"+|"+$/g, '').trim()
|
|
||||||
return stripped ? [stripped] : []
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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'
|
|
||||||
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
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UDashboardPanel id="estudios-ts-detail">
|
|
||||||
<UDashboardNavbar :toggle="false">
|
|
||||||
<template #leading>
|
|
||||||
<UButton
|
|
||||||
icon="i-lucide-arrow-left"
|
|
||||||
color="neutral"
|
|
||||||
variant="ghost"
|
|
||||||
class="-ms-1.5"
|
|
||||||
@click="emits('close')"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #title>
|
|
||||||
<span class="truncate font-semibold text-sm min-w-0 block" :title="document?.title">
|
|
||||||
{{ documentLoading ? 'Cargando...' : (document?.title || '—') }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #right>
|
|
||||||
<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"
|
|
||||||
:disabled="!document"
|
|
||||||
:aria-label="isFav ? 'Quitar de mi lista' : 'Guardar en mi lista'"
|
|
||||||
@click="onToggleFavorite"
|
|
||||||
/>
|
|
||||||
</UTooltip>
|
|
||||||
</template>
|
|
||||||
</UDashboardNavbar>
|
|
||||||
|
|
||||||
<!-- Metadata -->
|
|
||||||
<div class="flex flex-col gap-3 p-4 sm:px-6 border-b border-default shadow-sm z-10">
|
|
||||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
|
||||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 min-w-0">
|
|
||||||
<p v-if="safeDate()" class="text-sm text-highlighted flex items-center gap-1.5 shrink-0">
|
|
||||||
<UIcon name="ph:calendar" class="size-4 text-green-600" />
|
|
||||||
{{ safeDate() }}
|
|
||||||
</p>
|
|
||||||
<p v-if="safeLocation()" class="text-sm flex items-center gap-1.5 text-muted min-w-0">
|
|
||||||
<UIcon name="ph:map-pin" class="size-4 text-green-600 shrink-0" />
|
|
||||||
<span class="truncate max-w-55">{{ safeLocation() }}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<UButton
|
|
||||||
v-if="matchUrl"
|
|
||||||
:to="matchUrl"
|
|
||||||
target="_blank"
|
|
||||||
icon="i-lucide-external-link"
|
|
||||||
label="Ver en sitio"
|
|
||||||
color="primary"
|
|
||||||
variant="soft"
|
|
||||||
size="xs"
|
|
||||||
class="shrink-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="fileLinks.length" class="flex flex-wrap gap-2">
|
|
||||||
<UButton
|
|
||||||
v-for="(f, idx) in fileLinks"
|
|
||||||
:key="idx"
|
|
||||||
:to="f.to"
|
|
||||||
:target="f.target"
|
|
||||||
:icon="f.icon!"
|
|
||||||
:label="f.label"
|
|
||||||
color="neutral"
|
|
||||||
variant="subtle"
|
|
||||||
size="xs"
|
|
||||||
external
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Buscador interno del documento -->
|
|
||||||
<div v-if="paragraphs.length" class="flex items-center gap-2">
|
|
||||||
<UInput
|
|
||||||
v-model="localQuery"
|
|
||||||
icon="i-lucide-search"
|
|
||||||
:placeholder="props.query || 'Buscar en este documento...'"
|
|
||||||
class="flex-1 min-w-0"
|
|
||||||
: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>
|
|
||||||
<template v-if="matchElements.length || searchMode === 'local'">
|
|
||||||
<UBadge
|
|
||||||
v-if="searchMode === 'typesense' && matchElements.length"
|
|
||||||
label="Typesense"
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
color="warning"
|
|
||||||
class="shrink-0"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="text-xs tabular-nums whitespace-nowrap shrink-0 px-2 py-1 rounded-md bg-elevated border border-default"
|
|
||||||
:class="matchElements.length ? 'text-toned font-medium' : 'text-dimmed'"
|
|
||||||
>
|
|
||||||
{{ matchElements.length ? `${currentMatchIdx + 1} / ${matchElements.length}` : '0 / 0' }}
|
|
||||||
</span>
|
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
|
||||||
<UTooltip text="Anterior (Shift+Enter)">
|
|
||||||
<UButton icon="i-lucide-chevron-up" :disabled="!matchElements.length" aria-label="Anterior" color="neutral" variant="ghost" size="sm" @click="prevMatch" />
|
|
||||||
</UTooltip>
|
|
||||||
<UTooltip text="Siguiente (Enter)">
|
|
||||||
<UButton icon="i-lucide-chevron-down" :disabled="!matchElements.length" aria-label="Siguiente" color="neutral" variant="ghost" size="sm" @click="nextMatch" />
|
|
||||||
</UTooltip>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref="scrollContainer" class="flex-1 overflow-y-auto relative bg-gray-100">
|
|
||||||
<!-- Cargando párrafos -->
|
|
||||||
<div
|
|
||||||
v-if="paragraphsLoading"
|
|
||||||
class="flex items-center justify-center gap-2 py-16 text-sm text-muted"
|
|
||||||
>
|
|
||||||
<UIcon name="i-lucide-loader-circle" class="size-5 animate-spin" />
|
|
||||||
Cargando párrafos...
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Todos los párrafos -->
|
|
||||||
<div
|
|
||||||
v-else-if="paragraphs.length"
|
|
||||||
ref="paragraphsContainer"
|
|
||||||
class="p-1 sm:p-4 max-w-4xl m-4 sm:mx-auto sm:my-6"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="(hit, idx) in paragraphs"
|
|
||||||
:key="idx"
|
|
||||||
:data-paragraph-number="hit.document.number"
|
|
||||||
class="bg-white rounded-lg shadow-md p-4 mb-4 last:mb-0"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<UBadge
|
|
||||||
v-if="hit.document.number"
|
|
||||||
:label="`#${hit.document.number}`"
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
color="info"
|
|
||||||
/>
|
|
||||||
<UBadge
|
|
||||||
v-if="hit.document.type"
|
|
||||||
:label="hit.document.type"
|
|
||||||
size="xs"
|
|
||||||
variant="subtle"
|
|
||||||
color="neutral"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="paragraph-html text-sm leading-relaxed text-gray-800 dark:text-gray-200"
|
|
||||||
v-html="hit.document.text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sin contenido -->
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="flex flex-col items-center justify-center gap-2 py-16 text-dimmed text-sm"
|
|
||||||
>
|
|
||||||
<UIcon name="i-lucide-file-x" class="size-10" />
|
|
||||||
<p>No hay contenido disponible para este documento.</p>
|
|
||||||
<p v-if="matchUrl">
|
|
||||||
Puedes
|
|
||||||
<ULink :to="matchUrl" target="_blank" class="text-primary">verlo en el sitio</ULink>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UDashboardPanel>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.paragraph-html :deep(p) {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
.paragraph-html :deep(p:last-child) {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -34,12 +34,6 @@ const links = computed(() => {
|
||||||
to: '/conferencias',
|
to: '/conferencias',
|
||||||
onSelect: () => { open.value = false }
|
onSelect: () => { open.value = false }
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: t("nav.bible_studies_ts"),
|
|
||||||
icon: 'i-lucide-database',
|
|
||||||
to: '/estudios-typensense',
|
|
||||||
onSelect: () => { open.value = false }
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: t("nav.between_the_lines"),
|
label: t("nav.between_the_lines"),
|
||||||
icon: 'ph:list-magnifying-glass',
|
icon: 'ph:list-magnifying-glass',
|
||||||
|
|
|
||||||
|
|
@ -1,572 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
|
||||||
import { breakpointsTailwind, useDebounce } from '@vueuse/core'
|
|
||||||
import EstudiosTypensenseDetail from '~/components/estudiosTypensense/EstudiosTypensenseDetail.vue'
|
|
||||||
|
|
||||||
const PARAGRAPHS_COLLECTION = 'paragraphs'
|
|
||||||
const DOCUMENTS_COLLECTION = 'documents'
|
|
||||||
const QUERY_BY = 'text'
|
|
||||||
// Mayor page size para agrupar más documentos únicos por página
|
|
||||||
const PAGE_SIZE = 50
|
|
||||||
const FAVORITES_COLLECTION = 'bible-studies-ts'
|
|
||||||
|
|
||||||
const { $i18n } = useNuxtApp()
|
|
||||||
const t = $i18n.t
|
|
||||||
const { locale } = useI18n()
|
|
||||||
|
|
||||||
const filterBy = computed(() => `locale:=${locale.value}`)
|
|
||||||
const REQUEST_TIMEOUT_MS = 15000
|
|
||||||
|
|
||||||
const query = ref('')
|
|
||||||
const debouncedQuery = useDebounce(query, 150)
|
|
||||||
const loading = ref(false)
|
|
||||||
const loadingMore = ref(false)
|
|
||||||
const errorMsg = ref<string | null>(null)
|
|
||||||
|
|
||||||
// ---- Types ----------------------------------------------------------------
|
|
||||||
|
|
||||||
interface ParagraphDoc {
|
|
||||||
id?: string
|
|
||||||
document_id: string
|
|
||||||
text: string
|
|
||||||
number: number
|
|
||||||
locale: string
|
|
||||||
type: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DocMeta {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
date?: string
|
|
||||||
timestamp?: number
|
|
||||||
place?: string
|
|
||||||
city?: string
|
|
||||||
state?: string
|
|
||||||
country?: string
|
|
||||||
type?: string
|
|
||||||
slug?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DocumentDoc extends DocMeta {
|
|
||||||
code: string
|
|
||||||
files?: {
|
|
||||||
youtube?: string
|
|
||||||
video?: string
|
|
||||||
audio?: string
|
|
||||||
booklet?: string
|
|
||||||
simple?: string
|
|
||||||
}
|
|
||||||
body?: string
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TypesenseHighlight {
|
|
||||||
field?: string
|
|
||||||
snippet?: string
|
|
||||||
value?: string
|
|
||||||
matched_tokens?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TypesenseParagraphHit {
|
|
||||||
document: ParagraphDoc
|
|
||||||
highlights?: TypesenseHighlight[]
|
|
||||||
highlight?: Record<string, { snippet?: string, value?: string }>
|
|
||||||
text_match?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TypesenseSearchResponse {
|
|
||||||
found: number
|
|
||||||
hits?: TypesenseParagraphHit[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- State ----------------------------------------------------------------
|
|
||||||
|
|
||||||
// hits: lista plana de párrafos del buscador (sin agrupar)
|
|
||||||
const hits = ref<TypesenseParagraphHit[]>([])
|
|
||||||
const total = ref(0)
|
|
||||||
const currentPage = ref(1)
|
|
||||||
const hasMore = computed(() => hits.value.length < total.value)
|
|
||||||
|
|
||||||
// Progressive display: show 10 groups at a time, reveal more on scroll
|
|
||||||
const visibleGroupCount = ref(10)
|
|
||||||
const visibleGroups = computed(() => groupedHits.value.slice(0, visibleGroupCount.value))
|
|
||||||
const hasMoreVisible = computed(() => visibleGroupCount.value < groupedHits.value.length)
|
|
||||||
|
|
||||||
// groupedHits: computed — un elemento por document_id único.
|
|
||||||
// El conteo de coincidencias por documento se omite en la lista para evitar
|
|
||||||
// confusión con el contador del find-in-document (que cuenta sobre el body
|
|
||||||
// completo). El detalle es la fuente de verdad para "cuántas coincidencias".
|
|
||||||
const groupedHits = computed(() => {
|
|
||||||
const map = new Map<string, TypesenseParagraphHit[]>()
|
|
||||||
for (const hit of hits.value) {
|
|
||||||
const id = hit.document.document_id
|
|
||||||
if (!map.has(id)) map.set(id, [])
|
|
||||||
map.get(id)!.push(hit)
|
|
||||||
}
|
|
||||||
return [...map.entries()].map(([docId, docHits]) => ({
|
|
||||||
docId,
|
|
||||||
firstHit: docHits[0]!
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Cache de metadatos por document_id
|
|
||||||
const docCache = ref<Record<string, DocMeta>>({})
|
|
||||||
|
|
||||||
const { documentsApi } = useTypesenseApi()
|
|
||||||
|
|
||||||
// ---- Batch fetch de metadatos de documentos -------------------------------
|
|
||||||
|
|
||||||
async function fetchDocumentMeta(docIds: string[]) {
|
|
||||||
const unique = docIds.filter(id => id && !(id in docCache.value))
|
|
||||||
if (!unique.length) return
|
|
||||||
try {
|
|
||||||
const res = await documentsApi.multiSearch({
|
|
||||||
multiSearchParameters: {},
|
|
||||||
multiSearchSearchesParameter: {
|
|
||||||
searches: [{
|
|
||||||
collection: DOCUMENTS_COLLECTION,
|
|
||||||
q: '*',
|
|
||||||
queryBy: 'title',
|
|
||||||
filterBy: `id:=[${unique.join(',')}]`,
|
|
||||||
includeFields: 'id,title,date,timestamp,place,city,state,country,type,slug',
|
|
||||||
perPage: unique.length,
|
|
||||||
page: 1
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const docHits = (res?.results?.[0] as { hits?: Array<{ document: DocMeta }> })?.hits ?? []
|
|
||||||
for (const hit of docHits) {
|
|
||||||
if (hit.document.id) {
|
|
||||||
docCache.value[hit.document.id] = hit.document
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching document metadata', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Búsqueda plana -------------------------------------------------------
|
|
||||||
|
|
||||||
let searchSeq = 0
|
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
async function runSearch(q: string, append = false) {
|
|
||||||
const seq = ++searchSeq
|
|
||||||
if (append) loadingMore.value = true
|
|
||||||
else loading.value = true
|
|
||||||
errorMsg.value = null
|
|
||||||
|
|
||||||
if (timeoutId) clearTimeout(timeoutId)
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
if (seq === searchSeq) {
|
|
||||||
loading.value = false
|
|
||||||
loadingMore.value = false
|
|
||||||
errorMsg.value = 'La búsqueda tardó demasiado. Inténtalo de nuevo.'
|
|
||||||
}
|
|
||||||
}, REQUEST_TIMEOUT_MS)
|
|
||||||
|
|
||||||
const page = append ? currentPage.value + 1 : 1
|
|
||||||
|
|
||||||
try {
|
|
||||||
const multi = await documentsApi.multiSearch({
|
|
||||||
multiSearchParameters: {},
|
|
||||||
multiSearchSearchesParameter: {
|
|
||||||
searches: [{
|
|
||||||
collection: PARAGRAPHS_COLLECTION,
|
|
||||||
q: q || '*',
|
|
||||||
queryBy: QUERY_BY,
|
|
||||||
filterBy: filterBy.value,
|
|
||||||
perPage: PAGE_SIZE,
|
|
||||||
page,
|
|
||||||
highlightFullFields: QUERY_BY,
|
|
||||||
highlightFields: QUERY_BY,
|
|
||||||
highlightStartTag: '<mark class="search-match">',
|
|
||||||
highlightEndTag: '</mark>'
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (seq !== searchSeq) return
|
|
||||||
|
|
||||||
const res = (multi?.results?.[0] ?? {}) as TypesenseSearchResponse
|
|
||||||
const newHits = res?.hits ?? []
|
|
||||||
|
|
||||||
// Fetch metadata first so results never flash with bare docIds
|
|
||||||
if (!append) docCache.value = {}
|
|
||||||
const newDocIds = [...new Set(newHits.map(h => h.document.document_id).filter(Boolean))]
|
|
||||||
await fetchDocumentMeta(newDocIds)
|
|
||||||
|
|
||||||
// Abort if a newer search has started while we waited for metadata
|
|
||||||
if (seq !== searchSeq) return
|
|
||||||
|
|
||||||
hits.value = append ? hits.value.concat(newHits) : newHits
|
|
||||||
total.value = res?.found ?? hits.value.length
|
|
||||||
currentPage.value = page
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (seq !== searchSeq) return
|
|
||||||
console.error('Typesense error', err)
|
|
||||||
errorMsg.value = (err as Error)?.message || 'Error al buscar.'
|
|
||||||
if (!append) {
|
|
||||||
hits.value = []
|
|
||||||
total.value = 0
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (seq === searchSeq) {
|
|
||||||
if (timeoutId) clearTimeout(timeoutId)
|
|
||||||
loading.value = false
|
|
||||||
loadingMore.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadMore() {
|
|
||||||
if (loadingMore.value || loading.value || !hasMore.value) return
|
|
||||||
runSearch(query.value, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const listContainer = ref<HTMLElement | null>(null)
|
|
||||||
|
|
||||||
function onListScroll() {
|
|
||||||
const el = listContainer.value
|
|
||||||
if (!el) return
|
|
||||||
if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) {
|
|
||||||
if (hasMoreVisible.value) {
|
|
||||||
visibleGroupCount.value += 10
|
|
||||||
} else if (hasMore.value && !loadingMore.value && !loading.value) {
|
|
||||||
loadMore()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function retry() {
|
|
||||||
runSearch(query.value, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => runSearch(''))
|
|
||||||
onBeforeUnmount(() => { if (timeoutId) clearTimeout(timeoutId) })
|
|
||||||
|
|
||||||
watch(debouncedQuery, (q) => {
|
|
||||||
hits.value = []
|
|
||||||
total.value = 0
|
|
||||||
currentPage.value = 1
|
|
||||||
visibleGroupCount.value = 10
|
|
||||||
runSearch(q, false)
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---- Selección y carga del detalle ----------------------------------------
|
|
||||||
|
|
||||||
const selectedDocId = ref<string | null>(null)
|
|
||||||
const selectedDocument = ref<DocumentDoc | null>(null)
|
|
||||||
const documentLoading = ref(false)
|
|
||||||
const selectedParagraphs = ref<TypesenseParagraphHit[]>([])
|
|
||||||
const paragraphsLoading = ref(false)
|
|
||||||
|
|
||||||
// Hit original del grupo seleccionado (contiene highlights de Typesense)
|
|
||||||
const selectedHit = ref<TypesenseParagraphHit | null>(null)
|
|
||||||
|
|
||||||
// Todos los hits del grupo seleccionado (todos los párrafos con highlights de Typesense)
|
|
||||||
const selectedMatchingHits = ref<TypesenseParagraphHit[]>([])
|
|
||||||
|
|
||||||
async function fetchFullDocument(docId: string) {
|
|
||||||
documentLoading.value = true
|
|
||||||
selectedDocument.value = null
|
|
||||||
try {
|
|
||||||
const res = await documentsApi.multiSearch({
|
|
||||||
multiSearchParameters: {},
|
|
||||||
multiSearchSearchesParameter: {
|
|
||||||
searches: [{
|
|
||||||
collection: DOCUMENTS_COLLECTION,
|
|
||||||
q: '*',
|
|
||||||
queryBy: 'title',
|
|
||||||
filterBy: `id:=${docId}`,
|
|
||||||
perPage: 1,
|
|
||||||
page: 1
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const docHits = (res?.results?.[0] as { hits?: Array<{ document: DocumentDoc }> })?.hits ?? []
|
|
||||||
selectedDocument.value = docHits[0]?.document ?? null
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching document', err)
|
|
||||||
selectedDocument.value = null
|
|
||||||
} finally {
|
|
||||||
documentLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchDocumentParagraphs(docId: string) {
|
|
||||||
paragraphsLoading.value = true
|
|
||||||
selectedParagraphs.value = []
|
|
||||||
const PER_PAGE = 250
|
|
||||||
let page = 1
|
|
||||||
let total = 0
|
|
||||||
const all: Array<{ document: ParagraphDoc }> = []
|
|
||||||
try {
|
|
||||||
do {
|
|
||||||
const res = await documentsApi.multiSearch({
|
|
||||||
multiSearchParameters: {},
|
|
||||||
multiSearchSearchesParameter: {
|
|
||||||
searches: [{
|
|
||||||
collection: PARAGRAPHS_COLLECTION,
|
|
||||||
q: '*',
|
|
||||||
queryBy: '',
|
|
||||||
filterBy: `document_id:=${docId} && ${filterBy.value}`,
|
|
||||||
perPage: PER_PAGE,
|
|
||||||
page,
|
|
||||||
sortBy: 'number:asc'
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const result = (res?.results?.[0] as { found?: number, hits?: Array<{ document: ParagraphDoc }> } | undefined)
|
|
||||||
if (!result) break
|
|
||||||
if (page === 1) total = result.found ?? 0
|
|
||||||
all.push(...(result.hits ?? []))
|
|
||||||
page++
|
|
||||||
} while (all.length < total)
|
|
||||||
selectedParagraphs.value = all.map(h => ({ document: h.document }))
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching paragraphs', err)
|
|
||||||
selectedParagraphs.value = []
|
|
||||||
} finally {
|
|
||||||
paragraphsLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectGroup(group: { docId: string, firstHit: TypesenseParagraphHit }) {
|
|
||||||
selectedDocId.value = group.docId
|
|
||||||
selectedHit.value = group.firstHit
|
|
||||||
selectedMatchingHits.value = hits.value.filter(h => h.document.document_id === group.docId)
|
|
||||||
fetchFullDocument(group.docId)
|
|
||||||
fetchDocumentParagraphs(group.docId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPanelOpen = computed({
|
|
||||||
get() { return !!selectedDocId.value },
|
|
||||||
set(v: boolean) {
|
|
||||||
if (!v) {
|
|
||||||
selectedDocId.value = null
|
|
||||||
selectedDocument.value = null
|
|
||||||
selectedParagraphs.value = []
|
|
||||||
selectedHit.value = null
|
|
||||||
selectedMatchingHits.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(groupedHits, () => {
|
|
||||||
if (!selectedDocId.value) return
|
|
||||||
const still = groupedHits.value.find(g => g.docId === selectedDocId.value)
|
|
||||||
if (!still) {
|
|
||||||
selectedDocId.value = null
|
|
||||||
selectedDocument.value = null
|
|
||||||
selectedParagraphs.value = []
|
|
||||||
selectedHit.value = null
|
|
||||||
selectedMatchingHits.value = []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
|
||||||
const isMobile = breakpoints.smaller('lg')
|
|
||||||
|
|
||||||
useDetailHistory(isPanelOpen, isMobile)
|
|
||||||
|
|
||||||
// ---- Helpers de presentación ----------------------------------------------
|
|
||||||
|
|
||||||
function highlightedFor(hit: TypesenseParagraphHit, field: string): string | null {
|
|
||||||
const fromArr = hit.highlights?.find(h => h.field === field)
|
|
||||||
if (fromArr?.snippet) return fromArr.snippet
|
|
||||||
if (fromArr?.value) return fromArr.value
|
|
||||||
const fromObj = hit.highlight?.[field]
|
|
||||||
if (fromObj?.snippet) return fromObj.snippet
|
|
||||||
if (fromObj?.value) return fromObj.value
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function metaDate(meta: DocMeta | undefined): string {
|
|
||||||
if (!meta) return ''
|
|
||||||
const ts = meta.timestamp || (meta.date ? Math.floor(new Date(meta.date).getTime() / 1000) : null)
|
|
||||||
if (!ts) return meta.date || ''
|
|
||||||
return formatDate(ts)
|
|
||||||
}
|
|
||||||
|
|
||||||
function metaLocation(meta: DocMeta | undefined): string {
|
|
||||||
if (!meta) return ''
|
|
||||||
return formatLocation({
|
|
||||||
id: meta.id,
|
|
||||||
date: meta.timestamp ?? 0,
|
|
||||||
slug: meta.slug ?? '',
|
|
||||||
type: meta.type ?? '',
|
|
||||||
place: meta.place ?? '',
|
|
||||||
city: meta.city ?? '',
|
|
||||||
state: meta.state ?? '',
|
|
||||||
country: meta.country ?? '',
|
|
||||||
thumbnail: ''
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UDashboardPanel
|
|
||||||
id="estudios-ts-list"
|
|
||||||
:default-size="32"
|
|
||||||
:min-size="24"
|
|
||||||
:max-size="45"
|
|
||||||
resizable
|
|
||||||
>
|
|
||||||
<UDashboardNavbar :title="t('nav.bible_studies_ts')">
|
|
||||||
<template #leading>
|
|
||||||
<UDashboardSidebarCollapse />
|
|
||||||
</template>
|
|
||||||
<template #trailing>
|
|
||||||
<UBadge :label="groupedHits.length" variant="subtle" />
|
|
||||||
</template>
|
|
||||||
</UDashboardNavbar>
|
|
||||||
|
|
||||||
<div class="px-4 sm:px-6 py-3 border-b border-default">
|
|
||||||
<UInput
|
|
||||||
v-model="query"
|
|
||||||
icon="i-lucide-search"
|
|
||||||
:placeholder="t('search.placeholder')"
|
|
||||||
:loading="loading"
|
|
||||||
size="md"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UAlert
|
|
||||||
v-if="errorMsg"
|
|
||||||
:title="errorMsg"
|
|
||||||
color="error"
|
|
||||||
variant="subtle"
|
|
||||||
icon="i-lucide-triangle-alert"
|
|
||||||
class="mx-4 my-2"
|
|
||||||
:actions="[{ label: 'Reintentar', color: 'neutral', variant: 'outline', onClick: retry }]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div ref="listContainer" class="overflow-y-auto divide-y divide-default flex-1" @scroll="onListScroll">
|
|
||||||
<div
|
|
||||||
v-if="loading && !groupedHits.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="!groupedHits.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}"` : 'Sin resultados' }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="group in visibleGroups"
|
|
||||||
:key="group.docId"
|
|
||||||
class="bg-white p-4 sm:px-6 text-sm cursor-pointer border-l-2 transition-colors"
|
|
||||||
:class="[
|
|
||||||
selectedDocId === group.docId
|
|
||||||
? 'border-primary bg-primary/10'
|
|
||||||
: 'border-transparent hover:border-primary hover:bg-primary/5'
|
|
||||||
]"
|
|
||||||
@click="selectGroup(group)"
|
|
||||||
>
|
|
||||||
<!-- Título -->
|
|
||||||
<div class="mb-1">
|
|
||||||
<p class="text-sm font-semibold line-clamp-2 text-highlighted">
|
|
||||||
{{ docCache[group.docId]?.title || group.docId }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fecha y lugar -->
|
|
||||||
<p class="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 text-xs mb-2 text-muted">
|
|
||||||
<span
|
|
||||||
v-if="metaDate(docCache[group.docId])"
|
|
||||||
class="flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<UIcon name="ph:calendar" class="size-4 text-green-600" />
|
|
||||||
{{ metaDate(docCache[group.docId]) }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="metaLocation(docCache[group.docId])"
|
|
||||||
class="flex items-center gap-1 truncate"
|
|
||||||
>
|
|
||||||
<UIcon name="ph:map-pin" class="size-4 text-green-600 shrink-0" />
|
|
||||||
<span class="truncate">{{ metaLocation(docCache[group.docId]) }}</span>
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Primer párrafo coincidente con highlight -->
|
|
||||||
<div
|
|
||||||
class="snippet-html text-sm text-dimmed line-clamp-3"
|
|
||||||
v-html="highlightedFor(group.firstHit, 'text') || group.firstHit.document.text"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="loadingMore"
|
|
||||||
class="flex items-center justify-center gap-2 py-4 text-sm text-muted"
|
|
||||||
>
|
|
||||||
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
|
|
||||||
Cargando más...
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else-if="groupedHits.length && !hasMoreVisible && !hasMore && !loading"
|
|
||||||
class="py-3 text-center text-xs text-dimmed"
|
|
||||||
>
|
|
||||||
No hay más resultados
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</UDashboardPanel>
|
|
||||||
|
|
||||||
<!-- Panel de detalle (escritorio) -->
|
|
||||||
<EstudiosTypensenseDetail
|
|
||||||
v-if="selectedDocId && !isMobile"
|
|
||||||
:document="selectedDocument"
|
|
||||||
:document-loading="documentLoading"
|
|
||||||
:paragraphs="selectedParagraphs"
|
|
||||||
:paragraphs-loading="paragraphsLoading"
|
|
||||||
:collection="FAVORITES_COLLECTION"
|
|
||||||
:query="debouncedQuery"
|
|
||||||
:selected-hit="selectedHit"
|
|
||||||
:selected-matching-hits="selectedMatchingHits"
|
|
||||||
@close="isPanelOpen = false"
|
|
||||||
/>
|
|
||||||
<div v-else-if="!isMobile" class="hidden lg:flex flex-1 items-center justify-center">
|
|
||||||
<div class="flex flex-col items-center gap-2 text-dimmed">
|
|
||||||
<UIcon name="i-lucide-search" class="size-16" />
|
|
||||||
<p class="text-sm">Selecciona una actividad para ver el detalle</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Panel de detalle (móvil) -->
|
|
||||||
<ClientOnly>
|
|
||||||
<USlideover v-if="isMobile" v-model:open="isPanelOpen">
|
|
||||||
<template #content>
|
|
||||||
<EstudiosTypensenseDetail
|
|
||||||
v-if="selectedDocId"
|
|
||||||
:document="selectedDocument"
|
|
||||||
:document-loading="documentLoading"
|
|
||||||
:paragraphs="selectedParagraphs"
|
|
||||||
:paragraphs-loading="paragraphsLoading"
|
|
||||||
:collection="FAVORITES_COLLECTION"
|
|
||||||
:query="debouncedQuery"
|
|
||||||
:selected-hit="selectedHit"
|
|
||||||
:selected-matching-hits="selectedMatchingHits"
|
|
||||||
@close="isPanelOpen = false"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</USlideover>
|
|
||||||
</ClientOnly>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.snippet-html :deep(p) {
|
|
||||||
display: inline;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.snippet-html :deep(br) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"bible_studies": "Bible Studies",
|
"bible_studies": "Bible Studies",
|
||||||
"bible_studies_ts": "Bible Studies Typesense",
|
|
||||||
"conferences": "Conferences",
|
"conferences": "Conferences",
|
||||||
"between_the_lines": "Between the Lines",
|
"between_the_lines": "Between the Lines",
|
||||||
"my_list": "My List",
|
"my_list": "My List",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "Inicio",
|
"home": "Inicio",
|
||||||
"bible_studies": "Estudios Bíblicos",
|
"bible_studies": "Estudios Bíblicos",
|
||||||
"bible_studies_ts": "Estudios Bíblicos Typesense",
|
|
||||||
"conferences": "Conferencias",
|
"conferences": "Conferencias",
|
||||||
"between_the_lines": "Entrelíneas",
|
"between_the_lines": "Entrelíneas",
|
||||||
"my_list": "Mi Listado",
|
"my_list": "Mi Listado",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "Commencer",
|
"home": "Commencer",
|
||||||
"bible_studies": "Études Bibliques",
|
"bible_studies": "Études Bibliques",
|
||||||
"bible_studies_ts": "Études Bibliques Typesense",
|
|
||||||
"conferences": "Conférences",
|
"conferences": "Conférences",
|
||||||
"between_the_lines": "Entre les lignes",
|
"between_the_lines": "Entre les lignes",
|
||||||
"my_list": "Ma liste",
|
"my_list": "Ma liste",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "Inicio",
|
"home": "Inicio",
|
||||||
"bible_studies": "Estudios Bíblicos",
|
"bible_studies": "Estudios Bíblicos",
|
||||||
"bible_studies_ts": "Estudos Bíblicos Typesense",
|
|
||||||
"conferences": "Conferências",
|
"conferences": "Conferências",
|
||||||
"between_the_lines": "Entre as linhas",
|
"between_the_lines": "Entre as linhas",
|
||||||
"my_list": "Minha lista",
|
"my_list": "Minha lista",
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ export default defineNuxtConfig({
|
||||||
|
|
||||||
typesense: {
|
typesense: {
|
||||||
url: 'https://searchts.carpa.com', // Your Typesense server URL
|
url: 'https://searchts.carpa.com', // Your Typesense server URL
|
||||||
apiKey: 'a2lbIMTxh48KVteLLndpBfo4tuOIGiwD', // Your Typesense API key
|
apiKey: 'wXyOg0Vrhm8LXpaXbMaF7k24Bnf2rDAv', // Your Typesense API key
|
||||||
// Habilita los composables auto-importados en cliente
|
// Habilita los composables auto-importados en cliente
|
||||||
// (useTypesenseDocuments, useTypesenseApi, etc.).
|
// (useTypesenseDocuments, useTypesenseApi, etc.).
|
||||||
// ⚠️ Solo usa una clave de búsqueda (search-only) aquí: queda expuesta al navegador.
|
// ⚠️ Solo usa una clave de búsqueda (search-only) aquí: queda expuesta al navegador.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue