diff --git a/app/components/inbox/InboxList.vue b/app/components/inbox/InboxList.vue
index 3d2671e..395f6b1 100755
--- a/app/components/inbox/InboxList.vue
+++ b/app/components/inbox/InboxList.vue
@@ -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(
No hay más resultados
diff --git a/app/layouts/default.vue b/app/layouts/default.vue
index fc0acc0..960ecab 100755
--- a/app/layouts/default.vue
+++ b/app/layouts/default.vue
@@ -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[]
})
diff --git a/app/pages/actividades.vue b/app/pages/actividades.vue
index 8808ae0..2dd3793 100755
--- a/app/pages/actividades.vue
+++ b/app/pages/actividades.vue
@@ -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
(null)
-// Use the raw Meilisearch client so we can pass an AbortSignal.
const meili = useMeiliSearchRef()
const hits = ref([])
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(null)
@@ -110,10 +130,6 @@ const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('lg')
useDetailHistory(isActivityPanelOpen, isMobile)
-
-function retry() {
- runSearch(query.value, false)
-}
@@ -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"
/>
+
+
+
+
([])
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(null)
@@ -105,10 +130,6 @@ const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('lg')
useDetailHistory(isPanelOpen, isMobile)
-
-function retry() {
- runSearch(query.value, false)
-}
@@ -119,7 +140,7 @@ function retry() {
:max-size="40"
resizable
>
-
+
@@ -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"
/>
+
+
+
+
+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')
+ }
+]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('settings.page_size_title') }}
+
+
{{ t('settings.page_size_desc') }}
+
+
+
+
+
+
+
+
+ {{ t('settings.pagination_title') }}
+
+
{{ t('settings.pagination_desc') }}
+
+
+
+
+
diff --git a/app/pages/entrelineas.vue b/app/pages/entrelineas.vue
index 43eacd8..1eaaf8e 100644
--- a/app/pages/entrelineas.vue
+++ b/app/pages/entrelineas.vue
@@ -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([])
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 | 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 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: '',
@@ -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"
>
-
-
+
+
+
+
+
+
@@ -503,9 +485,6 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {