estados de pagíinacións y settings
This commit is contained in:
parent
756c62af86
commit
e9117a1adf
|
|
@ -16,6 +16,9 @@ const props = defineProps<{
|
|||
* Se propaga al sistema de favoritos para distinguir entre tipos de
|
||||
* contenido (actividades, conferencias, etc.). */
|
||||
collection?: string
|
||||
/** Muestra el mensaje "No hay más resultados" al llegar al final.
|
||||
* Poner en false en modo de paginación numerada. */
|
||||
showEndMessage?: boolean
|
||||
}>()
|
||||
|
||||
const favorites = useFavoritesStore()
|
||||
|
|
@ -771,7 +774,7 @@ useIntersectionObserver(
|
|||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="rows.length && !hasMore && !loading"
|
||||
v-else-if="rows.length && !hasMore && !loading && showEndMessage !== false"
|
||||
class="py-3 text-center text-xs text-dimmed"
|
||||
>
|
||||
No hay más resultados
|
||||
|
|
|
|||
|
|
@ -59,6 +59,12 @@ const links = computed(() => {
|
|||
to: '/historial',
|
||||
badge: histTotal.value > 0 ? String(histTotal.value) : undefined,
|
||||
onSelect: () => { open.value = false }
|
||||
},
|
||||
{
|
||||
label: t("nav.settings"),
|
||||
icon: 'i-lucide-settings',
|
||||
to: '/configuracion',
|
||||
onSelect: () => { open.value = false }
|
||||
}
|
||||
] satisfies NavigationMenuItem[]
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,32 +3,37 @@ 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 PAGE_SIZE = 15
|
||||
const REQUEST_TIMEOUT_MS = 15000
|
||||
|
||||
const settings = useSettingsStore()
|
||||
|
||||
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 activePage = ref(1)
|
||||
|
||||
const hasMore = computed(() => hits.value.length < total.value)
|
||||
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, append = false) {
|
||||
// Cancel any in-flight search; saves bandwidth and prevents pile-ups.
|
||||
async function runSearch(q: string, page = 1, append = false) {
|
||||
abortController?.abort()
|
||||
const ac = new AbortController()
|
||||
abortController = ac
|
||||
|
|
@ -38,15 +43,19 @@ async function runSearch(q: string, append = false) {
|
|||
else loading.value = true
|
||||
errorMsg.value = null
|
||||
|
||||
// Safety net in case the network just hangs.
|
||||
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: PAGE_SIZE,
|
||||
offset: append ? hits.value.length : 0,
|
||||
limit: settings.pageSize,
|
||||
offset,
|
||||
sort: q ? undefined : ['isodate:desc']
|
||||
}, { signal: ac.signal })
|
||||
|
||||
|
|
@ -55,9 +64,9 @@ async function runSearch(q: string, append = false) {
|
|||
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
|
||||
// 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)
|
||||
|
|
@ -76,12 +85,22 @@ async function runSearch(q: string, append = false) {
|
|||
}
|
||||
|
||||
function loadMore() {
|
||||
if (settings.paginationType !== 'infinite_scroll') return
|
||||
if (loadingMore.value || loading.value || !hasMore.value) return
|
||||
runSearch(query.value, true)
|
||||
runSearch(query.value, activePage.value, true)
|
||||
}
|
||||
|
||||
// Initial fetch on the client (avoids blocking SSR).
|
||||
onMounted(() => runSearch(''))
|
||||
function goToPage(p: number) {
|
||||
activePage.value = p
|
||||
hits.value = []
|
||||
runSearch(query.value, p, false)
|
||||
}
|
||||
|
||||
function retry() {
|
||||
runSearch(query.value, activePage.value, false)
|
||||
}
|
||||
|
||||
onMounted(() => runSearch('', 1, false))
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
abortController?.abort()
|
||||
|
|
@ -90,7 +109,8 @@ onBeforeUnmount(() => {
|
|||
watch(debouncedQuery, (q) => {
|
||||
hits.value = []
|
||||
total.value = 0
|
||||
runSearch(q, false)
|
||||
activePage.value = 1
|
||||
runSearch(q, 1, false)
|
||||
})
|
||||
|
||||
const selectedActivity = ref<SearchHit | null>(null)
|
||||
|
|
@ -110,10 +130,6 @@ const breakpoints = useBreakpoints(breakpointsTailwind)
|
|||
const isMobile = breakpoints.smaller('lg')
|
||||
|
||||
useDetailHistory(isActivityPanelOpen, isMobile)
|
||||
|
||||
function retry() {
|
||||
runSearch(query.value, false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -166,9 +182,23 @@ function retry() {
|
|||
: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
|
||||
|
|
|
|||
|
|
@ -3,13 +3,15 @@ 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 PAGE_SIZE = 15
|
||||
const REQUEST_TIMEOUT_MS = 15000
|
||||
|
||||
const settings = useSettingsStore()
|
||||
|
||||
const query = ref('')
|
||||
const debouncedQuery = useDebounce(query, 150)
|
||||
const loading = ref(false)
|
||||
|
|
@ -20,13 +22,18 @@ const meili = useMeiliSearchRef()
|
|||
|
||||
const hits = ref<SearchHit[]>([])
|
||||
const total = ref(0)
|
||||
const activePage = ref(1)
|
||||
|
||||
const hasMore = computed(() => hits.value.length < total.value)
|
||||
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, append = false) {
|
||||
async function runSearch(q: string, page = 1, append = false) {
|
||||
abortController?.abort()
|
||||
const ac = new AbortController()
|
||||
abortController = ac
|
||||
|
|
@ -38,12 +45,17 @@ async function runSearch(q: string, append = false) {
|
|||
|
||||
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(`conferences_${$i18n.locale.value.toUpperCase()}`).search(q || '', {
|
||||
attributesToRetrieve: ['*'],
|
||||
showMatchesPosition: true,
|
||||
limit: PAGE_SIZE,
|
||||
offset: append ? hits.value.length : 0,
|
||||
limit: settings.pageSize,
|
||||
offset,
|
||||
sort: q ? undefined : ['isodate:desc']
|
||||
}, { signal: ac.signal })
|
||||
|
||||
|
|
@ -52,6 +64,7 @@ async function runSearch(q: string, append = false) {
|
|||
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
|
||||
|
|
@ -72,11 +85,22 @@ async function runSearch(q: string, append = false) {
|
|||
}
|
||||
|
||||
function loadMore() {
|
||||
if (settings.paginationType !== 'infinite_scroll') return
|
||||
if (loadingMore.value || loading.value || !hasMore.value) return
|
||||
runSearch(query.value, true)
|
||||
runSearch(query.value, activePage.value, true)
|
||||
}
|
||||
|
||||
onMounted(() => runSearch(''))
|
||||
function goToPage(p: number) {
|
||||
activePage.value = p
|
||||
hits.value = []
|
||||
runSearch(query.value, p, false)
|
||||
}
|
||||
|
||||
function retry() {
|
||||
runSearch(query.value, activePage.value, false)
|
||||
}
|
||||
|
||||
onMounted(() => runSearch('', 1, false))
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
abortController?.abort()
|
||||
|
|
@ -85,7 +109,8 @@ onBeforeUnmount(() => {
|
|||
watch(debouncedQuery, (q) => {
|
||||
hits.value = []
|
||||
total.value = 0
|
||||
runSearch(q, false)
|
||||
activePage.value = 1
|
||||
runSearch(q, 1, false)
|
||||
})
|
||||
|
||||
const selected = ref<SearchHit | null>(null)
|
||||
|
|
@ -105,10 +130,6 @@ const breakpoints = useBreakpoints(breakpointsTailwind)
|
|||
const isMobile = breakpoints.smaller('lg')
|
||||
|
||||
useDetailHistory(isPanelOpen, isMobile)
|
||||
|
||||
function retry() {
|
||||
runSearch(query.value, false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -119,7 +140,7 @@ function retry() {
|
|||
:max-size="40"
|
||||
resizable
|
||||
>
|
||||
<UDashboardNavbar title="Conferencias">
|
||||
<UDashboardNavbar :title="t('nav.conferences')">
|
||||
<template #leading>
|
||||
<UDashboardSidebarCollapse />
|
||||
</template>
|
||||
|
|
@ -165,9 +186,23 @@ function retry() {
|
|||
:has-more="hasMore"
|
||||
:loading="loading"
|
||||
:loading-more="loadingMore"
|
||||
:show-end-message="settings.paginationType === 'infinite_scroll'"
|
||||
collection="conferences"
|
||||
@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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useSettingsStore, PAGE_SIZE_OPTIONS } from '~/stores/settings'
|
||||
|
||||
const { $i18n } = useNuxtApp()
|
||||
const t = $i18n.t
|
||||
|
||||
const settings = useSettingsStore()
|
||||
const { pageSize, paginationType } = storeToRefs(settings)
|
||||
|
||||
const pageSizeItems = PAGE_SIZE_OPTIONS.map(n => ({
|
||||
value: n,
|
||||
label: `${n} ${t('settings.results')}`
|
||||
}))
|
||||
|
||||
const paginationItems = [
|
||||
{
|
||||
value: 'infinite_scroll' as const,
|
||||
label: t('settings.infinite_scroll'),
|
||||
description: t('settings.infinite_scroll_desc')
|
||||
},
|
||||
{
|
||||
value: 'numbered' as const,
|
||||
label: t('settings.numbered'),
|
||||
description: t('settings.numbered_desc')
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardPanel id="settings-panel">
|
||||
<UDashboardNavbar :title="t('nav.settings')">
|
||||
<template #leading>
|
||||
<UDashboardSidebarCollapse />
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
|
||||
<div class="overflow-y-auto flex-1 p-6 space-y-8 max-w-lg">
|
||||
<!-- Resultados por búsqueda -->
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-highlighted mb-1">
|
||||
{{ t('settings.page_size_title') }}
|
||||
</p>
|
||||
<p class="text-xs text-muted mb-4">{{ t('settings.page_size_desc') }}</p>
|
||||
<URadioGroup v-model="pageSize" :items="pageSizeItems" />
|
||||
</div>
|
||||
|
||||
<USeparator />
|
||||
|
||||
<!-- Tipo de paginación -->
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-highlighted mb-1">
|
||||
{{ t('settings.pagination_title') }}
|
||||
</p>
|
||||
<p class="text-xs text-muted mb-4">{{ t('settings.pagination_desc') }}</p>
|
||||
<URadioGroup v-model="paginationType" :items="paginationItems" />
|
||||
</div>
|
||||
</div>
|
||||
</UDashboardPanel>
|
||||
</template>
|
||||
|
|
@ -3,52 +3,27 @@ import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
|||
import { breakpointsTailwind, useDebounce } from '@vueuse/core'
|
||||
import EntrelineaDetail from '~/components/entrelineas/EntrelineaDetail.vue'
|
||||
import { useFavoritesStore } from '~/stores/favorites'
|
||||
import { useSettingsStore } from '~/stores/settings'
|
||||
import type { SearchHit } from '~/types'
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* CONFIGURACIÓN — lo único que necesitas tocar */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Nombre de la colección de Typesense a consultar. */
|
||||
const COLLECTION = 'entrelineas'
|
||||
|
||||
/** Campos por los que se hará el match de la búsqueda (separados por coma). */
|
||||
const QUERY_BY = 'text'
|
||||
|
||||
/** Campos a traer en cada hit (separados por coma). Usa '*' para traerlos todos. */
|
||||
const INCLUDE_FIELDS = '*'
|
||||
|
||||
/** Filtros adicionales (sintaxis filter_by de Typesense) a combinar con el
|
||||
* filtro automático por idioma. Ej: 'page:>10', 'origin:=Nota'.
|
||||
* Dejar vacío si no se necesita nada extra — el filtro por `locale` se
|
||||
* añade siempre a partir del idioma actual de i18n. */
|
||||
const EXTRA_FILTER_BY = ''
|
||||
|
||||
/** Cantidad de resultados por página. */
|
||||
const PAGE_SIZE = 15
|
||||
|
||||
/** Identificador de la colección para el store de favoritos.
|
||||
* Vale cualquier string único entre buscadores. */
|
||||
const FAVORITES_COLLECTION = 'entrelineas'
|
||||
|
||||
// Nota: la base de ImageKit y los presets de transformación viven en
|
||||
// `~/utils/entrelineaImage.ts` (auto-importado). Ahí se cambia si toca
|
||||
// migrar de cuenta o ajustar tamaños.
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Estado */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const { $i18n } = useNuxtApp()
|
||||
const t = $i18n.t
|
||||
|
||||
// `locale` es reactivo: cambia cuando el usuario usa el switch de idiomas.
|
||||
// Lo usamos para construir dinámicamente el `filter_by` de Typesense.
|
||||
const { locale } = useI18n()
|
||||
|
||||
/** Filtro completo que mandamos a Typesense — locale dinámico + lo que el
|
||||
* usuario haya puesto en `EXTRA_FILTER_BY`. Es un computed para que se
|
||||
* reevalúe automáticamente cuando cambia el idioma. */
|
||||
const filterBy = computed(() => {
|
||||
const localeFilter = `locale:=${locale.value}`
|
||||
return EXTRA_FILTER_BY ? `${localeFilter} && ${EXTRA_FILTER_BY}` : localeFilter
|
||||
|
|
@ -56,6 +31,8 @@ const filterBy = computed(() => {
|
|||
|
||||
const REQUEST_TIMEOUT_MS = 15000
|
||||
|
||||
const settings = useSettingsStore()
|
||||
|
||||
const query = ref('')
|
||||
const debouncedQuery = useDebounce(query, 150)
|
||||
|
||||
|
|
@ -107,29 +84,30 @@ interface TypesenseSearchResponse {
|
|||
const hits = ref<TypesenseHit[]>([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const activePage = ref(1)
|
||||
|
||||
const hasMore = computed(() => hits.value.length < total.value)
|
||||
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
|
||||
)
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Búsqueda */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
// Usamos `documentsApi.multiSearch` (POST con body JSON) en vez de
|
||||
// `searchCollection` porque el wrapper auto-generado del módulo serializa mal
|
||||
// los parámetros del GET y Typesense responde con error.
|
||||
const { documentsApi } = useTypesenseApi()
|
||||
|
||||
let searchSeq = 0
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function runSearch(q: string, append = false) {
|
||||
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)
|
||||
// Safety net: si la red se cuelga, sacamos los spinners igualmente.
|
||||
timeoutId = setTimeout(() => {
|
||||
if (seq === searchSeq) {
|
||||
loading.value = false
|
||||
|
|
@ -138,29 +116,23 @@ async function runSearch(q: string, append = false) {
|
|||
}
|
||||
}, REQUEST_TIMEOUT_MS)
|
||||
|
||||
const page = append ? currentPage.value + 1 : 1
|
||||
const isInfinite = settings.paginationType === 'infinite_scroll'
|
||||
const typePage = isInfinite
|
||||
? (append ? currentPage.value + 1 : 1)
|
||||
: page
|
||||
|
||||
try {
|
||||
const multi = await documentsApi.multiSearch({
|
||||
// Parámetros comunes a todas las búsquedas; vacío porque cada búsqueda
|
||||
// ya lleva los suyos. El codegen exige el campo aunque no se use.
|
||||
multiSearchParameters: {},
|
||||
multiSearchSearchesParameter: {
|
||||
searches: [{
|
||||
// ⚠️ El serializer del módulo (MultiSearchCollectionParametersToJSON)
|
||||
// espera las claves en camelCase y las traduce a snake_case antes
|
||||
// de mandar el body. Si pasas snake_case directamente las ignora
|
||||
// silenciosamente y Typesense recibe la búsqueda sin filtros.
|
||||
collection: COLLECTION,
|
||||
q: q || '*',
|
||||
queryBy: QUERY_BY,
|
||||
includeFields: INCLUDE_FIELDS,
|
||||
filterBy: filterBy.value,
|
||||
perPage: PAGE_SIZE,
|
||||
page,
|
||||
// Resaltado nativo de Typesense — devuelve `value` con los matches
|
||||
// envueltos en <mark class="search-match"> para que estilemos en
|
||||
// CSS junto con los otros buscadores (ya hay regla global).
|
||||
perPage: settings.pageSize,
|
||||
page: typePage,
|
||||
highlightFullFields: QUERY_BY,
|
||||
highlightFields: QUERY_BY,
|
||||
highlightStartTag: '<mark class="search-match">',
|
||||
|
|
@ -169,7 +141,6 @@ async function runSearch(q: string, append = false) {
|
|||
}
|
||||
})
|
||||
|
||||
// Si arrancó otra búsqueda mientras esta venía en camino, descartamos.
|
||||
if (seq !== searchSeq) return
|
||||
|
||||
const res = (multi?.results?.[0] ?? {}) as TypesenseSearchResponse
|
||||
|
|
@ -177,7 +148,8 @@ async function runSearch(q: string, append = false) {
|
|||
|
||||
hits.value = append ? hits.value.concat(newHits) : newHits
|
||||
total.value = res?.found ?? hits.value.length
|
||||
currentPage.value = page
|
||||
currentPage.value = typePage
|
||||
if (!append) activePage.value = page
|
||||
} catch (err: unknown) {
|
||||
if (seq !== searchSeq) return
|
||||
console.error('Typesense error', err)
|
||||
|
|
@ -196,15 +168,22 @@ async function runSearch(q: string, append = false) {
|
|||
}
|
||||
|
||||
function loadMore() {
|
||||
if (settings.paginationType !== 'infinite_scroll') return
|
||||
if (loadingMore.value || loading.value || !hasMore.value) return
|
||||
runSearch(query.value, true)
|
||||
runSearch(query.value, currentPage.value, true)
|
||||
}
|
||||
|
||||
function goToPage(p: number) {
|
||||
activePage.value = p
|
||||
hits.value = []
|
||||
runSearch(query.value, p, false)
|
||||
}
|
||||
|
||||
function retry() {
|
||||
runSearch(query.value, false)
|
||||
runSearch(query.value, activePage.value, false)
|
||||
}
|
||||
|
||||
onMounted(() => runSearch(''))
|
||||
onMounted(() => runSearch('', 1, false))
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
|
|
@ -214,7 +193,8 @@ watch(debouncedQuery, (q) => {
|
|||
hits.value = []
|
||||
total.value = 0
|
||||
currentPage.value = 1
|
||||
runSearch(q, false)
|
||||
activePage.value = 1
|
||||
runSearch(q, 1, false)
|
||||
})
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
|
@ -228,7 +208,6 @@ const isPanelOpen = computed({
|
|||
set(v: boolean) { if (!v) selected.value = null }
|
||||
})
|
||||
|
||||
// Si el listado se actualiza y el seleccionado ya no está, cerramos el panel.
|
||||
watch(hits, () => {
|
||||
if (!selected.value) return
|
||||
const stillThere = hits.value.find(h => h.document.id === selected.value?.id)
|
||||
|
|
@ -247,8 +226,6 @@ useDetailHistory(isPanelOpen, isMobile)
|
|||
const favorites = useFavoritesStore()
|
||||
const toast = useToast()
|
||||
|
||||
/** Adapta el documento de Typesense al shape SearchHit que pide el store
|
||||
* de favoritos (que se reusa entre todos los buscadores). */
|
||||
function toSearchHit(doc: EntrelineaDoc): SearchHit {
|
||||
const id = doc.id || ''
|
||||
return {
|
||||
|
|
@ -266,7 +243,6 @@ function isFav(doc: EntrelineaDoc): boolean {
|
|||
}
|
||||
|
||||
function toggleFavorite(doc: EntrelineaDoc, ev?: Event) {
|
||||
// Evita que el click en la estrella abra el detalle.
|
||||
ev?.stopPropagation()
|
||||
if (!doc?.id) return
|
||||
const wasFav = isFav(doc)
|
||||
|
|
@ -284,14 +260,10 @@ function toggleFavorite(doc: EntrelineaDoc, ev?: Event) {
|
|||
/* Helpers de presentación */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** Devuelve el snippet o value resaltado por Typesense para un campo, o null
|
||||
* si no hay match. Se prefiere `snippet` (más corto, recortado alrededor del
|
||||
* match) sobre `value` (campo completo) para no inflar la fila. */
|
||||
function highlightedFor(hit: TypesenseHit, field: string): string | null {
|
||||
const fromArr = hit.highlights?.find(h => h.field === field)
|
||||
if (fromArr?.snippet) return fromArr.snippet
|
||||
if (fromArr?.value) return fromArr.value
|
||||
// Algunas versiones devuelven `highlight: { field: { snippet, value } }`.
|
||||
const fromObj = hit.highlight?.[field]
|
||||
if (fromObj?.snippet) return fromObj.snippet
|
||||
if (fromObj?.value) return fromObj.value
|
||||
|
|
@ -377,8 +349,6 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
|
|||
@click="selected = hit.document"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2 mb-1">
|
||||
<!-- Título pequeño = Origin (cae al id si no hay origin para no
|
||||
dejar la fila huérfana). -->
|
||||
<div class="min-w-0 flex-1 flex gap-2">
|
||||
<UBadge
|
||||
v-if="hit.document?.studies?.[0]?.date"
|
||||
|
|
@ -429,9 +399,6 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
|
|||
{{ (hit.document?.studies?.[0]?.title as string) || hit.document.id || `entrelinea_${index}` }}
|
||||
</div>
|
||||
|
||||
<!-- La entrelínea (text). Si Typesense devolvió un snippet con
|
||||
highlight, lo pintamos con v-html para que aparezca el <mark>;
|
||||
si no, mostramos el HTML crudo de `text`. -->
|
||||
<div
|
||||
v-if="highlightedFor(hit, 'text') || hit.document.text"
|
||||
class="snippet-html text-sm text-toned line-clamp-3"
|
||||
|
|
@ -445,8 +412,9 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Infinite scroll: cargando más -->
|
||||
<div
|
||||
v-if="hasMore && !loading"
|
||||
v-if="settings.paginationType === 'infinite_scroll' && hasMore && !loading"
|
||||
class="p-4 flex justify-center"
|
||||
>
|
||||
<UButton
|
||||
|
|
@ -461,12 +429,26 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
|
|||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="hits.length && !hasMore && !loading"
|
||||
v-else-if="settings.paginationType === 'infinite_scroll' && hits.length && !hasMore && !loading"
|
||||
class="py-3 text-center text-xs text-dimmed"
|
||||
>
|
||||
No hay más resultados
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paginación numerada -->
|
||||
<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>
|
||||
|
||||
<!-- Panel de detalle (escritorio) -->
|
||||
|
|
@ -503,9 +485,6 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* El estilo del <mark class="search-match"> ya está definido globalmente en
|
||||
`assets/css/main.css`. Aquí sólo nos aseguramos de que las etiquetas
|
||||
inline dentro del snippet (mark, strong, em, etc.) no rompan el layout. */
|
||||
.snippet-html :deep(p) {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
|
|
|
|||
|
|
@ -2,12 +2,11 @@
|
|||
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { breakpointsTailwind, useDebounce } from '@vueuse/core'
|
||||
import EstudiosTypensenseDetail from '~/components/estudiosTypensense/EstudiosTypensenseDetail.vue'
|
||||
import { useSettingsStore } from '~/stores/settings'
|
||||
|
||||
const PARAGRAPHS_COLLECTION = 'paragraphs'
|
||||
const DOCUMENTS_COLLECTION = 'documents'
|
||||
const QUERY_BY = 'text'
|
||||
// Mayor page size para agrupar más documentos únicos por página
|
||||
const PAGE_SIZE = 50
|
||||
const FAVORITES_COLLECTION = 'bible-studies-ts'
|
||||
|
||||
const { $i18n } = useNuxtApp()
|
||||
|
|
@ -17,6 +16,8 @@ const { locale } = useI18n()
|
|||
const filterBy = computed(() => `locale:=${locale.value}`)
|
||||
const REQUEST_TIMEOUT_MS = 15000
|
||||
|
||||
const settings = useSettingsStore()
|
||||
|
||||
const query = ref('')
|
||||
const debouncedQuery = useDebounce(query, 150)
|
||||
const loading = ref(false)
|
||||
|
|
@ -49,6 +50,7 @@ interface DocMeta {
|
|||
|
||||
export interface DocumentDoc extends DocMeta {
|
||||
code: string
|
||||
locale: string
|
||||
files?: {
|
||||
youtube?: string
|
||||
video?: string
|
||||
|
|
@ -79,23 +81,31 @@ interface TypesenseSearchResponse {
|
|||
hits?: TypesenseParagraphHit[]
|
||||
}
|
||||
|
||||
interface BrowseItem {
|
||||
docId: string
|
||||
meta: DocMeta
|
||||
}
|
||||
|
||||
interface DisplayGroup {
|
||||
docId: string
|
||||
meta: DocMeta | undefined
|
||||
firstHit: TypesenseParagraphHit | null
|
||||
}
|
||||
|
||||
// ---- State ----------------------------------------------------------------
|
||||
|
||||
// hits: lista plana de párrafos del buscador (sin agrupar)
|
||||
// Modo búsqueda (con query): hits de párrafos agrupados por documento
|
||||
const hits = ref<TypesenseParagraphHit[]>([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const hasMore = computed(() => hits.value.length < total.value)
|
||||
|
||||
// Progressive display: show 10 groups at a time, reveal more on scroll
|
||||
const hasMore = computed(() =>
|
||||
settings.paginationType === 'infinite_scroll' ? hits.value.length < total.value : false
|
||||
)
|
||||
|
||||
// Progressive display (sólo en scroll infinito)
|
||||
const visibleGroupCount = ref(10)
|
||||
const visibleGroups = computed(() => groupedHits.value.slice(0, visibleGroupCount.value))
|
||||
const hasMoreVisible = computed(() => visibleGroupCount.value < groupedHits.value.length)
|
||||
|
||||
// groupedHits: computed — un elemento por document_id único.
|
||||
// El conteo de coincidencias por documento se omite en la lista para evitar
|
||||
// confusión con el contador del find-in-document (que cuenta sobre el body
|
||||
// completo). El detalle es la fuente de verdad para "cuántas coincidencias".
|
||||
const groupedHits = computed(() => {
|
||||
const map = new Map<string, TypesenseParagraphHit[]>()
|
||||
for (const hit of hits.value) {
|
||||
|
|
@ -109,7 +119,56 @@ const groupedHits = computed(() => {
|
|||
}))
|
||||
})
|
||||
|
||||
// Cache de metadatos por document_id
|
||||
const visibleGroups = computed(() =>
|
||||
settings.paginationType === 'infinite_scroll'
|
||||
? groupedHits.value.slice(0, visibleGroupCount.value)
|
||||
: groupedHits.value
|
||||
)
|
||||
|
||||
const hasMoreVisible = computed(() =>
|
||||
settings.paginationType === 'infinite_scroll' &&
|
||||
visibleGroupCount.value < groupedHits.value.length
|
||||
)
|
||||
|
||||
// Modo exploración (sin query): documentos ordenados por fecha
|
||||
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
|
||||
)
|
||||
|
||||
// Grupo unificado para el template
|
||||
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
|
||||
}))
|
||||
})
|
||||
|
||||
// Paginación activa (compartida entre browse y search)
|
||||
const activePage = ref(1)
|
||||
|
||||
const displayTotal = computed(() =>
|
||||
debouncedQuery.value.trim() ? total.value : browseTotal.value
|
||||
)
|
||||
|
||||
const totalPages = computed(() =>
|
||||
Math.max(1, Math.ceil(displayTotal.value / settings.pageSize))
|
||||
)
|
||||
|
||||
// Cache de metadatos por document_id (sólo modo búsqueda)
|
||||
const docCache = ref<Record<string, DocMeta>>({})
|
||||
|
||||
const { documentsApi } = useTypesenseApi()
|
||||
|
|
@ -145,12 +204,12 @@ async function fetchDocumentMeta(docIds: string[]) {
|
|||
}
|
||||
}
|
||||
|
||||
// ---- Búsqueda plana -------------------------------------------------------
|
||||
// ---- Búsqueda de párrafos (con query) -------------------------------------
|
||||
|
||||
let searchSeq = 0
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function runSearch(q: string, append = false) {
|
||||
async function runSearch(q: string, page = 1, append = false) {
|
||||
const seq = ++searchSeq
|
||||
if (append) loadingMore.value = true
|
||||
else loading.value = true
|
||||
|
|
@ -165,7 +224,8 @@ async function runSearch(q: string, append = false) {
|
|||
}
|
||||
}, REQUEST_TIMEOUT_MS)
|
||||
|
||||
const page = append ? currentPage.value + 1 : 1
|
||||
const isInfinite = settings.paginationType === 'infinite_scroll'
|
||||
const typePage = isInfinite ? (append ? currentPage.value + 1 : 1) : page
|
||||
|
||||
try {
|
||||
const multi = await documentsApi.multiSearch({
|
||||
|
|
@ -176,8 +236,8 @@ async function runSearch(q: string, append = false) {
|
|||
q: q || '*',
|
||||
queryBy: QUERY_BY,
|
||||
filterBy: filterBy.value,
|
||||
perPage: PAGE_SIZE,
|
||||
page,
|
||||
perPage: settings.pageSize,
|
||||
page: typePage,
|
||||
highlightFullFields: QUERY_BY,
|
||||
highlightFields: QUERY_BY,
|
||||
highlightStartTag: '<mark class="search-match">',
|
||||
|
|
@ -191,17 +251,16 @@ async function runSearch(q: string, append = false) {
|
|||
const res = (multi?.results?.[0] ?? {}) as TypesenseSearchResponse
|
||||
const newHits = res?.hits ?? []
|
||||
|
||||
// Fetch metadata first so results never flash with bare docIds
|
||||
if (!append) docCache.value = {}
|
||||
const newDocIds = [...new Set(newHits.map(h => h.document.document_id).filter(Boolean))]
|
||||
await fetchDocumentMeta(newDocIds)
|
||||
|
||||
// Abort if a newer search has started while we waited for metadata
|
||||
if (seq !== searchSeq) return
|
||||
|
||||
hits.value = append ? hits.value.concat(newHits) : newHits
|
||||
total.value = res?.found ?? hits.value.length
|
||||
currentPage.value = page
|
||||
currentPage.value = typePage
|
||||
if (!append) activePage.value = page
|
||||
} catch (err: unknown) {
|
||||
if (seq !== searchSeq) return
|
||||
console.error('Typesense error', err)
|
||||
|
|
@ -219,38 +278,141 @@ async function runSearch(q: string, append = 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: DOCUMENTS_COLLECTION,
|
||||
q: '*',
|
||||
queryBy: 'title',
|
||||
sortBy: 'timestamp:desc',
|
||||
perPage: settings.pageSize,
|
||||
page: typePage,
|
||||
includeFields: 'id,title,date,timestamp,place,city,state,country,type,slug'
|
||||
}]
|
||||
}
|
||||
})
|
||||
|
||||
if (seq !== searchSeq) return
|
||||
|
||||
const result = (multi?.results?.[0] as { found?: number, hits?: Array<{ document: DocMeta }> } | undefined)
|
||||
const newItems = (result?.hits ?? []).map(h => ({
|
||||
docId: h.document.id!,
|
||||
meta: h.document
|
||||
}))
|
||||
|
||||
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, true)
|
||||
runSearch(query.value, currentPage.value, true)
|
||||
}
|
||||
|
||||
function goToPage(p: number) {
|
||||
activePage.value = p
|
||||
if (!debouncedQuery.value.trim()) {
|
||||
browseItems.value = []
|
||||
runBrowse(p, false)
|
||||
} else {
|
||||
hits.value = []
|
||||
runSearch(query.value, p, false)
|
||||
}
|
||||
}
|
||||
|
||||
const listContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
function onListScroll() {
|
||||
if (settings.paginationType !== 'infinite_scroll') return
|
||||
const el = listContainer.value
|
||||
if (!el) return
|
||||
if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) {
|
||||
if (hasMoreVisible.value) {
|
||||
visibleGroupCount.value += 10
|
||||
} else if (hasMore.value && !loadingMore.value && !loading.value) {
|
||||
loadMore()
|
||||
if (!debouncedQuery.value.trim()) {
|
||||
if (hasMoreBrowse.value && !loadingMore.value && !loading.value) {
|
||||
runBrowse(browsePage.value, true)
|
||||
}
|
||||
} else {
|
||||
if (hasMoreVisible.value) {
|
||||
visibleGroupCount.value += 10
|
||||
} else if (hasMore.value && !loadingMore.value && !loading.value) {
|
||||
loadMore()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function retry() {
|
||||
runSearch(query.value, false)
|
||||
if (!query.value.trim()) {
|
||||
runBrowse(activePage.value, false)
|
||||
} else {
|
||||
runSearch(query.value, activePage.value, false)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => runSearch(''))
|
||||
onMounted(() => runBrowse(1, false))
|
||||
onBeforeUnmount(() => { if (timeoutId) clearTimeout(timeoutId) })
|
||||
|
||||
watch(debouncedQuery, (q) => {
|
||||
hits.value = []
|
||||
total.value = 0
|
||||
currentPage.value = 1
|
||||
visibleGroupCount.value = 10
|
||||
runSearch(q, false)
|
||||
activePage.value = 1
|
||||
if (!q.trim()) {
|
||||
hits.value = []
|
||||
total.value = 0
|
||||
currentPage.value = 1
|
||||
visibleGroupCount.value = 10
|
||||
browseItems.value = []
|
||||
browseTotal.value = 0
|
||||
browsePage.value = 1
|
||||
runBrowse(1, false)
|
||||
} else {
|
||||
browseItems.value = []
|
||||
browseTotal.value = 0
|
||||
browsePage.value = 1
|
||||
hits.value = []
|
||||
total.value = 0
|
||||
currentPage.value = 1
|
||||
visibleGroupCount.value = 10
|
||||
runSearch(q, 1, false)
|
||||
}
|
||||
})
|
||||
|
||||
// ---- Selección y carga del detalle ----------------------------------------
|
||||
|
|
@ -261,10 +423,7 @@ const documentLoading = ref(false)
|
|||
const selectedParagraphs = ref<TypesenseParagraphHit[]>([])
|
||||
const paragraphsLoading = ref(false)
|
||||
|
||||
// Hit original del grupo seleccionado (contiene highlights de Typesense)
|
||||
const selectedHit = ref<TypesenseParagraphHit | null>(null)
|
||||
|
||||
// Todos los hits del grupo seleccionado (todos los párrafos con highlights de Typesense)
|
||||
const selectedMatchingHits = ref<TypesenseParagraphHit[]>([])
|
||||
|
||||
async function fetchFullDocument(docId: string) {
|
||||
|
|
@ -299,7 +458,7 @@ async function fetchDocumentParagraphs(docId: string) {
|
|||
selectedParagraphs.value = []
|
||||
const PER_PAGE = 250
|
||||
let page = 1
|
||||
let total = 0
|
||||
let totalParagraphs = 0
|
||||
const all: Array<{ document: ParagraphDoc }> = []
|
||||
try {
|
||||
do {
|
||||
|
|
@ -319,10 +478,10 @@ async function fetchDocumentParagraphs(docId: string) {
|
|||
})
|
||||
const result = (res?.results?.[0] as { found?: number, hits?: Array<{ document: ParagraphDoc }> } | undefined)
|
||||
if (!result) break
|
||||
if (page === 1) total = result.found ?? 0
|
||||
if (page === 1) totalParagraphs = result.found ?? 0
|
||||
all.push(...(result.hits ?? []))
|
||||
page++
|
||||
} while (all.length < total)
|
||||
} while (all.length < totalParagraphs)
|
||||
selectedParagraphs.value = all.map(h => ({ document: h.document }))
|
||||
} catch (err) {
|
||||
console.error('Error fetching paragraphs', err)
|
||||
|
|
@ -332,10 +491,12 @@ async function fetchDocumentParagraphs(docId: string) {
|
|||
}
|
||||
}
|
||||
|
||||
async function selectGroup(group: { docId: string, firstHit: TypesenseParagraphHit }) {
|
||||
async function selectGroup(group: DisplayGroup) {
|
||||
selectedDocId.value = group.docId
|
||||
selectedHit.value = group.firstHit
|
||||
selectedMatchingHits.value = hits.value.filter(h => h.document.document_id === group.docId)
|
||||
selectedMatchingHits.value = group.firstHit
|
||||
? hits.value.filter(h => h.document.document_id === group.docId)
|
||||
: []
|
||||
fetchFullDocument(group.docId)
|
||||
fetchDocumentParagraphs(group.docId)
|
||||
}
|
||||
|
|
@ -354,7 +515,7 @@ const isPanelOpen = computed({
|
|||
})
|
||||
|
||||
watch(groupedHits, () => {
|
||||
if (!selectedDocId.value) return
|
||||
if (!selectedDocId.value || !debouncedQuery.value.trim()) return
|
||||
const still = groupedHits.value.find(g => g.docId === selectedDocId.value)
|
||||
if (!still) {
|
||||
selectedDocId.value = null
|
||||
|
|
@ -418,7 +579,7 @@ function metaLocation(meta: DocMeta | undefined): string {
|
|||
<UDashboardSidebarCollapse />
|
||||
</template>
|
||||
<template #trailing>
|
||||
<UBadge :label="groupedHits.length" variant="subtle" />
|
||||
<UBadge :label="displayTotal" variant="subtle" />
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
|
||||
|
|
@ -445,7 +606,7 @@ function metaLocation(meta: DocMeta | undefined): string {
|
|||
|
||||
<div ref="listContainer" class="overflow-y-auto divide-y divide-default flex-1" @scroll="onListScroll">
|
||||
<div
|
||||
v-if="loading && !groupedHits.length"
|
||||
v-if="loading && !displayGroups.length"
|
||||
class="flex items-center justify-center gap-2 py-16 text-sm text-muted"
|
||||
>
|
||||
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
|
||||
|
|
@ -453,7 +614,7 @@ function metaLocation(meta: DocMeta | undefined): string {
|
|||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!groupedHits.length"
|
||||
v-else-if="!displayGroups.length"
|
||||
class="flex flex-col items-center justify-center gap-2 py-16 text-dimmed text-sm"
|
||||
>
|
||||
<UIcon name="i-lucide-inbox" class="size-10" />
|
||||
|
|
@ -461,7 +622,7 @@ function metaLocation(meta: DocMeta | undefined): string {
|
|||
</div>
|
||||
|
||||
<div
|
||||
v-for="group in visibleGroups"
|
||||
v-for="group in displayGroups"
|
||||
:key="group.docId"
|
||||
class="bg-white p-4 sm:px-6 text-sm cursor-pointer border-l-2 transition-colors"
|
||||
:class="[
|
||||
|
|
@ -471,40 +632,40 @@ function metaLocation(meta: DocMeta | undefined): string {
|
|||
]"
|
||||
@click="selectGroup(group)"
|
||||
>
|
||||
<!-- Título -->
|
||||
<div class="mb-1">
|
||||
<p class="text-sm font-semibold line-clamp-2 text-highlighted">
|
||||
{{ docCache[group.docId]?.title || group.docId }}
|
||||
{{ group.meta?.title || group.docId }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Fecha y lugar -->
|
||||
<p class="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 text-xs mb-2 text-muted">
|
||||
<span
|
||||
v-if="metaDate(docCache[group.docId])"
|
||||
v-if="metaDate(group.meta)"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<UIcon name="ph:calendar" class="size-4 text-green-600" />
|
||||
{{ metaDate(docCache[group.docId]) }}
|
||||
{{ metaDate(group.meta) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="metaLocation(docCache[group.docId])"
|
||||
v-if="metaLocation(group.meta)"
|
||||
class="flex items-center gap-1 truncate"
|
||||
>
|
||||
<UIcon name="ph:map-pin" class="size-4 text-green-600 shrink-0" />
|
||||
<span class="truncate">{{ metaLocation(docCache[group.docId]) }}</span>
|
||||
<span class="truncate">{{ metaLocation(group.meta) }}</span>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<!-- Primer párrafo coincidente con highlight -->
|
||||
<!-- Snippet con highlight: solo en modo búsqueda -->
|
||||
<div
|
||||
v-if="group.firstHit"
|
||||
class="snippet-html text-sm text-dimmed line-clamp-3"
|
||||
v-html="highlightedFor(group.firstHit, 'text') || group.firstHit.document.text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Infinite scroll: cargando más -->
|
||||
<div
|
||||
v-if="loadingMore"
|
||||
v-if="settings.paginationType === 'infinite_scroll' && loadingMore"
|
||||
class="flex items-center justify-center gap-2 py-4 text-sm text-muted"
|
||||
>
|
||||
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
|
||||
|
|
@ -512,12 +673,26 @@ function metaLocation(meta: DocMeta | undefined): string {
|
|||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="groupedHits.length && !hasMoreVisible && !hasMore && !loading"
|
||||
v-else-if="settings.paginationType === 'infinite_scroll' && displayGroups.length && !hasMoreBrowse && !hasMoreVisible && !hasMore && !loading"
|
||||
class="py-3 text-center text-xs text-dimmed"
|
||||
>
|
||||
No hay más resultados
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paginación numerada -->
|
||||
<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="displayTotal"
|
||||
:items-per-page="settings.pageSize"
|
||||
size="sm"
|
||||
@update:page="goToPage"
|
||||
/>
|
||||
</div>
|
||||
</UDashboardPanel>
|
||||
|
||||
<!-- Panel de detalle (escritorio) -->
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import { useSettingsStore } from '~/stores/settings'
|
||||
|
||||
/**
|
||||
* Hidratación cliente del store de ajustes.
|
||||
* Mismo patrón que favorites.client.ts: `enforce: 'post'` garantiza que
|
||||
* este plugin corre DESPUÉS de que Pinia aplique el estado SSR (vacío),
|
||||
* para que los valores del localStorage no sean sobreescritos.
|
||||
*/
|
||||
export default defineNuxtPlugin({
|
||||
name: 'settings-hydration',
|
||||
enforce: 'post',
|
||||
setup() {
|
||||
const settings = useSettingsStore()
|
||||
settings.hydrate()
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { ref, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export type PaginationType = 'infinite_scroll' | 'numbered'
|
||||
|
||||
export const PAGE_SIZE_OPTIONS = [10, 20, 30, 40, 50] as const
|
||||
export type PageSizeOption = typeof PAGE_SIZE_OPTIONS[number]
|
||||
|
||||
const STORAGE_KEY = 'lgcc:settings:v1'
|
||||
|
||||
interface SettingsData {
|
||||
pageSize: PageSizeOption
|
||||
paginationType: PaginationType
|
||||
}
|
||||
|
||||
const DEFAULTS: SettingsData = {
|
||||
pageSize: 10,
|
||||
paginationType: 'infinite_scroll'
|
||||
}
|
||||
|
||||
function readStorage(): SettingsData {
|
||||
if (typeof window === 'undefined') return { ...DEFAULTS }
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return { ...DEFAULTS }
|
||||
const parsed = JSON.parse(raw) as Partial<SettingsData>
|
||||
return {
|
||||
pageSize: (PAGE_SIZE_OPTIONS as readonly number[]).includes(parsed.pageSize as number)
|
||||
? (parsed.pageSize as PageSizeOption)
|
||||
: DEFAULTS.pageSize,
|
||||
paginationType:
|
||||
parsed.paginationType === 'numbered' || parsed.paginationType === 'infinite_scroll'
|
||||
? parsed.paginationType
|
||||
: DEFAULTS.paginationType
|
||||
}
|
||||
} catch {
|
||||
return { ...DEFAULTS }
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage(data: SettingsData) {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
|
||||
} catch (e) {
|
||||
console.warn('No se pudieron guardar los ajustes', e)
|
||||
}
|
||||
}
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const pageSize = ref<PageSizeOption>(DEFAULTS.pageSize)
|
||||
const paginationType = ref<PaginationType>(DEFAULTS.paginationType)
|
||||
const ready = ref(false)
|
||||
let hydrated = false
|
||||
|
||||
function hydrate() {
|
||||
if (typeof window === 'undefined') return
|
||||
if (hydrated) return
|
||||
const data = readStorage()
|
||||
pageSize.value = data.pageSize
|
||||
paginationType.value = data.paginationType
|
||||
ready.value = true
|
||||
hydrated = true
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
watch([pageSize, paginationType], () => {
|
||||
if (!hydrated) return
|
||||
writeStorage({ pageSize: pageSize.value, paginationType: paginationType.value })
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
pageSize,
|
||||
paginationType,
|
||||
ready,
|
||||
hydrate
|
||||
}
|
||||
})
|
||||
16
lang/en.json
16
lang/en.json
|
|
@ -7,7 +7,8 @@
|
|||
"conferences": "Conferences",
|
||||
"between_the_lines": "Between the Lines",
|
||||
"my_list": "My List",
|
||||
"history": "History"
|
||||
"history": "History",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search for...",
|
||||
|
|
@ -29,5 +30,16 @@
|
|||
"tab2": {
|
||||
"tab_title": "Conferences"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"page_size_title": "Results per search",
|
||||
"page_size_desc": "How many results to load per page or request.",
|
||||
"results": "results",
|
||||
"pagination_title": "Pagination type",
|
||||
"pagination_desc": "How you want to navigate through results.",
|
||||
"infinite_scroll": "Infinite scroll",
|
||||
"infinite_scroll_desc": "Results load automatically when you reach the end of the list.",
|
||||
"numbered": "Numbered pages",
|
||||
"numbered_desc": "Navigate between pages with pagination controls."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
19
lang/es.json
19
lang/es.json
|
|
@ -6,7 +6,8 @@
|
|||
"conferences": "Conferencias",
|
||||
"between_the_lines": "Entrelíneas",
|
||||
"my_list": "Mi Listado",
|
||||
"history": "Historial"
|
||||
"history": "Historial",
|
||||
"settings": "Configuración"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Buscar...",
|
||||
|
|
@ -33,7 +34,17 @@
|
|||
},
|
||||
"tab2": {
|
||||
"tab_title": "Conferencias"
|
||||
},
|
||||
"tip": "Tip: envuelve en \"comillas\" para frase exacta en ese orden."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"page_size_title": "Resultados por búsqueda",
|
||||
"page_size_desc": "Cuántos resultados cargar en cada página o petición.",
|
||||
"results": "resultados",
|
||||
"pagination_title": "Tipo de paginación",
|
||||
"pagination_desc": "Cómo quieres navegar entre los resultados.",
|
||||
"infinite_scroll": "Scroll infinito",
|
||||
"infinite_scroll_desc": "Los resultados se cargan automáticamente al llegar al final de la lista.",
|
||||
"numbered": "Páginas numeradas",
|
||||
"numbered_desc": "Navega entre páginas con controles de paginación."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
16
lang/fr.json
16
lang/fr.json
|
|
@ -7,7 +7,8 @@
|
|||
"conferences": "Conférences",
|
||||
"between_the_lines": "Entre les lignes",
|
||||
"my_list": "Ma liste",
|
||||
"history": "Historique"
|
||||
"history": "Historique",
|
||||
"settings": "Paramètres"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Rechercher des activités"
|
||||
|
|
@ -17,5 +18,16 @@
|
|||
"es": "Español",
|
||||
"fr": "Français",
|
||||
"pt": "Português"
|
||||
},
|
||||
"settings": {
|
||||
"page_size_title": "Résultats par recherche",
|
||||
"page_size_desc": "Combien de résultats charger par page ou requête.",
|
||||
"results": "résultats",
|
||||
"pagination_title": "Type de pagination",
|
||||
"pagination_desc": "Comment naviguer entre les résultats.",
|
||||
"infinite_scroll": "Défilement infini",
|
||||
"infinite_scroll_desc": "Les résultats se chargent automatiquement en fin de liste.",
|
||||
"numbered": "Pages numérotées",
|
||||
"numbered_desc": "Naviguez entre les pages avec des contrôles de pagination."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
16
lang/pt.json
16
lang/pt.json
|
|
@ -7,7 +7,8 @@
|
|||
"conferences": "Conferências",
|
||||
"between_the_lines": "Entre as linhas",
|
||||
"my_list": "Minha lista",
|
||||
"history": "Registro"
|
||||
"history": "Registro",
|
||||
"settings": "Configurações"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Digite para pesquisar...",
|
||||
|
|
@ -27,5 +28,16 @@
|
|||
"country": "País",
|
||||
"hits_per_page": "Resultados por página",
|
||||
"hits_retrieved_in": "Resultados recuperados em"
|
||||
},
|
||||
"settings": {
|
||||
"page_size_title": "Resultados por pesquisa",
|
||||
"page_size_desc": "Quantos resultados carregar por página ou requisição.",
|
||||
"results": "resultados",
|
||||
"pagination_title": "Tipo de paginação",
|
||||
"pagination_desc": "Como navegar pelos resultados.",
|
||||
"infinite_scroll": "Rolagem infinita",
|
||||
"infinite_scroll_desc": "Os resultados carregam automaticamente ao chegar ao final da lista.",
|
||||
"numbered": "Páginas numeradas",
|
||||
"numbered_desc": "Navegue entre páginas com controles de paginação."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue