202 lines
5.4 KiB
Vue
Executable File
202 lines
5.4 KiB
Vue
Executable File
<script setup lang="ts">
|
|
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
|
import { breakpointsTailwind, useDebounce } from '@vueuse/core'
|
|
import type { SearchHit } from '~/types'
|
|
import InboxActivity from '~/components/inbox/InboxActivity.vue'
|
|
|
|
const { $i18n } = useNuxtApp();
|
|
const t = $i18n.t;
|
|
|
|
const PAGE_SIZE = 15
|
|
const REQUEST_TIMEOUT_MS = 15000
|
|
|
|
const query = ref('')
|
|
const debouncedQuery = useDebounce(query, 150)
|
|
const loading = ref(false)
|
|
const loadingMore = ref(false)
|
|
const errorMsg = ref<string | null>(null)
|
|
|
|
// Use the raw Meilisearch client so we can pass an AbortSignal.
|
|
const meili = useMeiliSearchRef()
|
|
|
|
const hits = ref<SearchHit[]>([])
|
|
const total = ref(0)
|
|
|
|
const hasMore = computed(() => hits.value.length < total.value)
|
|
|
|
let searchSeq = 0
|
|
let abortController: AbortController | null = null
|
|
|
|
async function runSearch(q: string, append = false) {
|
|
// Cancel any in-flight search; saves bandwidth and prevents pile-ups.
|
|
abortController?.abort()
|
|
const ac = new AbortController()
|
|
abortController = ac
|
|
|
|
const seq = ++searchSeq
|
|
if (append) loadingMore.value = true
|
|
else loading.value = true
|
|
errorMsg.value = null
|
|
|
|
// Safety net in case the network just hangs.
|
|
const timeoutId = setTimeout(() => ac.abort(), REQUEST_TIMEOUT_MS)
|
|
|
|
try {
|
|
const res = await meili.index(`activities_${$i18n.locale.value.toUpperCase()}`).search(q || '', {
|
|
attributesToRetrieve: ['*'],
|
|
showMatchesPosition: true,
|
|
limit: PAGE_SIZE,
|
|
offset: append ? hits.value.length : 0,
|
|
sort: q ? undefined : ['isodate:desc']
|
|
}, { signal: ac.signal })
|
|
|
|
if (seq !== searchSeq) return
|
|
|
|
const newHits = (res?.hits ?? []) as SearchHit[]
|
|
hits.value = append ? hits.value.concat(newHits) : newHits
|
|
total.value = res?.estimatedTotalHits ?? hits.value.length
|
|
} catch (err: unknown) {
|
|
const name = (err as { name?: string })?.name
|
|
// Aborts are expected when the user types fast; don't surface them.
|
|
if (name === 'AbortError') return
|
|
if (seq !== searchSeq) return
|
|
console.error('Meilisearch error', err)
|
|
errorMsg.value = (err as Error)?.message || 'Error al buscar.'
|
|
if (!append) {
|
|
hits.value = []
|
|
total.value = 0
|
|
}
|
|
} finally {
|
|
clearTimeout(timeoutId)
|
|
if (seq === searchSeq) {
|
|
loading.value = false
|
|
loadingMore.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
function loadMore() {
|
|
if (loadingMore.value || loading.value || !hasMore.value) return
|
|
runSearch(query.value, true)
|
|
}
|
|
|
|
// Initial fetch on the client (avoids blocking SSR).
|
|
onMounted(() => runSearch(''))
|
|
|
|
onBeforeUnmount(() => {
|
|
abortController?.abort()
|
|
})
|
|
|
|
watch(debouncedQuery, (q) => {
|
|
hits.value = []
|
|
total.value = 0
|
|
runSearch(q, false)
|
|
})
|
|
|
|
const selectedActivity = ref<SearchHit | null>(null)
|
|
|
|
const isActivityPanelOpen = computed({
|
|
get() { return !!selectedActivity.value },
|
|
set(value: boolean) { if (!value) selectedActivity.value = null }
|
|
})
|
|
|
|
watch(hits, () => {
|
|
if (!selectedActivity.value) return
|
|
const stillThere = hits.value.find(h => h._id === selectedActivity.value?._id)
|
|
if (!stillThere) selectedActivity.value = null
|
|
})
|
|
|
|
const breakpoints = useBreakpoints(breakpointsTailwind)
|
|
const isMobile = breakpoints.smaller('lg')
|
|
|
|
function retry() {
|
|
runSearch(query.value, false)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<UDashboardPanel
|
|
id="activities-list"
|
|
:default-size="28"
|
|
:min-size="22"
|
|
:max-size="40"
|
|
resizable
|
|
>
|
|
<UDashboardNavbar :title="t('nav.bible_studies')">
|
|
<template #leading>
|
|
<UDashboardSidebarCollapse />
|
|
</template>
|
|
|
|
<template #trailing>
|
|
<UBadge :label="total" variant="subtle" />
|
|
</template>
|
|
</UDashboardNavbar>
|
|
|
|
<div class="px-4 sm:px-6 py-3 border-b border-default">
|
|
<UInput
|
|
v-model="query"
|
|
icon="i-lucide-search"
|
|
:placeholder="t('search.placeholder')"
|
|
:loading="loading"
|
|
size="md"
|
|
class="w-full"
|
|
/>
|
|
<p class="mt-1.5 flex items-center gap-1 text-[11px] text-dimmed">
|
|
<UIcon name="i-lucide-lightbulb" class="size-3" />
|
|
<span v-html="t('search.tip')" />
|
|
</p>
|
|
</div>
|
|
|
|
<UAlert
|
|
v-if="errorMsg"
|
|
:title="errorMsg"
|
|
color="error"
|
|
variant="subtle"
|
|
icon="i-lucide-triangle-alert"
|
|
class="mx-4 my-2"
|
|
:actions="[{ label: 'Reintentar', color: 'neutral', variant: 'outline', onClick: retry }]"
|
|
/>
|
|
|
|
<InboxList
|
|
v-model="selectedActivity"
|
|
:activities="hits"
|
|
:query="debouncedQuery"
|
|
:has-more="hasMore"
|
|
:loading="loading"
|
|
:loading-more="loadingMore"
|
|
collection="activities"
|
|
@load-more="loadMore"
|
|
/>
|
|
</UDashboardPanel>
|
|
|
|
<InboxActivity
|
|
v-if="selectedActivity"
|
|
:activity="selectedActivity"
|
|
collection="activities"
|
|
:query="debouncedQuery"
|
|
@close="selectedActivity = null"
|
|
/>
|
|
<div v-else class="hidden lg:flex flex-1 items-center justify-center">
|
|
<div class="flex flex-col items-center gap-2 text-dimmed">
|
|
<UIcon name="i-lucide-search" class="size-16" />
|
|
<p class="text-sm">
|
|
{{ query ? t('search.listselect') : t('search.emptytext') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<ClientOnly>
|
|
<USlideover v-if="isMobile" v-model:open="isActivityPanelOpen">
|
|
<template #content>
|
|
<InboxActivity
|
|
v-if="selectedActivity"
|
|
:activity="selectedActivity"
|
|
collection="activities"
|
|
:query="debouncedQuery"
|
|
@close="selectedActivity = null"
|
|
/>
|
|
</template>
|
|
</USlideover>
|
|
</ClientOnly>
|
|
</template>
|