add bible study filter system with responsive FiltersContainer and global useFilters composable
This commit is contained in:
parent
4a50969892
commit
8c81b69888
|
|
@ -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>
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { breakpointsTailwind, useDebounce } from '@vueuse/core'
|
||||
import PublicationDetail from '~/components/PublicationDetail.vue'
|
||||
import FiltersContainer from '~/components/searchPanel/FiltersContainer.vue'
|
||||
import { useSettingsStore } from '~/stores/settings'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -156,64 +157,15 @@ const sortMode = ref<'relevance' | 'date'>('relevance')
|
|||
|
||||
// ---- Filtro bible_study multi-chip (solo para Estudios) --------------------
|
||||
|
||||
interface BibleStudyChip {
|
||||
id: number
|
||||
title: string
|
||||
}
|
||||
|
||||
const bibleStudyInput = ref<number | null>(null)
|
||||
const activeBibleStudies = ref<BibleStudyChip[]>([])
|
||||
const isValidating = ref(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 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()
|
||||
}
|
||||
const {
|
||||
activeBibleStudies,
|
||||
activeCount,
|
||||
bibleStudyInput,
|
||||
isValidating,
|
||||
applyBibleStudyFilter,
|
||||
removeBibleStudyFilter,
|
||||
clearAllBibleStudyFilters,
|
||||
} = useFilters()
|
||||
|
||||
function refetchResults() {
|
||||
if (!debouncedQuery.value.trim()) {
|
||||
|
|
@ -226,6 +178,10 @@ function refetchResults() {
|
|||
}
|
||||
}
|
||||
|
||||
watch(activeBibleStudies, () => {
|
||||
refetchResults()
|
||||
})
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const groupedHits = ref<SearchGroup[]>([])
|
||||
|
|
@ -690,6 +646,7 @@ function metaLocation(meta: DocMeta | undefined): string {
|
|||
<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">
|
||||
<UInput
|
||||
v-model="query"
|
||||
|
|
@ -715,41 +672,11 @@ function metaLocation(meta: DocMeta | undefined): string {
|
|||
>{{ t('search.phrase') }}</button>
|
||||
</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">
|
||||
<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>
|
||||
<div v-if="activeBibleStudies.length > 0" class="flex flex-wrap items-center gap-1.5">
|
||||
<!-- ─── CHIPS ACTIVOS (solo desktop: fuera del FiltersContainer) ─── -->
|
||||
<!-- En desktop los chips van debajo del input; en mobile van DENTRO del slideover -->
|
||||
<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"
|
||||
|
|
@ -780,6 +707,92 @@ function metaLocation(meta: DocMeta | undefined): string {
|
|||
</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
|
||||
v-if="errorMsg"
|
||||
:title="errorMsg"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ export const typeConfig: Record<ChangeEntry['type'], { label: string; color: str
|
|||
export interface ChangeEntry {
|
||||
type: 'nuevo' | 'mejora' | 'fix' | 'eliminado'
|
||||
text: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface Release {
|
||||
version: string
|
||||
|
|
@ -16,9 +16,21 @@ export interface Release {
|
|||
title: string
|
||||
description?: string
|
||||
changes: ChangeEntry[]
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
date: '5 de junio, 2026',
|
||||
|
|
@ -143,4 +155,4 @@ export const releases: Release[] = [
|
|||
{ type: 'nuevo', text: 'Estructura base de la aplicación' }
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
"bible_study_placeholder": "Study no...",
|
||||
"bible_study_chip": "Study #{number} {title}",
|
||||
"bible_study_clear": "Clear filters",
|
||||
"filters": "Filters",
|
||||
"placeholder": "Search for...",
|
||||
"searching": "Searching...",
|
||||
"tip": "Tip: wrap in \"quotes\" for exact phrase in that order.",
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@
|
|||
"bible_study_placeholder": "N° de estudio...",
|
||||
"bible_study_chip": "Estudio #{number} {title}",
|
||||
"bible_study_clear": "Limpiar filtros",
|
||||
"filters": "Filtros",
|
||||
"words_tooltip": "Buscar por palabras",
|
||||
"phrases_tooltip": "Buscar por frases",
|
||||
"instructions": "Selecciona un resultado de búsqueda...",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"bible_study_placeholder": "N° d'étude...",
|
||||
"bible_study_chip": "Étude #{number} {title}",
|
||||
"bible_study_clear": "Effacer les filtres",
|
||||
"filters": "Filtres",
|
||||
"word": "Mot",
|
||||
"phrase": "Phrase",
|
||||
"placeholder": "Rechercher des activités",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"bible_study_placeholder": "N° do estudo...",
|
||||
"bible_study_chip": "Estudo #{number} {title}",
|
||||
"bible_study_clear": "Limpar filtros",
|
||||
"filters": "Filtros",
|
||||
"word": "Palavra",
|
||||
"phrase": "Frase",
|
||||
"placeholder": "Digite para pesquisar...",
|
||||
|
|
|
|||
Loading…
Reference in New Issue