EB typesense
This commit is contained in:
parent
858b67b9ca
commit
6668255304
|
|
@ -0,0 +1,732 @@
|
|||
<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
|
||||
}>()
|
||||
|
||||
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: ''
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Snippet resaltado por Typesense --------------------------------------
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Para análisis de fragmentos preferimos `value` (campo completo con marks)
|
||||
// en lugar del snippet truncado, para no perder grupos de marks adyacentes
|
||||
// que el snippet podría partir.
|
||||
function fullHighlightedFor(hit: TypesenseParagraphHit, field: string): string | null {
|
||||
const fromArr = hit.highlights?.find(h => h.field === field)
|
||||
if (fromArr?.value) return fromArr.value
|
||||
if (fromArr?.snippet) return fromArr.snippet
|
||||
const fromObj = hit.highlight?.[field]
|
||||
if (fromObj?.value) return fromObj.value
|
||||
if (fromObj?.snippet) return fromObj.snippet
|
||||
return null
|
||||
}
|
||||
|
||||
// Decodifica entidades HTML usando el parser nativo del navegador, sin
|
||||
// depender de mantener un mapa propio. Evita falsos negativos cuando el
|
||||
// fragmento extraído lleva entidades pero el body ya está renderizado.
|
||||
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
|
||||
}
|
||||
|
||||
// Recorre los <mark class="search-match"> del HTML resaltado por Typesense
|
||||
// y devuelve los fragmentos contiguos (marks separadas solo por whitespace
|
||||
// se unen como una frase; cualquier texto no-blanco entre marks rompe el
|
||||
// grupo). Esto reproduce la noción de "phrase hit" del usuario:
|
||||
// "<mark>programa</mark> <mark>divino</mark>" → ["programa divino"]
|
||||
// "<mark>programa</mark> de Dios <mark>divino</mark>" → ["programa", "divino"]
|
||||
function extractMarkFragments(html: string): string[] {
|
||||
if (!html) return []
|
||||
const markRe = /<mark[^>]*>([\s\S]*?)<\/mark>/gi
|
||||
const marks: { text: string, start: number, end: number }[] = []
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = markRe.exec(html)) !== null) {
|
||||
marks.push({
|
||||
text: decodeEntities(m[1]),
|
||||
start: m.index,
|
||||
end: m.index + m[0].length
|
||||
})
|
||||
}
|
||||
if (!marks.length) return []
|
||||
const groups: string[][] = [[marks[0].text]]
|
||||
for (let i = 1; i < marks.length; i++) {
|
||||
const between = html.slice(marks[i - 1].end, marks[i].start)
|
||||
if (/^\s*$/.test(between)) {
|
||||
groups[groups.length - 1].push(marks[i].text)
|
||||
} else {
|
||||
groups.push([marks[i].text])
|
||||
}
|
||||
}
|
||||
return groups.map(g => g.join(' ').trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
// Construye la query inicial del find-in-document a partir de los párrafos
|
||||
// que devolvió Typesense, para que el resaltado del body coincida con lo
|
||||
// que el motor realmente matcheó:
|
||||
// - 1 fragmento único en todos los hits → frase completa (ej. "programa divino").
|
||||
// - varios fragmentos distintos → cada uno como frase entre comillas, OR'd.
|
||||
// Si no hay párrafos (p. ej. usuario sin query), cae al stripOuterQuotes(props.query).
|
||||
function computeInitialQuery(paragraphs: TypesenseParagraphHit[] | undefined): string {
|
||||
const fallback = stripOuterQuotes(props.query || '')
|
||||
if (!paragraphs?.length) return fallback
|
||||
const seen = new Set<string>()
|
||||
const fragments: string[] = []
|
||||
for (const hit of paragraphs) {
|
||||
const html = fullHighlightedFor(hit, 'text')
|
||||
if (!html) continue
|
||||
for (const f of extractMarkFragments(html)) {
|
||||
const key = normalize(f)
|
||||
if (!key || seen.has(key)) continue
|
||||
seen.add(key)
|
||||
fragments.push(f)
|
||||
}
|
||||
}
|
||||
if (!fragments.length) return fallback
|
||||
if (fragments.length === 1) return fragments[0]
|
||||
// Frases más largas primero — al construir el regex en findMatchesInText,
|
||||
// la alternancia se evalúa de izquierda a derecha y queremos que
|
||||
// "programa divino" gane sobre "programa" cuando ambos están presentes.
|
||||
fragments.sort((a, b) => b.length - a.length)
|
||||
return fragments.map(f => `"${f}"`).join(' ')
|
||||
}
|
||||
|
||||
// ---- Find-in-document (igual que InboxActivity) ---------------------------
|
||||
|
||||
const bodyContainer = ref<HTMLElement | null>(null)
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
function stripOuterQuotes(s: string): string {
|
||||
return s.trim().replace(/^"+|"+$/g, '').trim()
|
||||
}
|
||||
|
||||
const localQuery = ref(stripOuterQuotes(props.query || ''))
|
||||
const debouncedLocalQuery = useDebounce(localQuery, 200)
|
||||
|
||||
const currentIdx = ref(0)
|
||||
const totalMatches = ref(0)
|
||||
|
||||
// Reset de contadores al cambiar de documento. localQuery se gestiona en el
|
||||
// watcher de párrafos de abajo — necesitamos que el reset y el refinamiento
|
||||
// vivan en un solo sitio para no pisarnos según el orden en que resuelvan
|
||||
// las dos llamadas (fetchFullDocument vs fetchDocumentParagraphs).
|
||||
watch(
|
||||
() => props.document?.id,
|
||||
() => {
|
||||
currentIdx.value = 0
|
||||
totalMatches.value = 0
|
||||
}
|
||||
)
|
||||
|
||||
// Cuando llegan los párrafos resaltados por Typesense (o cambian al re-buscar),
|
||||
// recalcular localQuery a partir de los fragmentos contiguos de marks. Así el
|
||||
// find-in-document busca exactamente lo que Typesense matcheó: un solo
|
||||
// fragmento adyacente → frase completa; varios fragmentos → cada uno como
|
||||
// frase OR'd. { immediate: true } cubre el primer render del componente.
|
||||
watch(
|
||||
() => props.paragraphs,
|
||||
(paragraphs) => {
|
||||
const next = computeInitialQuery(paragraphs)
|
||||
if (next !== localQuery.value) localQuery.value = next
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Sincroniza localQuery cuando el padre cambia su query mientras el detalle
|
||||
// sigue abierto y aún NO ha llegado un nuevo lote de párrafos para refinar.
|
||||
// Si los párrafos llegan después, el watcher de arriba lo afina.
|
||||
watch(() => props.query, (q) => {
|
||||
const stripped = stripOuterQuotes(q || '')
|
||||
if (stripped !== localQuery.value && !localQuery.value.includes('"')) {
|
||||
localQuery.value = stripped
|
||||
}
|
||||
})
|
||||
|
||||
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++) {
|
||||
const norm = text[i].normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
||||
for (let j = 0; j < norm.length; j++) {
|
||||
normalized += norm[j]
|
||||
map.push(i)
|
||||
}
|
||||
}
|
||||
return { normalized, map }
|
||||
}
|
||||
|
||||
function normalize(s: string): string {
|
||||
return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
||||
}
|
||||
|
||||
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 []
|
||||
// Ordenar por longitud desc para que en alternancia regex `(a|ab)`, la
|
||||
// frase más larga gane sobre el prefijo. Por ej., `["programa", "programa divino"]`
|
||||
// debe matchear "programa divino" como un solo hit, no como "programa" + sobra.
|
||||
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 []
|
||||
// Si la query trae comillas explícitas (caso típico cuando la inicializamos
|
||||
// desde los fragmentos de Typesense, ej. `"programa" "divino"` o
|
||||
// `"programa divino"`), parseTerms las separa correctamente en frases.
|
||||
if (query.includes('"')) return parseTerms(query)
|
||||
// Si la query coincide con la del padre (sin comillas), tokenizamos como
|
||||
// hasta ahora — cada palabra suelta cuenta como término independiente.
|
||||
if (query === (props.query || '')) return parseTerms(query)
|
||||
// Query escrita por el usuario en el input local (distinta del padre y
|
||||
// sin comillas) → tratarla como FRASE EXACTA, igual que antes.
|
||||
const stripped = stripOuterQuotes(query)
|
||||
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
|
||||
}
|
||||
|
||||
function getMarks(): HTMLElement[] {
|
||||
return bodyContainer.value
|
||||
? Array.from(bodyContainer.value.querySelectorAll('mark.search-match')) as HTMLElement[]
|
||||
: []
|
||||
}
|
||||
|
||||
function getMatchStarts(): HTMLElement[] {
|
||||
return bodyContainer.value
|
||||
? Array.from(bodyContainer.value.querySelectorAll('mark.search-match.match-start')) as HTMLElement[]
|
||||
: []
|
||||
}
|
||||
|
||||
function applyCurrent(scroll = true) {
|
||||
getMarks().forEach(m => m.classList.remove('is-current'))
|
||||
const starts = getMatchStarts()
|
||||
if (!starts.length) return
|
||||
const idx = Math.min(Math.max(currentIdx.value, 0), starts.length - 1)
|
||||
const target = starts[idx]
|
||||
if (!target) return
|
||||
target.classList.remove('is-current')
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
void target.offsetWidth
|
||||
target.classList.add('is-current')
|
||||
if (scroll) target.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function nextMatch() {
|
||||
if (!totalMatches.value) return
|
||||
currentIdx.value = (currentIdx.value + 1) % totalMatches.value
|
||||
applyCurrent()
|
||||
}
|
||||
|
||||
function prevMatch() {
|
||||
if (!totalMatches.value) return
|
||||
currentIdx.value = (currentIdx.value - 1 + totalMatches.value) % totalMatches.value
|
||||
applyCurrent()
|
||||
}
|
||||
|
||||
function clearLocalQuery() { localQuery.value = '' }
|
||||
|
||||
function applyHighlights({ query, scroll }: { query: string, scroll: boolean }) {
|
||||
const el = bodyContainer.value
|
||||
if (!el) return
|
||||
el.querySelectorAll('mark.search-match').forEach((m) => {
|
||||
m.parentNode?.replaceChild(document.createTextNode(m.textContent || ''), m)
|
||||
})
|
||||
el.normalize()
|
||||
const terms = termsFor(query)
|
||||
const count = terms.length ? highlightTextNodes(el, terms) : 0
|
||||
totalMatches.value = count
|
||||
currentIdx.value = 0
|
||||
if (count > 0) nextTick(() => applyCurrent(scroll))
|
||||
}
|
||||
|
||||
async function renderBody() {
|
||||
await nextTick()
|
||||
const el = bodyContainer.value
|
||||
if (!el) return
|
||||
el.innerHTML = props.document?.body ? ((fixLink(props.document.body) as string) || '') : ''
|
||||
if (scrollContainer.value) scrollContainer.value.scrollTop = 0
|
||||
applyHighlights({ query: localQuery.value, scroll: true })
|
||||
}
|
||||
|
||||
watch(() => props.document?.id, renderBody, { immediate: true })
|
||||
watch(debouncedLocalQuery, (q) => applyHighlights({ query: q, scroll: false }))
|
||||
onMounted(renderBody)
|
||||
|
||||
function onInputKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) prevMatch()
|
||||
else nextMatch()
|
||||
} else if (e.key === 'Escape') {
|
||||
clearLocalQuery()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardPanel id="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>
|
||||
|
||||
<div v-if="document?.body" class="flex items-center gap-2">
|
||||
<UInput
|
||||
v-model="localQuery"
|
||||
icon="i-lucide-search"
|
||||
placeholder="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="localQuery">
|
||||
<span
|
||||
class="text-xs tabular-nums whitespace-nowrap shrink-0 px-2 py-1 rounded-md bg-elevated border border-default"
|
||||
:class="totalMatches ? 'text-toned font-medium' : 'text-dimmed'"
|
||||
>
|
||||
{{ totalMatches ? `${currentIdx + 1} / ${totalMatches}` : '0 / 0' }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<UTooltip text="Anterior (Shift+Enter)">
|
||||
<UButton icon="i-lucide-chevron-up" :disabled="!totalMatches" aria-label="Anterior" color="neutral" variant="ghost" size="sm" @click="prevMatch" />
|
||||
</UTooltip>
|
||||
<UTooltip text="Siguiente (Enter)">
|
||||
<UButton icon="i-lucide-chevron-down" :disabled="!totalMatches" 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">
|
||||
<!-- Loading del documento -->
|
||||
<div
|
||||
v-if="documentLoading"
|
||||
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 documento...
|
||||
</div>
|
||||
|
||||
<!-- Documento no encontrado -->
|
||||
<div
|
||||
v-else-if="!document"
|
||||
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>Documento no encontrado</p>
|
||||
</div>
|
||||
|
||||
<!-- Contenido del documento -->
|
||||
<div v-else class="p-1 sm:p-4 max-w-4xl m-4 sm:mx-auto sm:my-6 flex flex-col gap-4">
|
||||
|
||||
<!-- Sección: párrafos coincidentes -->
|
||||
<div
|
||||
v-if="paragraphsLoading || paragraphs.length"
|
||||
class="bg-white rounded-lg shadow-md p-4"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<UIcon name="i-lucide-quote" class="size-4 text-yellow-600 shrink-0" />
|
||||
<span class="text-xs font-semibold text-yellow-700 dark:text-yellow-400 uppercase tracking-wide">
|
||||
Párrafos coincidentes
|
||||
</span>
|
||||
<UBadge
|
||||
v-if="paragraphs.length"
|
||||
:label="paragraphs.length"
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="warning"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="paragraphsLoading"
|
||||
class="flex items-center gap-2 py-4 text-sm text-muted justify-center"
|
||||
>
|
||||
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
|
||||
Cargando coincidencias...
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col divide-y divide-default">
|
||||
<div
|
||||
v-for="hit in paragraphs"
|
||||
:key="hit.document.id"
|
||||
class="py-3 first:pt-0 last:pb-0"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<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="snippet-html text-sm leading-relaxed text-gray-800 dark:text-gray-200"
|
||||
v-html="highlightedFor(hit, 'text') || hit.document.text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cuerpo del documento -->
|
||||
<div class="bg-white rounded-lg shadow-md">
|
||||
<article
|
||||
v-if="document.body"
|
||||
ref="bodyContainer"
|
||||
class="prose prose-sm max-w-none dark:prose-invert p-4 sm:p-6 pb-24 prose-p:mb-2 text-base/7"
|
||||
/>
|
||||
<p v-else class="p-4 sm:p-6 text-sm text-muted">
|
||||
No hay contenido disponible para este documento.
|
||||
<span v-if="matchUrl">
|
||||
Puedes
|
||||
<ULink :to="matchUrl" target="_blank" class="text-primary">verlo en el sitio</ULink>.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</UDashboardPanel>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.snippet-html :deep(p) {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
}
|
||||
.snippet-html :deep(br) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -34,6 +34,12 @@ const links = computed(() => {
|
|||
to: '/conferencias',
|
||||
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"),
|
||||
icon: 'ph:list-magnifying-glass',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,541 @@
|
|||
<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)
|
||||
|
||||
// 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 ?? []
|
||||
|
||||
hits.value = append ? hits.value.concat(newHits) : newHits
|
||||
total.value = res?.found ?? hits.value.length
|
||||
currentPage.value = page
|
||||
|
||||
if (!append) docCache.value = {}
|
||||
const newDocIds = [...new Set(newHits.map(h => h.document.document_id).filter(Boolean))]
|
||||
fetchDocumentMeta(newDocIds)
|
||||
} 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)
|
||||
}
|
||||
|
||||
function retry() {
|
||||
runSearch(query.value, false)
|
||||
}
|
||||
|
||||
onMounted(() => runSearch(''))
|
||||
onBeforeUnmount(() => { if (timeoutId) clearTimeout(timeoutId) })
|
||||
|
||||
watch(debouncedQuery, (q) => {
|
||||
hits.value = []
|
||||
total.value = 0
|
||||
currentPage.value = 1
|
||||
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)
|
||||
|
||||
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, q: string) {
|
||||
paragraphsLoading.value = true
|
||||
selectedParagraphs.value = []
|
||||
try {
|
||||
const res = await documentsApi.multiSearch({
|
||||
multiSearchParameters: {},
|
||||
multiSearchSearchesParameter: {
|
||||
searches: [{
|
||||
collection: PARAGRAPHS_COLLECTION,
|
||||
q: q || '*',
|
||||
queryBy: QUERY_BY,
|
||||
filterBy: `document_id:=${docId} && ${filterBy.value}`,
|
||||
perPage: 250,
|
||||
page: 1,
|
||||
sortBy: 'number:asc',
|
||||
highlightFullFields: QUERY_BY,
|
||||
highlightFields: QUERY_BY,
|
||||
highlightStartTag: '<mark class="search-match">',
|
||||
highlightEndTag: '</mark>'
|
||||
}]
|
||||
}
|
||||
})
|
||||
selectedParagraphs.value = (res?.results?.[0] as { hits?: TypesenseParagraphHit[] })?.hits ?? []
|
||||
} catch (err) {
|
||||
console.error('Error fetching paragraphs', err)
|
||||
selectedParagraphs.value = []
|
||||
} finally {
|
||||
paragraphsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function selectGroup(docId: string) {
|
||||
selectedDocId.value = docId
|
||||
fetchFullDocument(docId)
|
||||
if (query.value.trim()) {
|
||||
fetchDocumentParagraphs(docId, query.value)
|
||||
} else {
|
||||
selectedParagraphs.value = []
|
||||
paragraphsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isPanelOpen = computed({
|
||||
get() { return !!selectedDocId.value },
|
||||
set(v: boolean) {
|
||||
if (!v) {
|
||||
selectedDocId.value = null
|
||||
selectedDocument.value = null
|
||||
selectedParagraphs.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 = []
|
||||
}
|
||||
})
|
||||
|
||||
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"
|
||||
/>
|
||||
<p class="mt-1.5 flex items-center gap-1 text-[11px] text-dimmed">
|
||||
<UIcon name="i-lucide-lightbulb" class="size-3" />
|
||||
<span v-html="t('search.tip')" />
|
||||
</p>
|
||||
</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 class="overflow-y-auto divide-y divide-default flex-1">
|
||||
<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 groupedHits"
|
||||
: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.docId)"
|
||||
>
|
||||
<!-- 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="hasMore && !loading"
|
||||
class="p-4 flex justify-center"
|
||||
>
|
||||
<UButton
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
:loading="loadingMore"
|
||||
@click="loadMore"
|
||||
>
|
||||
Cargar más
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="groupedHits.length && !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"
|
||||
@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"
|
||||
@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,6 +3,7 @@
|
|||
"nav": {
|
||||
"home": "Home",
|
||||
"bible_studies": "Bible Studies",
|
||||
"bible_studies_ts": "Bible Studies Typesense",
|
||||
"conferences": "Conferences",
|
||||
"between_the_lines": "Between the Lines",
|
||||
"my_list": "My List",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"nav": {
|
||||
"home": "Inicio",
|
||||
"bible_studies": "Estudios Bíblicos",
|
||||
"bible_studies_ts": "Estudios Bíblicos Typesense",
|
||||
"conferences": "Conferencias",
|
||||
"between_the_lines": "Entrelíneas",
|
||||
"my_list": "Mi Listado",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
"nav": {
|
||||
"home": "Commencer",
|
||||
"bible_studies": "Études Bibliques",
|
||||
"bible_studies_ts": "Études Bibliques Typesense",
|
||||
"conferences": "Conférences",
|
||||
"between_the_lines": "Entre les lignes",
|
||||
"my_list": "Ma liste",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
"nav": {
|
||||
"home": "Inicio",
|
||||
"bible_studies": "Estudios Bíblicos",
|
||||
"bible_studies_ts": "Estudos Bíblicos Typesense",
|
||||
"conferences": "Conferências",
|
||||
"between_the_lines": "Entre as linhas",
|
||||
"my_list": "Minha lista",
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ export default defineNuxtConfig({
|
|||
|
||||
typesense: {
|
||||
url: 'https://searchts.carpa.com', // Your Typesense server URL
|
||||
apiKey: 'wXyOg0Vrhm8LXpaXbMaF7k24Bnf2rDAv', // Your Typesense API key
|
||||
apiKey: 'a2lbIMTxh48KVteLLndpBfo4tuOIGiwD', // Your Typesense API key
|
||||
// Habilita los composables auto-importados en cliente
|
||||
// (useTypesenseDocuments, useTypesenseApi, etc.).
|
||||
// ⚠️ Solo usa una clave de búsqueda (search-only) aquí: queda expuesta al navegador.
|
||||
|
|
|
|||
Loading…
Reference in New Issue