Compare commits
2 Commits
756c62af86
...
af88c88aa6
| Author | SHA1 | Date |
|---|---|---|
|
|
af88c88aa6 | |
|
|
e9117a1adf |
|
|
@ -16,6 +16,9 @@ const props = defineProps<{
|
||||||
* Se propaga al sistema de favoritos para distinguir entre tipos de
|
* Se propaga al sistema de favoritos para distinguir entre tipos de
|
||||||
* contenido (actividades, conferencias, etc.). */
|
* contenido (actividades, conferencias, etc.). */
|
||||||
collection?: string
|
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()
|
const favorites = useFavoritesStore()
|
||||||
|
|
@ -639,6 +642,11 @@ const rows = computed<RowVm[]>(() => {
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---- Scroll element (exposed for parent scroll tracking / restoration) ---
|
||||||
|
|
||||||
|
const listEl = ref<HTMLElement | null>(null)
|
||||||
|
defineExpose({ listEl })
|
||||||
|
|
||||||
// ---- Infinite scroll ---------------------------------------------------
|
// ---- Infinite scroll ---------------------------------------------------
|
||||||
|
|
||||||
const sentinel = ref<HTMLElement | null>(null)
|
const sentinel = ref<HTMLElement | null>(null)
|
||||||
|
|
@ -656,7 +664,7 @@ useIntersectionObserver(
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="overflow-y-auto divide-y divide-default flex-1">
|
<div ref="listEl" class="overflow-y-auto divide-y divide-default flex-1">
|
||||||
<div
|
<div
|
||||||
v-if="loading && !rows.length"
|
v-if="loading && !rows.length"
|
||||||
class="flex items-center justify-center gap-2 py-16 text-sm text-muted"
|
class="flex items-center justify-center gap-2 py-16 text-sm text-muted"
|
||||||
|
|
@ -771,7 +779,7 @@ useIntersectionObserver(
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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"
|
class="py-3 text-center text-xs text-dimmed"
|
||||||
>
|
>
|
||||||
No hay más resultados
|
No hay más resultados
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
import { watch, onBeforeUnmount } from 'vue'
|
||||||
|
import type { Ref, ComputedRef } from 'vue'
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface UrlSearchState {
|
||||||
|
query: string
|
||||||
|
page: number
|
||||||
|
scroll: number
|
||||||
|
selectedId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseSearchUrlSyncOptions {
|
||||||
|
query: Ref<string>
|
||||||
|
page: Ref<number>
|
||||||
|
/** Computed or ref yielding the ID of the currently selected item */
|
||||||
|
selectedId: Ref<string | null> | ComputedRef<string | null>
|
||||||
|
/** The scrollable list container, if any (for scroll tracking & restoration) */
|
||||||
|
scrollEl?: Ref<HTMLElement | null> | ComputedRef<HTMLElement | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Read initial state from URL ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads search state from the current route's query params.
|
||||||
|
* Safe to call during SSR (falls back to empty defaults).
|
||||||
|
*
|
||||||
|
* Call this BEFORE creating your reactive refs and initialise them with
|
||||||
|
* the returned values so the first search uses the URL state directly —
|
||||||
|
* this avoids a redundant re-search triggered by the debounced watcher.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { query: q0, page: p0, scroll: s0, selectedId: sid0 } = useSearchUrlState()
|
||||||
|
* const query = ref(q0) // debouncedQuery starts at q0 → watcher won't re-fire
|
||||||
|
* const activePage = ref(p0)
|
||||||
|
*/
|
||||||
|
export function useSearchUrlState(): UrlSearchState {
|
||||||
|
const route = useRoute()
|
||||||
|
return {
|
||||||
|
query: String(route.query.q ?? ''),
|
||||||
|
page: Math.max(1, parseInt(String(route.query.page ?? '1'), 10) || 1),
|
||||||
|
scroll: Math.max(0, parseInt(String(route.query.scroll ?? '0'), 10) || 0),
|
||||||
|
selectedId: route.query.selected ? String(route.query.selected) : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Keep state in sync with URL ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watches query, page, selectedId and (optionally) scroll on a container
|
||||||
|
* element, writing each change to the URL via history.replaceState so the
|
||||||
|
* state can be copied and restored by any user.
|
||||||
|
*
|
||||||
|
* Uses replaceState (not pushState) so the browser history stack stays clean.
|
||||||
|
* Existing URL params unrelated to search are preserved.
|
||||||
|
*/
|
||||||
|
export function useSearchUrlSync(options: UseSearchUrlSyncOptions): void {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
|
||||||
|
let scrollTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let queryTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
// ── Write a partial patch to the current URL ────────────────────────────
|
||||||
|
|
||||||
|
function patchUrl(params: Record<string, string | null>) {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
for (const [key, val] of Object.entries(params)) {
|
||||||
|
if (val !== null && val !== '') {
|
||||||
|
url.searchParams.set(key, val)
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
history.replaceState(history.state ?? null, '', url.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Watchers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Query: debounce to avoid thrashing on every keystroke.
|
||||||
|
// Also resets page and scroll since a new query starts fresh.
|
||||||
|
watch(options.query, (q) => {
|
||||||
|
if (queryTimer) clearTimeout(queryTimer)
|
||||||
|
queryTimer = setTimeout(
|
||||||
|
() => patchUrl({ q: q || null, page: null, scroll: null }),
|
||||||
|
150,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Page: immediate — user navigated intentionally.
|
||||||
|
// Scroll position is no longer meaningful when page changes.
|
||||||
|
watch(options.page, (p) => {
|
||||||
|
patchUrl({ page: p > 1 ? String(p) : null, scroll: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Selected item: immediate.
|
||||||
|
watch(options.selectedId, (id) => {
|
||||||
|
patchUrl({ selected: id || null })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Scroll tracking ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (options.scrollEl) {
|
||||||
|
function handleScroll(ev: Event) {
|
||||||
|
if (scrollTimer) clearTimeout(scrollTimer)
|
||||||
|
scrollTimer = setTimeout(() => {
|
||||||
|
const top = (ev.target as HTMLElement).scrollTop
|
||||||
|
patchUrl({ scroll: top > 0 ? String(Math.round(top)) : null })
|
||||||
|
}, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
options.scrollEl,
|
||||||
|
(el, oldEl) => {
|
||||||
|
;(oldEl as HTMLElement | null)?.removeEventListener('scroll', handleScroll)
|
||||||
|
;(el as HTMLElement | null)?.addEventListener('scroll', handleScroll, { passive: true })
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
;(options.scrollEl!.value as HTMLElement | null)
|
||||||
|
?.removeEventListener('scroll', handleScroll)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (scrollTimer) clearTimeout(scrollTimer)
|
||||||
|
if (queryTimer) clearTimeout(queryTimer)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Scroll restoration helper ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restores a scroll position on an element after a short settling delay.
|
||||||
|
* Call this inside onMounted, after awaiting the first search, so the list
|
||||||
|
* has had time to render its items.
|
||||||
|
*/
|
||||||
|
export function restoreScrollPosition(
|
||||||
|
scrollEl: HTMLElement | null | undefined,
|
||||||
|
scrollY: number,
|
||||||
|
delayMs = 80,
|
||||||
|
): void {
|
||||||
|
if (!scrollY || !scrollEl || !import.meta.client) return
|
||||||
|
setTimeout(() => { scrollEl.scrollTop = scrollY }, delayMs)
|
||||||
|
}
|
||||||
|
|
@ -59,6 +59,12 @@ const links = computed(() => {
|
||||||
to: '/historial',
|
to: '/historial',
|
||||||
badge: histTotal.value > 0 ? String(histTotal.value) : undefined,
|
badge: histTotal.value > 0 ? String(histTotal.value) : undefined,
|
||||||
onSelect: () => { open.value = false }
|
onSelect: () => { open.value = false }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("nav.settings"),
|
||||||
|
icon: 'i-lucide-settings',
|
||||||
|
to: '/configuracion',
|
||||||
|
onSelect: () => { open.value = false }
|
||||||
}
|
}
|
||||||
] satisfies NavigationMenuItem[]
|
] satisfies NavigationMenuItem[]
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,32 +3,40 @@ import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { breakpointsTailwind, useDebounce } from '@vueuse/core'
|
import { breakpointsTailwind, useDebounce } from '@vueuse/core'
|
||||||
import type { SearchHit } from '~/types'
|
import type { SearchHit } from '~/types'
|
||||||
import InboxActivity from '~/components/inbox/InboxActivity.vue'
|
import InboxActivity from '~/components/inbox/InboxActivity.vue'
|
||||||
|
import { useSettingsStore } from '~/stores/settings'
|
||||||
|
|
||||||
const { $i18n } = useNuxtApp();
|
const { $i18n } = useNuxtApp();
|
||||||
const t = $i18n.t;
|
const t = $i18n.t;
|
||||||
|
|
||||||
const PAGE_SIZE = 15
|
|
||||||
const REQUEST_TIMEOUT_MS = 15000
|
const REQUEST_TIMEOUT_MS = 15000
|
||||||
|
|
||||||
const query = ref('')
|
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 debouncedQuery = useDebounce(query, 150)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const loadingMore = ref(false)
|
const loadingMore = ref(false)
|
||||||
const errorMsg = ref<string | null>(null)
|
const errorMsg = ref<string | null>(null)
|
||||||
|
|
||||||
// Use the raw Meilisearch client so we can pass an AbortSignal.
|
|
||||||
const meili = useMeiliSearchRef()
|
const meili = useMeiliSearchRef()
|
||||||
|
|
||||||
const hits = ref<SearchHit[]>([])
|
const hits = ref<SearchHit[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
const activePage = ref(p0)
|
||||||
|
|
||||||
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 searchSeq = 0
|
||||||
let abortController: AbortController | null = null
|
let abortController: AbortController | null = null
|
||||||
|
|
||||||
async function runSearch(q: string, append = false) {
|
async function runSearch(q: string, page = 1, append = false) {
|
||||||
// Cancel any in-flight search; saves bandwidth and prevents pile-ups.
|
|
||||||
abortController?.abort()
|
abortController?.abort()
|
||||||
const ac = new AbortController()
|
const ac = new AbortController()
|
||||||
abortController = ac
|
abortController = ac
|
||||||
|
|
@ -38,15 +46,19 @@ async function runSearch(q: string, append = false) {
|
||||||
else loading.value = true
|
else loading.value = true
|
||||||
errorMsg.value = null
|
errorMsg.value = null
|
||||||
|
|
||||||
// Safety net in case the network just hangs.
|
|
||||||
const timeoutId = setTimeout(() => ac.abort(), REQUEST_TIMEOUT_MS)
|
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 {
|
try {
|
||||||
const res = await meili.index(`activities_${$i18n.locale.value.toUpperCase()}`).search(q || '', {
|
const res = await meili.index(`activities_${$i18n.locale.value.toUpperCase()}`).search(q || '', {
|
||||||
attributesToRetrieve: ['*'],
|
attributesToRetrieve: ['*'],
|
||||||
showMatchesPosition: true,
|
showMatchesPosition: true,
|
||||||
limit: PAGE_SIZE,
|
limit: settings.pageSize,
|
||||||
offset: append ? hits.value.length : 0,
|
offset,
|
||||||
sort: q ? undefined : ['isodate:desc']
|
sort: q ? undefined : ['isodate:desc']
|
||||||
}, { signal: ac.signal })
|
}, { signal: ac.signal })
|
||||||
|
|
||||||
|
|
@ -55,9 +67,9 @@ async function runSearch(q: string, append = false) {
|
||||||
const newHits = (res?.hits ?? []) as SearchHit[]
|
const newHits = (res?.hits ?? []) as SearchHit[]
|
||||||
hits.value = append ? hits.value.concat(newHits) : newHits
|
hits.value = append ? hits.value.concat(newHits) : newHits
|
||||||
total.value = res?.estimatedTotalHits ?? hits.value.length
|
total.value = res?.estimatedTotalHits ?? hits.value.length
|
||||||
|
if (!append) activePage.value = page
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const name = (err as { name?: string })?.name
|
const name = (err as { name?: string })?.name
|
||||||
// Aborts are expected when the user types fast; don't surface them.
|
|
||||||
if (name === 'AbortError') return
|
if (name === 'AbortError') return
|
||||||
if (seq !== searchSeq) return
|
if (seq !== searchSeq) return
|
||||||
console.error('Meilisearch error', err)
|
console.error('Meilisearch error', err)
|
||||||
|
|
@ -76,23 +88,22 @@ async function runSearch(q: string, append = false) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadMore() {
|
function loadMore() {
|
||||||
|
if (settings.paginationType !== 'infinite_scroll') return
|
||||||
if (loadingMore.value || loading.value || !hasMore.value) 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).
|
function goToPage(p: number) {
|
||||||
onMounted(() => runSearch(''))
|
activePage.value = p
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
abortController?.abort()
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(debouncedQuery, (q) => {
|
|
||||||
hits.value = []
|
hits.value = []
|
||||||
total.value = 0
|
runSearch(query.value, p, false)
|
||||||
runSearch(q, false)
|
}
|
||||||
})
|
|
||||||
|
|
||||||
|
function retry() {
|
||||||
|
runSearch(query.value, activePage.value, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Selección ──────────────────────────────────────────────────────────────
|
||||||
const selectedActivity = ref<SearchHit | null>(null)
|
const selectedActivity = ref<SearchHit | null>(null)
|
||||||
|
|
||||||
const isActivityPanelOpen = computed({
|
const isActivityPanelOpen = computed({
|
||||||
|
|
@ -100,6 +111,43 @@ const isActivityPanelOpen = computed({
|
||||||
set(value: boolean) { if (!value) selectedActivity.value = null }
|
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, () => {
|
watch(hits, () => {
|
||||||
if (!selectedActivity.value) return
|
if (!selectedActivity.value) return
|
||||||
const stillThere = hits.value.find(h => h._id === selectedActivity.value?._id)
|
const stillThere = hits.value.find(h => h._id === selectedActivity.value?._id)
|
||||||
|
|
@ -110,10 +158,6 @@ const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||||
const isMobile = breakpoints.smaller('lg')
|
const isMobile = breakpoints.smaller('lg')
|
||||||
|
|
||||||
useDetailHistory(isActivityPanelOpen, isMobile)
|
useDetailHistory(isActivityPanelOpen, isMobile)
|
||||||
|
|
||||||
function retry() {
|
|
||||||
runSearch(query.value, false)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -160,15 +204,30 @@ function retry() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InboxList
|
<InboxList
|
||||||
|
ref="inboxListRef"
|
||||||
v-model="selectedActivity"
|
v-model="selectedActivity"
|
||||||
:activities="hits"
|
:activities="hits"
|
||||||
:query="debouncedQuery"
|
:query="debouncedQuery"
|
||||||
:has-more="hasMore"
|
:has-more="hasMore"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:loading-more="loadingMore"
|
:loading-more="loadingMore"
|
||||||
|
:show-end-message="settings.paginationType === 'infinite_scroll'"
|
||||||
collection="activities"
|
collection="activities"
|
||||||
@load-more="loadMore"
|
@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>
|
</UDashboardPanel>
|
||||||
|
|
||||||
<InboxActivity
|
<InboxActivity
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,19 @@ import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { breakpointsTailwind, useDebounce } from '@vueuse/core'
|
import { breakpointsTailwind, useDebounce } from '@vueuse/core'
|
||||||
import type { SearchHit } from '~/types'
|
import type { SearchHit } from '~/types'
|
||||||
import InboxActivity from '~/components/inbox/InboxActivity.vue'
|
import InboxActivity from '~/components/inbox/InboxActivity.vue'
|
||||||
|
import { useSettingsStore } from '~/stores/settings'
|
||||||
|
|
||||||
const { $i18n } = useNuxtApp();
|
const { $i18n } = useNuxtApp();
|
||||||
const t = $i18n.t;
|
const t = $i18n.t;
|
||||||
|
|
||||||
const PAGE_SIZE = 15
|
|
||||||
const REQUEST_TIMEOUT_MS = 15000
|
const REQUEST_TIMEOUT_MS = 15000
|
||||||
|
|
||||||
const query = ref('')
|
const settings = useSettingsStore()
|
||||||
|
|
||||||
|
// ── Restore state from URL before creating refs ────────────────────────────
|
||||||
|
const { query: q0, page: p0, scroll: s0, selectedId: sid0 } = useSearchUrlState()
|
||||||
|
|
||||||
|
const query = ref(q0)
|
||||||
const debouncedQuery = useDebounce(query, 150)
|
const debouncedQuery = useDebounce(query, 150)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const loadingMore = ref(false)
|
const loadingMore = ref(false)
|
||||||
|
|
@ -20,13 +25,18 @@ const meili = useMeiliSearchRef()
|
||||||
|
|
||||||
const hits = ref<SearchHit[]>([])
|
const hits = ref<SearchHit[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
const activePage = ref(p0)
|
||||||
|
|
||||||
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 searchSeq = 0
|
||||||
let abortController: AbortController | null = null
|
let abortController: AbortController | null = null
|
||||||
|
|
||||||
async function runSearch(q: string, append = false) {
|
async function runSearch(q: string, page = 1, append = false) {
|
||||||
abortController?.abort()
|
abortController?.abort()
|
||||||
const ac = new AbortController()
|
const ac = new AbortController()
|
||||||
abortController = ac
|
abortController = ac
|
||||||
|
|
@ -38,12 +48,17 @@ async function runSearch(q: string, append = false) {
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => ac.abort(), REQUEST_TIMEOUT_MS)
|
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 {
|
try {
|
||||||
const res = await meili.index(`conferences_${$i18n.locale.value.toUpperCase()}`).search(q || '', {
|
const res = await meili.index(`conferences_${$i18n.locale.value.toUpperCase()}`).search(q || '', {
|
||||||
attributesToRetrieve: ['*'],
|
attributesToRetrieve: ['*'],
|
||||||
showMatchesPosition: true,
|
showMatchesPosition: true,
|
||||||
limit: PAGE_SIZE,
|
limit: settings.pageSize,
|
||||||
offset: append ? hits.value.length : 0,
|
offset,
|
||||||
sort: q ? undefined : ['isodate:desc']
|
sort: q ? undefined : ['isodate:desc']
|
||||||
}, { signal: ac.signal })
|
}, { signal: ac.signal })
|
||||||
|
|
||||||
|
|
@ -52,6 +67,7 @@ async function runSearch(q: string, append = false) {
|
||||||
const newHits = (res?.hits ?? []) as SearchHit[]
|
const newHits = (res?.hits ?? []) as SearchHit[]
|
||||||
hits.value = append ? hits.value.concat(newHits) : newHits
|
hits.value = append ? hits.value.concat(newHits) : newHits
|
||||||
total.value = res?.estimatedTotalHits ?? hits.value.length
|
total.value = res?.estimatedTotalHits ?? hits.value.length
|
||||||
|
if (!append) activePage.value = page
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const name = (err as { name?: string })?.name
|
const name = (err as { name?: string })?.name
|
||||||
if (name === 'AbortError') return
|
if (name === 'AbortError') return
|
||||||
|
|
@ -72,22 +88,22 @@ async function runSearch(q: string, append = false) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadMore() {
|
function loadMore() {
|
||||||
|
if (settings.paginationType !== 'infinite_scroll') return
|
||||||
if (loadingMore.value || loading.value || !hasMore.value) 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
|
||||||
onBeforeUnmount(() => {
|
|
||||||
abortController?.abort()
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(debouncedQuery, (q) => {
|
|
||||||
hits.value = []
|
hits.value = []
|
||||||
total.value = 0
|
runSearch(query.value, p, false)
|
||||||
runSearch(q, false)
|
}
|
||||||
})
|
|
||||||
|
|
||||||
|
function retry() {
|
||||||
|
runSearch(query.value, activePage.value, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Selección ──────────────────────────────────────────────────────────────
|
||||||
const selected = ref<SearchHit | null>(null)
|
const selected = ref<SearchHit | null>(null)
|
||||||
|
|
||||||
const isPanelOpen = computed({
|
const isPanelOpen = computed({
|
||||||
|
|
@ -95,6 +111,43 @@ const isPanelOpen = computed({
|
||||||
set(value: boolean) { if (!value) selected.value = null }
|
set(value: boolean) { if (!value) selected.value = null }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ID del item seleccionado para sincronizar con la URL
|
||||||
|
const selectedId = computed(() => selected.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) selected.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, () => {
|
watch(hits, () => {
|
||||||
if (!selected.value) return
|
if (!selected.value) return
|
||||||
const stillThere = hits.value.find(h => h._id === selected.value?._id)
|
const stillThere = hits.value.find(h => h._id === selected.value?._id)
|
||||||
|
|
@ -105,10 +158,6 @@ const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||||
const isMobile = breakpoints.smaller('lg')
|
const isMobile = breakpoints.smaller('lg')
|
||||||
|
|
||||||
useDetailHistory(isPanelOpen, isMobile)
|
useDetailHistory(isPanelOpen, isMobile)
|
||||||
|
|
||||||
function retry() {
|
|
||||||
runSearch(query.value, false)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -119,7 +168,7 @@ function retry() {
|
||||||
:max-size="40"
|
:max-size="40"
|
||||||
resizable
|
resizable
|
||||||
>
|
>
|
||||||
<UDashboardNavbar title="Conferencias">
|
<UDashboardNavbar :title="t('nav.conferences')">
|
||||||
<template #leading>
|
<template #leading>
|
||||||
<UDashboardSidebarCollapse />
|
<UDashboardSidebarCollapse />
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -159,15 +208,30 @@ function retry() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InboxList
|
<InboxList
|
||||||
|
ref="inboxListRef"
|
||||||
v-model="selected"
|
v-model="selected"
|
||||||
:activities="hits"
|
:activities="hits"
|
||||||
:query="debouncedQuery"
|
:query="debouncedQuery"
|
||||||
:has-more="hasMore"
|
:has-more="hasMore"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:loading-more="loadingMore"
|
:loading-more="loadingMore"
|
||||||
|
:show-end-message="settings.paginationType === 'infinite_scroll'"
|
||||||
collection="conferences"
|
collection="conferences"
|
||||||
@load-more="loadMore"
|
@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>
|
</UDashboardPanel>
|
||||||
|
|
||||||
<InboxActivity
|
<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 { breakpointsTailwind, useDebounce } from '@vueuse/core'
|
||||||
import EntrelineaDetail from '~/components/entrelineas/EntrelineaDetail.vue'
|
import EntrelineaDetail from '~/components/entrelineas/EntrelineaDetail.vue'
|
||||||
import { useFavoritesStore } from '~/stores/favorites'
|
import { useFavoritesStore } from '~/stores/favorites'
|
||||||
|
import { useSettingsStore } from '~/stores/settings'
|
||||||
import type { SearchHit } from '~/types'
|
import type { SearchHit } from '~/types'
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
/* CONFIGURACIÓN — lo único que necesitas tocar */
|
/* CONFIGURACIÓN — lo único que necesitas tocar */
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
/** Nombre de la colección de Typesense a consultar. */
|
|
||||||
const COLLECTION = 'entrelineas'
|
const COLLECTION = 'entrelineas'
|
||||||
|
|
||||||
/** Campos por los que se hará el match de la búsqueda (separados por coma). */
|
|
||||||
const QUERY_BY = 'text'
|
const QUERY_BY = 'text'
|
||||||
|
|
||||||
/** Campos a traer en cada hit (separados por coma). Usa '*' para traerlos todos. */
|
|
||||||
const INCLUDE_FIELDS = '*'
|
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 = ''
|
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'
|
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 */
|
/* Estado */
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
const { $i18n } = useNuxtApp()
|
const { $i18n } = useNuxtApp()
|
||||||
const t = $i18n.t
|
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()
|
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 filterBy = computed(() => {
|
||||||
const localeFilter = `locale:=${locale.value}`
|
const localeFilter = `locale:=${locale.value}`
|
||||||
return EXTRA_FILTER_BY ? `${localeFilter} && ${EXTRA_FILTER_BY}` : localeFilter
|
return EXTRA_FILTER_BY ? `${localeFilter} && ${EXTRA_FILTER_BY}` : localeFilter
|
||||||
|
|
@ -56,7 +31,12 @@ const filterBy = computed(() => {
|
||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = 15000
|
const REQUEST_TIMEOUT_MS = 15000
|
||||||
|
|
||||||
const query = ref('')
|
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 debouncedQuery = useDebounce(query, 150)
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
@ -107,29 +87,30 @@ interface TypesenseSearchResponse {
|
||||||
const hits = ref<TypesenseHit[]>([])
|
const hits = ref<TypesenseHit[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
|
const activePage = ref(p0)
|
||||||
|
|
||||||
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 */
|
/* 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()
|
const { documentsApi } = useTypesenseApi()
|
||||||
|
|
||||||
let searchSeq = 0
|
let searchSeq = 0
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
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
|
const seq = ++searchSeq
|
||||||
if (append) loadingMore.value = true
|
if (append) loadingMore.value = true
|
||||||
else loading.value = true
|
else loading.value = true
|
||||||
errorMsg.value = null
|
errorMsg.value = null
|
||||||
|
|
||||||
if (timeoutId) clearTimeout(timeoutId)
|
if (timeoutId) clearTimeout(timeoutId)
|
||||||
// Safety net: si la red se cuelga, sacamos los spinners igualmente.
|
|
||||||
timeoutId = setTimeout(() => {
|
timeoutId = setTimeout(() => {
|
||||||
if (seq === searchSeq) {
|
if (seq === searchSeq) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
|
@ -138,29 +119,23 @@ async function runSearch(q: string, append = false) {
|
||||||
}
|
}
|
||||||
}, REQUEST_TIMEOUT_MS)
|
}, 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 {
|
try {
|
||||||
const multi = await documentsApi.multiSearch({
|
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: {},
|
multiSearchParameters: {},
|
||||||
multiSearchSearchesParameter: {
|
multiSearchSearchesParameter: {
|
||||||
searches: [{
|
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,
|
collection: COLLECTION,
|
||||||
q: q || '*',
|
q: q || '*',
|
||||||
queryBy: QUERY_BY,
|
queryBy: QUERY_BY,
|
||||||
includeFields: INCLUDE_FIELDS,
|
includeFields: INCLUDE_FIELDS,
|
||||||
filterBy: filterBy.value,
|
filterBy: filterBy.value,
|
||||||
perPage: PAGE_SIZE,
|
perPage: settings.pageSize,
|
||||||
page,
|
page: typePage,
|
||||||
// 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).
|
|
||||||
highlightFullFields: QUERY_BY,
|
highlightFullFields: QUERY_BY,
|
||||||
highlightFields: QUERY_BY,
|
highlightFields: QUERY_BY,
|
||||||
highlightStartTag: '<mark class="search-match">',
|
highlightStartTag: '<mark class="search-match">',
|
||||||
|
|
@ -169,7 +144,6 @@ async function runSearch(q: string, append = false) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Si arrancó otra búsqueda mientras esta venía en camino, descartamos.
|
|
||||||
if (seq !== searchSeq) return
|
if (seq !== searchSeq) return
|
||||||
|
|
||||||
const res = (multi?.results?.[0] ?? {}) as TypesenseSearchResponse
|
const res = (multi?.results?.[0] ?? {}) as TypesenseSearchResponse
|
||||||
|
|
@ -177,7 +151,8 @@ async function runSearch(q: string, append = false) {
|
||||||
|
|
||||||
hits.value = append ? hits.value.concat(newHits) : newHits
|
hits.value = append ? hits.value.concat(newHits) : newHits
|
||||||
total.value = res?.found ?? hits.value.length
|
total.value = res?.found ?? hits.value.length
|
||||||
currentPage.value = page
|
currentPage.value = typePage
|
||||||
|
if (!append) activePage.value = page
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (seq !== searchSeq) return
|
if (seq !== searchSeq) return
|
||||||
console.error('Typesense error', err)
|
console.error('Typesense error', err)
|
||||||
|
|
@ -196,16 +171,21 @@ async function runSearch(q: string, append = false) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadMore() {
|
function loadMore() {
|
||||||
|
if (settings.paginationType !== 'infinite_scroll') return
|
||||||
if (loadingMore.value || loading.value || !hasMore.value) 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() {
|
function retry() {
|
||||||
runSearch(query.value, false)
|
runSearch(query.value, activePage.value, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => runSearch(''))
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (timeoutId) clearTimeout(timeoutId)
|
if (timeoutId) clearTimeout(timeoutId)
|
||||||
})
|
})
|
||||||
|
|
@ -214,7 +194,8 @@ watch(debouncedQuery, (q) => {
|
||||||
hits.value = []
|
hits.value = []
|
||||||
total.value = 0
|
total.value = 0
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
runSearch(q, false)
|
activePage.value = 1
|
||||||
|
runSearch(q, 1, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
@ -228,13 +209,35 @@ const isPanelOpen = computed({
|
||||||
set(v: boolean) { if (!v) selected.value = null }
|
set(v: boolean) { if (!v) selected.value = null }
|
||||||
})
|
})
|
||||||
|
|
||||||
// Si el listado se actualiza y el seleccionado ya no está, cerramos el panel.
|
|
||||||
watch(hits, () => {
|
watch(hits, () => {
|
||||||
if (!selected.value) return
|
if (!selected.value) return
|
||||||
const stillThere = hits.value.find(h => h.document.id === selected.value?.id)
|
const stillThere = hits.value.find(h => h.document.id === selected.value?.id)
|
||||||
if (!stillThere) selected.value = null
|
if (!stillThere) selected.value = null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ID del item seleccionado para sincronizar con la URL
|
||||||
|
const selectedId = computed(() => selected.value?.id?.toString() ?? null)
|
||||||
|
|
||||||
|
// Contenedor scrollable de la lista
|
||||||
|
const listEl = ref<HTMLElement | null>(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 => h.document.id === sid0)
|
||||||
|
if (found) selected.value = found.document
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||||
const isMobile = breakpoints.smaller('lg')
|
const isMobile = breakpoints.smaller('lg')
|
||||||
|
|
||||||
|
|
@ -247,8 +250,6 @@ useDetailHistory(isPanelOpen, isMobile)
|
||||||
const favorites = useFavoritesStore()
|
const favorites = useFavoritesStore()
|
||||||
const toast = useToast()
|
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 {
|
function toSearchHit(doc: EntrelineaDoc): SearchHit {
|
||||||
const id = doc.id || ''
|
const id = doc.id || ''
|
||||||
return {
|
return {
|
||||||
|
|
@ -266,7 +267,6 @@ function isFav(doc: EntrelineaDoc): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleFavorite(doc: EntrelineaDoc, ev?: Event) {
|
function toggleFavorite(doc: EntrelineaDoc, ev?: Event) {
|
||||||
// Evita que el click en la estrella abra el detalle.
|
|
||||||
ev?.stopPropagation()
|
ev?.stopPropagation()
|
||||||
if (!doc?.id) return
|
if (!doc?.id) return
|
||||||
const wasFav = isFav(doc)
|
const wasFav = isFav(doc)
|
||||||
|
|
@ -284,14 +284,10 @@ function toggleFavorite(doc: EntrelineaDoc, ev?: Event) {
|
||||||
/* Helpers de presentación */
|
/* 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 {
|
function highlightedFor(hit: TypesenseHit, field: string): string | null {
|
||||||
const fromArr = hit.highlights?.find(h => h.field === field)
|
const fromArr = hit.highlights?.find(h => h.field === field)
|
||||||
if (fromArr?.snippet) return fromArr.snippet
|
if (fromArr?.snippet) return fromArr.snippet
|
||||||
if (fromArr?.value) return fromArr.value
|
if (fromArr?.value) return fromArr.value
|
||||||
// Algunas versiones devuelven `highlight: { field: { snippet, value } }`.
|
|
||||||
const fromObj = hit.highlight?.[field]
|
const fromObj = hit.highlight?.[field]
|
||||||
if (fromObj?.snippet) return fromObj.snippet
|
if (fromObj?.snippet) return fromObj.snippet
|
||||||
if (fromObj?.value) return fromObj.value
|
if (fromObj?.value) return fromObj.value
|
||||||
|
|
@ -348,7 +344,7 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
|
||||||
:actions="[{ label: 'Reintentar', color: 'neutral', variant: 'outline', onClick: retry }]"
|
:actions="[{ label: 'Reintentar', color: 'neutral', variant: 'outline', onClick: retry }]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="overflow-y-auto divide-y divide-default flex-1">
|
<div ref="listEl" class="overflow-y-auto divide-y divide-default flex-1">
|
||||||
<div
|
<div
|
||||||
v-if="loading && !hits.length"
|
v-if="loading && !hits.length"
|
||||||
class="flex items-center justify-center gap-2 py-16 text-sm text-muted"
|
class="flex items-center justify-center gap-2 py-16 text-sm text-muted"
|
||||||
|
|
@ -377,8 +373,6 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
|
||||||
@click="selected = hit.document"
|
@click="selected = hit.document"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between gap-2 mb-1">
|
<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">
|
<div class="min-w-0 flex-1 flex gap-2">
|
||||||
<UBadge
|
<UBadge
|
||||||
v-if="hit.document?.studies?.[0]?.date"
|
v-if="hit.document?.studies?.[0]?.date"
|
||||||
|
|
@ -429,9 +423,6 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
|
||||||
{{ (hit.document?.studies?.[0]?.title as string) || hit.document.id || `entrelinea_${index}` }}
|
{{ (hit.document?.studies?.[0]?.title as string) || hit.document.id || `entrelinea_${index}` }}
|
||||||
</div>
|
</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
|
<div
|
||||||
v-if="highlightedFor(hit, 'text') || hit.document.text"
|
v-if="highlightedFor(hit, 'text') || hit.document.text"
|
||||||
class="snippet-html text-sm text-toned line-clamp-3"
|
class="snippet-html text-sm text-toned line-clamp-3"
|
||||||
|
|
@ -445,8 +436,9 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Infinite scroll: cargando más -->
|
||||||
<div
|
<div
|
||||||
v-if="hasMore && !loading"
|
v-if="settings.paginationType === 'infinite_scroll' && hasMore && !loading"
|
||||||
class="p-4 flex justify-center"
|
class="p-4 flex justify-center"
|
||||||
>
|
>
|
||||||
<UButton
|
<UButton
|
||||||
|
|
@ -461,12 +453,26 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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"
|
class="py-3 text-center text-xs text-dimmed"
|
||||||
>
|
>
|
||||||
No hay más resultados
|
No hay más resultados
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</UDashboardPanel>
|
||||||
|
|
||||||
<!-- Panel de detalle (escritorio) -->
|
<!-- Panel de detalle (escritorio) -->
|
||||||
|
|
@ -503,9 +509,6 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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) {
|
.snippet-html :deep(p) {
|
||||||
display: inline;
|
display: inline;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,11 @@
|
||||||
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { breakpointsTailwind, useDebounce } from '@vueuse/core'
|
import { breakpointsTailwind, useDebounce } from '@vueuse/core'
|
||||||
import EstudiosTypensenseDetail from '~/components/estudiosTypensense/EstudiosTypensenseDetail.vue'
|
import EstudiosTypensenseDetail from '~/components/estudiosTypensense/EstudiosTypensenseDetail.vue'
|
||||||
|
import { useSettingsStore } from '~/stores/settings'
|
||||||
|
|
||||||
const PARAGRAPHS_COLLECTION = 'paragraphs'
|
const PARAGRAPHS_COLLECTION = 'paragraphs'
|
||||||
const DOCUMENTS_COLLECTION = 'documents'
|
const DOCUMENTS_COLLECTION = 'documents'
|
||||||
const QUERY_BY = 'text'
|
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 FAVORITES_COLLECTION = 'bible-studies-ts'
|
||||||
|
|
||||||
const { $i18n } = useNuxtApp()
|
const { $i18n } = useNuxtApp()
|
||||||
|
|
@ -17,7 +16,12 @@ const { locale } = useI18n()
|
||||||
const filterBy = computed(() => `locale:=${locale.value}`)
|
const filterBy = computed(() => `locale:=${locale.value}`)
|
||||||
const REQUEST_TIMEOUT_MS = 15000
|
const REQUEST_TIMEOUT_MS = 15000
|
||||||
|
|
||||||
const query = ref('')
|
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 debouncedQuery = useDebounce(query, 150)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const loadingMore = ref(false)
|
const loadingMore = ref(false)
|
||||||
|
|
@ -49,6 +53,7 @@ interface DocMeta {
|
||||||
|
|
||||||
export interface DocumentDoc extends DocMeta {
|
export interface DocumentDoc extends DocMeta {
|
||||||
code: string
|
code: string
|
||||||
|
locale: string
|
||||||
files?: {
|
files?: {
|
||||||
youtube?: string
|
youtube?: string
|
||||||
video?: string
|
video?: string
|
||||||
|
|
@ -79,23 +84,31 @@ interface TypesenseSearchResponse {
|
||||||
hits?: TypesenseParagraphHit[]
|
hits?: TypesenseParagraphHit[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BrowseItem {
|
||||||
|
docId: string
|
||||||
|
meta: DocMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DisplayGroup {
|
||||||
|
docId: string
|
||||||
|
meta: DocMeta | undefined
|
||||||
|
firstHit: TypesenseParagraphHit | null
|
||||||
|
}
|
||||||
|
|
||||||
// ---- State ----------------------------------------------------------------
|
// ---- 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 hits = ref<TypesenseParagraphHit[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const currentPage = ref(1)
|
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 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 groupedHits = computed(() => {
|
||||||
const map = new Map<string, TypesenseParagraphHit[]>()
|
const map = new Map<string, TypesenseParagraphHit[]>()
|
||||||
for (const hit of hits.value) {
|
for (const hit of hits.value) {
|
||||||
|
|
@ -109,7 +122,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(p0)
|
||||||
|
|
||||||
|
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 docCache = ref<Record<string, DocMeta>>({})
|
||||||
|
|
||||||
const { documentsApi } = useTypesenseApi()
|
const { documentsApi } = useTypesenseApi()
|
||||||
|
|
@ -145,12 +207,12 @@ async function fetchDocumentMeta(docIds: string[]) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Búsqueda plana -------------------------------------------------------
|
// ---- Búsqueda de párrafos (con query) -------------------------------------
|
||||||
|
|
||||||
let searchSeq = 0
|
let searchSeq = 0
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
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
|
const seq = ++searchSeq
|
||||||
if (append) loadingMore.value = true
|
if (append) loadingMore.value = true
|
||||||
else loading.value = true
|
else loading.value = true
|
||||||
|
|
@ -165,7 +227,8 @@ async function runSearch(q: string, append = false) {
|
||||||
}
|
}
|
||||||
}, REQUEST_TIMEOUT_MS)
|
}, 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 {
|
try {
|
||||||
const multi = await documentsApi.multiSearch({
|
const multi = await documentsApi.multiSearch({
|
||||||
|
|
@ -176,8 +239,8 @@ async function runSearch(q: string, append = false) {
|
||||||
q: q || '*',
|
q: q || '*',
|
||||||
queryBy: QUERY_BY,
|
queryBy: QUERY_BY,
|
||||||
filterBy: filterBy.value,
|
filterBy: filterBy.value,
|
||||||
perPage: PAGE_SIZE,
|
perPage: settings.pageSize,
|
||||||
page,
|
page: typePage,
|
||||||
highlightFullFields: QUERY_BY,
|
highlightFullFields: QUERY_BY,
|
||||||
highlightFields: QUERY_BY,
|
highlightFields: QUERY_BY,
|
||||||
highlightStartTag: '<mark class="search-match">',
|
highlightStartTag: '<mark class="search-match">',
|
||||||
|
|
@ -191,17 +254,16 @@ async function runSearch(q: string, append = false) {
|
||||||
const res = (multi?.results?.[0] ?? {}) as TypesenseSearchResponse
|
const res = (multi?.results?.[0] ?? {}) as TypesenseSearchResponse
|
||||||
const newHits = res?.hits ?? []
|
const newHits = res?.hits ?? []
|
||||||
|
|
||||||
// Fetch metadata first so results never flash with bare docIds
|
|
||||||
if (!append) docCache.value = {}
|
if (!append) docCache.value = {}
|
||||||
const newDocIds = [...new Set(newHits.map(h => h.document.document_id).filter(Boolean))]
|
const newDocIds = [...new Set(newHits.map(h => h.document.document_id).filter(Boolean))]
|
||||||
await fetchDocumentMeta(newDocIds)
|
await fetchDocumentMeta(newDocIds)
|
||||||
|
|
||||||
// Abort if a newer search has started while we waited for metadata
|
|
||||||
if (seq !== searchSeq) return
|
if (seq !== searchSeq) return
|
||||||
|
|
||||||
hits.value = append ? hits.value.concat(newHits) : newHits
|
hits.value = append ? hits.value.concat(newHits) : newHits
|
||||||
total.value = res?.found ?? hits.value.length
|
total.value = res?.found ?? hits.value.length
|
||||||
currentPage.value = page
|
currentPage.value = typePage
|
||||||
|
if (!append) activePage.value = page
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (seq !== searchSeq) return
|
if (seq !== searchSeq) return
|
||||||
console.error('Typesense error', err)
|
console.error('Typesense error', err)
|
||||||
|
|
@ -219,38 +281,140 @@ 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() {
|
function loadMore() {
|
||||||
|
if (settings.paginationType !== 'infinite_scroll') return
|
||||||
if (loadingMore.value || loading.value || !hasMore.value) 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)
|
const listContainer = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
function onListScroll() {
|
function onListScroll() {
|
||||||
|
if (settings.paginationType !== 'infinite_scroll') return
|
||||||
const el = listContainer.value
|
const el = listContainer.value
|
||||||
if (!el) return
|
if (!el) return
|
||||||
if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) {
|
if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) {
|
||||||
|
if (!debouncedQuery.value.trim()) {
|
||||||
|
if (hasMoreBrowse.value && !loadingMore.value && !loading.value) {
|
||||||
|
runBrowse(browsePage.value, true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
if (hasMoreVisible.value) {
|
if (hasMoreVisible.value) {
|
||||||
visibleGroupCount.value += 10
|
visibleGroupCount.value += 10
|
||||||
} else if (hasMore.value && !loadingMore.value && !loading.value) {
|
} else if (hasMore.value && !loadingMore.value && !loading.value) {
|
||||||
loadMore()
|
loadMore()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function retry() {
|
function retry() {
|
||||||
runSearch(query.value, false)
|
if (!query.value.trim()) {
|
||||||
|
runBrowse(activePage.value, false)
|
||||||
|
} else {
|
||||||
|
runSearch(query.value, activePage.value, false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => runSearch(''))
|
|
||||||
onBeforeUnmount(() => { if (timeoutId) clearTimeout(timeoutId) })
|
onBeforeUnmount(() => { if (timeoutId) clearTimeout(timeoutId) })
|
||||||
|
|
||||||
watch(debouncedQuery, (q) => {
|
watch(debouncedQuery, (q) => {
|
||||||
|
activePage.value = 1
|
||||||
|
if (!q.trim()) {
|
||||||
hits.value = []
|
hits.value = []
|
||||||
total.value = 0
|
total.value = 0
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
visibleGroupCount.value = 10
|
visibleGroupCount.value = 10
|
||||||
runSearch(q, false)
|
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 ----------------------------------------
|
// ---- Selección y carga del detalle ----------------------------------------
|
||||||
|
|
@ -261,10 +425,7 @@ const documentLoading = ref(false)
|
||||||
const selectedParagraphs = ref<TypesenseParagraphHit[]>([])
|
const selectedParagraphs = ref<TypesenseParagraphHit[]>([])
|
||||||
const paragraphsLoading = ref(false)
|
const paragraphsLoading = ref(false)
|
||||||
|
|
||||||
// Hit original del grupo seleccionado (contiene highlights de Typesense)
|
|
||||||
const selectedHit = ref<TypesenseParagraphHit | null>(null)
|
const selectedHit = ref<TypesenseParagraphHit | null>(null)
|
||||||
|
|
||||||
// Todos los hits del grupo seleccionado (todos los párrafos con highlights de Typesense)
|
|
||||||
const selectedMatchingHits = ref<TypesenseParagraphHit[]>([])
|
const selectedMatchingHits = ref<TypesenseParagraphHit[]>([])
|
||||||
|
|
||||||
async function fetchFullDocument(docId: string) {
|
async function fetchFullDocument(docId: string) {
|
||||||
|
|
@ -299,7 +460,7 @@ async function fetchDocumentParagraphs(docId: string) {
|
||||||
selectedParagraphs.value = []
|
selectedParagraphs.value = []
|
||||||
const PER_PAGE = 250
|
const PER_PAGE = 250
|
||||||
let page = 1
|
let page = 1
|
||||||
let total = 0
|
let totalParagraphs = 0
|
||||||
const all: Array<{ document: ParagraphDoc }> = []
|
const all: Array<{ document: ParagraphDoc }> = []
|
||||||
try {
|
try {
|
||||||
do {
|
do {
|
||||||
|
|
@ -319,10 +480,10 @@ async function fetchDocumentParagraphs(docId: string) {
|
||||||
})
|
})
|
||||||
const result = (res?.results?.[0] as { found?: number, hits?: Array<{ document: ParagraphDoc }> } | undefined)
|
const result = (res?.results?.[0] as { found?: number, hits?: Array<{ document: ParagraphDoc }> } | undefined)
|
||||||
if (!result) break
|
if (!result) break
|
||||||
if (page === 1) total = result.found ?? 0
|
if (page === 1) totalParagraphs = result.found ?? 0
|
||||||
all.push(...(result.hits ?? []))
|
all.push(...(result.hits ?? []))
|
||||||
page++
|
page++
|
||||||
} while (all.length < total)
|
} while (all.length < totalParagraphs)
|
||||||
selectedParagraphs.value = all.map(h => ({ document: h.document }))
|
selectedParagraphs.value = all.map(h => ({ document: h.document }))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching paragraphs', err)
|
console.error('Error fetching paragraphs', err)
|
||||||
|
|
@ -332,10 +493,12 @@ async function fetchDocumentParagraphs(docId: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectGroup(group: { docId: string, firstHit: TypesenseParagraphHit }) {
|
async function selectGroup(group: DisplayGroup) {
|
||||||
selectedDocId.value = group.docId
|
selectedDocId.value = group.docId
|
||||||
selectedHit.value = group.firstHit
|
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)
|
fetchFullDocument(group.docId)
|
||||||
fetchDocumentParagraphs(group.docId)
|
fetchDocumentParagraphs(group.docId)
|
||||||
}
|
}
|
||||||
|
|
@ -354,7 +517,7 @@ const isPanelOpen = computed({
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(groupedHits, () => {
|
watch(groupedHits, () => {
|
||||||
if (!selectedDocId.value) return
|
if (!selectedDocId.value || !debouncedQuery.value.trim()) return
|
||||||
const still = groupedHits.value.find(g => g.docId === selectedDocId.value)
|
const still = groupedHits.value.find(g => g.docId === selectedDocId.value)
|
||||||
if (!still) {
|
if (!still) {
|
||||||
selectedDocId.value = null
|
selectedDocId.value = null
|
||||||
|
|
@ -365,6 +528,39 @@ watch(groupedHits, () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ID del documento seleccionado para sincronizar con la URL
|
||||||
|
const selectedId = computed(() => selectedDocId.value)
|
||||||
|
|
||||||
|
// ── Sincronización con URL ─────────────────────────────────────────────────
|
||||||
|
useSearchUrlSync({ query, page: activePage, selectedId, scrollEl: listContainer })
|
||||||
|
|
||||||
|
// ── Ciclo de vida ──────────────────────────────────────────────────────────
|
||||||
|
onMounted(async () => {
|
||||||
|
// Carga inicial: browse si no hay query, search si hay query
|
||||||
|
if (q0.trim()) {
|
||||||
|
await runSearch(q0, p0, false)
|
||||||
|
} else {
|
||||||
|
await runBrowse(p0, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restaurar scroll
|
||||||
|
restoreScrollPosition(listContainer.value, s0)
|
||||||
|
|
||||||
|
// Restaurar documento seleccionado
|
||||||
|
if (sid0) {
|
||||||
|
const group = displayGroups.value.find(g => g.docId === sid0)
|
||||||
|
if (group) {
|
||||||
|
selectGroup(group)
|
||||||
|
} else {
|
||||||
|
// El documento puede no estar en la lista visible (p.ej. browse con paginación)
|
||||||
|
// Lo cargamos directamente por ID
|
||||||
|
selectedDocId.value = sid0
|
||||||
|
fetchFullDocument(sid0)
|
||||||
|
fetchDocumentParagraphs(sid0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||||
const isMobile = breakpoints.smaller('lg')
|
const isMobile = breakpoints.smaller('lg')
|
||||||
|
|
||||||
|
|
@ -418,7 +614,7 @@ function metaLocation(meta: DocMeta | undefined): string {
|
||||||
<UDashboardSidebarCollapse />
|
<UDashboardSidebarCollapse />
|
||||||
</template>
|
</template>
|
||||||
<template #trailing>
|
<template #trailing>
|
||||||
<UBadge :label="groupedHits.length" variant="subtle" />
|
<UBadge :label="displayTotal" variant="subtle" />
|
||||||
</template>
|
</template>
|
||||||
</UDashboardNavbar>
|
</UDashboardNavbar>
|
||||||
|
|
||||||
|
|
@ -445,7 +641,7 @@ function metaLocation(meta: DocMeta | undefined): string {
|
||||||
|
|
||||||
<div ref="listContainer" class="overflow-y-auto divide-y divide-default flex-1" @scroll="onListScroll">
|
<div ref="listContainer" class="overflow-y-auto divide-y divide-default flex-1" @scroll="onListScroll">
|
||||||
<div
|
<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"
|
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" />
|
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
|
||||||
|
|
@ -453,7 +649,7 @@ function metaLocation(meta: DocMeta | undefined): string {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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"
|
class="flex flex-col items-center justify-center gap-2 py-16 text-dimmed text-sm"
|
||||||
>
|
>
|
||||||
<UIcon name="i-lucide-inbox" class="size-10" />
|
<UIcon name="i-lucide-inbox" class="size-10" />
|
||||||
|
|
@ -461,7 +657,7 @@ function metaLocation(meta: DocMeta | undefined): string {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-for="group in visibleGroups"
|
v-for="group in displayGroups"
|
||||||
:key="group.docId"
|
:key="group.docId"
|
||||||
class="bg-white p-4 sm:px-6 text-sm cursor-pointer border-l-2 transition-colors"
|
class="bg-white p-4 sm:px-6 text-sm cursor-pointer border-l-2 transition-colors"
|
||||||
:class="[
|
:class="[
|
||||||
|
|
@ -471,40 +667,40 @@ function metaLocation(meta: DocMeta | undefined): string {
|
||||||
]"
|
]"
|
||||||
@click="selectGroup(group)"
|
@click="selectGroup(group)"
|
||||||
>
|
>
|
||||||
<!-- Título -->
|
|
||||||
<div class="mb-1">
|
<div class="mb-1">
|
||||||
<p class="text-sm font-semibold line-clamp-2 text-highlighted">
|
<p class="text-sm font-semibold line-clamp-2 text-highlighted">
|
||||||
{{ docCache[group.docId]?.title || group.docId }}
|
{{ group.meta?.title || group.docId }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<p class="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 text-xs mb-2 text-muted">
|
||||||
<span
|
<span
|
||||||
v-if="metaDate(docCache[group.docId])"
|
v-if="metaDate(group.meta)"
|
||||||
class="flex items-center gap-1"
|
class="flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<UIcon name="ph:calendar" class="size-4 text-green-600" />
|
<UIcon name="ph:calendar" class="size-4 text-green-600" />
|
||||||
{{ metaDate(docCache[group.docId]) }}
|
{{ metaDate(group.meta) }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="metaLocation(docCache[group.docId])"
|
v-if="metaLocation(group.meta)"
|
||||||
class="flex items-center gap-1 truncate"
|
class="flex items-center gap-1 truncate"
|
||||||
>
|
>
|
||||||
<UIcon name="ph:map-pin" class="size-4 text-green-600 shrink-0" />
|
<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>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Primer párrafo coincidente con highlight -->
|
<!-- Snippet con highlight: solo en modo búsqueda -->
|
||||||
<div
|
<div
|
||||||
|
v-if="group.firstHit"
|
||||||
class="snippet-html text-sm text-dimmed line-clamp-3"
|
class="snippet-html text-sm text-dimmed line-clamp-3"
|
||||||
v-html="highlightedFor(group.firstHit, 'text') || group.firstHit.document.text"
|
v-html="highlightedFor(group.firstHit, 'text') || group.firstHit.document.text"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Infinite scroll: cargando más -->
|
||||||
<div
|
<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"
|
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" />
|
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
|
||||||
|
|
@ -512,12 +708,26 @@ function metaLocation(meta: DocMeta | undefined): string {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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"
|
class="py-3 text-center text-xs text-dimmed"
|
||||||
>
|
>
|
||||||
No hay más resultados
|
No hay más resultados
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</UDashboardPanel>
|
||||||
|
|
||||||
<!-- Panel de detalle (escritorio) -->
|
<!-- 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
|
||||||
|
}
|
||||||
|
})
|
||||||
14
lang/en.json
14
lang/en.json
|
|
@ -7,7 +7,8 @@
|
||||||
"conferences": "Conferences",
|
"conferences": "Conferences",
|
||||||
"between_the_lines": "Between the Lines",
|
"between_the_lines": "Between the Lines",
|
||||||
"my_list": "My List",
|
"my_list": "My List",
|
||||||
"history": "History"
|
"history": "History",
|
||||||
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Search for...",
|
"placeholder": "Search for...",
|
||||||
|
|
@ -29,5 +30,16 @@
|
||||||
"tab2": {
|
"tab2": {
|
||||||
"tab_title": "Conferences"
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
15
lang/es.json
15
lang/es.json
|
|
@ -6,7 +6,8 @@
|
||||||
"conferences": "Conferencias",
|
"conferences": "Conferencias",
|
||||||
"between_the_lines": "Entrelíneas",
|
"between_the_lines": "Entrelíneas",
|
||||||
"my_list": "Mi Listado",
|
"my_list": "Mi Listado",
|
||||||
"history": "Historial"
|
"history": "Historial",
|
||||||
|
"settings": "Configuración"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Buscar...",
|
"placeholder": "Buscar...",
|
||||||
|
|
@ -33,7 +34,17 @@
|
||||||
},
|
},
|
||||||
"tab2": {
|
"tab2": {
|
||||||
"tab_title": "Conferencias"
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
14
lang/fr.json
14
lang/fr.json
|
|
@ -7,7 +7,8 @@
|
||||||
"conferences": "Conférences",
|
"conferences": "Conférences",
|
||||||
"between_the_lines": "Entre les lignes",
|
"between_the_lines": "Entre les lignes",
|
||||||
"my_list": "Ma liste",
|
"my_list": "Ma liste",
|
||||||
"history": "Historique"
|
"history": "Historique",
|
||||||
|
"settings": "Paramètres"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Rechercher des activités"
|
"placeholder": "Rechercher des activités"
|
||||||
|
|
@ -17,5 +18,16 @@
|
||||||
"es": "Español",
|
"es": "Español",
|
||||||
"fr": "Français",
|
"fr": "Français",
|
||||||
"pt": "Português"
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
14
lang/pt.json
14
lang/pt.json
|
|
@ -7,7 +7,8 @@
|
||||||
"conferences": "Conferências",
|
"conferences": "Conferências",
|
||||||
"between_the_lines": "Entre as linhas",
|
"between_the_lines": "Entre as linhas",
|
||||||
"my_list": "Minha lista",
|
"my_list": "Minha lista",
|
||||||
"history": "Registro"
|
"history": "Registro",
|
||||||
|
"settings": "Configurações"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Digite para pesquisar...",
|
"placeholder": "Digite para pesquisar...",
|
||||||
|
|
@ -27,5 +28,16 @@
|
||||||
"country": "País",
|
"country": "País",
|
||||||
"hits_per_page": "Resultados por página",
|
"hits_per_page": "Resultados por página",
|
||||||
"hits_retrieved_in": "Resultados recuperados em"
|
"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