diff --git a/app/components/searchPanel/SearchPanel.vue b/app/components/searchPanel/SearchPanel.vue index 4a157ba..013755a 100644 --- a/app/components/searchPanel/SearchPanel.vue +++ b/app/components/searchPanel/SearchPanel.vue @@ -50,6 +50,43 @@ const bibleStudyInput = ref(null) const activeBibleStudies = ref([]) 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() { const val = bibleStudyInput.value if (val === null || val <= 0) return @@ -100,268 +137,40 @@ function clearAllBibleStudyFilters() { refetchResults() } -function refetchResults() { - if (!debouncedQuery.value.trim()) { - browseItems.value = [] - runBrowse(1, false) - } else { - groupedHits.value = [] - currentPage.value = 1 - runSearch(query.value, 1, false) +// ---- Types ---------------------------------------------------------------- + +interface DocumentDoc extends CachedDocMeta { + code: string + locale: string + files?: { + youtube?: string + video?: string + audio?: string + booklet?: string + simple?: string } + body?: string + [key: string]: unknown } -// ---------------------------------------------------------------------------- +// ---- Colors ---------------------------------------------------------------- -const groupedHits = ref([]) -const total = ref(0) -const currentPage = ref(1) - -const hasMore = computed(() => - settings.paginationType === 'infinite_scroll' ? groupedHits.value.length < total.value : false -) - -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([]) -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 - })) +const colors = computed(() => { + if (props.accentColor === 'green') { + return { + selectedItem: 'border-carpagreen bg-carpagreen/10', + hoverItem: 'border-gray-200 hover:border-carpagreen hover:bg-carpagreen/5', + icon: 'text-carpagreen', + } + } + return { + selectedItem: 'border-carpablue bg-carpablue/10', + hoverItem: 'border-gray-200 hover:border-carpablue hover:bg-carpablue/5', + icon: 'text-carpablue', } - return visibleGroups.value.map(g => ({ - docId: g.docId, - meta: docCache.value[g.docId], - firstHit: g.firstHit - })) }) -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>({}) - -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) ------------------------------------- - -let searchSeq = 0 -let timeoutId: ReturnType | 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: '', - highlightEndTag: '', - 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)[props.mainCollection] as Partial | 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) - } -} +// ---- Scroll infinito y detalle --------------------------------------------- const listContainer = ref(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 ---------------------------------------- const selectedDocId = ref(null) @@ -421,7 +226,7 @@ async function fetchDocumentWithParagraphs(docId: string) { } } catch (err) { if (seq !== detailSeq) return - if (isAbortError(err)) return + if ((err as { name?: string })?.name === 'AbortError') return console.error('Error fetching document with paragraphs', err) selectedDocument.value = null selectedParagraphs.value = [] @@ -575,7 +380,6 @@ function metaLocation(meta: CachedDocMeta | undefined): string { -
-