search/app/pages/estudios-typensense.vue

783 lines
22 KiB
Vue

<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'
import { useSettingsStore } from '~/stores/settings'
const PARAGRAPHS_COLLECTION = 'paragraphs'
const DOCUMENTS_COLLECTION = 'documents'
const QUERY_BY = 'text'
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 settings = useSettingsStore()
// ── Restaurar estado desde URL antes de crear los refs ─────────────────────
const { query: q0, page: p0, scroll: s0, selectedId: sid0 } = useSearchUrlState()
const query = ref(q0)
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
locale: 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[]
}
interface BrowseItem {
docId: string
meta: DocMeta
}
interface DisplayGroup {
docId: string
meta: DocMeta | undefined
firstHit: TypesenseParagraphHit | null
}
// ---- State ----------------------------------------------------------------
// Modo búsqueda (con query): hits de párrafos agrupados por documento
const hits = ref<TypesenseParagraphHit[]>([])
const total = ref(0)
const currentPage = ref(1)
const hasMore = computed(() =>
settings.paginationType === 'infinite_scroll' ? hits.value.length < total.value : false
)
// Progressive display (sólo en scroll infinito)
const visibleGroupCount = ref(10)
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]!
}))
})
const visibleGroups = computed(() =>
settings.paginationType === 'infinite_scroll'
? groupedHits.value.slice(0, visibleGroupCount.value)
: groupedHits.value
)
const hasMoreVisible = computed(() =>
settings.paginationType === 'infinite_scroll' &&
visibleGroupCount.value < groupedHits.value.length
)
// Modo exploración (sin query): documentos ordenados por fecha
const browseItems = ref<BrowseItem[]>([])
const browseTotal = ref(0)
const browsePage = ref(1)
const hasMoreBrowse = computed(() =>
settings.paginationType === 'infinite_scroll'
? browseItems.value.length < browseTotal.value
: false
)
// Grupo unificado para el template
const displayGroups = computed((): DisplayGroup[] => {
if (!debouncedQuery.value.trim()) {
return browseItems.value.map(item => ({
docId: item.docId,
meta: item.meta,
firstHit: null
}))
}
return visibleGroups.value.map(g => ({
docId: g.docId,
meta: docCache.value[g.docId],
firstHit: g.firstHit
}))
})
// Paginación activa (compartida entre browse y search)
const activePage = ref(p0)
const displayTotal = computed(() =>
debouncedQuery.value.trim() ? total.value : browseTotal.value
)
const totalPages = computed(() =>
Math.max(1, Math.ceil(displayTotal.value / settings.pageSize))
)
// Cache de metadatos por document_id (sólo modo búsqueda)
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 de párrafos (con query) -------------------------------------
let searchSeq = 0
let timeoutId: ReturnType<typeof setTimeout> | null = null
async function runSearch(q: string, page = 1, 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 isInfinite = settings.paginationType === 'infinite_scroll'
const typePage = isInfinite ? (append ? currentPage.value + 1 : 1) : page
try {
const multi = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: PARAGRAPHS_COLLECTION,
q: q || '*',
queryBy: QUERY_BY,
filterBy: filterBy.value,
perPage: settings.pageSize,
page: typePage,
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 ?? []
if (!append) docCache.value = {}
const newDocIds = [...new Set(newHits.map(h => h.document.document_id).filter(Boolean))]
await fetchDocumentMeta(newDocIds)
if (seq !== searchSeq) return
hits.value = append ? hits.value.concat(newHits) : newHits
total.value = res?.found ?? hits.value.length
currentPage.value = typePage
if (!append) activePage.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
}
}
}
// ---- Exploración por fecha (sin query) ------------------------------------
async function runBrowse(page = 1, 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 isInfinite = settings.paginationType === 'infinite_scroll'
const typePage = isInfinite ? (append ? browsePage.value + 1 : 1) : page
try {
const multi = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: DOCUMENTS_COLLECTION,
q: '*',
queryBy: 'title',
sortBy: 'timestamp:desc',
perPage: settings.pageSize,
page: typePage,
includeFields: 'id,title,date,timestamp,place,city,state,country,type,slug'
}]
}
})
if (seq !== searchSeq) return
const result = (multi?.results?.[0] as { found?: number, hits?: Array<{ document: DocMeta }> } | undefined)
const newItems = (result?.hits ?? []).map(h => ({
docId: h.document.id!,
meta: h.document
}))
browseItems.value = append ? browseItems.value.concat(newItems) : newItems
browseTotal.value = result?.found ?? browseItems.value.length
browsePage.value = typePage
if (!append) activePage.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) {
browseItems.value = []
browseTotal.value = 0
}
} finally {
if (seq === searchSeq) {
if (timeoutId) clearTimeout(timeoutId)
loading.value = false
loadingMore.value = false
}
}
}
function loadMore() {
if (settings.paginationType !== 'infinite_scroll') return
if (loadingMore.value || loading.value || !hasMore.value) return
runSearch(query.value, currentPage.value, true)
}
function goToPage(p: number) {
activePage.value = p
if (!debouncedQuery.value.trim()) {
browseItems.value = []
runBrowse(p, false)
} else {
hits.value = []
runSearch(query.value, p, false)
}
}
const listContainer = ref<HTMLElement | null>(null)
function onListScroll() {
if (settings.paginationType !== 'infinite_scroll') return
const el = listContainer.value
if (!el) return
if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) {
if (!debouncedQuery.value.trim()) {
if (hasMoreBrowse.value && !loadingMore.value && !loading.value) {
runBrowse(browsePage.value, true)
}
} else {
if (hasMoreVisible.value) {
visibleGroupCount.value += 10
} else if (hasMore.value && !loadingMore.value && !loading.value) {
loadMore()
}
}
}
}
function retry() {
if (!query.value.trim()) {
runBrowse(activePage.value, false)
} else {
runSearch(query.value, activePage.value, false)
}
}
onBeforeUnmount(() => { if (timeoutId) clearTimeout(timeoutId) })
watch(debouncedQuery, (q) => {
activePage.value = 1
if (!q.trim()) {
hits.value = []
total.value = 0
currentPage.value = 1
visibleGroupCount.value = 10
browseItems.value = []
browseTotal.value = 0
browsePage.value = 1
runBrowse(1, false)
} else {
browseItems.value = []
browseTotal.value = 0
browsePage.value = 1
hits.value = []
total.value = 0
currentPage.value = 1
visibleGroupCount.value = 10
runSearch(q, 1, 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)
const selectedHit = ref<TypesenseParagraphHit | null>(null)
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 totalParagraphs = 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) totalParagraphs = result.found ?? 0
all.push(...(result.hits ?? []))
page++
} while (all.length < totalParagraphs)
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: DisplayGroup) {
selectedDocId.value = group.docId
selectedHit.value = group.firstHit
selectedMatchingHits.value = group.firstHit
? 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 || !debouncedQuery.value.trim()) 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 = []
}
})
// ID del documento seleccionado para sincronizar con la URL
const selectedId = computed(() => selectedDocId.value)
// ── Sincronización con URL ─────────────────────────────────────────────────
useSearchUrlSync({ query, page: activePage, selectedId, scrollEl: listContainer })
// ── Ciclo de vida ──────────────────────────────────────────────────────────
onMounted(async () => {
// Carga inicial: browse si no hay query, search si hay query
if (q0.trim()) {
await runSearch(q0, p0, false)
} else {
await runBrowse(p0, false)
}
// Restaurar scroll
restoreScrollPosition(listContainer.value, s0)
// Restaurar documento seleccionado
if (sid0) {
const group = displayGroups.value.find(g => g.docId === sid0)
if (group) {
selectGroup(group)
} else {
// El documento puede no estar en la lista visible (p.ej. browse con paginación)
// Lo cargamos directamente por ID
selectedDocId.value = sid0
fetchFullDocument(sid0)
fetchDocumentParagraphs(sid0)
}
}
})
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="displayTotal" 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 && !displayGroups.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="!displayGroups.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 displayGroups"
: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)"
>
<div class="mb-1">
<p class="text-sm font-semibold line-clamp-2 text-highlighted">
{{ group.meta?.title || group.docId }}
</p>
</div>
<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(group.meta)"
class="flex items-center gap-1"
>
<UIcon name="ph:calendar" class="size-4 text-green-600" />
{{ metaDate(group.meta) }}
</span>
<span
v-if="metaLocation(group.meta)"
class="flex items-center gap-1 truncate"
>
<UIcon name="ph:map-pin" class="size-4 text-green-600 shrink-0" />
<span class="truncate">{{ metaLocation(group.meta) }}</span>
</span>
</p>
<!-- Snippet con highlight: solo en modo búsqueda -->
<div
v-if="group.firstHit"
class="snippet-html text-sm text-dimmed line-clamp-3"
v-html="highlightedFor(group.firstHit, 'text') || group.firstHit.document.text"
/>
</div>
<!-- Infinite scroll: cargando más -->
<div
v-if="settings.paginationType === 'infinite_scroll' && 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="settings.paginationType === 'infinite_scroll' && displayGroups.length && !hasMoreBrowse && !hasMoreVisible && !hasMore && !loading"
class="py-3 text-center text-xs text-dimmed"
>
No hay más resultados
</div>
</div>
<!-- Paginación numerada -->
<div
v-if="settings.paginationType === 'numbered' && totalPages > 1 && !loading"
class="px-4 py-3 border-t border-default flex justify-center shrink-0"
>
<UPagination
:page="activePage"
:total="displayTotal"
:items-per-page="settings.pageSize"
size="sm"
@update:page="goToPage"
/>
</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>