Deploy to live #19

Merged
julioruiz merged 25 commits from staging into production 2026-07-28 04:03:34 +00:00
8 changed files with 434 additions and 226 deletions
Showing only changes of commit 8c81b69888 - Show all commits

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, useDebounce } 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 {
@ -156,64 +157,15 @@ const sortMode = ref<'relevance' | 'date'>('relevance')
// ---- Filtro bible_study multi-chip (solo para Estudios) -------------------- // ---- Filtro bible_study multi-chip (solo para Estudios) --------------------
interface BibleStudyChip { const {
id: number activeBibleStudies,
title: string activeCount,
} bibleStudyInput,
isValidating,
const bibleStudyInput = ref<number | null>(null) applyBibleStudyFilter,
const activeBibleStudies = ref<BibleStudyChip[]>([]) removeBibleStudyFilter,
const isValidating = ref(false) clearAllBibleStudyFilters,
} = useFilters()
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 documentsApi.multiSearch({
multiSearchParameters: {},
multiSearchSearchesParameter: {
searches: [{
collection: props.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
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()
}
function refetchResults() { function refetchResults() {
if (!debouncedQuery.value.trim()) { if (!debouncedQuery.value.trim()) {
@ -226,6 +178,10 @@ function refetchResults() {
} }
} }
watch(activeBibleStudies, () => {
refetchResults()
})
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
const groupedHits = ref<SearchGroup[]>([]) const groupedHits = ref<SearchGroup[]>([])
@ -690,6 +646,7 @@ function metaLocation(meta: DocMeta | 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"
@ -715,41 +672,11 @@ function metaLocation(meta: DocMeta | 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"
@ -780,6 +707,92 @@ function metaLocation(meta: DocMeta | 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

@ -19,6 +19,18 @@ export interface Release {
} }
export const releases: Release[] = [ export const releases: Release[] = [
{
version: '0.9',
date: '20 de julio, 2026',
title: 'Filtro por N° de Estudio',
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: 'Se pueden agregar varios estudios a la vez, cada uno como un chip individual' },
{ type: 'nuevo', text: 'Los chips con los filtros activos se muestran siempre visibles debajo de la barra de búsqueda' },
{ type: 'mejora', text: 'En escritorio los filtros se agrupan en una sección colapsable para no ocupar espacio innecesario' },
{ type: 'mejora', text: 'En móvil los filtros se configuran desde un panel deslizante para aprovechar mejor la pantalla' }
]
},
{ {
version: '0.8', version: '0.8',
date: '5 de junio, 2026', date: '5 de junio, 2026',

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...",