This commit is contained in:
David Ascanio 2026-07-24 15:31:47 -03:00
commit 4513f4d839
10 changed files with 780 additions and 232 deletions

View File

@ -0,0 +1,79 @@
name: Deploy Search Typesense
on:
push:
branches:
- staging
- production
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
# Route each branch to a specific directory and PM2 environment
- name: Setup Environment Variables
run: |
if [[ "${{ github.ref_name }}" == "production" ]]; then
TARGET="/var/www/search.carpa.com"
echo "PM2_ENV=production" >> $GITHUB_ENV
elif [[ "${{ github.ref_name }}" == "staging" ]]; then
TARGET="/var/www/dev.search.carpa.com"
echo "PM2_ENV=staging" >> $GITHUB_ENV
fi
PM2_NAME="search-${{ github.ref_name }}"
echo "TARGET_DIR=$TARGET" >> $GITHUB_ENV
echo "PM2_NAME=$PM2_NAME" >> $GITHUB_ENV
echo "NUXT_PUBLIC_FEEDBACK_WEBHOOK=${{ secrets.NUXT_PUBLIC_FEEDBACK_WEBHOOK }}" >> $GITHUB_ENV
echo "NUXT_PUBLIC_RECAPTCHA_SITE_KEY=${{ secrets.NUXT_PUBLIC_RECAPTCHA_SITE_KEY }}" >> $GITHUB_ENV
echo "NUXT_PUBLIC_FEEDBACK_MAX_PER_HOUR=${{ secrets.NUXT_PUBLIC_FEEDBACK_MAX_PER_HOUR }}" >> $GITHUB_ENV
echo "NUXT_PUBLIC_FEEDBACK_MAX_PER_SESSION=${{ secrets.NUXT_PUBLIC_FEEDBACK_MAX_PER_SESSION }}" >> $GITHUB_ENV
echo "NUXT_PUBLIC_FEEDBACK_COOLDOWN_SEC=${{ secrets.NUXT_PUBLIC_FEEDBACK_COOLDOWN_SEC }}" >> $GITHUB_ENV
echo "NUXT_PUBLIC_FEEDBACK_MIN_SECONDS=${{ secrets.NUXT_PUBLIC_FEEDBACK_MIN_SECONDS }}" >> $GITHUB_ENV
echo "NUXT_FEEDBACK_TOKEN=${{ secrets.NUXT_FEEDBACK_TOKEN }}" >> $GITHUB_ENV
echo "NUXT_PUBLIC_TYPESENSE_URL=${{ secrets.NUXT_PUBLIC_TYPESENSE_URL }}" >> $GITHUB_ENV
echo "NUXT_PUBLIC_TYPESENSE_API_KEY=${{ secrets.NUXT_PUBLIC_TYPESENSE_API_KEY }}" >> $GITHUB_ENV
echo "NUXT_PUBLIC_ENTRELINEAS_DEV_KEY=${{ secrets.NUXT_PUBLIC_ENTRELINEAS_DEV_KEY }}" >> $GITHUB_ENV
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
- name: Install Dependencies
run: npm ci
- name: Build Nuxt Application
run: npm run build
env:
NITRO_PRESET: node-server
- name: Copy Build Files to Vultr
uses: appleboy/scp-action@master
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: 22
source: ".output/*,package.json,ecosystem.config.cjs"
target: "${{ env.TARGET_DIR }}"
- name: Restart PM2 on Vultr
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: 22
script: |
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
cd ${{ env.TARGET_DIR }}
PM2_NAME=${{ env.PM2_NAME }} pm2 reload ${{ env.PM2_NAME }} --env ${{ env.PM2_ENV }} || \
PM2_NAME=${{ env.PM2_NAME }} pm2 start ecosystem.config.cjs --env ${{ env.PM2_ENV }}

View File

@ -0,0 +1,106 @@
<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>

View File

@ -2,6 +2,7 @@
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue' import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { breakpointsTailwind } from '@vueuse/core' import { breakpointsTailwind } 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 {
@ -49,43 +50,6 @@ const bibleStudyInput = ref<number | null>(null)
const activeBibleStudies = ref<BibleStudyChip[]>([]) const activeBibleStudies = ref<BibleStudyChip[]>([])
const isValidating = ref(false) const isValidating = ref(false)
const {
query, debouncedQuery, loading, loadingMore, errorMsg,
exactSearch, sortMode,
groupedHits, visibleGroupCount, visibleGroups, hasMoreVisible, hasMore,
browseItems, hasMoreBrowse,
displayGroups, activePage, displayTotal, totalPages,
runSearch, runBrowse, loadMore, goToPage, retry
} = useGroupedTypesenseSearch({
paragraphsCollection: props.paragraphsCollection,
mainCollection: props.mainCollection,
groupByField: props.groupByField,
queryBy: QUERY_BY,
filterBy: () => {
let base = `locale:=${locale.value}`
if (activeBibleStudies.value.length > 0) {
const ids = activeBibleStudies.value.map(bs => bs.id).join(',')
base += ` && $${props.mainCollection}(bible_study:=[${ids}])`
}
return base
},
isUnlocked: () => unlocked.value,
pageSize: () => settings.pageSize,
paginationType: () => settings.paginationType,
initialQuery: q0,
initialPage: p0
})
function refetchResults() {
if (!debouncedQuery.value.trim()) {
browseItems.value = []
runBrowse(1, false)
} else {
groupedHits.value = []
runSearch(query.value, 1, false)
}
}
async function applyBibleStudyFilter() { async function applyBibleStudyFilter() {
const val = bibleStudyInput.value const val = bibleStudyInput.value
if (val === null || val <= 0) return if (val === null || val <= 0) return
@ -136,40 +100,268 @@ function clearAllBibleStudyFilters() {
refetchResults() refetchResults()
} }
// ---- Types ---------------------------------------------------------------- function refetchResults() {
if (!debouncedQuery.value.trim()) {
interface DocumentDoc extends CachedDocMeta { browseItems.value = []
code: string runBrowse(1, false)
locale: string } else {
files?: { groupedHits.value = []
youtube?: string currentPage.value = 1
video?: string runSearch(query.value, 1, false)
audio?: string
booklet?: string
simple?: string
} }
body?: string
[key: string]: unknown
} }
// ---- Colors ---------------------------------------------------------------- // ----------------------------------------------------------------------------
const colors = computed(() => { const groupedHits = ref<SearchGroup[]>([])
if (props.accentColor === 'green') { const total = ref(0)
return { const currentPage = ref(1)
selectedItem: 'border-carpagreen bg-carpagreen/10',
hoverItem: 'border-gray-200 hover:border-carpagreen hover:bg-carpagreen/5', const hasMore = computed(() =>
icon: 'text-carpagreen', settings.paginationType === 'infinite_scroll' ? groupedHits.value.length < total.value : false
} )
}
return { const visibleGroupCount = ref(10)
selectedItem: 'border-carpablue bg-carpablue/10',
hoverItem: 'border-gray-200 hover:border-carpablue hover:bg-carpablue/5', const visibleGroups = computed(() =>
icon: 'text-carpablue', 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
}))
}) })
// ---- Scroll infinito y detalle --------------------------------------------- const activePage = ref(p0)
const displayTotal = computed(() =>
debouncedQuery.value.trim() ? total.value : browseTotal.value
)
const totalPages = computed(() =>
Math.max(1, Math.ceil(displayTotal.value / settings.pageSize))
)
const docCache = ref<Record<string, DocMeta>>({})
const { documentsApi } = useTypesenseApi()
const toast = useToast()
// ---- Batch fetch de metadatos ---------------------------------------------
async function fetchDocumentMeta(docIds: string[]) {
const unique = docIds.filter(id => id && !(id in docCache.value))
if (!unique.length) return
try {
const res = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: props.mainCollection,
q: '*',
queryBy: 'title',
filterBy: `id:=[${unique.join(',')}]${unlocked.value ? '' : ' && private:=false'}`,
includeFields: 'id,title,date,timestamp,place,city,state,country,type,slug,draft',
perPage: unique.length,
page: 1
}]
}
})
const docHits = (res?.results?.[0] as { hits?: Array<{ document: DocMeta }> })?.hits ?? []
for (const hit of docHits) {
if (hit.document.id) docCache.value[hit.document.id] = hit.document
}
} catch (err) {
console.error('Error fetching document metadata', err)
}
}
// ---- Búsqueda de párrafos (con query) -------------------------------------
let searchSeq = 0
let timeoutId: ReturnType<typeof setTimeout> | null = null
async function runSearch(q: string, page = 1, append = false) {
const seq = ++searchSeq
if (append) loadingMore.value = true
else loading.value = true
errorMsg.value = null
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
if (seq === searchSeq) {
loading.value = false
loadingMore.value = false
errorMsg.value = 'La búsqueda tardó demasiado. Inténtalo de nuevo.'
}
}, REQUEST_TIMEOUT_MS)
const isInfinite = settings.paginationType === 'infinite_scroll'
const typePage = isInfinite ? (append ? currentPage.value + 1 : 1) : page
try {
const shouldSortByDate = sortMode.value === 'date' && q.trim()
const multi = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: props.paragraphsCollection,
q: exactSearch.value && q ? `"${q}"` : q || '*',
queryBy: QUERY_BY,
filterBy: unlocked.value ? filterBy.value : `${filterBy.value} && $${props.mainCollection}(private:=false)`,
...(shouldSortByDate ? { sortBy: `$${props.mainCollection}(timestamp:desc)` } : {}),
perPage: settings.pageSize,
page: typePage,
highlightFullFields: QUERY_BY,
highlightFields: QUERY_BY,
highlightStartTag: '<mark class="search-match">',
highlightEndTag: '</mark>',
highlightAffixNumTokens: 30,
groupBy: props.groupByField
}]
}
})
if (seq !== searchSeq) return
const res = (multi?.results?.[0] ?? {}) as TypesenseSearchResponse
const rawGroups = res?.groupedHits ?? []
const newGroups: SearchGroup[] = rawGroups.map(g => ({
docId: g.groupKey[0]!,
firstHit: g.hits[0]!,
allHits: g.hits
}))
if (!append) docCache.value = {}
await fetchDocumentMeta(newGroups.map(g => g.docId).filter(Boolean))
if (seq !== searchSeq) return
groupedHits.value = append ? groupedHits.value.concat(newGroups) : newGroups
total.value = res?.found ?? groupedHits.value.length
currentPage.value = typePage
if (!append) activePage.value = page
} catch (err: unknown) {
if (seq !== searchSeq) return
console.error('Typesense error', err)
errorMsg.value = (err as Error)?.message || 'Error al buscar.'
if (!append) { groupedHits.value = []; total.value = 0 }
} finally {
if (seq === searchSeq) {
if (timeoutId) clearTimeout(timeoutId)
loading.value = false
loadingMore.value = false
}
}
}
// ---- Exploración por fecha (sin query) ------------------------------------
async function runBrowse(page = 1, append = false) {
const seq = ++searchSeq
if (append) loadingMore.value = true
else loading.value = true
errorMsg.value = null
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
if (seq === searchSeq) {
loading.value = false
loadingMore.value = false
errorMsg.value = 'La búsqueda tardó demasiado. Inténtalo de nuevo.'
}
}, REQUEST_TIMEOUT_MS)
const isInfinite = settings.paginationType === 'infinite_scroll'
const typePage = isInfinite ? (append ? browsePage.value + 1 : 1) : page
try {
const multi = await documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: props.paragraphsCollection,
q: '*',
queryBy: QUERY_BY,
filterBy: `${filterBy.value} && $${props.mainCollection}(locale:=${locale.value}${unlocked.value ? '' : ' && private:=false'})`,
sortBy: `$${props.mainCollection}(timestamp:desc)`,
groupBy: props.groupByField,
perPage: settings.pageSize,
page: typePage,
includeFields: `$${props.mainCollection}(id,title,date,timestamp,place,city,state,country,type,slug,draft)`
}]
}
})
if (seq !== searchSeq) return
const result = (multi?.results?.[0] as TypesenseSearchResponse | undefined)
const rawGroups = result?.groupedHits ?? []
const newItems = rawGroups.map(g => {
const docId = g.groupKey[0]!
const parentMeta = (g.hits[0]?.document as unknown as Record<string, unknown>)[props.mainCollection] as Partial<DocMeta> | undefined
return { docId, meta: { id: docId, ...parentMeta } as DocMeta }
})
browseItems.value = append ? browseItems.value.concat(newItems) : newItems
browseTotal.value = result?.found ?? browseItems.value.length
browsePage.value = typePage
if (!append) activePage.value = page
} catch (err: unknown) {
if (seq !== searchSeq) return
console.error('Typesense error', err)
errorMsg.value = (err as Error)?.message || 'Error al buscar.'
if (!append) { browseItems.value = []; browseTotal.value = 0 }
} finally {
if (seq === searchSeq) {
if (timeoutId) clearTimeout(timeoutId)
loading.value = false
loadingMore.value = false
}
}
}
function loadMore() {
if (settings.paginationType !== 'infinite_scroll') return
if (loadingMore.value || loading.value || !hasMore.value) return
runSearch(query.value, currentPage.value, true)
}
function goToPage(p: number) {
activePage.value = p
if (!debouncedQuery.value.trim()) {
browseItems.value = []
runBrowse(p, false)
} else {
groupedHits.value = []
runSearch(query.value, p, false)
}
}
const listContainer = ref<HTMLElement | null>(null) const listContainer = ref<HTMLElement | null>(null)
@ -355,6 +547,7 @@ function metaLocation(meta: CachedDocMeta | undefined): string {
<span class="italic">{{ author }}</span> <span class="italic">{{ author }}</span>
</div> </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"
@ -380,41 +573,11 @@ function metaLocation(meta: CachedDocMeta | undefined): string {
>{{ t('search.phrase') }}</button> >{{ t('search.phrase') }}</button>
</div> </div>
</div> </div>
<div v-if="query.trim()" class="px-4 sm:px-6 py-3">
<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>
<div v-if="showBibleStudyFilter" class="px-4 sm:px-6 py-2 border-b border-default space-y-2"> <!-- CHIPS ACTIVOS (solo desktop: fuera del FiltersContainer) -->
<div class="flex items-center gap-2"> <!-- En desktop los chips van debajo del input; en mobile van DENTRO del slideover -->
<UInput <div v-if="showBibleStudyFilter && activeBibleStudies.length > 0 && !isMobile" class="px-4 sm:px-6 py-2 border-b border-default">
v-model="bibleStudyInput" <div class="flex flex-wrap items-center gap-1.5">
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>
<div v-if="activeBibleStudies.length > 0" class="flex flex-wrap items-center gap-1.5">
<UBadge <UBadge
v-for="bs in activeBibleStudies" v-for="bs in activeBibleStudies"
:key="bs.id" :key="bs.id"
@ -445,6 +608,92 @@ function metaLocation(meta: CachedDocMeta | undefined): string {
</div> </div>
</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) -->
<!--
El componente FiltersContainer renderiza DISTINTO según la pantalla:
- Desktop (>lg): acordeón colapsable con header "Filtros"
- Mobile (<lg): botón "Filtros" que abre un USlideover
El slot #content (input + botón) es el MISMO en ambos casos.
El slot #chips se usa solo en mobile (dentro del slideover debajo del content).
-->
<template v-if="showBibleStudyFilter">
<FiltersContainer
:active-count="activeBibleStudies.length"
title="search.filters"
>
<!-- #chips se renderiza dentro del slideover en mobile -->
<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(props.mainCollection)"
/>
<UButton
size="sm"
color="neutral"
variant="outline"
:loading="isValidating"
:disabled="bibleStudyInput === null || bibleStudyInput <= 0"
@click="applyBibleStudyFilter(props.mainCollection)"
>
+ {{ $t('search.filter') }}
</UButton>
</div>
</template>
</FiltersContainer>
</template>
<UAlert <UAlert
v-if="errorMsg" v-if="errorMsg"
:title="errorMsg" :title="errorMsg"

View File

@ -0,0 +1,73 @@
interface BibleStudyChip {
id: number
title: string
}
const activeBibleStudies = useState<BibleStudyChip[]>('bible-study-chips', () => [])
export function useFilters() {
const { documentsApi } = useTypesenseApi()
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 documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: mainCollection,
q: '*',
queryBy: 'title',
filterBy: `bible_study:=${val}`,
perPage: 1,
includeFields: 'bible_study,title'
}]
}
})
const hit = (res?.results?.[0] as { hits?: Array<{ document: { title?: string } }> })?.hits?.[0]
if (hit) {
activeBibleStudies.value = [...activeBibleStudies.value, { id: val, title: hit.document.title || '' }]
bibleStudyInput.value = null
} else {
toast.add({ title: 'Estudio no encontrado', description: `No existe el estudio #${val}`, color: 'warning' })
}
} catch (err) {
console.error('Error validando bible_study', err)
} finally {
isValidating.value = false
}
}
function removeBibleStudyFilter(id: number) {
activeBibleStudies.value = activeBibleStudies.value.filter(bs => bs.id !== id)
}
function clearAllBibleStudyFilters() {
activeBibleStudies.value = []
}
return {
activeBibleStudies,
activeCount,
bibleStudyInput,
isValidating,
applyBibleStudyFilter,
removeBibleStudyFilter,
clearAllBibleStudyFilters
}
}

View File

@ -6,141 +6,153 @@ 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.8', version: '0.9',
date: '5 de junio, 2026', date: '20 de julio, 2026',
title: 'Autoría visible en Estudios Bíblicos, Conferencias e Historial', title: 'Filtro por N° de Estudio',
changes: [ changes: [
{ 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: '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: 'Nombre del autor visible en el panel lateral de Conferencias (Dr. William Soto Santiago)' }, { type: 'nuevo', text: 'Se pueden agregar varios estudios a la vez, cada uno como un chip individual' },
{ type: 'nuevo', text: 'Nombre del autor visible en el panel de detalle del documento para ambas secciones' }, { type: 'nuevo', text: 'Los chips con los filtros activos se muestran siempre visibles debajo de la barra de búsqueda' },
{ type: 'nuevo', text: 'El historial muestra el nombre del autor en cada entrada de Estudios Bíblicos y Conferencias' }, { type: 'mejora', text: 'En escritorio los filtros se agrupan en una sección colapsable para no ocupar espacio innecesario' },
{ 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: 'En móvil los filtros se configuran desde un panel deslizante para aprovechar mejor la pantalla' }
{ type: 'mejora', text: 'El panel de detalle abierto desde el historial también muestra el autor correspondiente' }, ]
{ 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.8',
}, date: '5 de junio, 2026',
{ title: 'Autoría visible en Estudios Bíblicos, Conferencias e Historial',
version: '0.7', changes: [
date: '31 de mayo, 2026 11:50PM', { type: 'nuevo', text: 'Nombre del autor visible en el panel lateral de Estudios Bíblicos (Dr. José Benjamín Pérez Matos)' },
title: 'Tour y optimizaciones', { type: 'nuevo', text: 'Nombre del autor visible en el panel lateral de Conferencias (Dr. William Soto Santiago)' },
changes: [ { type: 'nuevo', text: 'Nombre del autor visible en el panel de detalle del documento para ambas secciones' },
{ type: 'nuevo', text: 'Agregado tour virtual con localización para explicar funcionamiento del buscador.'}, { type: 'nuevo', text: 'El historial muestra el nombre del autor en cada entrada de Estudios Bíblicos y Conferencias' },
{ type: 'nuevo', text: '_Agregada página de inicio que muestra la versión más reciente del changelog.' }, { 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: '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 panel de detalle abierto desde el historial también muestra el autor correspondiente' },
{ type: 'nuevo', text: 'Finalizado flow de automatizacion entre backend y typesense'} { 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.6', {
date: '31 de mayo, 2026', version: '0.7',
title: 'Feedback con traducciones, bloqueo de Entrelíneas y acceso desarrollador', date: '31 de mayo, 2026 11:50PM',
changes: [ title: 'Tour y optimizaciones',
{ type: 'nuevo', text: 'Página de Feedback con traducciones completas en 4 idiomas' }, changes: [
{ type: 'nuevo', text: 'Sistema de bloqueo por clave de desarrollador para secciones en desarrollo' }, { type: 'nuevo', text: 'Agregado tour virtual con localización para explicar funcionamiento del buscador.'},
{ type: 'nuevo', text: 'Acceso de desarrollador en Configuración con desbloqueo por clave' }, { type: 'nuevo', text: '_Agregada página de inicio que muestra la versión más reciente del changelog.' },
{ type: 'nuevo', text: 'Banner visual mejorado para Entrelíneas cuando está bloqueado' }, { 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: 'Traducciones añadidas al componente BugReportInput' }, { type: 'nuevo', text: 'Finalizado flow de automatizacion entre backend y typesense'}
{ type: 'mejora', text: 'Nombre del tab Feedback ahora usa traducciones' }, ]
{ type: 'mejora', text: 'Textos del banner de Entrelíneas traducidos a 4 idiomas' } },
] {
}, version: '0.6',
{ date: '31 de mayo, 2026',
version: '0.5', title: 'Feedback con traducciones, bloqueo de Entrelíneas y acceso desarrollador',
date: '30 de mayo, 2026', changes: [
title: 'Soporte de HTML en Entrelíneas', { type: 'nuevo', text: 'Página de Feedback con traducciones completas en 4 idiomas' },
description: 'El visor de Entrelíneas ahora renderiza contenido en HTML además de texto plano.', { type: 'nuevo', text: 'Sistema de bloqueo por clave de desarrollador para secciones en desarrollo' },
changes: [ { type: 'nuevo', text: 'Acceso de desarrollador en Configuración con desbloqueo por clave' },
{ type: 'nuevo', text: 'Renderizado de contenido HTML en el detalle de Entrelíneas' }, { type: 'nuevo', text: 'Banner visual mejorado para Entrelíneas cuando está bloqueado' },
{ type: 'mejora', text: 'Los fragmentos HTML se muestran con formato original preservado' }, { type: 'mejora', text: 'Traducciones añadidas al componente BugReportInput' },
{ type: 'fix', text: 'Corrección de errores de sintaxis en el componente de detalle' }, { type: 'mejora', text: 'Nombre del tab Feedback ahora usa traducciones' },
{ type: 'mejora', text: 'El historial ahora muestra correctamente documentos de Entrelíneas con HTML' } { type: 'mejora', text: 'Textos del banner de Entrelíneas traducidos a 4 idiomas' }
] ]
}, },
{ {
version: '0.4', version: '0.5',
date: '26 de mayo, 2026', date: '30 de mayo, 2026',
title: 'Mejoras de interfaz y exploracion', title: 'Soporte de HTML en Entrelíneas',
changes: [ description: 'El visor de Entrelíneas ahora renderiza contenido en HTML además de texto plano.',
{ type: 'mejora', text: 'El código del documento aparece visible en la pantalla de detalle' }, changes: [
{ type: 'mejora', text: 'El badge "Borrador" solo se muestra cuando el documento es borrador' }, { type: 'nuevo', text: 'Renderizado de contenido HTML en el detalle de Entrelíneas' },
{ type: 'mejora', text: 'Tooltip mejorado al copiar párrafos al portapapeles' }, { type: 'mejora', text: 'Los fragmentos HTML se muestran con formato original preservado' },
{ type: 'mejora', text: 'El explorador filtra resultados mostrando solo ítems con párrafos' }, { type: 'fix', text: 'Corrección de errores de sintaxis en el componente de detalle' },
{ type: 'mejora', text: 'Panel lateral preparado para notas y resaltados (próximamente)' }, { type: 'mejora', text: 'El historial ahora muestra correctamente documentos de Entrelíneas con HTML' }
{ type: 'fix', text: 'Posicionamiento del panel lateral corregido en documentos cortos' }, ]
{ type: 'fix', text: 'Corrección de traducciones en francés' }, },
{ type: 'fix', text: 'Ajustes visuales en múltiples vistas' } {
] version: '0.4',
}, date: '26 de mayo, 2026',
{ title: 'Mejoras de interfaz y exploracion',
version: '0.3', changes: [
date: '25 de mayo, 2026', { type: 'mejora', text: 'El código del documento aparece visible en la pantalla de detalle' },
title: 'Portapapeles y corrección de bugs', { type: 'mejora', text: 'El badge "Borrador" solo se muestra cuando el documento es borrador' },
changes: [ { type: 'mejora', text: 'Tooltip mejorado al copiar párrafos al portapapeles' },
{ type: 'nuevo', text: 'Panel de copiado mejorado al seleccionar párrafos' }, { type: 'mejora', text: 'El explorador filtra resultados mostrando solo ítems con párrafos' },
{ type: 'mejora', text: 'Función de copiar al portapapeles más robusta e independiente' }, { type: 'mejora', text: 'Panel lateral preparado para notas y resaltados (próximamente)' },
{ type: 'fix', text: 'Corrección de bug al guardar y mostrar favoritos' }, { type: 'fix', text: 'Posicionamiento del panel lateral corregido en documentos cortos' },
{ type: 'fix', text: 'Corrección de bug en la carga de referencias' }, { type: 'fix', text: 'Corrección de traducciones en francés' },
{ type: 'fix', text: 'Corrección de bug en el detalle de publicaciones de referencia' } { type: 'fix', text: 'Ajustes visuales en múltiples vistas' }
] ]
}, },
{ {
version: '0.2', version: '0.3',
date: '2124 de mayo, 2026', date: '25 de mayo, 2026',
title: 'Conferencias, copiar párrafos y estabilidad', title: 'Portapapeles y corrección de bugs',
changes: [ changes: [
{ type: 'nuevo', text: 'Soporte de documentos de Conferencias y Actividades' }, { type: 'nuevo', text: 'Panel de copiado mejorado al seleccionar párrafos' },
{ type: 'nuevo', text: 'Clic en párrafo para copiarlo directamente al portapapeles' }, { type: 'mejora', text: 'Función de copiar al portapapeles más robusta e independiente' },
{ type: 'nuevo', text: 'Zoom de imagen en el visor de Entrelíneas' }, { type: 'fix', text: 'Corrección de bug al guardar y mostrar favoritos' },
{ type: 'mejora', text: 'Número de párrafo más discreto en el detalle del documento' }, { type: 'fix', text: 'Corrección de bug en la carga de referencias' },
{ type: 'mejora', text: 'Publicación del documento visible en todas las vistas y traducida' }, { type: 'fix', text: 'Corrección de bug en el detalle de publicaciones de referencia' }
{ type: 'mejora', text: 'Corrección de estilos visuales para contenido antiguo' }, ]
{ type: 'fix', text: 'Corrección de bug en favoritos (detalle)' }, },
{ 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' }, version: '0.2',
{ type: 'fix', text: 'Corrección de scroll infinito en móvil para Entrelíneas' } date: '2124 de mayo, 2026',
] title: 'Conferencias, copiar párrafos y estabilidad',
}, changes: [
{ { type: 'nuevo', text: 'Soporte de documentos de Conferencias y Actividades' },
version: '0.1', { type: 'nuevo', text: 'Clic en párrafo para copiarlo directamente al portapapeles' },
date: '1012 de mayo, 2026', { type: 'nuevo', text: 'Zoom de imagen en el visor de Entrelíneas' },
title: 'Historial, Favoritos y multilengua', { type: 'mejora', text: 'Número de párrafo más discreto en el detalle del documento' },
description: 'Primera versión funcional con las secciones principales activas.', { type: 'mejora', text: 'Publicación del documento visible en todas las vistas y traducida' },
changes: [ { type: 'mejora', text: 'Corrección de estilos visuales para contenido antiguo' },
{ type: 'nuevo', text: 'Historial de búsqueda y exploración' }, { type: 'fix', text: 'Corrección de bug en favoritos (detalle)' },
{ type: 'nuevo', text: 'Favoritos: guarda y accede a documentos desde Mi Listado' }, { type: 'fix', text: 'Badge "Borrador" agregado en lista y detalle de Estudios Bíblicos' },
{ type: 'nuevo', text: 'Selector de idioma: Español, English, Français, Português' }, { type: 'fix', text: 'Rutas de navegación migradas correctamente a nuevo motor de búsqueda' },
{ type: 'nuevo', text: 'Cantidad aproximada de resultados visible en los listados' }, { type: 'fix', text: 'Corrección de scroll infinito en móvil para Entrelíneas' }
{ 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.1',
] date: '1012 de mayo, 2026',
}, title: 'Historial, Favoritos y multilengua',
{ description: 'Primera versión funcional con las secciones principales activas.',
version: '0.0.1', changes: [
date: '7 de mayo, 2026', { type: 'nuevo', text: 'Historial de búsqueda y exploración' },
title: 'Lanzamiento inicial', { type: 'nuevo', text: 'Favoritos: guarda y accede a documentos desde Mi Listado' },
description: 'Primera versión del buscador con Estudios Bíblicos y Entrelíneas.', { type: 'nuevo', text: 'Selector de idioma: Español, English, Français, Português' },
changes: [ { type: 'nuevo', text: 'Cantidad aproximada de resultados visible en los listados' },
{ type: 'nuevo', text: 'Búsqueda en Estudios Bíblicos' }, { type: 'mejora', text: 'Paginación configurable: scroll infinito o páginas numeradas' },
{ type: 'nuevo', text: 'Visor de Entrelíneas con imagen y texto' }, { type: 'mejora', text: 'Ajuste de cantidad de resultados por página en Configuración' },
{ type: 'nuevo', text: 'Búsqueda por palabras o por frase exacta' }, { type: 'fix', text: 'Corrección de traducciones en el menú principal' },
{ type: 'nuevo', text: 'Estructura base de la aplicación' } { 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' }
]
}
]

25
ecosystem.config.cjs Normal file
View File

@ -0,0 +1,25 @@
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
}
}
]
};

View File

@ -23,6 +23,7 @@
"bible_study_placeholder": "Study no...", "bible_study_placeholder": "Study no...",
"bible_study_chip": "Study #{number} {title}", "bible_study_chip": "Study #{number} {title}",
"bible_study_clear": "Clear filters", "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.",

View File

@ -67,6 +67,7 @@
"bible_study_placeholder": "N° de estudio...", "bible_study_placeholder": "N° de estudio...",
"bible_study_chip": "Estudio #{number} {title}", "bible_study_chip": "Estudio #{number} {title}",
"bible_study_clear": "Limpiar filtros", "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 búsqueda...",

View File

@ -21,6 +21,7 @@
"bible_study_placeholder": "N° d'étude...", "bible_study_placeholder": "N° d'étude...",
"bible_study_chip": "Étude #{number} {title}", "bible_study_chip": "Étude #{number} {title}",
"bible_study_clear": "Effacer les filtres", "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",

View File

@ -21,6 +21,7 @@
"bible_study_placeholder": "N° do estudo...", "bible_study_placeholder": "N° do estudo...",
"bible_study_chip": "Estudo #{number} {title}", "bible_study_chip": "Estudo #{number} {title}",
"bible_study_clear": "Limpar filtros", "bible_study_clear": "Limpar filtros",
"filters": "Filtros",
"word": "Palavra", "word": "Palavra",
"phrase": "Frase", "phrase": "Frase",
"placeholder": "Digite para pesquisar...", "placeholder": "Digite para pesquisar...",