Deploy to live #19
|
|
@ -50,6 +50,43 @@ const bibleStudyInput = ref<number | null>(null)
|
||||||
const activeBibleStudies = ref<BibleStudyChip[]>([])
|
const activeBibleStudies = ref<BibleStudyChip[]>([])
|
||||||
const isValidating = ref(false)
|
const isValidating = ref(false)
|
||||||
|
|
||||||
|
const {
|
||||||
|
query, debouncedQuery, loading, loadingMore, errorMsg,
|
||||||
|
exactSearch, sortMode,
|
||||||
|
groupedHits, visibleGroupCount, visibleGroups, hasMoreVisible, hasMore,
|
||||||
|
browseItems, hasMoreBrowse,
|
||||||
|
displayGroups, activePage, displayTotal, totalPages,
|
||||||
|
runSearch, runBrowse, loadMore, goToPage, retry
|
||||||
|
} = useGroupedTypesenseSearch({
|
||||||
|
paragraphsCollection: props.paragraphsCollection,
|
||||||
|
mainCollection: props.mainCollection,
|
||||||
|
groupByField: props.groupByField,
|
||||||
|
queryBy: QUERY_BY,
|
||||||
|
filterBy: () => {
|
||||||
|
let base = `locale:=${locale.value}`
|
||||||
|
if (activeBibleStudies.value.length > 0) {
|
||||||
|
const ids = activeBibleStudies.value.map(bs => bs.id).join(',')
|
||||||
|
base += ` && $${props.mainCollection}(bible_study:=[${ids}])`
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
},
|
||||||
|
isUnlocked: () => unlocked.value,
|
||||||
|
pageSize: () => settings.pageSize,
|
||||||
|
paginationType: () => settings.paginationType,
|
||||||
|
initialQuery: q0,
|
||||||
|
initialPage: p0
|
||||||
|
})
|
||||||
|
|
||||||
|
function refetchResults() {
|
||||||
|
if (!debouncedQuery.value.trim()) {
|
||||||
|
browseItems.value = []
|
||||||
|
runBrowse(1, false)
|
||||||
|
} else {
|
||||||
|
groupedHits.value = []
|
||||||
|
runSearch(query.value, 1, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function applyBibleStudyFilter() {
|
async function applyBibleStudyFilter() {
|
||||||
const val = bibleStudyInput.value
|
const val = bibleStudyInput.value
|
||||||
if (val === null || val <= 0) return
|
if (val === null || val <= 0) return
|
||||||
|
|
@ -100,268 +137,40 @@ function clearAllBibleStudyFilters() {
|
||||||
refetchResults()
|
refetchResults()
|
||||||
}
|
}
|
||||||
|
|
||||||
function refetchResults() {
|
// ---- Types ----------------------------------------------------------------
|
||||||
if (!debouncedQuery.value.trim()) {
|
|
||||||
browseItems.value = []
|
interface DocumentDoc extends CachedDocMeta {
|
||||||
runBrowse(1, false)
|
code: string
|
||||||
} else {
|
locale: string
|
||||||
groupedHits.value = []
|
files?: {
|
||||||
currentPage.value = 1
|
youtube?: string
|
||||||
runSearch(query.value, 1, false)
|
video?: string
|
||||||
|
audio?: string
|
||||||
|
booklet?: string
|
||||||
|
simple?: string
|
||||||
}
|
}
|
||||||
|
body?: string
|
||||||
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ---- Colors ----------------------------------------------------------------
|
||||||
|
|
||||||
const groupedHits = ref<SearchGroup[]>([])
|
const colors = computed(() => {
|
||||||
const total = ref(0)
|
if (props.accentColor === 'green') {
|
||||||
const currentPage = ref(1)
|
return {
|
||||||
|
selectedItem: 'border-carpagreen bg-carpagreen/10',
|
||||||
const hasMore = computed(() =>
|
hoverItem: 'border-gray-200 hover:border-carpagreen hover:bg-carpagreen/5',
|
||||||
settings.paginationType === 'infinite_scroll' ? groupedHits.value.length < total.value : false
|
icon: 'text-carpagreen',
|
||||||
)
|
|
||||||
|
|
||||||
const visibleGroupCount = ref(10)
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
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,
|
return {
|
||||||
meta: docCache.value[g.docId],
|
selectedItem: 'border-carpablue bg-carpablue/10',
|
||||||
firstHit: g.firstHit
|
hoverItem: 'border-gray-200 hover:border-carpablue hover:bg-carpablue/5',
|
||||||
}))
|
icon: 'text-carpablue',
|
||||||
})
|
|
||||||
|
|
||||||
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))
|
|
||||||
)
|
|
||||||
|
|
||||||
const docCache = ref<Record<string, DocMeta>>({})
|
|
||||||
|
|
||||||
const { documentsApi } = useTypesenseApi()
|
|
||||||
const toast = useToast()
|
|
||||||
|
|
||||||
// ---- Batch fetch de metadatos ---------------------------------------------
|
|
||||||
|
|
||||||
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: props.mainCollection,
|
|
||||||
q: '*',
|
|
||||||
queryBy: 'title',
|
|
||||||
filterBy: `id:=[${unique.join(',')}]${unlocked.value ? '' : ' && private:=false'}`,
|
|
||||||
includeFields: 'id,title,date,timestamp,place,city,state,country,type,slug,draft',
|
|
||||||
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) -------------------------------------
|
// ---- Scroll infinito y detalle ---------------------------------------------
|
||||||
|
|
||||||
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 shouldSortByDate = sortMode.value === 'date' && q.trim()
|
|
||||||
|
|
||||||
const multi = await documentsApi.multiSearch({
|
|
||||||
multiSearchParameters: {},
|
|
||||||
multiSearchSearchesParameter: {
|
|
||||||
searches: [{
|
|
||||||
collection: props.paragraphsCollection,
|
|
||||||
q: exactSearch.value && q ? `"${q}"` : q || '*',
|
|
||||||
queryBy: QUERY_BY,
|
|
||||||
filterBy: unlocked.value ? filterBy.value : `${filterBy.value} && $${props.mainCollection}(private:=false)`,
|
|
||||||
...(shouldSortByDate ? { sortBy: `$${props.mainCollection}(timestamp:desc)` } : {}),
|
|
||||||
perPage: settings.pageSize,
|
|
||||||
page: typePage,
|
|
||||||
highlightFullFields: QUERY_BY,
|
|
||||||
highlightFields: QUERY_BY,
|
|
||||||
highlightStartTag: '<mark class="search-match">',
|
|
||||||
highlightEndTag: '</mark>',
|
|
||||||
highlightAffixNumTokens: 30,
|
|
||||||
groupBy: props.groupByField
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (seq !== searchSeq) return
|
|
||||||
|
|
||||||
const res = (multi?.results?.[0] ?? {}) as TypesenseSearchResponse
|
|
||||||
const rawGroups = res?.groupedHits ?? []
|
|
||||||
const newGroups: SearchGroup[] = rawGroups.map(g => ({
|
|
||||||
docId: g.groupKey[0]!,
|
|
||||||
firstHit: g.hits[0]!,
|
|
||||||
allHits: g.hits
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (!append) docCache.value = {}
|
|
||||||
await fetchDocumentMeta(newGroups.map(g => g.docId).filter(Boolean))
|
|
||||||
|
|
||||||
if (seq !== searchSeq) return
|
|
||||||
|
|
||||||
groupedHits.value = append ? groupedHits.value.concat(newGroups) : newGroups
|
|
||||||
total.value = res?.found ?? groupedHits.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) { groupedHits.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: props.paragraphsCollection,
|
|
||||||
q: '*',
|
|
||||||
queryBy: QUERY_BY,
|
|
||||||
filterBy: `${filterBy.value} && $${props.mainCollection}(locale:=${locale.value}${unlocked.value ? '' : ' && private:=false'})`,
|
|
||||||
sortBy: `$${props.mainCollection}(timestamp:desc)`,
|
|
||||||
groupBy: props.groupByField,
|
|
||||||
perPage: settings.pageSize,
|
|
||||||
page: typePage,
|
|
||||||
includeFields: `$${props.mainCollection}(id,title,date,timestamp,place,city,state,country,type,slug,draft)`
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (seq !== searchSeq) return
|
|
||||||
const result = (multi?.results?.[0] as TypesenseSearchResponse | undefined)
|
|
||||||
const rawGroups = result?.groupedHits ?? []
|
|
||||||
const newItems = rawGroups.map(g => {
|
|
||||||
const docId = g.groupKey[0]!
|
|
||||||
const parentMeta = (g.hits[0]?.document as unknown as Record<string, unknown>)[props.mainCollection] as Partial<DocMeta> | undefined
|
|
||||||
return { docId, meta: { id: docId, ...parentMeta } as DocMeta }
|
|
||||||
})
|
|
||||||
|
|
||||||
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 {
|
|
||||||
groupedHits.value = []
|
|
||||||
runSearch(query.value, p, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const listContainer = ref<HTMLElement | null>(null)
|
const listContainer = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
|
@ -379,10 +188,6 @@ function onListScroll() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAbortError(err: unknown): boolean {
|
|
||||||
return (err as { name?: string } | null)?.name === 'AbortError'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Selección y carga del detalle ----------------------------------------
|
// ---- Selección y carga del detalle ----------------------------------------
|
||||||
|
|
||||||
const selectedDocId = ref<string | null>(null)
|
const selectedDocId = ref<string | null>(null)
|
||||||
|
|
@ -421,7 +226,7 @@ async function fetchDocumentWithParagraphs(docId: string) {
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (seq !== detailSeq) return
|
if (seq !== detailSeq) return
|
||||||
if (isAbortError(err)) return
|
if ((err as { name?: string })?.name === 'AbortError') return
|
||||||
console.error('Error fetching document with paragraphs', err)
|
console.error('Error fetching document with paragraphs', err)
|
||||||
selectedDocument.value = null
|
selectedDocument.value = null
|
||||||
selectedParagraphs.value = []
|
selectedParagraphs.value = []
|
||||||
|
|
@ -575,7 +380,6 @@ function metaLocation(meta: CachedDocMeta | undefined): string {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ─── CHIPS ACTIVOS (solo desktop: fuera del FiltersContainer) ─── -->
|
<!-- ─── CHIPS ACTIVOS (solo desktop: fuera del FiltersContainer) ─── -->
|
||||||
<!-- En desktop los chips van debajo del input; en mobile van DENTRO del slideover -->
|
|
||||||
<div v-if="showBibleStudyFilter && activeBibleStudies.length > 0 && !isMobile" class="px-4 sm:px-6 py-2 border-b border-default">
|
<div v-if="showBibleStudyFilter && activeBibleStudies.length > 0 && !isMobile" class="px-4 sm:px-6 py-2 border-b border-default">
|
||||||
<div class="flex flex-wrap items-center gap-1.5">
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
<UBadge
|
<UBadge
|
||||||
|
|
@ -623,19 +427,11 @@ function metaLocation(meta: CachedDocMeta | undefined): string {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ─── FILTROS: AGREGAR NUEVOS (acordeón desktop / slideover mobile) ─── -->
|
<!-- ─── FILTROS: AGREGAR NUEVOS (acordeón desktop / slideover mobile) ─── -->
|
||||||
<!--
|
|
||||||
El componente FiltersContainer renderiza DISTINTO según la pantalla:
|
|
||||||
- Desktop (>lg): acordeón colapsable con header "Filtros"
|
|
||||||
- Mobile (<lg): botón "Filtros" que abre un USlideover
|
|
||||||
El slot #content (input + botón) es el MISMO en ambos casos.
|
|
||||||
El slot #chips se usa solo en mobile (dentro del slideover debajo del content).
|
|
||||||
-->
|
|
||||||
<template v-if="showBibleStudyFilter">
|
<template v-if="showBibleStudyFilter">
|
||||||
<FiltersContainer
|
<FiltersContainer
|
||||||
:active-count="activeBibleStudies.length"
|
:active-count="activeBibleStudies.length"
|
||||||
title="search.filters"
|
title="search.filters"
|
||||||
>
|
>
|
||||||
<!-- #chips se renderiza dentro del slideover en mobile -->
|
|
||||||
<template #chips>
|
<template #chips>
|
||||||
<div class="flex flex-wrap items-center gap-1.5">
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
<UBadge
|
<UBadge
|
||||||
|
|
@ -677,7 +473,7 @@ function metaLocation(meta: CachedDocMeta | undefined): string {
|
||||||
:placeholder="$t('search.bible_study_placeholder')"
|
:placeholder="$t('search.bible_study_placeholder')"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="w-36"
|
class="w-36"
|
||||||
@keyup.enter="applyBibleStudyFilter(props.mainCollection)"
|
@keyup.enter="applyBibleStudyFilter"
|
||||||
/>
|
/>
|
||||||
<UButton
|
<UButton
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -685,7 +481,7 @@ function metaLocation(meta: CachedDocMeta | undefined): string {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:loading="isValidating"
|
:loading="isValidating"
|
||||||
:disabled="bibleStudyInput === null || bibleStudyInput <= 0"
|
:disabled="bibleStudyInput === null || bibleStudyInput <= 0"
|
||||||
@click="applyBibleStudyFilter(props.mainCollection)"
|
@click="applyBibleStudyFilter"
|
||||||
>
|
>
|
||||||
+ {{ $t('search.filter') }}
|
+ {{ $t('search.filter') }}
|
||||||
</UButton>
|
</UButton>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue