diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..906be45 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,79 @@ +name: Deploy Search Typesense + +on: + push: + branches: + - staging + - production + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + # Route each branch to a specific directory and PM2 environment + - name: Setup Environment Variables + run: | + if [[ "${{ github.ref_name }}" == "production" ]]; then + TARGET="/var/www/search.carpa.com" + echo "PM2_ENV=production" >> $GITHUB_ENV + elif [[ "${{ github.ref_name }}" == "staging" ]]; then + TARGET="/var/www/dev.search.carpa.com" + echo "PM2_ENV=staging" >> $GITHUB_ENV + fi + + PM2_NAME="search-${{ github.ref_name }}" + + echo "TARGET_DIR=$TARGET" >> $GITHUB_ENV + echo "PM2_NAME=$PM2_NAME" >> $GITHUB_ENV + + echo "NUXT_PUBLIC_FEEDBACK_WEBHOOK=${{ secrets.NUXT_PUBLIC_FEEDBACK_WEBHOOK }}" >> $GITHUB_ENV + echo "NUXT_PUBLIC_RECAPTCHA_SITE_KEY=${{ secrets.NUXT_PUBLIC_RECAPTCHA_SITE_KEY }}" >> $GITHUB_ENV + echo "NUXT_PUBLIC_FEEDBACK_MAX_PER_HOUR=${{ secrets.NUXT_PUBLIC_FEEDBACK_MAX_PER_HOUR }}" >> $GITHUB_ENV + echo "NUXT_PUBLIC_FEEDBACK_MAX_PER_SESSION=${{ secrets.NUXT_PUBLIC_FEEDBACK_MAX_PER_SESSION }}" >> $GITHUB_ENV + echo "NUXT_PUBLIC_FEEDBACK_COOLDOWN_SEC=${{ secrets.NUXT_PUBLIC_FEEDBACK_COOLDOWN_SEC }}" >> $GITHUB_ENV + echo "NUXT_PUBLIC_FEEDBACK_MIN_SECONDS=${{ secrets.NUXT_PUBLIC_FEEDBACK_MIN_SECONDS }}" >> $GITHUB_ENV + echo "NUXT_FEEDBACK_TOKEN=${{ secrets.NUXT_FEEDBACK_TOKEN }}" >> $GITHUB_ENV + echo "NUXT_PUBLIC_TYPESENSE_URL=${{ secrets.NUXT_PUBLIC_TYPESENSE_URL }}" >> $GITHUB_ENV + echo "NUXT_PUBLIC_TYPESENSE_API_KEY=${{ secrets.NUXT_PUBLIC_TYPESENSE_API_KEY }}" >> $GITHUB_ENV + echo "NUXT_PUBLIC_ENTRELINEAS_DEV_KEY=${{ secrets.NUXT_PUBLIC_ENTRELINEAS_DEV_KEY }}" >> $GITHUB_ENV + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + + - name: Install Dependencies + run: npm ci + + - name: Build Nuxt Application + run: npm run build + env: + NITRO_PRESET: node-server + + - name: Copy Build Files to Vultr + uses: appleboy/scp-action@master + with: + host: ${{ secrets.VPS_HOST }} + username: ${{ secrets.VPS_USER }} + key: ${{ secrets.VPS_SSH_KEY }} + port: 22 + source: ".output/*,package.json,ecosystem.config.cjs" + target: "${{ env.TARGET_DIR }}" + + - name: Restart PM2 on Vultr + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.VPS_HOST }} + username: ${{ secrets.VPS_USER }} + key: ${{ secrets.VPS_SSH_KEY }} + port: 22 + script: | + export NVM_DIR="$HOME/.nvm" + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + cd ${{ env.TARGET_DIR }} + PM2_NAME=${{ env.PM2_NAME }} pm2 reload ${{ env.PM2_NAME }} --env ${{ env.PM2_ENV }} || \ + PM2_NAME=${{ env.PM2_NAME }} pm2 start ecosystem.config.cjs --env ${{ env.PM2_ENV }} diff --git a/app/components/searchPanel/FiltersContainer.vue b/app/components/searchPanel/FiltersContainer.vue new file mode 100644 index 0000000..8b68d01 --- /dev/null +++ b/app/components/searchPanel/FiltersContainer.vue @@ -0,0 +1,106 @@ + + + + + + + + + + {{ t(title) }} + + {{ activeCount }} + + + + + + + + + + + + + + + {{ t(title) }} + + + + {{ activeCount }} + + + + + + + + + + + + + {{ t(title) }} + + + + + + + + + + + + + + diff --git a/app/components/searchPanel/SearchPanel.vue b/app/components/searchPanel/SearchPanel.vue index 64e5138..4a157ba 100644 --- a/app/components/searchPanel/SearchPanel.vue +++ b/app/components/searchPanel/SearchPanel.vue @@ -2,6 +2,7 @@ import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue' import { breakpointsTailwind } from '@vueuse/core' import PublicationDetail from '~/components/PublicationDetail.vue' +import FiltersContainer from '~/components/searchPanel/FiltersContainer.vue' import { useSettingsStore } from '~/stores/settings' interface Props { @@ -49,43 +50,6 @@ 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 @@ -136,40 +100,268 @@ function clearAllBibleStudyFilters() { refetchResults() } -// ---- Types ---------------------------------------------------------------- - -interface DocumentDoc extends CachedDocMeta { - code: string - locale: string - files?: { - youtube?: string - video?: string - audio?: string - booklet?: string - simple?: string +function refetchResults() { + if (!debouncedQuery.value.trim()) { + browseItems.value = [] + runBrowse(1, false) + } else { + groupedHits.value = [] + currentPage.value = 1 + runSearch(query.value, 1, false) } - body?: string - [key: string]: unknown } -// ---- Colors ---------------------------------------------------------------- +// ---------------------------------------------------------------------------- -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', +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 + })) } + return visibleGroups.value.map(g => ({ + docId: g.docId, + meta: docCache.value[g.docId], + firstHit: g.firstHit + })) }) -// ---- Scroll infinito y detalle --------------------------------------------- +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) + } +} const listContainer = ref(null) @@ -355,6 +547,7 @@ function metaLocation(meta: CachedDocMeta | undefined): string { {{ author }} + {{ t('search.phrase') }} - - - - - - - - + {{ $t('search.filter') }} - - - + + + + + + + Busqueda: + + + + + + + + + + + + {{ $t('search.bible_study_chip', { number: bs.id }) }} + + + + + {{ $t('search.bible_study_clear') }} + + + + + + + + + + {{ $t('search.filter') }} + + + + + + ('bible-study-chips', () => []) + +export function useFilters() { + const { documentsApi } = useTypesenseApi() + const toast = useToast() + + const bibleStudyInput = ref(null) + const isValidating = ref(false) + + const activeCount = computed(() => activeBibleStudies.value.length) + + async function applyBibleStudyFilter(mainCollection: string) { + const val = bibleStudyInput.value + if (val === null || val <= 0) return + if (activeBibleStudies.value.some(bs => bs.id === val)) { + toast.add({ title: 'Estudio ya agregado', description: `El estudio #${val} ya está en el filtro`, color: 'info' }) + bibleStudyInput.value = null + return + } + + isValidating.value = true + try { + const res = await documentsApi.multiSearch({ + multiSearchParameters: {}, + multiSearchSearchesParameter: { + searches: [{ + collection: mainCollection, + q: '*', + queryBy: 'title', + filterBy: `bible_study:=${val}`, + perPage: 1, + includeFields: 'bible_study,title' + }] + } + }) + + const hit = (res?.results?.[0] as { hits?: Array<{ document: { title?: string } }> })?.hits?.[0] + if (hit) { + activeBibleStudies.value = [...activeBibleStudies.value, { id: val, title: hit.document.title || '' }] + bibleStudyInput.value = null + } else { + toast.add({ title: 'Estudio no encontrado', description: `No existe el estudio #${val}`, color: 'warning' }) + } + } catch (err) { + console.error('Error validando bible_study', err) + } finally { + isValidating.value = false + } + } + + function removeBibleStudyFilter(id: number) { + activeBibleStudies.value = activeBibleStudies.value.filter(bs => bs.id !== id) + } + + function clearAllBibleStudyFilters() { + activeBibleStudies.value = [] + } + + return { + activeBibleStudies, + activeCount, + bibleStudyInput, + isValidating, + applyBibleStudyFilter, + removeBibleStudyFilter, + clearAllBibleStudyFilters + } +} diff --git a/app/utils/changelog.ts b/app/utils/changelog.ts index 45313f8..17e981b 100644 --- a/app/utils/changelog.ts +++ b/app/utils/changelog.ts @@ -6,141 +6,153 @@ export const typeConfig: Record
Busqueda: