263 lines
7.9 KiB
Vue
Executable File
263 lines
7.9 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'
|
|
import { useSettingsStore } from '~/stores/settings'
|
|
|
|
const { $i18n } = useNuxtApp();
|
|
const t = $i18n.t;
|
|
|
|
const REQUEST_TIMEOUT_MS = 15000
|
|
|
|
const settings = useSettingsStore()
|
|
|
|
// ── Restaurar estado desde URL antes de crear los refs ─────────────────────
|
|
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 meili = useMeiliSearchRef()
|
|
|
|
const hits = ref<SearchHit[]>([])
|
|
const total = ref(0)
|
|
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
|
|
)
|
|
|
|
let searchSeq = 0
|
|
let abortController: AbortController | null = null
|
|
|
|
async function runSearch(q: string, page = 1, append = false) {
|
|
abortController?.abort()
|
|
const ac = new AbortController()
|
|
abortController = ac
|
|
|
|
const seq = ++searchSeq
|
|
if (append) loadingMore.value = true
|
|
else loading.value = true
|
|
errorMsg.value = null
|
|
|
|
const timeoutId = setTimeout(() => ac.abort(), REQUEST_TIMEOUT_MS)
|
|
|
|
const isInfinite = settings.paginationType === 'infinite_scroll'
|
|
const offset = isInfinite
|
|
? (append ? hits.value.length : 0)
|
|
: (page - 1) * settings.pageSize
|
|
|
|
try {
|
|
const res = await meili.index(`activities_${$i18n.locale.value.toUpperCase()}`).search(q || '', {
|
|
attributesToRetrieve: ['*'],
|
|
showMatchesPosition: true,
|
|
limit: settings.pageSize,
|
|
offset,
|
|
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
|
|
if (!append) activePage.value = page
|
|
} catch (err: unknown) {
|
|
const name = (err as { name?: string })?.name
|
|
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 (settings.paginationType !== 'infinite_scroll') return
|
|
if (loadingMore.value || loading.value || !hasMore.value) return
|
|
runSearch(query.value, activePage.value, true)
|
|
}
|
|
|
|
function goToPage(p: number) {
|
|
activePage.value = p
|
|
hits.value = []
|
|
runSearch(query.value, p, false)
|
|
}
|
|
|
|
function retry() {
|
|
runSearch(query.value, activePage.value, false)
|
|
}
|
|
|
|
// ── Selección ──────────────────────────────────────────────────────────────
|
|
const selectedActivity = ref<SearchHit | null>(null)
|
|
|
|
const isActivityPanelOpen = computed({
|
|
get() { return !!selectedActivity.value },
|
|
set(value: boolean) { if (!value) selectedActivity.value = null }
|
|
})
|
|
|
|
// ID del item seleccionado para sincronizar con la URL
|
|
const selectedId = computed(() => selectedActivity.value?._id?.toString() ?? null)
|
|
|
|
// ── Scroll ref expuesto desde InboxList ────────────────────────────────────
|
|
const inboxListRef = ref<{ listEl: HTMLElement | null } | null>(null)
|
|
const listEl = computed(() => inboxListRef.value?.listEl ?? null)
|
|
|
|
// ── Sincronización con URL ─────────────────────────────────────────────────
|
|
useSearchUrlSync({ query, page: activePage, selectedId, scrollEl: listEl })
|
|
|
|
// ── Ciclo de vida ──────────────────────────────────────────────────────────
|
|
onMounted(async () => {
|
|
await runSearch(q0, p0, false)
|
|
|
|
// Restaurar scroll
|
|
restoreScrollPosition(listEl.value, s0)
|
|
|
|
// Restaurar item seleccionado
|
|
if (sid0) {
|
|
const found = hits.value.find(h => String(h._id) === sid0)
|
|
if (found) selectedActivity.value = found
|
|
}
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
abortController?.abort()
|
|
})
|
|
|
|
// Query debounced: solo dispara cuando el usuario cambia la búsqueda,
|
|
// NO en la carga inicial (debouncedQuery ya arranca en q0).
|
|
watch(debouncedQuery, (q) => {
|
|
hits.value = []
|
|
total.value = 0
|
|
activePage.value = 1
|
|
runSearch(q, 1, false)
|
|
})
|
|
|
|
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')
|
|
|
|
useDetailHistory(isActivityPanelOpen, isMobile)
|
|
</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
|
|
ref="inboxListRef"
|
|
v-model="selectedActivity"
|
|
:activities="hits"
|
|
:query="debouncedQuery"
|
|
:has-more="hasMore"
|
|
:loading="loading"
|
|
:loading-more="loadingMore"
|
|
:show-end-message="settings.paginationType === 'infinite_scroll'"
|
|
collection="activities"
|
|
@load-more="loadMore"
|
|
/>
|
|
|
|
<div
|
|
v-if="settings.paginationType === 'numbered' && totalPages > 1 && !loading"
|
|
class="px-4 py-3 border-t border-default flex justify-center shrink-0"
|
|
>
|
|
<UPagination
|
|
:page="activePage"
|
|
:total="total"
|
|
:items-per-page="settings.pageSize"
|
|
size="sm"
|
|
@update:page="goToPage"
|
|
/>
|
|
</div>
|
|
</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>
|