Compare commits
No commits in common. "main" and "new-collections" have entirely different histories.
main
...
new-collec
|
|
@ -1,86 +0,0 @@
|
||||||
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_API_KEY=${{ secrets.NUXT_PUBLIC_TYPESENSE_API_KEY }}' >> $GITHUB_ENV
|
|
||||||
echo 'TYPESENSE_NODES=${{ secrets.TYPESENSE_NODES }}' >> $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
|
|
||||||
TYPESENSE_NODES: ${{ secrets.TYPESENSE_NODES }}
|
|
||||||
|
|
||||||
- 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 }} \
|
|
||||||
NUXT_FEEDBACK_TOKEN=${{ secrets.NUXT_FEEDBACK_TOKEN }} \
|
|
||||||
TYPESENSE_NODES=${{ secrets.TYPESENSE_NODES }} \
|
|
||||||
pm2 reload ecosystem.config.cjs --update-env --env ${{ env.PM2_ENV }} || \
|
|
||||||
PM2_NAME=${{ env.PM2_NAME }} \
|
|
||||||
NUXT_FEEDBACK_TOKEN=${{ secrets.NUXT_FEEDBACK_TOKEN }} \
|
|
||||||
TYPESENSE_NODES=${{ secrets.TYPESENSE_NODES }} \
|
|
||||||
pm2 start ecosystem.config.cjs --update-env --env ${{ env.PM2_ENV }}
|
|
||||||
|
|
@ -17,7 +17,6 @@ logs
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.fleet
|
.fleet
|
||||||
.idea
|
.idea
|
||||||
--port
|
|
||||||
|
|
||||||
# Local env files
|
# Local env files
|
||||||
.env
|
.env
|
||||||
|
|
|
||||||
|
|
@ -61,4 +61,4 @@ Check out the [deployment documentation](https://nuxt.com/docs/getting-started/d
|
||||||
|
|
||||||
## Renovate integration
|
## Renovate integration
|
||||||
|
|
||||||
Install [Renovate GitHub app](https://github.com/apps/renovate/installations/select_target) on your repository and you are good to go. chage v4
|
Install [Renovate GitHub app](https://github.com/apps/renovate/installations/select_target) on your repository and you are good to go.
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,6 @@ const props = defineProps<{
|
||||||
selectedHit?: TypesenseParagraphHit | null
|
selectedHit?: TypesenseParagraphHit | null
|
||||||
selectedMatchingHits?: TypesenseParagraphHit[] | null
|
selectedMatchingHits?: TypesenseParagraphHit[] | null
|
||||||
accentColor?: 'green' | 'blue'
|
accentColor?: 'green' | 'blue'
|
||||||
noTrackVisit?: boolean
|
|
||||||
author?: string
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emits = defineEmits(['close'])
|
const emits = defineEmits(['close'])
|
||||||
|
|
@ -105,7 +103,7 @@ watch(
|
||||||
() => [props.collection, props.document?.id] as const,
|
() => [props.collection, props.document?.id] as const,
|
||||||
([collection, id]) => {
|
([collection, id]) => {
|
||||||
if (!collection || !id || !props.document) return
|
if (!collection || !id || !props.document) return
|
||||||
if (!props.noTrackVisit) history.visit(collection, toSearchHit(props.document))
|
history.visit(collection, toSearchHit(props.document))
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
@ -846,7 +844,7 @@ const items = computed(() => {
|
||||||
@click="toggleInternalSearch"
|
@click="toggleInternalSearch"
|
||||||
/>
|
/>
|
||||||
</UTooltip>
|
</UTooltip>
|
||||||
<UTooltip text="¿Cómo funciona?">
|
<UTooltip text="Como funciona?">
|
||||||
<UButton
|
<UButton
|
||||||
icon="ph-student"
|
icon="ph-student"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
|
|
@ -886,10 +884,6 @@ const items = computed(() => {
|
||||||
<UIcon name="ph:file-dashed" class="size-4 text-carpared" />
|
<UIcon name="ph:file-dashed" class="size-4 text-carpared" />
|
||||||
{{ $t('search.draft') }}
|
{{ $t('search.draft') }}
|
||||||
</p>
|
</p>
|
||||||
<p v-if="author" class="text-sm text-highlighted flex items-center gap-1.5 shrink-0">
|
|
||||||
<UIcon name="ph:user-circle" :class="['size-4', iconColor]" />
|
|
||||||
<span class="italic">{{ author }}</span>
|
|
||||||
</p>
|
|
||||||
<p v-if="safeDate()" class="text-sm text-highlighted flex items-center gap-1.5 shrink-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', iconColor]" />
|
<UIcon name="ph:calendar" :class="['size-4', iconColor]" />
|
||||||
{{ safeDate() }}
|
{{ safeDate() }}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ const props = defineProps<{
|
||||||
document: EntrelineaDoc
|
document: EntrelineaDoc
|
||||||
collection?: string
|
collection?: string
|
||||||
highlightedText?: string | null
|
highlightedText?: string | null
|
||||||
noTrackVisit?: boolean
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emits = defineEmits<{ close: [] }>()
|
const emits = defineEmits<{ close: [] }>()
|
||||||
|
|
@ -105,7 +104,7 @@ watch(
|
||||||
() => [props.collection, props.document?.id] as const,
|
() => [props.collection, props.document?.id] as const,
|
||||||
([collection, id]) => {
|
([collection, id]) => {
|
||||||
if (!collection || !id) return
|
if (!collection || !id) return
|
||||||
if (!props.noTrackVisit) history.visit(collection, toSearchHit(props.document))
|
history.visit(collection, toSearchHit(props.document))
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { breakpointsTailwind } from '@vueuse/core'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
activeCount?: number
|
|
||||||
title?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
|
||||||
activeCount: 0,
|
|
||||||
title: 'search.filters'
|
|
||||||
})
|
|
||||||
|
|
||||||
/*
|
|
||||||
─── RESPONSIVE: un solo componente, dos renders ───
|
|
||||||
- Desktop (>lg): acordeón colapsable (#content dentro)
|
|
||||||
- Mobile (<lg): botón → USlideover (#content dentro del slide)
|
|
||||||
|
|
||||||
El slot #content es el MISMO en ambos casos
|
|
||||||
(el input + botón para agregar bible_study)
|
|
||||||
*/
|
|
||||||
|
|
||||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
|
||||||
const isMobile = breakpoints.smaller('lg')
|
|
||||||
|
|
||||||
const isExpanded = ref(true)
|
|
||||||
const isSlideoverOpen = ref(false)
|
|
||||||
|
|
||||||
const { $i18n } = useNuxtApp()
|
|
||||||
const t = $i18n.t
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="border-b border-default">
|
|
||||||
<!-- ─── DESKTOP (>lg): ACORDEÓN COLAPSABLE ──────── -->
|
|
||||||
<div v-if="!isMobile" class="select-none">
|
|
||||||
<button
|
|
||||||
class="w-full flex items-center justify-between px-4 sm:px-6 py-2 text-sm font-medium text-muted hover:text-foreground transition-colors"
|
|
||||||
@click="isExpanded = !isExpanded"
|
|
||||||
>
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
<UIcon name="i-lucide-filter" class="size-4" />
|
|
||||||
{{ t(title) }}
|
|
||||||
<UBadge v-if="activeCount > 0" size="sm" variant="solid" color="primary">
|
|
||||||
{{ activeCount }}
|
|
||||||
</UBadge>
|
|
||||||
</span>
|
|
||||||
<UIcon
|
|
||||||
:name="isExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
|
||||||
class="size-4 transition-transform"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div v-show="isExpanded" class="px-4 sm:px-6 pb-3">
|
|
||||||
<slot name="content" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ─── MOBILE (<lg): BOTÓN + SLIDEOVER ─────────── -->
|
|
||||||
<div v-else class="px-4 sm:px-6 py-2">
|
|
||||||
<UButton
|
|
||||||
size="sm"
|
|
||||||
color="neutral"
|
|
||||||
variant="outline"
|
|
||||||
class="w-full justify-between"
|
|
||||||
@click="isSlideoverOpen = true"
|
|
||||||
>
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
<UIcon name="i-lucide-filter" class="size-4" />
|
|
||||||
{{ t(title) }}
|
|
||||||
</span>
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
<UBadge v-if="activeCount > 0" size="sm" variant="solid" color="primary">
|
|
||||||
{{ activeCount }}
|
|
||||||
</UBadge>
|
|
||||||
<UIcon name="i-lucide-chevron-right" class="size-4" />
|
|
||||||
</span>
|
|
||||||
</UButton>
|
|
||||||
|
|
||||||
<ClientOnly>
|
|
||||||
<USlideover v-model:open="isSlideoverOpen">
|
|
||||||
<template #content>
|
|
||||||
<div class="p-4 space-y-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="text-lg font-semibold flex items-center gap-2">
|
|
||||||
<UIcon name="i-lucide-filter" class="size-5" />
|
|
||||||
{{ t(title) }}
|
|
||||||
</h3>
|
|
||||||
<UButton
|
|
||||||
icon="i-lucide-x"
|
|
||||||
size="sm"
|
|
||||||
color="neutral"
|
|
||||||
variant="ghost"
|
|
||||||
@click="isSlideoverOpen = false"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<slot name="content" />
|
|
||||||
<div v-if="$slots.chips" class="pt-2">
|
|
||||||
<slot name="chips" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</USlideover>
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { breakpointsTailwind } from '@vueuse/core'
|
import { breakpointsTailwind, useDebounce } from '@vueuse/core'
|
||||||
import PublicationDetail from '~/components/PublicationDetail.vue'
|
import PublicationDetail from '~/components/PublicationDetail.vue'
|
||||||
import FiltersContainer from '~/components/searchPanel/FiltersContainer.vue'
|
|
||||||
import { useSettingsStore } from '~/stores/settings'
|
import { useSettingsStore } from '~/stores/settings'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -15,14 +14,10 @@ interface Props {
|
||||||
accentColor: 'green' | 'blue'
|
accentColor: 'green' | 'blue'
|
||||||
emptyDetailText: string
|
emptyDetailText: string
|
||||||
showDraft?: boolean
|
showDraft?: boolean
|
||||||
author?: string
|
|
||||||
showBibleStudyFilter?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
showDraft: false,
|
showDraft: false
|
||||||
author: '',
|
|
||||||
showBibleStudyFilter: false
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const QUERY_BY = 'text'
|
const QUERY_BY = 'text'
|
||||||
|
|
@ -31,122 +26,46 @@ const { $i18n } = useNuxtApp()
|
||||||
const t = $i18n.t
|
const t = $i18n.t
|
||||||
const { locale } = useI18n()
|
const { locale } = useI18n()
|
||||||
|
|
||||||
|
const filterBy = computed(() => `locale:=${locale.value}`)
|
||||||
|
const REQUEST_TIMEOUT_MS = 15000
|
||||||
|
|
||||||
const settings = useSettingsStore()
|
const settings = useSettingsStore()
|
||||||
const { unlocked } = useDevMode()
|
|
||||||
const toast = useToast()
|
|
||||||
const typesenseClient = useTypesenseClient()
|
|
||||||
|
|
||||||
// ── Restaurar estado desde URL antes de crear los refs ─────────────────────
|
// ── Restaurar estado desde URL antes de crear los refs ─────────────────────
|
||||||
const { query: q0, page: p0, scroll: s0, selectedId: sid0 } = useSearchUrlState()
|
const { query: q0, page: p0, scroll: s0, selectedId: sid0 } = useSearchUrlState()
|
||||||
|
|
||||||
// ---- Filtro bible_study multi-chip (solo para Estudios) --------------------
|
const query = ref(q0)
|
||||||
|
const debouncedQuery = useDebounce(query, 150)
|
||||||
interface BibleStudyChip {
|
const loading = ref(false)
|
||||||
id: number
|
const loadingMore = ref(false)
|
||||||
title: string
|
const errorMsg = ref<string | null>(null)
|
||||||
}
|
|
||||||
|
|
||||||
const bibleStudyInput = ref<number | null>(null)
|
|
||||||
const activeBibleStudies = ref<BibleStudyChip[]>([])
|
|
||||||
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
|
|
||||||
},
|
|
||||||
browseFilterBy: () => {
|
|
||||||
let base = `locale:=${locale.value}`
|
|
||||||
if (activeBibleStudies.value.length > 0) {
|
|
||||||
const ids = activeBibleStudies.value.map(bs => bs.id).join(',')
|
|
||||||
base += ` && 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
|
|
||||||
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 typesenseClient.multiSearch.perform({
|
|
||||||
searches: [{
|
|
||||||
collection: props.mainCollection,
|
|
||||||
q: '*',
|
|
||||||
query_by: 'title',
|
|
||||||
filter_by: `bible_study:=${val}`,
|
|
||||||
per_page: 1,
|
|
||||||
include_fields: 'bible_study,title',
|
|
||||||
use_cache: true,
|
|
||||||
cache_ttl: 3600
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
|
|
||||||
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
|
|
||||||
refetchResults()
|
|
||||||
} 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)
|
|
||||||
refetchResults()
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAllBibleStudyFilters() {
|
|
||||||
activeBibleStudies.value = []
|
|
||||||
refetchResults()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Types ----------------------------------------------------------------
|
// ---- Types ----------------------------------------------------------------
|
||||||
|
|
||||||
interface DocumentDoc extends CachedDocMeta {
|
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
|
||||||
|
draft?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentDoc extends DocMeta {
|
||||||
code: string
|
code: string
|
||||||
locale: string
|
locale: string
|
||||||
files?: {
|
files?: {
|
||||||
|
|
@ -160,6 +79,47 @@ interface DocumentDoc extends CachedDocMeta {
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TypesenseHighlight {
|
||||||
|
field?: string
|
||||||
|
snippet?: string
|
||||||
|
value?: string
|
||||||
|
matched_tokens?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TypesenseParagraphHit {
|
||||||
|
document: ParagraphDoc
|
||||||
|
highlights?: TypesenseHighlight[]
|
||||||
|
highlight?: Record<string, { snippet?: string, value?: string }>
|
||||||
|
text_match?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TypesenseGroupedHit {
|
||||||
|
groupKey: string[]
|
||||||
|
hits: TypesenseParagraphHit[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TypesenseSearchResponse {
|
||||||
|
found: number
|
||||||
|
groupedHits?: TypesenseGroupedHit[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchGroup {
|
||||||
|
docId: string
|
||||||
|
firstHit: TypesenseParagraphHit
|
||||||
|
allHits: TypesenseParagraphHit[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrowseItem {
|
||||||
|
docId: string
|
||||||
|
meta: DocMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DisplayGroup {
|
||||||
|
docId: string
|
||||||
|
meta: DocMeta | undefined
|
||||||
|
firstHit: TypesenseParagraphHit | null
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Colors ----------------------------------------------------------------
|
// ---- Colors ----------------------------------------------------------------
|
||||||
|
|
||||||
const colors = computed(() => {
|
const colors = computed(() => {
|
||||||
|
|
@ -177,7 +137,256 @@ const colors = computed(() => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- Scroll infinito y detalle ---------------------------------------------
|
// ---- State ----------------------------------------------------------------
|
||||||
|
|
||||||
|
const exactSearch = ref(false)
|
||||||
|
|
||||||
|
const groupedHits = ref<SearchGroup[]>([])
|
||||||
|
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<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,
|
||||||
|
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<Record<string, DocMeta>>({})
|
||||||
|
|
||||||
|
const { documentsApi } = useTypesenseApi()
|
||||||
|
|
||||||
|
// ---- 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(',')}]`,
|
||||||
|
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<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: props.paragraphsCollection,
|
||||||
|
q: exactSearch.value && q ? `"${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>',
|
||||||
|
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,
|
||||||
|
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)`
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log('Browse result', multi)
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
@ -187,7 +396,7 @@ function onListScroll() {
|
||||||
if (!el) return
|
if (!el) return
|
||||||
if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) {
|
if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) {
|
||||||
if (!debouncedQuery.value.trim()) {
|
if (!debouncedQuery.value.trim()) {
|
||||||
if (hasMoreBrowse.value && !loadingMore.value && !loading.value) runBrowse(1, true)
|
if (hasMoreBrowse.value && !loadingMore.value && !loading.value) runBrowse(browsePage.value, true)
|
||||||
} else {
|
} else {
|
||||||
if (hasMoreVisible.value) visibleGroupCount.value += 10
|
if (hasMoreVisible.value) visibleGroupCount.value += 10
|
||||||
else if (hasMore.value && !loadingMore.value && !loading.value) loadMore()
|
else if (hasMore.value && !loadingMore.value && !loading.value) loadMore()
|
||||||
|
|
@ -195,53 +404,75 @@ function onListScroll() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()) {
|
||||||
|
groupedHits.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
|
||||||
|
groupedHits.value = []; total.value = 0; currentPage.value = 1; visibleGroupCount.value = 10
|
||||||
|
runSearch(q, 1, false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(exactSearch, () => {
|
||||||
|
if (query.value.trim()) runSearch(query.value, 1, false)
|
||||||
|
})
|
||||||
|
|
||||||
// ---- Selección y carga del detalle ----------------------------------------
|
// ---- Selección y carga del detalle ----------------------------------------
|
||||||
|
|
||||||
const selectedDocId = ref<string | null>(null)
|
const selectedDocId = ref<string | null>(null)
|
||||||
const selectedDocument = ref<DocumentDoc | null>(null)
|
const selectedDocument = ref<DocumentDoc | null>(null)
|
||||||
const documentLoading = ref(false)
|
const documentLoading = ref(false)
|
||||||
const selectedParagraphs = ref<TypesenseGroupedParagraphHit[]>([])
|
const selectedParagraphs = ref<TypesenseParagraphHit[]>([])
|
||||||
const paragraphsLoading = ref(false)
|
const paragraphsLoading = ref(false)
|
||||||
const selectedHit = ref<TypesenseGroupedParagraphHit | null>(null)
|
const selectedHit = ref<TypesenseParagraphHit | null>(null)
|
||||||
const selectedMatchingHits = ref<TypesenseGroupedParagraphHit[]>([])
|
const selectedMatchingHits = ref<TypesenseParagraphHit[]>([])
|
||||||
|
|
||||||
let detailSeq = 0
|
|
||||||
let detailController: AbortController | null = null
|
|
||||||
|
|
||||||
onBeforeUnmount(() => { detailController?.abort() })
|
|
||||||
|
|
||||||
const { fetchDocumentDetail } = useDocumentDetailFetch()
|
|
||||||
|
|
||||||
async function fetchDocumentWithParagraphs(docId: string) {
|
async function fetchDocumentWithParagraphs(docId: string) {
|
||||||
const seq = ++detailSeq
|
|
||||||
detailController?.abort()
|
|
||||||
const controller = new AbortController()
|
|
||||||
detailController = controller
|
|
||||||
documentLoading.value = true
|
documentLoading.value = true
|
||||||
paragraphsLoading.value = true
|
paragraphsLoading.value = true
|
||||||
selectedDocument.value = null
|
selectedDocument.value = null
|
||||||
selectedParagraphs.value = []
|
selectedParagraphs.value = []
|
||||||
try {
|
try {
|
||||||
const detail = await fetchDocumentDetail(typesenseClient, props.mainCollection, props.paragraphsCollection, docId, controller.signal)
|
const res = await documentsApi.multiSearch({
|
||||||
if (seq !== detailSeq) return
|
multiSearchParameters: {},
|
||||||
if (detail) {
|
multiSearchSearchesParameter: {
|
||||||
const rawParagraphs = detail.paragraphs as unknown as ParagraphDoc[]
|
searches: [{
|
||||||
selectedDocument.value = detail.document as unknown as DocumentDoc
|
collection: props.mainCollection,
|
||||||
|
q: '*',
|
||||||
|
queryBy: 'title',
|
||||||
|
filterBy: `id:=${docId} && $${props.paragraphsCollection}(id: *)`,
|
||||||
|
includeFields: `*, $${props.paragraphsCollection}(*)`
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const hit = (res?.results?.[0] as { hits?: Array<{ document: Record<string, unknown> }> })?.hits?.[0]
|
||||||
|
if (hit) {
|
||||||
|
const docRaw = { ...hit.document }
|
||||||
|
const rawParagraphs = (docRaw[props.paragraphsCollection] as ParagraphDoc[] | undefined) ?? []
|
||||||
|
delete docRaw[props.paragraphsCollection]
|
||||||
|
selectedDocument.value = docRaw as unknown as DocumentDoc
|
||||||
selectedParagraphs.value = [...rawParagraphs]
|
selectedParagraphs.value = [...rawParagraphs]
|
||||||
.sort((a, b) => (a.number ?? 0) - (b.number ?? 0))
|
.sort((a, b) => (a.number ?? 0) - (b.number ?? 0))
|
||||||
.map(p => ({ document: p }))
|
.map(p => ({ document: p }))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (seq !== detailSeq) 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 = []
|
||||||
} finally {
|
} finally {
|
||||||
if (seq === detailSeq) {
|
documentLoading.value = false
|
||||||
documentLoading.value = false
|
paragraphsLoading.value = false
|
||||||
paragraphsLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -303,7 +534,7 @@ useDetailHistory(isPanelOpen, isMobile)
|
||||||
|
|
||||||
// ---- Helpers de presentación ----------------------------------------------
|
// ---- Helpers de presentación ----------------------------------------------
|
||||||
|
|
||||||
function highlightedFor(hit: TypesenseGroupedParagraphHit, field: string): string | null {
|
function highlightedFor(hit: TypesenseParagraphHit, field: string): string | null {
|
||||||
const fromArr = hit.highlights?.find(h => h.field === field)
|
const fromArr = hit.highlights?.find(h => h.field === field)
|
||||||
if (fromArr?.snippet) return fromArr.snippet
|
if (fromArr?.snippet) return fromArr.snippet
|
||||||
if (fromArr?.value) return fromArr.value
|
if (fromArr?.value) return fromArr.value
|
||||||
|
|
@ -313,14 +544,14 @@ function highlightedFor(hit: TypesenseGroupedParagraphHit, field: string): strin
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function metaDate(meta: CachedDocMeta | undefined): string {
|
function metaDate(meta: DocMeta | undefined): string {
|
||||||
if (!meta) return ''
|
if (!meta) return ''
|
||||||
const ts = meta.timestamp || (meta.date ? Math.floor(new Date(meta.date).getTime() / 1000) : null)
|
const ts = meta.timestamp || (meta.date ? Math.floor(new Date(meta.date).getTime() / 1000) : null)
|
||||||
if (!ts) return meta.date || ''
|
if (!ts) return meta.date || ''
|
||||||
return formatDate(ts)
|
return formatDate(ts)
|
||||||
}
|
}
|
||||||
|
|
||||||
function metaLocation(meta: CachedDocMeta | undefined): string {
|
function metaLocation(meta: DocMeta | undefined): string {
|
||||||
if (!meta) return ''
|
if (!meta) return ''
|
||||||
return formatLocation({
|
return formatLocation({
|
||||||
id: meta.id, date: meta.timestamp ?? 0, slug: meta.slug ?? '',
|
id: meta.id, date: meta.timestamp ?? 0, slug: meta.slug ?? '',
|
||||||
|
|
@ -351,15 +582,6 @@ function metaLocation(meta: CachedDocMeta | undefined): string {
|
||||||
</template>
|
</template>
|
||||||
</UDashboardNavbar>
|
</UDashboardNavbar>
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="author"
|
|
||||||
class="px-4 sm:px-6 py-2 border-b border-default flex items-center gap-1.5 text-xs text-muted"
|
|
||||||
>
|
|
||||||
<UIcon name="ph:user-circle" :class="['size-3.5 shrink-0', colors.icon]" />
|
|
||||||
<span class="italic">{{ author }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ─── BUSCADOR ─────────────────────────────────── -->
|
|
||||||
<div class="px-4 sm:px-6 py-3 border-b border-default flex items-center gap-2" id="inputField">
|
<div class="px-4 sm:px-6 py-3 border-b border-default flex items-center gap-2" id="inputField">
|
||||||
<UInput
|
<UInput
|
||||||
v-model="query"
|
v-model="query"
|
||||||
|
|
@ -386,117 +608,6 @@ function metaLocation(meta: CachedDocMeta | undefined): string {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ─── CHIPS ACTIVOS (solo desktop: fuera del FiltersContainer) ─── -->
|
|
||||||
<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">
|
|
||||||
<UBadge
|
|
||||||
v-for="bs in activeBibleStudies"
|
|
||||||
:key="bs.id"
|
|
||||||
size="sm"
|
|
||||||
variant="subtle"
|
|
||||||
color="primary"
|
|
||||||
class="max-w-full"
|
|
||||||
>
|
|
||||||
<span class="truncate">{{ $t('search.bible_study_chip', { number: bs.id }) }}</span>
|
|
||||||
<UButton
|
|
||||||
icon="i-lucide-x"
|
|
||||||
size="2xs"
|
|
||||||
color="neutral"
|
|
||||||
variant="ghost"
|
|
||||||
class="ml-1 shrink-0"
|
|
||||||
@click="removeBibleStudyFilter(bs.id)"
|
|
||||||
/>
|
|
||||||
</UBadge>
|
|
||||||
<UButton
|
|
||||||
size="2xs"
|
|
||||||
color="neutral"
|
|
||||||
variant="ghost"
|
|
||||||
class="text-xs"
|
|
||||||
@click="clearAllBibleStudyFilters"
|
|
||||||
>
|
|
||||||
{{ $t('search.bible_study_clear') }}
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ─── SORT ────────────────────────────────────── -->
|
|
||||||
<div v-if="query.trim()" class="px-4 sm:px-6 py-3 flex items-center gap-2">
|
|
||||||
<p class="text-sm">Busqueda: </p>
|
|
||||||
<USelect
|
|
||||||
v-model="sortMode"
|
|
||||||
:items="[
|
|
||||||
{ label: t('search.sort.relevance'), value: 'relevance' },
|
|
||||||
{ label: t('search.sort.date'), value: 'date' }
|
|
||||||
]"
|
|
||||||
size="sm"
|
|
||||||
class="shrink-0 min-w-[130px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ─── FILTROS: AGREGAR NUEVOS (acordeón desktop / slideover mobile) ─── -->
|
|
||||||
<template v-if="showBibleStudyFilter">
|
|
||||||
<FiltersContainer
|
|
||||||
:active-count="activeBibleStudies.length"
|
|
||||||
title="search.filters"
|
|
||||||
>
|
|
||||||
<template #chips>
|
|
||||||
<div class="flex flex-wrap items-center gap-1.5">
|
|
||||||
<UBadge
|
|
||||||
v-for="bs in activeBibleStudies"
|
|
||||||
:key="bs.id"
|
|
||||||
size="sm"
|
|
||||||
variant="subtle"
|
|
||||||
color="primary"
|
|
||||||
class="max-w-full"
|
|
||||||
>
|
|
||||||
<span class="truncate">{{ $t('search.bible_study_chip', { number: bs.id }) }}</span>
|
|
||||||
<UButton
|
|
||||||
icon="i-lucide-x"
|
|
||||||
size="2xs"
|
|
||||||
color="neutral"
|
|
||||||
variant="ghost"
|
|
||||||
class="ml-1 shrink-0"
|
|
||||||
@click="removeBibleStudyFilter(bs.id)"
|
|
||||||
/>
|
|
||||||
</UBadge>
|
|
||||||
<div v-if="activeBibleStudies.length > 0">
|
|
||||||
<UButton
|
|
||||||
size="sm"
|
|
||||||
color="neutral"
|
|
||||||
variant="ghost"
|
|
||||||
class="text-xs"
|
|
||||||
@click="clearAllBibleStudyFilters">
|
|
||||||
{{ $t('search.bible_study_clear') }}
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #content>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<UInput
|
|
||||||
v-model="bibleStudyInput"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
:placeholder="$t('search.bible_study_placeholder')"
|
|
||||||
size="sm"
|
|
||||||
class="w-36"
|
|
||||||
@keyup.enter="applyBibleStudyFilter"
|
|
||||||
/>
|
|
||||||
<UButton
|
|
||||||
size="sm"
|
|
||||||
color="neutral"
|
|
||||||
variant="outline"
|
|
||||||
:loading="isValidating"
|
|
||||||
:disabled="bibleStudyInput === null || bibleStudyInput <= 0"
|
|
||||||
@click="applyBibleStudyFilter"
|
|
||||||
>
|
|
||||||
+ {{ $t('search.filter') }}
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</FiltersContainer>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<UAlert
|
<UAlert
|
||||||
v-if="errorMsg"
|
v-if="errorMsg"
|
||||||
:title="errorMsg"
|
:title="errorMsg"
|
||||||
|
|
@ -604,7 +715,6 @@ function metaLocation(meta: CachedDocMeta | undefined): string {
|
||||||
:selected-hit="selectedHit"
|
:selected-hit="selectedHit"
|
||||||
:selected-matching-hits="selectedMatchingHits"
|
:selected-matching-hits="selectedMatchingHits"
|
||||||
:accent-color="accentColor"
|
:accent-color="accentColor"
|
||||||
:author="author"
|
|
||||||
@close="isPanelOpen = false"
|
@close="isPanelOpen = false"
|
||||||
/>
|
/>
|
||||||
<div v-else-if="!isMobile" class="hidden lg:flex flex-1 items-center justify-center">
|
<div v-else-if="!isMobile" class="hidden lg:flex flex-1 items-center justify-center">
|
||||||
|
|
@ -629,7 +739,6 @@ function metaLocation(meta: CachedDocMeta | undefined): string {
|
||||||
:selected-hit="selectedHit"
|
:selected-hit="selectedHit"
|
||||||
:selected-matching-hits="selectedMatchingHits"
|
:selected-matching-hits="selectedMatchingHits"
|
||||||
:accent-color="accentColor"
|
:accent-color="accentColor"
|
||||||
:author="author"
|
|
||||||
@close="isPanelOpen = false"
|
@close="isPanelOpen = false"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
export interface CachedDocMeta {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
date?: string
|
|
||||||
timestamp?: number
|
|
||||||
place?: string
|
|
||||||
city?: string
|
|
||||||
state?: string
|
|
||||||
country?: string
|
|
||||||
type?: string
|
|
||||||
slug?: string
|
|
||||||
draft?: string
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_ENTRIES = 500
|
|
||||||
|
|
||||||
function cacheKey(collection: string, id: string): string {
|
|
||||||
return `${collection}:${id}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Caché de metadata de documentos (título, fecha, lugar...) compartida entre
|
|
||||||
* páginas de búsqueda, con vida de sesión (sobrevive entre queries, no se
|
|
||||||
* vacía al buscar de nuevo). Clave `collection:id` porque los IDs no son
|
|
||||||
* únicos entre colecciones (p.ej. `conferences` vs `activities`).
|
|
||||||
*
|
|
||||||
* Tope de tamaño con eviction LRU simple: en cada lectura/escritura la
|
|
||||||
* entrada se reinserta al final del Map (orden de inserción = recencia), así
|
|
||||||
* que la más antigua a evictar siempre es `.keys().next().value`.
|
|
||||||
*/
|
|
||||||
export function useDocMetaCache() {
|
|
||||||
const cache = useState<Map<string, CachedDocMeta>>('doc-meta-cache', () => new Map())
|
|
||||||
|
|
||||||
function get(collection: string, id: string): CachedDocMeta | undefined {
|
|
||||||
const key = cacheKey(collection, id)
|
|
||||||
const entry = cache.value.get(key)
|
|
||||||
if (entry) {
|
|
||||||
cache.value.delete(key)
|
|
||||||
cache.value.set(key, entry)
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
function set(collection: string, id: string, meta: CachedDocMeta) {
|
|
||||||
const key = cacheKey(collection, id)
|
|
||||||
cache.value.delete(key)
|
|
||||||
cache.value.set(key, meta)
|
|
||||||
if (cache.value.size > MAX_ENTRIES) {
|
|
||||||
const oldestKey = cache.value.keys().next().value
|
|
||||||
if (oldestKey !== undefined) cache.value.delete(oldestKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { get, set }
|
|
||||||
}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
export interface DocumentDetailResult {
|
|
||||||
document: Record<string, unknown>
|
|
||||||
paragraphs: Record<string, unknown>[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_ENTRIES = 25
|
|
||||||
|
|
||||||
function cacheKey(mainCollection: string, docId: string): string {
|
|
||||||
return `${mainCollection}:${docId}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch de "documento principal + sus párrafos" (join de Typesense), con
|
|
||||||
* caché de sesión compartida entre SearchPanel.vue y usePublicationFetch.ts
|
|
||||||
* — antes cada uno hacía la misma query por separado sin cachear nada, así
|
|
||||||
* que reabrir el mismo documento en la sesión repetía la ida y vuelta de red.
|
|
||||||
* Tope de ~25 entradas (documentos completos con cuerpo/párrafos pesan más
|
|
||||||
* que la metadata de useDocMetaCache) con eviction LRU simple.
|
|
||||||
*/
|
|
||||||
export function useDocumentDetailFetch() {
|
|
||||||
const cache = useState<Map<string, DocumentDetailResult>>('doc-detail-cache', () => new Map())
|
|
||||||
|
|
||||||
function getCached(mainCollection: string, docId: string): DocumentDetailResult | undefined {
|
|
||||||
const key = cacheKey(mainCollection, docId)
|
|
||||||
const entry = cache.value.get(key)
|
|
||||||
if (entry) {
|
|
||||||
cache.value.delete(key)
|
|
||||||
cache.value.set(key, entry)
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCached(mainCollection: string, docId: string, detail: DocumentDetailResult) {
|
|
||||||
const key = cacheKey(mainCollection, docId)
|
|
||||||
cache.value.delete(key)
|
|
||||||
cache.value.set(key, detail)
|
|
||||||
if (cache.value.size > MAX_ENTRIES) {
|
|
||||||
const oldestKey = cache.value.keys().next().value
|
|
||||||
if (oldestKey !== undefined) cache.value.delete(oldestKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchDocumentDetail(
|
|
||||||
typesenseClient: ReturnType<typeof useTypesenseClient>,
|
|
||||||
mainCollection: string,
|
|
||||||
paragraphsCollection: string,
|
|
||||||
docId: string,
|
|
||||||
signal?: AbortSignal
|
|
||||||
): Promise<DocumentDetailResult | null> {
|
|
||||||
const cached = getCached(mainCollection, docId)
|
|
||||||
if (cached) return cached
|
|
||||||
|
|
||||||
const res = await typesenseClient.multiSearch.perform({
|
|
||||||
searches: [{
|
|
||||||
collection: mainCollection,
|
|
||||||
q: '*',
|
|
||||||
query_by: 'title',
|
|
||||||
filter_by: `id:=${docId} && $${paragraphsCollection}(id: *)`,
|
|
||||||
include_fields: `*, $${paragraphsCollection}(*)`,
|
|
||||||
use_cache: true,
|
|
||||||
cache_ttl: 3600
|
|
||||||
}]
|
|
||||||
}, {}, { abortSignal: signal })
|
|
||||||
|
|
||||||
const hit = (res?.results?.[0] as { hits?: Array<{ document: Record<string, unknown> }> })?.hits?.[0]
|
|
||||||
if (!hit) return null
|
|
||||||
|
|
||||||
const docRaw = { ...hit.document }
|
|
||||||
const raw = docRaw[paragraphsCollection]
|
|
||||||
const paragraphs = (Array.isArray(raw) ? raw : (raw ? [raw] : [])) as Record<string, unknown>[]
|
|
||||||
delete docRaw[paragraphsCollection]
|
|
||||||
|
|
||||||
const detail: DocumentDetailResult = { document: docRaw, paragraphs }
|
|
||||||
setCached(mainCollection, docId, detail)
|
|
||||||
return detail
|
|
||||||
}
|
|
||||||
|
|
||||||
return { fetchDocumentDetail }
|
|
||||||
}
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
interface BibleStudyChip {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeBibleStudies = useState<BibleStudyChip[]>('bible-study-chips', () => [])
|
|
||||||
|
|
||||||
export function useFilters() {
|
|
||||||
const typesenseClient = useTypesenseClient()
|
|
||||||
const toast = useToast()
|
|
||||||
|
|
||||||
const bibleStudyInput = ref<number | null>(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 typesenseClient.multiSearch.perform({
|
|
||||||
searches: [{
|
|
||||||
collection: mainCollection,
|
|
||||||
q: '*',
|
|
||||||
query_by: 'title',
|
|
||||||
filter_by: `bible_study:=${val}`,
|
|
||||||
per_page: 1,
|
|
||||||
include_fields: 'bible_study,title',
|
|
||||||
use_cache: true,
|
|
||||||
cache_ttl: 3600
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -49,15 +49,7 @@ export function usePublicationFetch() {
|
||||||
const detailParagraphs = ref<TypesenseParagraphHit[]>([])
|
const detailParagraphs = ref<TypesenseParagraphHit[]>([])
|
||||||
const detailParagraphsLoading = ref(false)
|
const detailParagraphsLoading = ref(false)
|
||||||
|
|
||||||
const typesenseClient = useTypesenseClient()
|
const { documentsApi } = useTypesenseApi()
|
||||||
const { fetchDocumentDetail } = useDocumentDetailFetch()
|
|
||||||
|
|
||||||
let fetchSeq = 0
|
|
||||||
let fetchController: AbortController | null = null
|
|
||||||
|
|
||||||
function isAbortError(err: unknown): boolean {
|
|
||||||
return (err as { name?: string } | null)?.name === 'AbortError'
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchDetail(hit: SearchHit, favoritesCollection: string) {
|
async function fetchDetail(hit: SearchHit, favoritesCollection: string) {
|
||||||
const config = COLLECTION_CONFIG[favoritesCollection]
|
const config = COLLECTION_CONFIG[favoritesCollection]
|
||||||
|
|
@ -67,35 +59,40 @@ export function usePublicationFetch() {
|
||||||
detailParagraphs.value = []
|
detailParagraphs.value = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const seq = ++fetchSeq
|
|
||||||
fetchController?.abort()
|
|
||||||
const controller = new AbortController()
|
|
||||||
fetchController = controller
|
|
||||||
detailDocumentLoading.value = true
|
detailDocumentLoading.value = true
|
||||||
detailParagraphsLoading.value = true
|
detailParagraphsLoading.value = true
|
||||||
detailDocument.value = null
|
detailDocument.value = null
|
||||||
detailParagraphs.value = []
|
detailParagraphs.value = []
|
||||||
try {
|
try {
|
||||||
const detail = await fetchDocumentDetail(typesenseClient, config.main, config.paragraphs, docId, controller.signal)
|
const res = await documentsApi.multiSearch({
|
||||||
if (seq !== fetchSeq) return
|
multiSearchParameters: {},
|
||||||
if (detail) {
|
multiSearchSearchesParameter: {
|
||||||
const rawParagraphs = detail.paragraphs as unknown as ParagraphDoc[]
|
searches: [{
|
||||||
detailDocument.value = detail.document as unknown as DocumentDoc
|
collection: config.main,
|
||||||
|
q: '*',
|
||||||
|
queryBy: 'title',
|
||||||
|
filterBy: `id:=${docId} && $${config.paragraphs}(id: *)`,
|
||||||
|
includeFields: `*, $${config.paragraphs}(*)`
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const docHit = (res?.results?.[0] as { hits?: Array<{ document: Record<string, unknown> }> })?.hits?.[0]
|
||||||
|
if (docHit) {
|
||||||
|
const docRaw = { ...docHit.document }
|
||||||
|
const rawParagraphs = (docRaw[config.paragraphs] as ParagraphDoc[] | undefined) ?? []
|
||||||
|
delete docRaw[config.paragraphs]
|
||||||
|
detailDocument.value = docRaw as unknown as DocumentDoc
|
||||||
detailParagraphs.value = [...rawParagraphs]
|
detailParagraphs.value = [...rawParagraphs]
|
||||||
.sort((a, b) => (a.number ?? 0) - (b.number ?? 0))
|
.sort((a, b) => (a.number ?? 0) - (b.number ?? 0))
|
||||||
.map(p => ({ document: p }))
|
.map(p => ({ document: p }))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (seq !== fetchSeq) return
|
|
||||||
if (isAbortError(err)) return
|
|
||||||
console.error('[usePublicationFetch] Error fetching publication detail', err)
|
console.error('[usePublicationFetch] Error fetching publication detail', err)
|
||||||
detailDocument.value = null
|
detailDocument.value = null
|
||||||
detailParagraphs.value = []
|
detailParagraphs.value = []
|
||||||
} finally {
|
} finally {
|
||||||
if (seq === fetchSeq) {
|
detailDocumentLoading.value = false
|
||||||
detailDocumentLoading.value = false
|
detailParagraphsLoading.value = false
|
||||||
detailParagraphsLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import type Client from 'typesense/Typesense/Client'
|
|
||||||
|
|
||||||
export function useTypesenseClient(): Client {
|
|
||||||
const nuxtApp = useNuxtApp()
|
|
||||||
return nuxtApp.$typesenseClient as Client
|
|
||||||
}
|
|
||||||
|
|
@ -1,533 +0,0 @@
|
||||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|
||||||
import { useDebounce } from '@vueuse/core'
|
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 15000
|
|
||||||
|
|
||||||
function isAbortError(err: unknown): boolean {
|
|
||||||
return (err as { name?: string } | null)?.name === 'AbortError'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Boilerplate compartido de cancelación: cancela el intento anterior antes de
|
|
||||||
* lanzar uno nuevo, arma un timeout que aborta si tarda demasiado, y da un
|
|
||||||
* guard de secuencia para descartar respuestas de requests ya superados.
|
|
||||||
* Usado por los dos modos de búsqueda (plano y agrupado) de este archivo.
|
|
||||||
*/
|
|
||||||
function createAbortableRunner() {
|
|
||||||
let seq = 0
|
|
||||||
let controller: AbortController | null = null
|
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
function start(onTimeout: () => void, timeoutMs: number) {
|
|
||||||
const mySeq = ++seq
|
|
||||||
controller?.abort()
|
|
||||||
const myController = new AbortController()
|
|
||||||
controller = myController
|
|
||||||
if (timeoutId) clearTimeout(timeoutId)
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
if (mySeq === seq) {
|
|
||||||
myController.abort()
|
|
||||||
onTimeout()
|
|
||||||
}
|
|
||||||
}, timeoutMs)
|
|
||||||
return { seq: mySeq, signal: myController.signal }
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCurrent(mySeq: number) {
|
|
||||||
return mySeq === seq
|
|
||||||
}
|
|
||||||
|
|
||||||
function settle(mySeq: number) {
|
|
||||||
if (mySeq === seq && timeoutId) clearTimeout(timeoutId)
|
|
||||||
}
|
|
||||||
|
|
||||||
function dispose() {
|
|
||||||
if (timeoutId) clearTimeout(timeoutId)
|
|
||||||
controller?.abort()
|
|
||||||
}
|
|
||||||
|
|
||||||
return { start, isCurrent, settle, dispose }
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TypesenseHighlight {
|
|
||||||
field?: string
|
|
||||||
snippet?: string
|
|
||||||
value?: string
|
|
||||||
matched_tokens?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Modo plano: una sola colección, sin agrupar (entrelineas.vue) ───────────
|
|
||||||
|
|
||||||
export interface TypesenseFlatHit<TDoc> {
|
|
||||||
document: TDoc
|
|
||||||
highlights?: TypesenseHighlight[]
|
|
||||||
highlight?: Record<string, { snippet?: string, value?: string }>
|
|
||||||
text_match?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FlatTypesenseSearchOptions {
|
|
||||||
collection: string
|
|
||||||
queryBy: string
|
|
||||||
/** Reevaluado en cada request (p.ej. depende de `locale.value`). */
|
|
||||||
filterBy: () => string
|
|
||||||
includeFields?: string
|
|
||||||
pageSize: () => number
|
|
||||||
paginationType: () => 'infinite_scroll' | 'numbered'
|
|
||||||
initialQuery: string
|
|
||||||
initialPage: number
|
|
||||||
timeoutMs?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useFlatTypesenseSearch<TDoc = Record<string, unknown>>(options: FlatTypesenseSearchOptions) {
|
|
||||||
const typesenseClient = useTypesenseClient()
|
|
||||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
||||||
|
|
||||||
const query = ref(options.initialQuery)
|
|
||||||
const debouncedQuery = useDebounce(query, 150)
|
|
||||||
const loading = ref(false)
|
|
||||||
const loadingMore = ref(false)
|
|
||||||
const errorMsg = ref<string | null>(null)
|
|
||||||
const exactSearch = ref(false)
|
|
||||||
|
|
||||||
const hits = ref<TypesenseFlatHit<TDoc>[]>([])
|
|
||||||
const total = ref(0)
|
|
||||||
const currentPage = ref(1)
|
|
||||||
const activePage = ref(options.initialPage)
|
|
||||||
|
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / options.pageSize())))
|
|
||||||
const hasMore = computed(() =>
|
|
||||||
options.paginationType() === 'infinite_scroll' ? hits.value.length < total.value : false
|
|
||||||
)
|
|
||||||
|
|
||||||
const runner = createAbortableRunner()
|
|
||||||
|
|
||||||
async function runSearch(q: string, page = 1, append = false) {
|
|
||||||
const { seq, signal } = runner.start(() => {
|
|
||||||
loading.value = false
|
|
||||||
loadingMore.value = false
|
|
||||||
errorMsg.value = 'La búsqueda tardó demasiado. Inténtalo de nuevo.'
|
|
||||||
}, timeoutMs)
|
|
||||||
|
|
||||||
if (append) loadingMore.value = true
|
|
||||||
else loading.value = true
|
|
||||||
errorMsg.value = null
|
|
||||||
|
|
||||||
const isInfinite = options.paginationType() === 'infinite_scroll'
|
|
||||||
const typePage = isInfinite ? (append ? currentPage.value + 1 : 1) : page
|
|
||||||
|
|
||||||
try {
|
|
||||||
const multi = await typesenseClient.multiSearch.perform({
|
|
||||||
searches: [{
|
|
||||||
collection: options.collection,
|
|
||||||
q: exactSearch.value && q ? `"${q}"` : q || '*',
|
|
||||||
query_by: options.queryBy,
|
|
||||||
include_fields: options.includeFields ?? '*',
|
|
||||||
filter_by: options.filterBy(),
|
|
||||||
per_page: options.pageSize(),
|
|
||||||
page: typePage,
|
|
||||||
highlight_full_fields: options.queryBy,
|
|
||||||
highlight_fields: options.queryBy,
|
|
||||||
highlight_start_tag: '<mark class="search-match">',
|
|
||||||
highlight_end_tag: '</mark>'
|
|
||||||
}]
|
|
||||||
}, {}, { abortSignal: signal })
|
|
||||||
|
|
||||||
if (!runner.isCurrent(seq)) return
|
|
||||||
|
|
||||||
const res = (multi?.results?.[0] ?? {}) as { found?: number, hits?: TypesenseFlatHit<TDoc>[] }
|
|
||||||
const newHits = res?.hits ?? []
|
|
||||||
|
|
||||||
hits.value = (append ? [...hits.value, ...newHits] : newHits) as TypesenseFlatHit<TDoc>[]
|
|
||||||
total.value = res?.found ?? hits.value.length
|
|
||||||
currentPage.value = typePage
|
|
||||||
if (!append) activePage.value = page
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (!runner.isCurrent(seq)) return
|
|
||||||
if (isAbortError(err)) return
|
|
||||||
console.error('Typesense error', err)
|
|
||||||
errorMsg.value = (err as Error)?.message || 'Error al buscar.'
|
|
||||||
if (!append) { hits.value = []; total.value = 0 }
|
|
||||||
} finally {
|
|
||||||
if (runner.isCurrent(seq)) {
|
|
||||||
runner.settle(seq)
|
|
||||||
loading.value = false
|
|
||||||
loadingMore.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadMore() {
|
|
||||||
if (options.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
|
|
||||||
hits.value = []
|
|
||||||
runSearch(query.value, p, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function retry() {
|
|
||||||
runSearch(query.value, activePage.value, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeUnmount(() => runner.dispose())
|
|
||||||
|
|
||||||
watch(debouncedQuery, (q) => {
|
|
||||||
hits.value = []
|
|
||||||
total.value = 0
|
|
||||||
currentPage.value = 1
|
|
||||||
activePage.value = 1
|
|
||||||
runSearch(q, 1, false)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(exactSearch, () => {
|
|
||||||
if (query.value.trim()) runSearch(query.value, 1, false)
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
query, debouncedQuery, loading, loadingMore, errorMsg, exactSearch,
|
|
||||||
hits, total, currentPage, activePage, totalPages, hasMore,
|
|
||||||
runSearch, loadMore, goToPage, retry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Modo agrupado: párrafos + join a la colección principal (SearchPanel.vue) ─
|
|
||||||
|
|
||||||
export interface ParagraphDoc {
|
|
||||||
id?: string
|
|
||||||
document_id: string
|
|
||||||
text: string
|
|
||||||
number: number
|
|
||||||
locale: string
|
|
||||||
type: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TypesenseGroupedParagraphHit {
|
|
||||||
document: ParagraphDoc
|
|
||||||
highlights?: TypesenseHighlight[]
|
|
||||||
highlight?: Record<string, { snippet?: string, value?: string }>
|
|
||||||
text_match?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TypesenseGroupedHit {
|
|
||||||
group_key: string[]
|
|
||||||
hits: TypesenseGroupedParagraphHit[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GroupedSearchResponse {
|
|
||||||
found: number
|
|
||||||
grouped_hits?: TypesenseGroupedHit[]
|
|
||||||
hits?: Array<{ document: Record<string, unknown> }>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchGroup {
|
|
||||||
docId: string
|
|
||||||
firstHit: TypesenseGroupedParagraphHit
|
|
||||||
allHits: TypesenseGroupedParagraphHit[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BrowseItem {
|
|
||||||
docId: string
|
|
||||||
meta: CachedDocMeta
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DisplayGroup {
|
|
||||||
docId: string
|
|
||||||
meta: CachedDocMeta | undefined
|
|
||||||
firstHit: TypesenseGroupedParagraphHit | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GroupedTypesenseSearchOptions {
|
|
||||||
paragraphsCollection: string
|
|
||||||
mainCollection: string
|
|
||||||
groupByField: string
|
|
||||||
queryBy: string
|
|
||||||
/** Reevaluado en cada request (p.ej. depende de `locale.value`). */
|
|
||||||
filterBy: () => string
|
|
||||||
browseFilterBy?: () => string
|
|
||||||
/** Cuando devuelve false, se excluyen los documentos `private:=true`. */
|
|
||||||
isUnlocked: () => boolean
|
|
||||||
pageSize: () => number
|
|
||||||
paginationType: () => 'infinite_scroll' | 'numbered'
|
|
||||||
initialQuery: string
|
|
||||||
initialPage: number
|
|
||||||
highlightAffixNumTokens?: number
|
|
||||||
timeoutMs?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const META_FIELDS = 'id,title,date,timestamp,place,city,state,country,type,slug,draft'
|
|
||||||
|
|
||||||
export function useGroupedTypesenseSearch(options: GroupedTypesenseSearchOptions) {
|
|
||||||
const typesenseClient = useTypesenseClient()
|
|
||||||
const docMetaCache = useDocMetaCache()
|
|
||||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
||||||
const highlightAffixNumTokens = options.highlightAffixNumTokens ?? 15
|
|
||||||
|
|
||||||
const query = ref(options.initialQuery)
|
|
||||||
const debouncedQuery = useDebounce(query, 150)
|
|
||||||
const loading = ref(false)
|
|
||||||
const loadingMore = ref(false)
|
|
||||||
const errorMsg = ref<string | null>(null)
|
|
||||||
const exactSearch = ref(false)
|
|
||||||
const sortMode = ref<'relevance' | 'date'>('relevance')
|
|
||||||
|
|
||||||
const groupedHits = ref<SearchGroup[]>([])
|
|
||||||
const total = ref(0)
|
|
||||||
const currentPage = ref(1)
|
|
||||||
const activePage = ref(options.initialPage)
|
|
||||||
|
|
||||||
const hasMore = computed(() =>
|
|
||||||
options.paginationType() === 'infinite_scroll' ? groupedHits.value.length < total.value : false
|
|
||||||
)
|
|
||||||
|
|
||||||
const visibleGroupCount = ref(10)
|
|
||||||
const visibleGroups = computed(() =>
|
|
||||||
options.paginationType() === 'infinite_scroll'
|
|
||||||
? groupedHits.value.slice(0, visibleGroupCount.value)
|
|
||||||
: groupedHits.value
|
|
||||||
)
|
|
||||||
const hasMoreVisible = computed(() =>
|
|
||||||
options.paginationType() === 'infinite_scroll' &&
|
|
||||||
visibleGroupCount.value < groupedHits.value.length
|
|
||||||
)
|
|
||||||
|
|
||||||
const browseItems = ref<BrowseItem[]>([])
|
|
||||||
const browseTotal = ref(0)
|
|
||||||
const browsePage = ref(1)
|
|
||||||
const hasMoreBrowse = computed(() =>
|
|
||||||
options.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: docMetaCache.get(options.mainCollection, g.docId),
|
|
||||||
firstHit: g.firstHit
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
const displayTotal = computed(() =>
|
|
||||||
debouncedQuery.value.trim() ? total.value : browseTotal.value
|
|
||||||
)
|
|
||||||
const totalPages = computed(() =>
|
|
||||||
Math.max(1, Math.ceil(displayTotal.value / options.pageSize()))
|
|
||||||
)
|
|
||||||
|
|
||||||
function cacheParentMeta(newGroups: SearchGroup[]) {
|
|
||||||
for (const g of newGroups) {
|
|
||||||
if (!g.docId) continue
|
|
||||||
const parentMeta = (g.firstHit?.document as unknown as Record<string, unknown>)?.[options.mainCollection] as Partial<CachedDocMeta> | undefined
|
|
||||||
if (parentMeta) docMetaCache.set(options.mainCollection, g.docId, { id: g.docId, ...parentMeta } as CachedDocMeta)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchFilterBy() {
|
|
||||||
return options.isUnlocked()
|
|
||||||
? options.filterBy()
|
|
||||||
: `${options.filterBy()} && $${options.mainCollection}(private:=false)`
|
|
||||||
}
|
|
||||||
|
|
||||||
function browseFilterBy() {
|
|
||||||
const base = options.browseFilterBy ? options.browseFilterBy() : options.filterBy()
|
|
||||||
return options.isUnlocked()
|
|
||||||
? `${base} && has_paragraphs:=true`
|
|
||||||
: `${base} && private:=false && has_paragraphs:=true`
|
|
||||||
}
|
|
||||||
|
|
||||||
const runner = createAbortableRunner()
|
|
||||||
|
|
||||||
async function runSearch(q: string, page = 1, append = false) {
|
|
||||||
const { seq, signal } = runner.start(() => {
|
|
||||||
loading.value = false
|
|
||||||
loadingMore.value = false
|
|
||||||
errorMsg.value = 'La búsqueda tardó demasiado. Inténtalo de nuevo.'
|
|
||||||
}, timeoutMs)
|
|
||||||
|
|
||||||
if (append) loadingMore.value = true
|
|
||||||
else loading.value = true
|
|
||||||
errorMsg.value = null
|
|
||||||
|
|
||||||
const isInfinite = options.paginationType() === 'infinite_scroll'
|
|
||||||
const typePage = isInfinite ? (append ? currentPage.value + 1 : 1) : page
|
|
||||||
|
|
||||||
try {
|
|
||||||
const shouldSortByDate = sortMode.value === 'date' && q.trim()
|
|
||||||
|
|
||||||
const multi = await typesenseClient.multiSearch.perform({
|
|
||||||
searches: [{
|
|
||||||
collection: options.paragraphsCollection,
|
|
||||||
q: exactSearch.value && q ? `"${q}"` : q || '*',
|
|
||||||
query_by: options.queryBy,
|
|
||||||
filter_by: searchFilterBy(),
|
|
||||||
...(shouldSortByDate ? { sort_by: `$${options.mainCollection}(timestamp:desc)` } : {}),
|
|
||||||
per_page: options.pageSize(),
|
|
||||||
page: typePage,
|
|
||||||
highlight_full_fields: options.queryBy,
|
|
||||||
highlight_fields: options.queryBy,
|
|
||||||
highlight_start_tag: '<mark class="search-match">',
|
|
||||||
highlight_end_tag: '</mark>',
|
|
||||||
highlight_affix_num_tokens: highlightAffixNumTokens,
|
|
||||||
group_by: options.groupByField,
|
|
||||||
include_fields: `*, $${options.mainCollection}(${META_FIELDS})`
|
|
||||||
}]
|
|
||||||
}, {}, { abortSignal: signal })
|
|
||||||
if (!runner.isCurrent(seq)) return
|
|
||||||
|
|
||||||
const res = (multi?.results?.[0] ?? {}) as GroupedSearchResponse
|
|
||||||
const rawGroups = res?.grouped_hits ?? []
|
|
||||||
const newGroups: SearchGroup[] = rawGroups.map(g => ({
|
|
||||||
docId: g.group_key[0]!,
|
|
||||||
firstHit: g.hits[0]!,
|
|
||||||
allHits: g.hits
|
|
||||||
}))
|
|
||||||
|
|
||||||
cacheParentMeta(newGroups)
|
|
||||||
|
|
||||||
if (!runner.isCurrent(seq)) 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 (!runner.isCurrent(seq)) return
|
|
||||||
if (isAbortError(err)) return
|
|
||||||
console.error('Typesense error', err)
|
|
||||||
errorMsg.value = (err as Error)?.message || 'Error al buscar.'
|
|
||||||
if (!append) { groupedHits.value = []; total.value = 0 }
|
|
||||||
} finally {
|
|
||||||
if (runner.isCurrent(seq)) {
|
|
||||||
runner.settle(seq)
|
|
||||||
loading.value = false
|
|
||||||
loadingMore.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runBrowse(page = 1, append = false) {
|
|
||||||
const { seq, signal } = runner.start(() => {
|
|
||||||
loading.value = false
|
|
||||||
loadingMore.value = false
|
|
||||||
errorMsg.value = 'La búsqueda tardó demasiado. Inténtalo de nuevo.'
|
|
||||||
}, timeoutMs)
|
|
||||||
|
|
||||||
if (append) loadingMore.value = true
|
|
||||||
else loading.value = true
|
|
||||||
errorMsg.value = null
|
|
||||||
|
|
||||||
const isInfinite = options.paginationType() === 'infinite_scroll'
|
|
||||||
const typePage = isInfinite ? (append ? browsePage.value + 1 : 1) : page
|
|
||||||
|
|
||||||
console.log("testing" + browseFilterBy())
|
|
||||||
try {
|
|
||||||
const multi = await typesenseClient.multiSearch.perform({
|
|
||||||
searches: [{
|
|
||||||
collection: options.mainCollection,
|
|
||||||
q: '*',
|
|
||||||
query_by: 'title',
|
|
||||||
filter_by: browseFilterBy(),
|
|
||||||
sort_by: 'timestamp:desc',
|
|
||||||
per_page: options.pageSize(),
|
|
||||||
page: typePage,
|
|
||||||
include_fields: META_FIELDS
|
|
||||||
}]
|
|
||||||
}, {}, { abortSignal: signal })
|
|
||||||
if (!runner.isCurrent(seq)) return
|
|
||||||
const result = (multi?.results?.[0] as GroupedSearchResponse | undefined)
|
|
||||||
const rawHits = result?.hits ?? []
|
|
||||||
const newItems = rawHits.map((h) => {
|
|
||||||
const meta = h.document as Partial<CachedDocMeta>
|
|
||||||
const docId = String(meta.id ?? '')
|
|
||||||
return { docId, meta: { id: docId, ...meta } as CachedDocMeta }
|
|
||||||
})
|
|
||||||
|
|
||||||
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 (!runner.isCurrent(seq)) return
|
|
||||||
if (isAbortError(err)) return
|
|
||||||
console.error('Typesense error', err)
|
|
||||||
errorMsg.value = (err as Error)?.message || 'Error al buscar.'
|
|
||||||
if (!append) { browseItems.value = []; browseTotal.value = 0 }
|
|
||||||
} finally {
|
|
||||||
if (runner.isCurrent(seq)) {
|
|
||||||
runner.settle(seq)
|
|
||||||
loading.value = false
|
|
||||||
loadingMore.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadMore() {
|
|
||||||
if (options.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function retry() {
|
|
||||||
if (!query.value.trim()) runBrowse(activePage.value, false)
|
|
||||||
else runSearch(query.value, activePage.value, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeUnmount(() => runner.dispose())
|
|
||||||
|
|
||||||
watch(debouncedQuery, (q) => {
|
|
||||||
activePage.value = 1
|
|
||||||
if (!q.trim()) {
|
|
||||||
groupedHits.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
|
|
||||||
groupedHits.value = []; total.value = 0; currentPage.value = 1; visibleGroupCount.value = 10
|
|
||||||
runSearch(q, 1, false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(exactSearch, () => {
|
|
||||||
if (query.value.trim()) runSearch(query.value, 1, false)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(sortMode, () => {
|
|
||||||
if (query.value.trim()) {
|
|
||||||
groupedHits.value = []
|
|
||||||
total.value = 0
|
|
||||||
currentPage.value = 1
|
|
||||||
runSearch(query.value, 1, false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
query, debouncedQuery, loading, loadingMore, errorMsg,
|
|
||||||
exactSearch, sortMode,
|
|
||||||
groupedHits, total, currentPage,
|
|
||||||
visibleGroupCount, visibleGroups, hasMoreVisible, hasMore,
|
|
||||||
browseItems, browseTotal, browsePage, hasMoreBrowse,
|
|
||||||
displayGroups, activePage, displayTotal, totalPages,
|
|
||||||
runSearch, runBrowse, loadMore, goToPage, retry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -22,9 +22,6 @@ const { total: favTotal } = storeToRefs(favorites)
|
||||||
const history = useHistoryStore()
|
const history = useHistoryStore()
|
||||||
const { total: histTotal } = storeToRefs(history)
|
const { total: histTotal } = storeToRefs(history)
|
||||||
|
|
||||||
const toCarpa = () => {
|
|
||||||
window.location.href = `https://carpa.com/${$i18n.locale.value}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const links = computed(() => {
|
const links = computed(() => {
|
||||||
const links = [
|
const links = [
|
||||||
|
|
@ -32,7 +29,7 @@ const links = computed(() => {
|
||||||
id: 'bible-studies',
|
id: 'bible-studies',
|
||||||
label: t('nav.bible_studies'),
|
label: t('nav.bible_studies'),
|
||||||
icon: 'ph-books',
|
icon: 'ph-books',
|
||||||
to: '/estudios',
|
to: '/estudios-biblicos',
|
||||||
onSelect: () => { open.value = false },
|
onSelect: () => { open.value = false },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -121,9 +118,9 @@ const links = computed(() => {
|
||||||
class="bg-elevated/25 bg-gradient-to-tr from-blue-100 to-white" :ui="{ footer: 'lg:border-t lg:border-default' }">
|
class="bg-elevated/25 bg-gradient-to-tr from-blue-100 to-white" :ui="{ footer: 'lg:border-t lg:border-default' }">
|
||||||
<template #header="{ collapsed }">
|
<template #header="{ collapsed }">
|
||||||
<div v-if="!collapsed" class="mt-2 flex justify-center">
|
<div v-if="!collapsed" class="mt-2 flex justify-center">
|
||||||
<img v-on:click="toCarpa" src="/logo.svg" class="w-full cursor-pointer" alt="Buscador - La Gran Carpa Catedral" />
|
<img src="/logo.svg" class="w-full" alt="Buscador - La Gran Carpa Catedral" />
|
||||||
</div>
|
</div>
|
||||||
<img v-if="collapsed" v-on:click="toCarpa" src="/logo_round.svg" class="w-full cursor-pointer" alt="Buscador - La Gran Carpa Catedral" />
|
<img v-if="collapsed" src="/logo_round.svg" class="w-full" alt="Buscador - La Gran Carpa Catedral" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #default="{ collapsed }">
|
<template #default="{ collapsed }">
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,5 @@
|
||||||
nav-title-key="nav.conferences_ts"
|
nav-title-key="nav.conferences_ts"
|
||||||
accent-color="blue"
|
accent-color="blue"
|
||||||
:empty-detail-text="$t('ui.empty_conferences')"
|
:empty-detail-text="$t('ui.empty_conferences')"
|
||||||
author="Dr. William Soto Santiago"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch, onMounted } from 'vue'
|
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { breakpointsTailwind } from '@vueuse/core'
|
import { breakpointsTailwind, useDebounce } from '@vueuse/core'
|
||||||
import EntrelineaDetail from '~/components/entrelineas/EntrelineaDetail.vue'
|
import EntrelineaDetail from '~/components/entrelineas/EntrelineaDetail.vue'
|
||||||
import { useFavoritesStore } from '~/stores/favorites'
|
import { useFavoritesStore } from '~/stores/favorites'
|
||||||
import { useSettingsStore } from '~/stores/settings'
|
import { useSettingsStore } from '~/stores/settings'
|
||||||
|
|
@ -23,10 +23,20 @@ const filterBy = computed(() => {
|
||||||
return EXTRA_FILTER_BY ? `${localeFilter} && ${EXTRA_FILTER_BY}` : localeFilter
|
return EXTRA_FILTER_BY ? `${localeFilter} && ${EXTRA_FILTER_BY}` : localeFilter
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const REQUEST_TIMEOUT_MS = 15000
|
||||||
|
|
||||||
const settings = useSettingsStore()
|
const settings = useSettingsStore()
|
||||||
|
|
||||||
const { query: q0, page: p0, scroll: s0, selectedId: sid0 } = useSearchUrlState()
|
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)
|
||||||
|
const exactSearch = ref(false)
|
||||||
|
|
||||||
interface Study {
|
interface Study {
|
||||||
id?: number
|
id?: number
|
||||||
title?: string
|
title?: string
|
||||||
|
|
@ -49,19 +59,139 @@ interface EntrelineaDoc {
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
interface TypesenseHighlight {
|
||||||
query, debouncedQuery, loading, loadingMore, errorMsg, exactSearch,
|
field?: string
|
||||||
hits, total, activePage, totalPages, hasMore,
|
snippet?: string
|
||||||
runSearch, loadMore, goToPage, retry
|
value?: string
|
||||||
} = useFlatTypesenseSearch<EntrelineaDoc>({
|
matched_tokens?: string[]
|
||||||
collection: COLLECTION,
|
}
|
||||||
queryBy: QUERY_BY,
|
|
||||||
filterBy: () => filterBy.value,
|
interface TypesenseHit {
|
||||||
includeFields: INCLUDE_FIELDS,
|
document: EntrelineaDoc
|
||||||
pageSize: () => settings.pageSize,
|
highlights?: TypesenseHighlight[]
|
||||||
paginationType: () => settings.paginationType,
|
highlight?: Record<string, { snippet?: string, value?: string }>
|
||||||
initialQuery: q0,
|
text_match?: number
|
||||||
initialPage: p0
|
}
|
||||||
|
|
||||||
|
interface TypesenseSearchResponse {
|
||||||
|
found: number
|
||||||
|
out_of?: number
|
||||||
|
page?: number
|
||||||
|
hits?: TypesenseHit[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const hits = ref<TypesenseHit[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const activePage = ref(p0)
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / settings.pageSize)))
|
||||||
|
|
||||||
|
const hasMore = computed(() =>
|
||||||
|
settings.paginationType === 'infinite_scroll' ? hits.value.length < total.value : false
|
||||||
|
)
|
||||||
|
|
||||||
|
const { documentsApi } = useTypesenseApi()
|
||||||
|
|
||||||
|
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: COLLECTION,
|
||||||
|
q: exactSearch.value && q ? `"${q}"` : q || '*',
|
||||||
|
queryBy: QUERY_BY,
|
||||||
|
includeFields: INCLUDE_FIELDS,
|
||||||
|
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 ?? []
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
hits.value = []
|
||||||
|
runSearch(query.value, p, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function retry() {
|
||||||
|
runSearch(query.value, activePage.value, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(debouncedQuery, (q) => {
|
||||||
|
hits.value = []
|
||||||
|
total.value = 0
|
||||||
|
currentPage.value = 1
|
||||||
|
activePage.value = 1
|
||||||
|
runSearch(q, 1, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(exactSearch, () => {
|
||||||
|
if (query.value.trim()) runSearch(query.value, 1, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
const selected = ref<EntrelineaDoc | null>(null)
|
const selected = ref<EntrelineaDoc | null>(null)
|
||||||
|
|
@ -132,7 +262,7 @@ function toggleFavorite(doc: EntrelineaDoc, ev?: Event) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function highlightedFor(hit: TypesenseFlatHit<EntrelineaDoc>, field: string): string | null {
|
function highlightedFor(hit: TypesenseHit, field: string): string | null {
|
||||||
const fromArr = hit.highlights?.find(h => h.field === field)
|
const fromArr = hit.highlights?.find(h => h.field === field)
|
||||||
if (fromArr?.snippet) return fromArr.snippet
|
if (fromArr?.snippet) return fromArr.snippet
|
||||||
if (fromArr?.value) return fromArr.value
|
if (fromArr?.value) return fromArr.value
|
||||||
|
|
@ -155,11 +285,6 @@ function highlightedFor(hit: TypesenseFlatHit<EntrelineaDoc>, field: string): st
|
||||||
<template #leading>
|
<template #leading>
|
||||||
<UDashboardSidebarCollapse />
|
<UDashboardSidebarCollapse />
|
||||||
</template>
|
</template>
|
||||||
<template #trailing>
|
|
||||||
<UBadge :label="total" variant="subtle" :ui="{
|
|
||||||
base: 'total-results'
|
|
||||||
}" />
|
|
||||||
</template>
|
|
||||||
</UDashboardNavbar>
|
</UDashboardNavbar>
|
||||||
|
|
||||||
<!-- Banner: se muestra cuando NO hay clave de desarrollador -->
|
<!-- Banner: se muestra cuando NO hay clave de desarrollador -->
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,5 @@
|
||||||
accent-color="green"
|
accent-color="green"
|
||||||
:empty-detail-text="$t('ui.empty_bible_studies')"
|
:empty-detail-text="$t('ui.empty_bible_studies')"
|
||||||
:show-draft="true"
|
:show-draft="true"
|
||||||
author="Dr. José Benjamín Pérez Matos"
|
|
||||||
:show-bible-study-filter="true"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -30,28 +30,14 @@ const toast = useToast()
|
||||||
|
|
||||||
const ENTRELINEAS_COLLECTION = 'entrelineas'
|
const ENTRELINEAS_COLLECTION = 'entrelineas'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const COLLECTION_LABELS: Record<string, string> = {
|
||||||
|
activities: 'Actividades',
|
||||||
|
conferences: 'Conferencias',
|
||||||
|
entrelineas: 'Entre Líneas'
|
||||||
|
}
|
||||||
|
|
||||||
function labelFor(c: string): string {
|
function labelFor(c: string): string {
|
||||||
const labels: Record<string, string> = {
|
return COLLECTION_LABELS[c] || c.charAt(0).toUpperCase() + c.slice(1)
|
||||||
activities: t('nav.bible_studies_ts'),
|
|
||||||
'bible-studies-ts': t('nav.bible_studies_ts'),
|
|
||||||
conferences: t('nav.conferences_ts'),
|
|
||||||
'conferences-ts': t('nav.conferences_ts'),
|
|
||||||
entrelineas: t('nav.between_the_lines'),
|
|
||||||
}
|
|
||||||
return labels[c] || c.charAt(0).toUpperCase() + c.slice(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const COLLECTION_AUTHORS: Record<string, string> = {
|
|
||||||
'bible-studies-ts': 'Dr. José Benjamín Pérez Matos',
|
|
||||||
activities: 'Dr. José Benjamín Pérez Matos',
|
|
||||||
'conferences-ts': 'Dr. William Soto Santiago',
|
|
||||||
conferences: 'Dr. William Soto Santiago',
|
|
||||||
}
|
|
||||||
|
|
||||||
function authorFor(c: string): string {
|
|
||||||
return COLLECTION_AUTHORS[c] || ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtros: pestaña por colección o "todos".
|
// Filtros: pestaña por colección o "todos".
|
||||||
|
|
@ -71,9 +57,9 @@ const tabs = computed(() => {
|
||||||
items.push({
|
items.push({
|
||||||
value: c,
|
value: c,
|
||||||
label: `${labelFor(c)} (${count})`,
|
label: `${labelFor(c)} (${count})`,
|
||||||
icon: (c === 'activities' || c === 'bible-studies-ts')
|
icon: c === 'activities'
|
||||||
? 'i-lucide-calendar-days'
|
? 'i-lucide-calendar-days'
|
||||||
: (c === 'conferences' || c === 'conferences-ts')
|
: c === 'conferences'
|
||||||
? 'i-lucide-mic'
|
? 'i-lucide-mic'
|
||||||
: c === 'entrelineas'
|
: c === 'entrelineas'
|
||||||
? 'i-lucide-book-open'
|
? 'i-lucide-book-open'
|
||||||
|
|
@ -106,7 +92,6 @@ const selected = ref<HistoryItem | null>(null)
|
||||||
|
|
||||||
const selectedHit = computed<SearchHit | null>(() => selected.value?.hit ?? null)
|
const selectedHit = computed<SearchHit | null>(() => selected.value?.hit ?? null)
|
||||||
const selectedCollection = computed<string | undefined>(() => selected.value?.collection)
|
const selectedCollection = computed<string | undefined>(() => selected.value?.collection)
|
||||||
const selectedAuthor = computed(() => authorFor(selectedCollection.value ?? ''))
|
|
||||||
|
|
||||||
const isEntrelinea = computed(() => selectedCollection.value === ENTRELINEAS_COLLECTION)
|
const isEntrelinea = computed(() => selectedCollection.value === ENTRELINEAS_COLLECTION)
|
||||||
|
|
||||||
|
|
@ -567,13 +552,6 @@ const nearLimit = computed(() => histTotal.value >= Math.floor(HISTORY_LIMIT * 0
|
||||||
<span v-if="hasDate(it.hit)">{{ safeDate(it.hit) }}</span>
|
<span v-if="hasDate(it.hit)">{{ safeDate(it.hit) }}</span>
|
||||||
<USeparator v-if="formatLocation(it.hit)" orientation="vertical" class="h-3 hidden sm:block" />
|
<USeparator v-if="formatLocation(it.hit)" orientation="vertical" class="h-3 hidden sm:block" />
|
||||||
<span class="truncate">{{ formatLocation(it.hit) }}</span>
|
<span class="truncate">{{ formatLocation(it.hit) }}</span>
|
||||||
<template v-if="authorFor(it.collection)">
|
|
||||||
<USeparator orientation="vertical" class="h-3 hidden sm:block" />
|
|
||||||
<span class="inline-flex items-center gap-1 italic truncate">
|
|
||||||
<UIcon name="ph:user-circle" class="size-3 shrink-0" />
|
|
||||||
{{ authorFor(it.collection) }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -585,7 +563,6 @@ const nearLimit = computed(() => histTotal.value >= Math.floor(HISTORY_LIMIT * 0
|
||||||
v-if="selected && !isMobile && isEntrelinea"
|
v-if="selected && !isMobile && isEntrelinea"
|
||||||
:document="selectedEntrelineaDoc!"
|
:document="selectedEntrelineaDoc!"
|
||||||
:collection="selectedCollection"
|
:collection="selectedCollection"
|
||||||
no-track-visit
|
|
||||||
@close="selected = null"
|
@close="selected = null"
|
||||||
/>
|
/>
|
||||||
<!-- Resto (actividades, conferencias) → detalle completo con párrafos. -->
|
<!-- Resto (actividades, conferencias) → detalle completo con párrafos. -->
|
||||||
|
|
@ -596,8 +573,6 @@ const nearLimit = computed(() => histTotal.value >= Math.floor(HISTORY_LIMIT * 0
|
||||||
:paragraphs="detailParagraphs"
|
:paragraphs="detailParagraphs"
|
||||||
:paragraphs-loading="detailParagraphsLoading"
|
:paragraphs-loading="detailParagraphsLoading"
|
||||||
:collection="selectedCollection!"
|
:collection="selectedCollection!"
|
||||||
:author="selectedAuthor"
|
|
||||||
no-track-visit
|
|
||||||
@close="selected = null"
|
@close="selected = null"
|
||||||
/>
|
/>
|
||||||
<div v-else-if="!selected" class="hidden lg:flex flex-1 items-center justify-center">
|
<div v-else-if="!selected" class="hidden lg:flex flex-1 items-center justify-center">
|
||||||
|
|
@ -616,7 +591,6 @@ const nearLimit = computed(() => histTotal.value >= Math.floor(HISTORY_LIMIT * 0
|
||||||
v-if="selected && isEntrelinea"
|
v-if="selected && isEntrelinea"
|
||||||
:document="selectedEntrelineaDoc!"
|
:document="selectedEntrelineaDoc!"
|
||||||
:collection="selectedCollection"
|
:collection="selectedCollection"
|
||||||
no-track-visit
|
|
||||||
@close="selected = null"
|
@close="selected = null"
|
||||||
/>
|
/>
|
||||||
<PublicationDetail
|
<PublicationDetail
|
||||||
|
|
@ -626,8 +600,6 @@ const nearLimit = computed(() => histTotal.value >= Math.floor(HISTORY_LIMIT * 0
|
||||||
:paragraphs="detailParagraphs"
|
:paragraphs="detailParagraphs"
|
||||||
:paragraphs-loading="detailParagraphsLoading"
|
:paragraphs-loading="detailParagraphsLoading"
|
||||||
:collection="selectedCollection!"
|
:collection="selectedCollection!"
|
||||||
:author="selectedAuthor"
|
|
||||||
no-track-visit
|
|
||||||
@close="selected = null"
|
@close="selected = null"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ const links = ref<ButtonProps[]>([
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t('nav.bible_studies'),
|
label: t('nav.bible_studies'),
|
||||||
to: `/${$i18n.locale.value}/estudios`,
|
to: `/${$i18n.locale.value}/estudios-biblicos`,
|
||||||
icon: 'ph-books',
|
icon: 'ph-books',
|
||||||
color: 'primary'
|
color: 'primary'
|
||||||
},
|
},
|
||||||
|
|
@ -52,7 +52,7 @@ const links = ref<ButtonProps[]>([
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl sm:text-4xl font-bold text-highlighted tracking-tight">
|
<h1 class="text-3xl sm:text-4xl font-bold text-highlighted tracking-tight">
|
||||||
{{ $t('nav.search_title') }}
|
Buscador Carpa
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-3 text-base text-muted leading-relaxed">
|
<p class="mt-3 text-base text-muted leading-relaxed">
|
||||||
{{ $t('home.instructions') }}
|
{{ $t('home.instructions') }}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import Typesense from 'typesense'
|
|
||||||
|
|
||||||
export default defineNuxtPlugin({
|
|
||||||
name: 'typesense-client',
|
|
||||||
setup() {
|
|
||||||
const config = useRuntimeConfig()
|
|
||||||
const nodes = JSON.parse(config.public.typeSenseNodes as string)
|
|
||||||
const apiKey = config.public.typeSenseApiKey as string
|
|
||||||
|
|
||||||
const client = new Typesense.Client({
|
|
||||||
nodes,
|
|
||||||
apiKey,
|
|
||||||
numRetries: 3,
|
|
||||||
retryIntervalSeconds: 0.5,
|
|
||||||
healthcheckIntervalSeconds: 30
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
provide: {
|
|
||||||
typesenseClient: client
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -118,11 +118,10 @@ export const useHistoryStore = defineStore('history', () => {
|
||||||
// entradas. Como `visit()` siempre añade al inicio, recortamos por el final.
|
// entradas. Como `visit()` siempre añade al inicio, recortamos por el final.
|
||||||
const trimmed = next.length > HISTORY_LIMIT ? next.slice(0, HISTORY_LIMIT) : next
|
const trimmed = next.length > HISTORY_LIMIT ? next.slice(0, HISTORY_LIMIT) : next
|
||||||
items.value = trimmed
|
items.value = trimmed
|
||||||
|
writeStorage(trimmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Única vía de persistencia: cualquier mutación de `items.value` (incluida
|
// Red de seguridad: cualquier mutación directa de `items.value` se persiste.
|
||||||
// la de `commit`) se guarda aquí. No duplicar con un `writeStorage` extra
|
|
||||||
// en `commit`, o cada visita serializa y escribe el historial dos veces.
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
watch(items, (next) => {
|
watch(items, (next) => {
|
||||||
if (!hydrated) return
|
if (!hydrated) return
|
||||||
|
|
|
||||||
|
|
@ -6,153 +6,126 @@ export const typeConfig: Record<ChangeEntry['type'], { label: string; color: str
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChangeEntry {
|
export interface ChangeEntry {
|
||||||
type: 'nuevo' | 'mejora' | 'fix' | 'eliminado'
|
type: 'nuevo' | 'mejora' | 'fix' | 'eliminado'
|
||||||
text: string
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Release {
|
export interface Release {
|
||||||
version: string
|
version: string
|
||||||
date: string
|
date: string
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
changes: ChangeEntry[]
|
changes: ChangeEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const releases: Release[] = [
|
export const releases: Release[] = [
|
||||||
{
|
{
|
||||||
version: '0.9',
|
version: '0.7',
|
||||||
date: '20 de julio, 2026',
|
date: '31 de mayo, 2026 11:50PM',
|
||||||
title: 'Filtro por N° de Estudio',
|
title: 'Tour y optimizaciones',
|
||||||
changes: [
|
changes: [
|
||||||
{ type: 'nuevo', text: 'Nuevo filtro por número de Estudio en la sección de Estudios: ingresá el número y se agrega como chip para buscar dentro de ese estudio' },
|
{ type: 'nuevo', text: 'Agregado tour virtual con localizacion para explicar funcionamiento del buscador'},
|
||||||
{ type: 'nuevo', text: 'Se pueden agregar varios estudios a la vez, cada uno como un chip individual' },
|
{ type: 'nuevo', text: 'Agregada pagina de inicio que muestra la version mas reciente del changelog' },
|
||||||
{ type: 'nuevo', text: 'Los chips con los filtros activos se muestran siempre visibles debajo de la barra de búsqueda' },
|
{ type: 'mejora', text: 'Separacion de changelog a un TS aparte para utilizar en changelog y en el home sin duplicacion de codigo'},
|
||||||
{ type: 'mejora', text: 'En escritorio los filtros se agrupan en una sección colapsable para no ocupar espacio innecesario' },
|
{ type: 'nuevo', text: 'Finalizado flow de automatizacion entre backend y typesense'}
|
||||||
{ type: 'mejora', text: 'En móvil los filtros se configuran desde un panel deslizante para aprovechar mejor la pantalla' }
|
]
|
||||||
]
|
},
|
||||||
},
|
{
|
||||||
{
|
version: '0.6',
|
||||||
version: '0.8',
|
date: '31 de mayo, 2026',
|
||||||
date: '5 de junio, 2026',
|
title: 'Feedback con traducciones, bloqueo de Entrelíneas y acceso desarrollador',
|
||||||
title: 'Autoría visible en Estudios Bíblicos, Conferencias e Historial',
|
changes: [
|
||||||
changes: [
|
{ type: 'nuevo', text: 'Página de Feedback con traducciones completas en 4 idiomas' },
|
||||||
{ type: 'nuevo', text: 'Nombre del autor visible en el panel lateral de Estudios Bíblicos (Dr. José Benjamín Pérez Matos)' },
|
{ type: 'nuevo', text: 'Sistema de bloqueo por clave de desarrollador para secciones en desarrollo' },
|
||||||
{ type: 'nuevo', text: 'Nombre del autor visible en el panel lateral de Conferencias (Dr. William Soto Santiago)' },
|
{ type: 'nuevo', text: 'Acceso de desarrollador en Configuración con desbloqueo por clave' },
|
||||||
{ type: 'nuevo', text: 'Nombre del autor visible en el panel de detalle del documento para ambas secciones' },
|
{ type: 'nuevo', text: 'Banner visual mejorado para Entrelíneas cuando está bloqueado' },
|
||||||
{ type: 'nuevo', text: 'El historial muestra el nombre del autor en cada entrada de Estudios Bíblicos y Conferencias' },
|
{ type: 'mejora', text: 'Traducciones añadidas al componente BugReportInput' },
|
||||||
{ type: 'nuevo', text: 'Se agregó al momento de hacer una búsqueda un desplegable con los resultados "Más recientes" y "Normal"' },
|
{ type: 'mejora', text: 'Nombre del tab Feedback ahora usa traducciones' },
|
||||||
{ type: 'mejora', text: 'El panel de detalle abierto desde el historial también muestra el autor correspondiente' },
|
{ type: 'mejora', text: 'Textos del banner de Entrelíneas traducidos a 4 idiomas' }
|
||||||
{ type: 'fix', text: 'Las colecciones en el historial ahora muestran "Estudios Bíblicos" y "Conferencias" en lugar de los identificadores internos' },
|
]
|
||||||
{ type: 'fix', text: 'Corrección de etiquetas en inglés para las pestañas del historial' }
|
},
|
||||||
]
|
{
|
||||||
},
|
version: '0.5',
|
||||||
{
|
date: '30 de mayo, 2026',
|
||||||
version: '0.7',
|
title: 'Soporte de HTML en Entrelíneas',
|
||||||
date: '31 de mayo, 2026 11:50PM',
|
description: 'El visor de Entrelíneas ahora renderiza contenido en HTML además de texto plano.',
|
||||||
title: 'Tour y optimizaciones',
|
changes: [
|
||||||
changes: [
|
{ type: 'nuevo', text: 'Renderizado de contenido HTML en el detalle de Entrelíneas' },
|
||||||
{ type: 'nuevo', text: 'Agregado tour virtual con localización para explicar funcionamiento del buscador.'},
|
{ type: 'mejora', text: 'Los fragmentos HTML se muestran con formato original preservado' },
|
||||||
{ type: 'nuevo', text: '_Agregada página de inicio que muestra la versión más reciente del changelog.' },
|
{ type: 'fix', text: 'Corrección de errores de sintaxis en el componente de detalle' },
|
||||||
{ type: 'mejora', text: 'Separación de changelog a un TS aparte, para utilizar en changelog y en el home sin duplicación de código.'},
|
{ type: 'mejora', text: 'El historial ahora muestra correctamente documentos de Entrelíneas con HTML' }
|
||||||
{ type: 'nuevo', text: 'Finalizado flow de automatizacion entre backend y typesense'}
|
]
|
||||||
]
|
},
|
||||||
},
|
{
|
||||||
{
|
version: '0.4',
|
||||||
version: '0.6',
|
date: '26 de mayo, 2026',
|
||||||
date: '31 de mayo, 2026',
|
title: 'Mejoras de interfaz y exploracion',
|
||||||
title: 'Feedback con traducciones, bloqueo de Entrelíneas y acceso desarrollador',
|
changes: [
|
||||||
changes: [
|
{ type: 'mejora', text: 'El código del documento aparece visible en la pantalla de detalle' },
|
||||||
{ type: 'nuevo', text: 'Página de Feedback con traducciones completas en 4 idiomas' },
|
{ type: 'mejora', text: 'El badge "Borrador" solo se muestra cuando el documento es borrador' },
|
||||||
{ type: 'nuevo', text: 'Sistema de bloqueo por clave de desarrollador para secciones en desarrollo' },
|
{ type: 'mejora', text: 'Tooltip mejorado al copiar párrafos al portapapeles' },
|
||||||
{ type: 'nuevo', text: 'Acceso de desarrollador en Configuración con desbloqueo por clave' },
|
{ type: 'mejora', text: 'El explorador filtra resultados mostrando solo ítems con párrafos' },
|
||||||
{ type: 'nuevo', text: 'Banner visual mejorado para Entrelíneas cuando está bloqueado' },
|
{ type: 'mejora', text: 'Panel lateral preparado para notas y resaltados (próximamente)' },
|
||||||
{ type: 'mejora', text: 'Traducciones añadidas al componente BugReportInput' },
|
{ type: 'fix', text: 'Posicionamiento del panel lateral corregido en documentos cortos' },
|
||||||
{ type: 'mejora', text: 'Nombre del tab Feedback ahora usa traducciones' },
|
{ type: 'fix', text: 'Corrección de traducciones en francés' },
|
||||||
{ type: 'mejora', text: 'Textos del banner de Entrelíneas traducidos a 4 idiomas' }
|
{ type: 'fix', text: 'Ajustes visuales en múltiples vistas' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
version: '0.5',
|
version: '0.3',
|
||||||
date: '30 de mayo, 2026',
|
date: '25 de mayo, 2026',
|
||||||
title: 'Soporte de HTML en Entrelíneas',
|
title: 'Portapapeles y corrección de bugs',
|
||||||
description: 'El visor de Entrelíneas ahora renderiza contenido en HTML además de texto plano.',
|
changes: [
|
||||||
changes: [
|
{ type: 'nuevo', text: 'Panel de copiado mejorado al seleccionar párrafos' },
|
||||||
{ type: 'nuevo', text: 'Renderizado de contenido HTML en el detalle de Entrelíneas' },
|
{ type: 'mejora', text: 'Función de copiar al portapapeles más robusta e independiente' },
|
||||||
{ type: 'mejora', text: 'Los fragmentos HTML se muestran con formato original preservado' },
|
{ type: 'fix', text: 'Corrección de bug al guardar y mostrar favoritos' },
|
||||||
{ type: 'fix', text: 'Corrección de errores de sintaxis en el componente de detalle' },
|
{ type: 'fix', text: 'Corrección de bug en la carga de referencias' },
|
||||||
{ type: 'mejora', text: 'El historial ahora muestra correctamente documentos de Entrelíneas con HTML' }
|
{ type: 'fix', text: 'Corrección de bug en el detalle de publicaciones de referencia' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
version: '0.4',
|
version: '0.2',
|
||||||
date: '26 de mayo, 2026',
|
date: '21–24 de mayo, 2026',
|
||||||
title: 'Mejoras de interfaz y exploracion',
|
title: 'Conferencias, copiar párrafos y estabilidad',
|
||||||
changes: [
|
changes: [
|
||||||
{ type: 'mejora', text: 'El código del documento aparece visible en la pantalla de detalle' },
|
{ type: 'nuevo', text: 'Soporte de documentos de Conferencias y Actividades' },
|
||||||
{ type: 'mejora', text: 'El badge "Borrador" solo se muestra cuando el documento es borrador' },
|
{ type: 'nuevo', text: 'Clic en párrafo para copiarlo directamente al portapapeles' },
|
||||||
{ type: 'mejora', text: 'Tooltip mejorado al copiar párrafos al portapapeles' },
|
{ type: 'nuevo', text: 'Zoom de imagen en el visor de Entrelíneas' },
|
||||||
{ type: 'mejora', text: 'El explorador filtra resultados mostrando solo ítems con párrafos' },
|
{ type: 'mejora', text: 'Número de párrafo más discreto en el detalle del documento' },
|
||||||
{ type: 'mejora', text: 'Panel lateral preparado para notas y resaltados (próximamente)' },
|
{ type: 'mejora', text: 'Publicación del documento visible en todas las vistas y traducida' },
|
||||||
{ type: 'fix', text: 'Posicionamiento del panel lateral corregido en documentos cortos' },
|
{ type: 'mejora', text: 'Corrección de estilos visuales para contenido antiguo' },
|
||||||
{ type: 'fix', text: 'Corrección de traducciones en francés' },
|
{ type: 'fix', text: 'Corrección de bug en favoritos (detalle)' },
|
||||||
{ type: 'fix', text: 'Ajustes visuales en múltiples vistas' }
|
{ type: 'fix', text: 'Badge "Borrador" agregado en lista y detalle de Estudios Bíblicos' },
|
||||||
]
|
{ type: 'fix', text: 'Rutas de navegación migradas correctamente a nuevo motor de búsqueda' },
|
||||||
},
|
{ type: 'fix', text: 'Corrección de scroll infinito en móvil para Entrelíneas' }
|
||||||
{
|
]
|
||||||
version: '0.3',
|
},
|
||||||
date: '25 de mayo, 2026',
|
{
|
||||||
title: 'Portapapeles y corrección de bugs',
|
version: '0.1',
|
||||||
changes: [
|
date: '10–12 de mayo, 2026',
|
||||||
{ type: 'nuevo', text: 'Panel de copiado mejorado al seleccionar párrafos' },
|
title: 'Historial, Favoritos y multilengua',
|
||||||
{ type: 'mejora', text: 'Función de copiar al portapapeles más robusta e independiente' },
|
description: 'Primera versión funcional con las secciones principales activas.',
|
||||||
{ type: 'fix', text: 'Corrección de bug al guardar y mostrar favoritos' },
|
changes: [
|
||||||
{ type: 'fix', text: 'Corrección de bug en la carga de referencias' },
|
{ type: 'nuevo', text: 'Historial de búsqueda y exploración' },
|
||||||
{ type: 'fix', text: 'Corrección de bug en el detalle de publicaciones de referencia' }
|
{ type: 'nuevo', text: 'Favoritos: guarda y accede a documentos desde Mi Listado' },
|
||||||
]
|
{ type: 'nuevo', text: 'Selector de idioma: Español, English, Français, Português' },
|
||||||
},
|
{ type: 'nuevo', text: 'Cantidad aproximada de resultados visible en los listados' },
|
||||||
{
|
{ type: 'mejora', text: 'Paginación configurable: scroll infinito o páginas numeradas' },
|
||||||
version: '0.2',
|
{ type: 'mejora', text: 'Ajuste de cantidad de resultados por página en Configuración' },
|
||||||
date: '21–24 de mayo, 2026',
|
{ type: 'fix', text: 'Corrección de traducciones en el menú principal' },
|
||||||
title: 'Conferencias, copiar párrafos y estabilidad',
|
{ type: 'fix', text: 'Ajustes de colores y badges en listas de resultados' }
|
||||||
changes: [
|
]
|
||||||
{ type: 'nuevo', text: 'Soporte de documentos de Conferencias y Actividades' },
|
},
|
||||||
{ type: 'nuevo', text: 'Clic en párrafo para copiarlo directamente al portapapeles' },
|
{
|
||||||
{ type: 'nuevo', text: 'Zoom de imagen en el visor de Entrelíneas' },
|
version: '0.0.1',
|
||||||
{ type: 'mejora', text: 'Número de párrafo más discreto en el detalle del documento' },
|
date: '7 de mayo, 2026',
|
||||||
{ type: 'mejora', text: 'Publicación del documento visible en todas las vistas y traducida' },
|
title: 'Lanzamiento inicial',
|
||||||
{ type: 'mejora', text: 'Corrección de estilos visuales para contenido antiguo' },
|
description: 'Primera versión del buscador con Estudios Bíblicos y Entrelíneas.',
|
||||||
{ type: 'fix', text: 'Corrección de bug en favoritos (detalle)' },
|
changes: [
|
||||||
{ type: 'fix', text: 'Badge "Borrador" agregado en lista y detalle de Estudios Bíblicos' },
|
{ type: 'nuevo', text: 'Búsqueda en Estudios Bíblicos' },
|
||||||
{ type: 'fix', text: 'Rutas de navegación migradas correctamente a nuevo motor de búsqueda' },
|
{ type: 'nuevo', text: 'Visor de Entrelíneas con imagen y texto' },
|
||||||
{ type: 'fix', text: 'Corrección de scroll infinito en móvil para Entrelíneas' }
|
{ type: 'nuevo', text: 'Búsqueda por palabras o por frase exacta' },
|
||||||
]
|
{ type: 'nuevo', text: 'Estructura base de la aplicación' }
|
||||||
},
|
]
|
||||||
{
|
}
|
||||||
version: '0.1',
|
]
|
||||||
date: '10–12 de mayo, 2026',
|
|
||||||
title: 'Historial, Favoritos y multilengua',
|
|
||||||
description: 'Primera versión funcional con las secciones principales activas.',
|
|
||||||
changes: [
|
|
||||||
{ type: 'nuevo', text: 'Historial de búsqueda y exploración' },
|
|
||||||
{ type: 'nuevo', text: 'Favoritos: guarda y accede a documentos desde Mi Listado' },
|
|
||||||
{ type: 'nuevo', text: 'Selector de idioma: Español, English, Français, Português' },
|
|
||||||
{ type: 'nuevo', text: 'Cantidad aproximada de resultados visible en los listados' },
|
|
||||||
{ type: 'mejora', text: 'Paginación configurable: scroll infinito o páginas numeradas' },
|
|
||||||
{ type: 'mejora', text: 'Ajuste de cantidad de resultados por página en Configuración' },
|
|
||||||
{ type: 'fix', text: 'Corrección de traducciones en el menú principal' },
|
|
||||||
{ type: 'fix', text: 'Ajustes de colores y badges en listas de resultados' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
version: '0.0.1',
|
|
||||||
date: '7 de mayo, 2026',
|
|
||||||
title: 'Lanzamiento inicial',
|
|
||||||
description: 'Primera versión del buscador con Estudios Bíblicos y Entrelíneas.',
|
|
||||||
changes: [
|
|
||||||
{ type: 'nuevo', text: 'Búsqueda en Estudios Bíblicos' },
|
|
||||||
{ type: 'nuevo', text: 'Visor de Entrelíneas con imagen y texto' },
|
|
||||||
{ type: 'nuevo', text: 'Búsqueda por palabras o por frase exacta' },
|
|
||||||
{ type: 'nuevo', text: 'Estructura base de la aplicación' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
# Limpieza de HTML embebido (Publicaciones y Entrelíneas)
|
|
||||||
|
|
||||||
Notas técnicas sobre el saneamiento de HTML que llega desde el CMS/índice de búsqueda
|
|
||||||
y se renderiza con `v-html` en los paneles de detalle. Documenta el problema, la causa
|
|
||||||
raíz y el fix aplicado en cada caso, para poder depurar o mejorar esto más adelante.
|
|
||||||
|
|
||||||
## Contexto general
|
|
||||||
|
|
||||||
Varios documentos (publicaciones, entrelíneas) traen un campo `html` que en teoría es
|
|
||||||
un fragmento de texto formateado, pero en la práctica viene "contaminado" con marcado
|
|
||||||
que no debería estar ahí: estilos de Word, anchos fijos, o incluso HTML ya renderizado
|
|
||||||
de otro componente que fue copiado/pegado en Directus por error. Ese marcado extra
|
|
||||||
puede romper el layout porque se inyecta directo en el DOM de la app (clases de
|
|
||||||
Tailwind, `data-*`, tablas de Word con `width` en `cm`/`pt`, etc.).
|
|
||||||
|
|
||||||
Cada vista que hace `v-html` de estos campos tiene su propia función de limpieza
|
|
||||||
(basada en regex, no en un parser DOM) justo antes de renderizar.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Caso 1 — `PublicationDetail.vue`: HTML pegado desde Word
|
|
||||||
|
|
||||||
**Archivo:** [`app/components/PublicationDetail.vue`](../app/components/PublicationDetail.vue)
|
|
||||||
|
|
||||||
**Síntoma:** tablas/celdas con overflow horizontal o contenido comprimido en el panel
|
|
||||||
de detalle de publicaciones.
|
|
||||||
|
|
||||||
**Causa raíz:** el HTML exportado/pegado desde Word trae:
|
|
||||||
- Bloques `<style>` completos con reglas `mso-*`.
|
|
||||||
- Anchos fijos en unidades absolutas (`width: 15.5cm`, `pt`, etc.) en tablas y celdas,
|
|
||||||
que no responden al layout del contenedor.
|
|
||||||
|
|
||||||
**Fix:** función `cleanWordHtml()` (cerca de la línea 85) aplicada al `v-html` del
|
|
||||||
párrafo:
|
|
||||||
- Elimina bloques `<style>`.
|
|
||||||
- Elimina declaraciones `width: <número><cm|mm|pt|px|em|rem|in|pc>`.
|
|
||||||
- Colapsa saltos de línea literales a un espacio.
|
|
||||||
|
|
||||||
Reforzado con CSS en `.paragraph-html` (`:deep(p|span|li|td|th)` → `white-space:
|
|
||||||
normal`, tablas a `width: 100%` / `table-layout: auto`, celdas y divs a `width: auto` /
|
|
||||||
`max-width: 100%`) para cubrir estilos que la regex no puede tocar.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Caso 2 — `EntrelineaDetail.vue`: HTML contaminado con clases de otro componente
|
|
||||||
|
|
||||||
**Archivo:** [`app/components/entrelineas/EntrelineaDetail.vue`](../app/components/entrelineas/EntrelineaDetail.vue)
|
|
||||||
|
|
||||||
**Síntoma:** el texto de la entrelínea se mostraba con **una palabra por línea**,
|
|
||||||
dejando la mayor parte del panel en blanco.
|
|
||||||
|
|
||||||
**Causa raíz (confirmada consultando el documento directo en Typesense):** el campo
|
|
||||||
`html` de ese registro no contenía un `<p>` por palabra (esa fue la hipótesis inicial,
|
|
||||||
descartada). Contenía el HTML **ya renderizado de `PublicationDetail.vue`** pegado por
|
|
||||||
error en Directus, incluyendo:
|
|
||||||
- Atributos de scoping de Vue (`data-v-d5ee3d80`).
|
|
||||||
- `data-paragraph-number="37"`.
|
|
||||||
- Clases de Tailwind reales: `grid grid-cols-1fr items-start gap-2 mb-2
|
|
||||||
grid-cols-[20px_1fr]`.
|
|
||||||
|
|
||||||
Como esas clases son utilidades globales de Tailwind, se aplicaban igual dentro de
|
|
||||||
`EntrelineaDetail`. El contenedor quedaba como grid de 2 columnas (`20px 1fr`) y, al
|
|
||||||
tener un solo hijo, el auto-placement lo metía en la columna de **20px** → todo el
|
|
||||||
párrafo se comprimía a un ancho mínimo y cada palabra terminaba en su propia línea al
|
|
||||||
hacer wrap.
|
|
||||||
|
|
||||||
**Cómo se verificó:** se consultó el documento crudo directo en Typesense:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s "$NUXT_PUBLIC_TYPESENSE_URL/multi_search" \
|
|
||||||
-H "X-TYPESENSE-API-KEY: $NUXT_PUBLIC_TYPESENSE_API_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"searches":[{"collection":"entrelineas","q":"*","filter_by":"id:=<ID_DEL_DOC>","include_fields":"*"}]}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Esto es útil para cualquier bug futuro de renderizado: primero confirmar qué HTML
|
|
||||||
crudo hay realmente en el índice antes de asumir la causa.
|
|
||||||
|
|
||||||
**Fix:** función `formatEntrelineaText()` (cerca de la línea 56), agrega dos
|
|
||||||
reemplazos antes de los ya existentes:
|
|
||||||
- Elimina cualquier atributo `class="..."` / `class='...'`.
|
|
||||||
- Elimina cualquier atributo `data-*="..."` / `data-*='...'`.
|
|
||||||
|
|
||||||
Esto neutraliza clases o atributos de scoping filtrados desde cualquier otra fuente,
|
|
||||||
sin tocar los `style` inline (que sí son necesarios: cursiva, colores, fuente del
|
|
||||||
documento original).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Limitaciones conocidas
|
|
||||||
|
|
||||||
- Ambas limpiezas son **basadas en regex**, no en un parser DOM real. Cubren los casos
|
|
||||||
vistos hasta ahora, pero no garantizan sanear cualquier HTML arbitrario (por ejemplo,
|
|
||||||
no tocan `style="display: grid; ..."` puesto inline, solo `width` en unidades
|
|
||||||
absolutas).
|
|
||||||
- La lógica está **duplicada** entre `cleanWordHtml` (Publicaciones) y
|
|
||||||
`formatEntrelineaText` (Entrelíneas). Si aparece un caso nuevo, hay que recordar
|
|
||||||
aplicarlo en los dos lugares (o consolidarlos, ver abajo).
|
|
||||||
- El origen del problema está en los datos (Directus / proceso de carga), no en el
|
|
||||||
frontend. Estas funciones son un parche en el punto de renderizado, no una
|
|
||||||
corrección en la fuente.
|
|
||||||
|
|
||||||
## Ideas para mejorar esto a futuro
|
|
||||||
|
|
||||||
1. **Consolidar en un solo util compartido**, por ejemplo en
|
|
||||||
[`app/utils/textUtilities.ts`](../app/utils/textUtilities.ts) o un nuevo
|
|
||||||
`app/utils/htmlSanitizer.ts`, con funciones nombradas por lo que hacen
|
|
||||||
(`stripStyleBlocks`, `stripAbsoluteWidths`, `stripClassAndDataAttrs`,
|
|
||||||
`collapseNewlines`) y componerlas según necesite cada vista, en vez de tener dos
|
|
||||||
funciones casi idénticas.
|
|
||||||
2. **Agregar tests de regresión** con fixtures de HTML "sucio" ya vistos en producción
|
|
||||||
(el de Word con `mso-*`, el de la entrelínea contaminada con clases de grid) para
|
|
||||||
que un cambio futuro no reintroduzca estos bugs silenciosamente.
|
|
||||||
3. **Sanear en el origen** (al indexar en Typesense o al guardar en Directus) en lugar
|
|
||||||
de en cada punto de render, para que cualquier consumidor futuro del campo `html`
|
|
||||||
(snippets en listas, exportaciones, etc.) reciba datos ya limpios.
|
|
||||||
4. Si se detectan más casos de contaminación cruzada entre componentes, vale la pena
|
|
||||||
revisar el flujo de carga de contenido en Directus para encontrar dónde se está
|
|
||||||
pegando HTML renderizado en vez de HTML fuente.
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
apps: [
|
|
||||||
{
|
|
||||||
// You can append the PM2_ENV dynamically in the script if you want unique names in the PM2 list,
|
|
||||||
// or rely on the directory path to keep them separate.
|
|
||||||
name: process.env.PM2_NAME || 'SearchTs',
|
|
||||||
// port: '3000',
|
|
||||||
// exec_mode: 'cluster',
|
|
||||||
// instances: 'max',
|
|
||||||
script: './.output/server/index.mjs',
|
|
||||||
|
|
||||||
// Triggered by 'staging' branch
|
|
||||||
env_staging: {
|
|
||||||
NODE_ENV: 'staging',
|
|
||||||
PORT: 3005
|
|
||||||
},
|
|
||||||
|
|
||||||
// Triggered by 'production' branch
|
|
||||||
env_production: {
|
|
||||||
NODE_ENV: 'production',
|
|
||||||
PORT: 3010
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
25
lang/en.json
25
lang/en.json
|
|
@ -2,28 +2,19 @@
|
||||||
"name": "English",
|
"name": "English",
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"bible_studies": "Studies",
|
"bible_studies": "Bible Studies",
|
||||||
"bible_studies_ts": "Studies",
|
"bible_studies_ts": "Bible Studies Typesense",
|
||||||
"conferences_ts": "Conferences",
|
"conferences_ts": "Conferences Typesense",
|
||||||
"conferences": "Conferences",
|
"conferences": "Conferences",
|
||||||
"between_the_lines": "Between the Lines",
|
"between_the_lines": "Between the Lines",
|
||||||
"my_list": "My List",
|
"my_list": "My List",
|
||||||
"history": "History",
|
"history": "History",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"changelog": "What's New",
|
"changelog": "What's New"
|
||||||
"search_title": "Search of La Gran Carpa Catedral"
|
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"sort": {
|
"word": "Word",
|
||||||
"relevance": "Normal",
|
|
||||||
"date": "Most recent"
|
|
||||||
},
|
|
||||||
"word": "Word",
|
|
||||||
"phrase": "Phrase",
|
"phrase": "Phrase",
|
||||||
"bible_study_placeholder": "Study no...",
|
|
||||||
"bible_study_chip": "Study #{number} {title}",
|
|
||||||
"bible_study_clear": "Clear filters",
|
|
||||||
"filters": "Filters",
|
|
||||||
"placeholder": "Search for...",
|
"placeholder": "Search for...",
|
||||||
"searching": "Searching...",
|
"searching": "Searching...",
|
||||||
"tip": "Tip: wrap in \"quotes\" for exact phrase in that order.",
|
"tip": "Tip: wrap in \"quotes\" for exact phrase in that order.",
|
||||||
|
|
@ -92,9 +83,9 @@
|
||||||
"development_subtitle_line2": "Come back soon."
|
"development_subtitle_line2": "Come back soon."
|
||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"draft": "Draft",
|
"draft": "Draft",
|
||||||
"empty_bible_studies": "Choose a Study to see the detail",
|
"empty_bible_studies": "Choose a Bible Study to see the detail",
|
||||||
"empty_conferences": "Choose a Conference to see the detail"
|
"empty_conferences": "Choose a Conference to see the detail"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
53
lang/es.json
53
lang/es.json
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "Inicio",
|
"home": "Inicio",
|
||||||
"bible_studies": "Estudios",
|
"bible_studies": "Estudios Bíblicos",
|
||||||
"bible_studies_ts": "Estudios",
|
"bible_studies_ts": "Estudios Bíblicos",
|
||||||
"conferences_ts": "Conferencias",
|
"conferences_ts": "Conferencias",
|
||||||
"conferences": "Conferencias",
|
"conferences": "Conferencias",
|
||||||
"between_the_lines": "Entrelíneas",
|
"between_the_lines": "Entrelíneas",
|
||||||
|
|
@ -10,39 +10,32 @@
|
||||||
"history": "Historial",
|
"history": "Historial",
|
||||||
"settings": "Configuración",
|
"settings": "Configuración",
|
||||||
"changelog": "Novedades",
|
"changelog": "Novedades",
|
||||||
"tour": "Toma el tour",
|
"tour": "Toma el tour"
|
||||||
"localeselector": "Selector de idioma",
|
|
||||||
"search_title": "Buscador de La Gran Carpa Catedral"
|
|
||||||
},
|
},
|
||||||
"tour": {
|
"tour": {
|
||||||
"progress": "{current} de {total}",
|
"progress": "{current} de {total}",
|
||||||
"next": "Siguiente",
|
"next": "Siguiente",
|
||||||
"prev": "Anterior",
|
"prev": "Anterior",
|
||||||
"done": "Finalizar",
|
"done": "Finalizar",
|
||||||
"bible_studies_description": "Realiza búsquedas en los Estudios predicados por el Dr. José Benjamín Pérez Matos",
|
"bible_studies_description": "Realiza busquedas en los estudios biblicos predicados por el Dr. José Benjamín Pérez",
|
||||||
"conferences_description": "Realiza búsquedas en las conferencias predicadas por el Dr. William Soto Santiago",
|
"conferences_description": "Realiza busquedas en las conferencias predicadas por el Dr. William Soto Santiago",
|
||||||
"betweenthelines_description": "Realiza búsquedas en las imágenes de entrelíneas de los estudios del Dr. José Benjamín Pérez Matos",
|
"betweenthelines_description": "Realiza busquedas en las imagenes de entrelineas de los estudios del Dr. José Benjamín Perez",
|
||||||
"favorites_description": "Listado de resultados guardados como favoritos para fácil acceso futuro",
|
"favorites_description": "Listado de resultados guardados como favoritos para facil acceso futuro",
|
||||||
"history_description": "Historial de los resultados de búsqueda que has visto",
|
"history_description": "Historial de los resultados de busqueda que has visto",
|
||||||
"settings_description": "Configuración, cantidad de resultados por página, tipo de paginación, entre otros",
|
"settings_description": "Configuracion, cantidad de resultados por pagina, tipo de paginacion, entre otros",
|
||||||
"changelog_description": "Bitácora de cambios realizados al sitio en orden cronológico",
|
"changelog_description": "Bitacora de cambios realizados al sitio en orden cronologico",
|
||||||
"feedback_description": "¿Tienes alguna sugerencia o queja? Realízala aquí.",
|
"feedback_description": "Tienes alguna sugerencia o queja? realizala aqui",
|
||||||
"localeselector_description": "Cambia fácilmente el idioma de la página",
|
"localeselector_description": "Cambia facilmente el idioma de la pagina",
|
||||||
"index_changelog": "Panel de últimos cambios subidos",
|
"index_changelog": "Panel de ultimos cambios subidos",
|
||||||
"favorites_toggle": "Botón para guardar / quitar este documento de mis favoritos",
|
"favorites_toggle": "Boton para guardar / quitar este documento de mis favoritos"
|
||||||
"collapse_sidebar_description": "Oculta o muestra la barra de navegación lateral para tener más espacio en pantalla",
|
|
||||||
"total_results_description": "Muestra la cantidad total de resultados encontrados para tu búsqueda",
|
|
||||||
"favorites_button": "Botón de favoritos"
|
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"instructions": "Bienvenidos, aquí podrán buscar, entre los Estudios de las escrituras, las conferencias y las entrelíneas que están disponibles en el material de archivo de La Gran Carpa Catedral."
|
"instructions": "Bienvenidos, aqui podran buscar entre los estudios biblicos, las conferencias y las entrelineas que estan disponibles en el material de archivo de La Gran Carpa Catedral."
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Buscar...",
|
"placeholder": "Buscar...",
|
||||||
"searching": "Buscando...",
|
"searching": "Buscando...",
|
||||||
"tip": "Consejo: envuelve en \"comillas\" para frase exacta en ese orden.",
|
"tip": "Consejo: envuelve en \"comillas\" para frase exacta en ese orden.",
|
||||||
"collapse": "Colapsar menú lateral",
|
|
||||||
"total_results": "Total de resultados",
|
|
||||||
"publication": "Publicación",
|
"publication": "Publicación",
|
||||||
"draft": "Borrador",
|
"draft": "Borrador",
|
||||||
"country": "País",
|
"country": "País",
|
||||||
|
|
@ -56,21 +49,13 @@
|
||||||
"hits_per_page": "aciertos por página",
|
"hits_per_page": "aciertos por página",
|
||||||
"hits_retrieved_in": "aciertos logrados en",
|
"hits_retrieved_in": "aciertos logrados en",
|
||||||
"for": "Buscando",
|
"for": "Buscando",
|
||||||
"sort": {
|
|
||||||
"relevance": "Normal",
|
|
||||||
"date": "Más recientes"
|
|
||||||
},
|
|
||||||
"word": "Palabra",
|
"word": "Palabra",
|
||||||
"phrase": "Frase",
|
"phrase": "Frase",
|
||||||
"words": "palabras",
|
"words": "palabras",
|
||||||
"phrases": "frases",
|
"phrases": "frases",
|
||||||
"bible_study_placeholder": "N° de estudio...",
|
|
||||||
"bible_study_chip": "Estudio #{number} {title}",
|
|
||||||
"bible_study_clear": "Limpiar filtros",
|
|
||||||
"filters": "Filtros",
|
|
||||||
"words_tooltip": "Buscar por palabras",
|
"words_tooltip": "Buscar por palabras",
|
||||||
"phrases_tooltip": "Buscar por frases",
|
"phrases_tooltip": "Buscar por frases",
|
||||||
"instructions": "Selecciona un resultado de búsqueda...",
|
"instructions": "Selecciona un resultado de busqueda...",
|
||||||
"tab1": {
|
"tab1": {
|
||||||
"tab_title": "Actividades"
|
"tab_title": "Actividades"
|
||||||
},
|
},
|
||||||
|
|
@ -83,7 +68,7 @@
|
||||||
"page_size_desc": "Cuántos resultados cargar en cada página o petición.",
|
"page_size_desc": "Cuántos resultados cargar en cada página o petición.",
|
||||||
"results": "resultados",
|
"results": "resultados",
|
||||||
"pagination_title": "Tipo de paginación",
|
"pagination_title": "Tipo de paginación",
|
||||||
"pagination_desc": "¿Cómo quieres navegar entre los resultados?",
|
"pagination_desc": "Cómo quieres navegar entre los resultados.",
|
||||||
"infinite_scroll": "Scroll infinito",
|
"infinite_scroll": "Scroll infinito",
|
||||||
"infinite_scroll_desc": "Los resultados se cargan automáticamente al llegar al final de la lista.",
|
"infinite_scroll_desc": "Los resultados se cargan automáticamente al llegar al final de la lista.",
|
||||||
"numbered": "Páginas numeradas",
|
"numbered": "Páginas numeradas",
|
||||||
|
|
@ -101,7 +86,7 @@
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"book": "Libro",
|
"book": "Libro",
|
||||||
"simple": "Sencillo"
|
"simple": "Sencillo",
|
||||||
},
|
},
|
||||||
"feedback": {
|
"feedback": {
|
||||||
"title": "Reportar un error",
|
"title": "Reportar un error",
|
||||||
|
|
@ -126,7 +111,7 @@
|
||||||
"ui": {
|
"ui": {
|
||||||
"copy": "Copiar",
|
"copy": "Copiar",
|
||||||
"draft": "Borrador",
|
"draft": "Borrador",
|
||||||
"empty_bible_studies": "Selecciona un Estudio para ver el detalle",
|
"empty_bible_studies": "Selecciona un Estudio Bíblico para ver el detalle",
|
||||||
"empty_conferences": "Selecciona una Conferencia para ver el detalle"
|
"empty_conferences": "Selecciona una Conferencia para ver el detalle"
|
||||||
},
|
},
|
||||||
"seo": {
|
"seo": {
|
||||||
|
|
|
||||||
17
lang/fr.json
17
lang/fr.json
|
|
@ -2,26 +2,17 @@
|
||||||
"name": "Français",
|
"name": "Français",
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "Commencer",
|
"home": "Commencer",
|
||||||
"bible_studies": "Études",
|
"bible_studies": "Études Bibliques",
|
||||||
"bible_studies_ts": "Études",
|
"bible_studies_ts": "Études Bibliques Typesense",
|
||||||
"conferences_ts": "Conférences",
|
"conferences_ts": "Conférences Typesense",
|
||||||
"conferences": "Conférences",
|
"conferences": "Conférences",
|
||||||
"between_the_lines": "Entre les lignes",
|
"between_the_lines": "Entre les lignes",
|
||||||
"my_list": "Ma liste",
|
"my_list": "Ma liste",
|
||||||
"history": "Historique",
|
"history": "Historique",
|
||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
"changelog": "Nouveautés",
|
"changelog": "Nouveautés"
|
||||||
"search_title": "Buscador de La Gran Carpa Catedral"
|
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"sort": {
|
|
||||||
"relevance": "Normal",
|
|
||||||
"date": "Plus récents"
|
|
||||||
},
|
|
||||||
"bible_study_placeholder": "N° d'étude...",
|
|
||||||
"bible_study_chip": "Étude #{number} {title}",
|
|
||||||
"bible_study_clear": "Effacer les filtres",
|
|
||||||
"filters": "Filtres",
|
|
||||||
"word": "Mot",
|
"word": "Mot",
|
||||||
"phrase": "Phrase",
|
"phrase": "Phrase",
|
||||||
"placeholder": "Rechercher des activités",
|
"placeholder": "Rechercher des activités",
|
||||||
|
|
|
||||||
15
lang/pt.json
15
lang/pt.json
|
|
@ -2,26 +2,17 @@
|
||||||
"name": "Português",
|
"name": "Português",
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "Inicio",
|
"home": "Inicio",
|
||||||
"bible_studies": "Estudios",
|
"bible_studies": "Estudios Bíblicos",
|
||||||
"bible_studies_ts": "Estudos",
|
"bible_studies_ts": "Estudos Bíblicos Typesense",
|
||||||
"conferences_ts": "Conferências Typesense",
|
"conferences_ts": "Conferências Typesense",
|
||||||
"conferences": "Conferências",
|
"conferences": "Conferências",
|
||||||
"between_the_lines": "Entre as linhas",
|
"between_the_lines": "Entre as linhas",
|
||||||
"my_list": "Minha lista",
|
"my_list": "Minha lista",
|
||||||
"history": "Registro",
|
"history": "Registro",
|
||||||
"settings": "Configurações",
|
"settings": "Configurações",
|
||||||
"changelog": "Novidades",
|
"changelog": "Novidades"
|
||||||
"search_title": "Buscador de La Gran Carpa Catedral"
|
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"sort": {
|
|
||||||
"relevance": "Normal",
|
|
||||||
"date": "Mais recentes"
|
|
||||||
},
|
|
||||||
"bible_study_placeholder": "N° do estudo...",
|
|
||||||
"bible_study_chip": "Estudo #{number} {title}",
|
|
||||||
"bible_study_clear": "Limpar filtros",
|
|
||||||
"filters": "Filtros",
|
|
||||||
"word": "Palavra",
|
"word": "Palavra",
|
||||||
"phrase": "Frase",
|
"phrase": "Frase",
|
||||||
"placeholder": "Digite para pesquisar...",
|
"placeholder": "Digite para pesquisar...",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
modules: ['@nuxt/eslint', '@nuxt/ui', '@vueuse/nuxt', '@nuxtjs/i18n', '@pinia/nuxt', 'nuxt-driver.js'],
|
modules: ['@nuxt/eslint', '@nuxt/ui', '@vueuse/nuxt', '@nuxtjs/i18n', '@pinia/nuxt', '@sfxcode/nuxt-typesense','nuxt-driver.js'],
|
||||||
|
|
||||||
app: {
|
app: {
|
||||||
head: {
|
head: {
|
||||||
|
|
@ -28,9 +28,7 @@ export default defineNuxtConfig({
|
||||||
feedbackMaxPerSession: Number(process.env.NUXT_PUBLIC_FEEDBACK_MAX_PER_SESSION) || 3,
|
feedbackMaxPerSession: Number(process.env.NUXT_PUBLIC_FEEDBACK_MAX_PER_SESSION) || 3,
|
||||||
feedbackCooldownSec: Number(process.env.NUXT_PUBLIC_FEEDBACK_COOLDOWN_SEC) || 45,
|
feedbackCooldownSec: Number(process.env.NUXT_PUBLIC_FEEDBACK_COOLDOWN_SEC) || 45,
|
||||||
feedbackMinSeconds: Number(process.env.NUXT_PUBLIC_FEEDBACK_MIN_SECONDS) || 4,
|
feedbackMinSeconds: Number(process.env.NUXT_PUBLIC_FEEDBACK_MIN_SECONDS) || 4,
|
||||||
entrelineasDevKey: process.env.NUXT_PUBLIC_ENTRELINEAS_DEV_KEY || '',
|
entrelineasDevKey: process.env.NUXT_PUBLIC_ENTRELINEAS_DEV_KEY || ''
|
||||||
typeSenseNodes: process.env.TYPESENSE_NODES || '[]',
|
|
||||||
typeSenseApiKey: process.env.NUXT_PUBLIC_TYPESENSE_API_KEY || ''
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -89,4 +87,10 @@ export default defineNuxtConfig({
|
||||||
optimizeTranslationDirective: false,
|
optimizeTranslationDirective: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
typesense: {
|
||||||
|
url: process.env.NUXT_PUBLIC_TYPESENSE_URL || 'https://searchts.carpa.com',
|
||||||
|
apiKey: process.env.NUXT_PUBLIC_TYPESENSE_API_KEY || '',
|
||||||
|
clientMode: true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,7 @@
|
||||||
"preview": "nuxt preview",
|
"preview": "nuxt preview",
|
||||||
"postinstall": "nuxt prepare",
|
"postinstall": "nuxt prepare",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"typecheck": "nuxt typecheck",
|
"typecheck": "nuxt typecheck"
|
||||||
"benchmark:search": "node --env-file=.env scripts/benchmark-search.mjs"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.29.2",
|
"@babel/runtime": "^7.29.2",
|
||||||
|
|
@ -19,6 +18,7 @@
|
||||||
"@nuxt/ui": "^4.7.0",
|
"@nuxt/ui": "^4.7.0",
|
||||||
"@nuxtjs/i18n": "^9.5.6",
|
"@nuxtjs/i18n": "^9.5.6",
|
||||||
"@pinia/nuxt": "^0.11.2",
|
"@pinia/nuxt": "^0.11.2",
|
||||||
|
"@sfxcode/nuxt-typesense": "^1.2.0",
|
||||||
"@tanstack/table-core": "^8.21.3",
|
"@tanstack/table-core": "^8.21.3",
|
||||||
"@unovis/ts": "^1.6.5",
|
"@unovis/ts": "^1.6.5",
|
||||||
"@unovis/vue": "^1.6.5",
|
"@unovis/vue": "^1.6.5",
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ importers:
|
||||||
'@pinia/nuxt':
|
'@pinia/nuxt':
|
||||||
specifier: ^0.11.2
|
specifier: ^0.11.2
|
||||||
version: 0.11.3(magicast@0.5.2)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))
|
version: 0.11.3(magicast@0.5.2)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))
|
||||||
|
'@sfxcode/nuxt-typesense':
|
||||||
|
specifier: ^1.2.0
|
||||||
|
version: 1.2.0(magicast@0.5.2)
|
||||||
'@tanstack/table-core':
|
'@tanstack/table-core':
|
||||||
specifier: ^8.21.3
|
specifier: ^8.21.3
|
||||||
version: 8.21.3
|
version: 8.21.3
|
||||||
|
|
@ -2057,6 +2060,9 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@sfxcode/nuxt-typesense@1.2.0':
|
||||||
|
resolution: {integrity: sha512-5h/nc7AL4POo/RBG+M7zl2fcK1aMED+LWuP/Ob18g9rGZa4cgArLNtp+8GwYFzGIr7aZa6fH6KM+Uuu4kbXFjQ==}
|
||||||
|
|
||||||
'@simple-git/args-pathspec@1.0.3':
|
'@simple-git/args-pathspec@1.0.3':
|
||||||
resolution: {integrity: sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==}
|
resolution: {integrity: sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==}
|
||||||
|
|
||||||
|
|
@ -8248,6 +8254,14 @@ snapshots:
|
||||||
'@rollup/rollup-win32-x64-msvc@4.60.3':
|
'@rollup/rollup-win32-x64-msvc@4.60.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@sfxcode/nuxt-typesense@1.2.0(magicast@0.5.2)':
|
||||||
|
dependencies:
|
||||||
|
'@nuxt/kit': 4.4.5(magicast@0.5.2)
|
||||||
|
consola: 3.4.2
|
||||||
|
defu: 6.1.7
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- magicast
|
||||||
|
|
||||||
'@simple-git/args-pathspec@1.0.3': {}
|
'@simple-git/args-pathspec@1.0.3': {}
|
||||||
|
|
||||||
'@simple-git/argv-parser@1.1.1':
|
'@simple-git/argv-parser@1.1.1':
|
||||||
|
|
|
||||||
|
|
@ -1,202 +0,0 @@
|
||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Benchmark de red para el buscador: compara la latencia del patrón de
|
|
||||||
* búsqueda ANTES de las optimizaciones (varias requests secuenciales) contra
|
|
||||||
* el patrón DESPUÉS (una sola request con join), golpeando directamente el
|
|
||||||
* cluster real de Typesense — sin pasar por el navegador ni por Nuxt.
|
|
||||||
*
|
|
||||||
* Sirve como línea base repetible: correr este script antes/después de un
|
|
||||||
* cambio futuro en las queries de búsqueda muestra si mejoró o empeoró la
|
|
||||||
* latencia real contra el servidor, no solo "se siente más rápido".
|
|
||||||
*
|
|
||||||
* Uso:
|
|
||||||
* node --env-file=.env scripts/benchmark-search.mjs
|
|
||||||
* node --env-file=.env scripts/benchmark-search.mjs --iterations 20 --query "amor"
|
|
||||||
* pnpm run benchmark:search -- --iterations 20
|
|
||||||
*
|
|
||||||
* Requiere NUXT_PUBLIC_TYPESENSE_URL y NUXT_PUBLIC_TYPESENSE_API_KEY en el
|
|
||||||
* entorno (--env-file=.env los carga automáticamente en Node 20.6+).
|
|
||||||
*/
|
|
||||||
|
|
||||||
const args = process.argv.slice(2)
|
|
||||||
function argValue(name, fallback) {
|
|
||||||
const idx = args.indexOf(`--${name}`)
|
|
||||||
return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
const ITERATIONS = Number(argValue('iterations', '15'))
|
|
||||||
const QUERY = argValue('query', 'amor')
|
|
||||||
const LOCALE = argValue('locale', 'es')
|
|
||||||
|
|
||||||
const TYPESENSE_URL = process.env.NUXT_PUBLIC_TYPESENSE_URL
|
|
||||||
const API_KEY = process.env.NUXT_PUBLIC_TYPESENSE_API_KEY
|
|
||||||
|
|
||||||
if (!TYPESENSE_URL || !API_KEY) {
|
|
||||||
console.error('Faltan NUXT_PUBLIC_TYPESENSE_URL / NUXT_PUBLIC_TYPESENSE_API_KEY.')
|
|
||||||
console.error('Corré con: node --env-file=.env scripts/benchmark-search.mjs')
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function multiSearch(searches) {
|
|
||||||
const res = await fetch(`${TYPESENSE_URL}/multi_search`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-TYPESENSE-API-KEY': API_KEY
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ searches })
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`)
|
|
||||||
return res.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function timeIt(fn) {
|
|
||||||
const start = performance.now()
|
|
||||||
await fn()
|
|
||||||
return performance.now() - start
|
|
||||||
}
|
|
||||||
|
|
||||||
function stats(samples) {
|
|
||||||
const sorted = [...samples].sort((a, b) => a - b)
|
|
||||||
const sum = sorted.reduce((a, b) => a + b, 0)
|
|
||||||
const p = q => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]
|
|
||||||
return {
|
|
||||||
mean: sum / sorted.length,
|
|
||||||
median: p(0.5),
|
|
||||||
p95: p(0.95),
|
|
||||||
min: sorted[0],
|
|
||||||
max: sorted[sorted.length - 1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runScenario(name, fn) {
|
|
||||||
const samples = []
|
|
||||||
// Un warmup fuera de la medición, para no medir handshake TLS/DNS frío.
|
|
||||||
await fn().catch(() => {})
|
|
||||||
for (let i = 0; i < ITERATIONS; i++) {
|
|
||||||
samples.push(await timeIt(fn))
|
|
||||||
}
|
|
||||||
return { name, ...stats(samples) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Escenarios por colección (conferences / activities) ─────────────────────
|
|
||||||
|
|
||||||
const COLLECTIONS = [
|
|
||||||
{ label: 'conferences', main: 'conferences', paragraphs: 'conferences_paragraphs', groupBy: 'conferences_id' },
|
|
||||||
{ label: 'activities', main: 'activities', paragraphs: 'activities_paragraphs', groupBy: 'activities_id' }
|
|
||||||
]
|
|
||||||
|
|
||||||
function oldSearchScenario({ main, paragraphs, groupBy }) {
|
|
||||||
return async () => {
|
|
||||||
// 1) búsqueda de párrafos, SIN join (como antes de la Fase 1.1)
|
|
||||||
const r1 = await multiSearch([{
|
|
||||||
collection: paragraphs,
|
|
||||||
q: QUERY,
|
|
||||||
query_by: 'text',
|
|
||||||
filter_by: `locale:=${LOCALE}`,
|
|
||||||
per_page: 10,
|
|
||||||
highlight_full_fields: 'text',
|
|
||||||
highlight_fields: 'text',
|
|
||||||
highlight_affix_num_tokens: 30,
|
|
||||||
group_by: groupBy
|
|
||||||
}])
|
|
||||||
const ids = (r1.results?.[0]?.grouped_hits ?? [])
|
|
||||||
.map(g => g.group_key?.[0])
|
|
||||||
.filter(Boolean)
|
|
||||||
if (!ids.length) return
|
|
||||||
// 2) segunda request para la metadata (la cascada eliminada en 1.1)
|
|
||||||
await multiSearch([{
|
|
||||||
collection: main,
|
|
||||||
q: '*',
|
|
||||||
query_by: 'title',
|
|
||||||
filter_by: `id:=[${ids.join(',')}]`,
|
|
||||||
per_page: ids.length,
|
|
||||||
include_fields: 'id,title,date,timestamp,place,city,state,country,type,slug,draft'
|
|
||||||
}])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function newSearchScenario({ main, paragraphs, groupBy }) {
|
|
||||||
return async () => {
|
|
||||||
// Una sola request: join a la colección principal + highlight recortado
|
|
||||||
await multiSearch([{
|
|
||||||
collection: paragraphs,
|
|
||||||
q: QUERY,
|
|
||||||
query_by: 'text',
|
|
||||||
filter_by: `locale:=${LOCALE}`,
|
|
||||||
per_page: 10,
|
|
||||||
highlight_full_fields: 'text',
|
|
||||||
highlight_fields: 'text',
|
|
||||||
highlight_affix_num_tokens: 15,
|
|
||||||
group_by: groupBy,
|
|
||||||
include_fields: `*, $${main}(id,title,date,timestamp,place,city,state,country,type,slug,draft)`
|
|
||||||
}])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function oldBrowseScenario({ main, paragraphs, groupBy }) {
|
|
||||||
return async () => {
|
|
||||||
// Explorar sin query contra la colección grande de párrafos (antes de 1.2)
|
|
||||||
await multiSearch([{
|
|
||||||
collection: paragraphs,
|
|
||||||
q: '*',
|
|
||||||
query_by: 'text',
|
|
||||||
filter_by: `locale:=${LOCALE} && $${main}(locale:=${LOCALE})`,
|
|
||||||
sort_by: `$${main}(timestamp:desc)`,
|
|
||||||
group_by: groupBy,
|
|
||||||
per_page: 10,
|
|
||||||
include_fields: `$${main}(id,title,date,timestamp,place,city,state,country,type,slug,draft)`
|
|
||||||
}])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function newBrowseScenario({ main }) {
|
|
||||||
return async () => {
|
|
||||||
// Explorar sin query contra la colección principal, directo (después de 1.2)
|
|
||||||
await multiSearch([{
|
|
||||||
collection: main,
|
|
||||||
q: '*',
|
|
||||||
query_by: 'title',
|
|
||||||
filter_by: `locale:=${LOCALE}`,
|
|
||||||
sort_by: 'timestamp:desc',
|
|
||||||
per_page: 10,
|
|
||||||
include_fields: 'id,title,date,timestamp,place,city,state,country,type,slug,draft'
|
|
||||||
}])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function printTable(rows) {
|
|
||||||
const cols = ['name', 'mean', 'median', 'p95', 'min', 'max']
|
|
||||||
const widths = cols.map(c => Math.max(c.length, ...rows.map(r => String(typeof r[c] === 'number' ? r[c].toFixed(1) : r[c]).length)))
|
|
||||||
const fmtRow = vals => vals.map((v, i) => String(v).padEnd(widths[i])).join(' ')
|
|
||||||
console.log(fmtRow(cols.map(c => c.toUpperCase())))
|
|
||||||
console.log(widths.map(w => '-'.repeat(w)).join(' '))
|
|
||||||
for (const r of rows) {
|
|
||||||
console.log(fmtRow(cols.map(c => (typeof r[c] === 'number' ? r[c].toFixed(1) + 'ms' : r[c]))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log(`Typesense: ${TYPESENSE_URL} | query="${QUERY}" | locale=${LOCALE} | iteraciones=${ITERATIONS}\n`)
|
|
||||||
|
|
||||||
for (const col of COLLECTIONS) {
|
|
||||||
console.log(`\n=== ${col.label} — búsqueda con texto ===`)
|
|
||||||
const oldR = await runScenario('antes (2 requests)', oldSearchScenario(col))
|
|
||||||
const newR = await runScenario('después (1 request)', newSearchScenario(col))
|
|
||||||
printTable([oldR, newR])
|
|
||||||
const improvement = ((oldR.mean - newR.mean) / oldR.mean * 100).toFixed(1)
|
|
||||||
console.log(`→ ${improvement}% más rápido en promedio`)
|
|
||||||
|
|
||||||
console.log(`\n=== ${col.label} — explorar sin query ===`)
|
|
||||||
const oldB = await runScenario('antes (colección párrafos)', oldBrowseScenario(col))
|
|
||||||
const newB = await runScenario('después (colección principal)', newBrowseScenario(col))
|
|
||||||
printTable([oldB, newB])
|
|
||||||
const improvementB = ((oldB.mean - newB.mean) / oldB.mean * 100).toFixed(1)
|
|
||||||
console.log(`→ ${improvementB}% más rápido en promedio`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error('Error corriendo el benchmark:', err)
|
|
||||||
process.exit(1)
|
|
||||||
})
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
export default defineEventHandler((event) => {
|
|
||||||
const url = getRequestURL(event)
|
|
||||||
|
|
||||||
if (url.pathname.includes('estudios-biblicos')) {
|
|
||||||
const newPath = url.pathname.replace('estudios-biblicos', 'estudios')
|
|
||||||
return sendRedirect(event, newPath + url.search, 301)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
Loading…
Reference in New Issue