Compare commits
No commits in common. "af88c88aa63e105410bd9844203a0ce4a3864688" and "756c62af86ce9719fbc07472f22421d82de7fa86" have entirely different histories.
af88c88aa6
...
756c62af86
|
|
@ -16,9 +16,6 @@ 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()
|
||||||
|
|
@ -642,11 +639,6 @@ 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)
|
||||||
|
|
@ -664,7 +656,7 @@ useIntersectionObserver(
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="listEl" class="overflow-y-auto divide-y divide-default flex-1">
|
<div 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"
|
||||||
|
|
@ -779,7 +771,7 @@ useIntersectionObserver(
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else-if="rows.length && !hasMore && !loading && showEndMessage !== false"
|
v-else-if="rows.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
|
||||||
|
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
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,12 +59,6 @@ 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,40 +3,32 @@ 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 settings = useSettingsStore()
|
const query = ref('')
|
||||||
|
|
||||||
// ── 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 totalPages = computed(() => Math.max(1, Math.ceil(total.value / settings.pageSize)))
|
const hasMore = computed(() => hits.value.length < total.value)
|
||||||
|
|
||||||
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, page = 1, append = false) {
|
async function runSearch(q: string, 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
|
||||||
|
|
@ -46,19 +38,15 @@ async function runSearch(q: string, page = 1, 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: settings.pageSize,
|
limit: PAGE_SIZE,
|
||||||
offset,
|
offset: append ? hits.value.length : 0,
|
||||||
sort: q ? undefined : ['isodate:desc']
|
sort: q ? undefined : ['isodate:desc']
|
||||||
}, { signal: ac.signal })
|
}, { signal: ac.signal })
|
||||||
|
|
||||||
|
|
@ -67,9 +55,9 @@ async function runSearch(q: string, page = 1, 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)
|
||||||
|
|
@ -88,64 +76,28 @@ async function runSearch(q: string, page = 1, 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, activePage.value, true)
|
runSearch(query.value, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function goToPage(p: number) {
|
// Initial fetch on the client (avoids blocking SSR).
|
||||||
activePage.value = p
|
onMounted(() => runSearch(''))
|
||||||
hits.value = []
|
|
||||||
runSearch(query.value, p, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function retry() {
|
|
||||||
runSearch(query.value, activePage.value, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Selección ──────────────────────────────────────────────────────────────
|
|
||||||
const selectedActivity = ref<SearchHit | null>(null)
|
|
||||||
|
|
||||||
const isActivityPanelOpen = computed({
|
|
||||||
get() { return !!selectedActivity.value },
|
|
||||||
set(value: boolean) { if (!value) selectedActivity.value = null }
|
|
||||||
})
|
|
||||||
|
|
||||||
// ID del item seleccionado para sincronizar con la URL
|
|
||||||
const selectedId = computed(() => selectedActivity.value?._id?.toString() ?? null)
|
|
||||||
|
|
||||||
// ── Scroll ref expuesto desde InboxList ────────────────────────────────────
|
|
||||||
const inboxListRef = ref<{ listEl: HTMLElement | null } | null>(null)
|
|
||||||
const listEl = computed(() => inboxListRef.value?.listEl ?? null)
|
|
||||||
|
|
||||||
// ── Sincronización con URL ─────────────────────────────────────────────────
|
|
||||||
useSearchUrlSync({ query, page: activePage, selectedId, scrollEl: listEl })
|
|
||||||
|
|
||||||
// ── Ciclo de vida ──────────────────────────────────────────────────────────
|
|
||||||
onMounted(async () => {
|
|
||||||
await runSearch(q0, p0, false)
|
|
||||||
|
|
||||||
// Restaurar scroll
|
|
||||||
restoreScrollPosition(listEl.value, s0)
|
|
||||||
|
|
||||||
// Restaurar item seleccionado
|
|
||||||
if (sid0) {
|
|
||||||
const found = hits.value.find(h => String(h._id) === sid0)
|
|
||||||
if (found) selectedActivity.value = found
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
abortController?.abort()
|
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) => {
|
watch(debouncedQuery, (q) => {
|
||||||
hits.value = []
|
hits.value = []
|
||||||
total.value = 0
|
total.value = 0
|
||||||
activePage.value = 1
|
runSearch(q, false)
|
||||||
runSearch(q, 1, false)
|
})
|
||||||
|
|
||||||
|
const selectedActivity = ref<SearchHit | null>(null)
|
||||||
|
|
||||||
|
const isActivityPanelOpen = computed({
|
||||||
|
get() { return !!selectedActivity.value },
|
||||||
|
set(value: boolean) { if (!value) selectedActivity.value = null }
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(hits, () => {
|
watch(hits, () => {
|
||||||
|
|
@ -158,6 +110,10 @@ 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>
|
||||||
|
|
@ -204,30 +160,15 @@ useDetailHistory(isActivityPanelOpen, isMobile)
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<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,19 +3,14 @@ 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 settings = useSettingsStore()
|
const query = ref('')
|
||||||
|
|
||||||
// ── 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)
|
||||||
|
|
@ -25,18 +20,13 @@ const meili = useMeiliSearchRef()
|
||||||
|
|
||||||
const hits = ref<SearchHit[]>([])
|
const hits = ref<SearchHit[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const activePage = ref(p0)
|
|
||||||
|
|
||||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / settings.pageSize)))
|
const hasMore = computed(() => hits.value.length < total.value)
|
||||||
|
|
||||||
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, page = 1, append = false) {
|
async function runSearch(q: string, append = false) {
|
||||||
abortController?.abort()
|
abortController?.abort()
|
||||||
const ac = new AbortController()
|
const ac = new AbortController()
|
||||||
abortController = ac
|
abortController = ac
|
||||||
|
|
@ -48,17 +38,12 @@ async function runSearch(q: string, page = 1, 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: settings.pageSize,
|
limit: PAGE_SIZE,
|
||||||
offset,
|
offset: append ? hits.value.length : 0,
|
||||||
sort: q ? undefined : ['isodate:desc']
|
sort: q ? undefined : ['isodate:desc']
|
||||||
}, { signal: ac.signal })
|
}, { signal: ac.signal })
|
||||||
|
|
||||||
|
|
@ -67,7 +52,6 @@ async function runSearch(q: string, page = 1, 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
|
||||||
|
|
@ -88,64 +72,27 @@ async function runSearch(q: string, page = 1, 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, activePage.value, true)
|
runSearch(query.value, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function goToPage(p: number) {
|
onMounted(() => runSearch(''))
|
||||||
activePage.value = p
|
|
||||||
hits.value = []
|
|
||||||
runSearch(query.value, p, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function retry() {
|
|
||||||
runSearch(query.value, activePage.value, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Selección ──────────────────────────────────────────────────────────────
|
|
||||||
const selected = ref<SearchHit | null>(null)
|
|
||||||
|
|
||||||
const isPanelOpen = computed({
|
|
||||||
get() { return !!selected.value },
|
|
||||||
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(() => {
|
onBeforeUnmount(() => {
|
||||||
abortController?.abort()
|
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) => {
|
watch(debouncedQuery, (q) => {
|
||||||
hits.value = []
|
hits.value = []
|
||||||
total.value = 0
|
total.value = 0
|
||||||
activePage.value = 1
|
runSearch(q, false)
|
||||||
runSearch(q, 1, false)
|
})
|
||||||
|
|
||||||
|
const selected = ref<SearchHit | null>(null)
|
||||||
|
|
||||||
|
const isPanelOpen = computed({
|
||||||
|
get() { return !!selected.value },
|
||||||
|
set(value: boolean) { if (!value) selected.value = null }
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(hits, () => {
|
watch(hits, () => {
|
||||||
|
|
@ -158,6 +105,10 @@ 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>
|
||||||
|
|
@ -168,7 +119,7 @@ useDetailHistory(isPanelOpen, isMobile)
|
||||||
:max-size="40"
|
:max-size="40"
|
||||||
resizable
|
resizable
|
||||||
>
|
>
|
||||||
<UDashboardNavbar :title="t('nav.conferences')">
|
<UDashboardNavbar title="Conferencias">
|
||||||
<template #leading>
|
<template #leading>
|
||||||
<UDashboardSidebarCollapse />
|
<UDashboardSidebarCollapse />
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -208,30 +159,15 @@ useDetailHistory(isPanelOpen, isMobile)
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<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
|
||||||
|
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
<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,27 +3,52 @@ 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
|
||||||
|
|
@ -31,12 +56,7 @@ const filterBy = computed(() => {
|
||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = 15000
|
const REQUEST_TIMEOUT_MS = 15000
|
||||||
|
|
||||||
const settings = useSettingsStore()
|
const query = ref('')
|
||||||
|
|
||||||
// ── 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)
|
||||||
|
|
@ -87,30 +107,29 @@ 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 totalPages = computed(() => Math.max(1, Math.ceil(total.value / settings.pageSize)))
|
const hasMore = computed(() => hits.value.length < total.value)
|
||||||
|
|
||||||
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, page = 1, append = false) {
|
async function runSearch(q: string, 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
|
||||||
|
|
@ -119,23 +138,29 @@ async function runSearch(q: string, page = 1, append = false) {
|
||||||
}
|
}
|
||||||
}, REQUEST_TIMEOUT_MS)
|
}, REQUEST_TIMEOUT_MS)
|
||||||
|
|
||||||
const isInfinite = settings.paginationType === 'infinite_scroll'
|
const page = append ? currentPage.value + 1 : 1
|
||||||
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: settings.pageSize,
|
perPage: PAGE_SIZE,
|
||||||
page: typePage,
|
page,
|
||||||
|
// Resaltado nativo de Typesense — devuelve `value` con los matches
|
||||||
|
// envueltos en <mark class="search-match"> para que estilemos en
|
||||||
|
// CSS junto con los otros buscadores (ya hay regla global).
|
||||||
highlightFullFields: QUERY_BY,
|
highlightFullFields: QUERY_BY,
|
||||||
highlightFields: QUERY_BY,
|
highlightFields: QUERY_BY,
|
||||||
highlightStartTag: '<mark class="search-match">',
|
highlightStartTag: '<mark class="search-match">',
|
||||||
|
|
@ -144,6 +169,7 @@ async function runSearch(q: string, page = 1, 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
|
||||||
|
|
@ -151,8 +177,7 @@ async function runSearch(q: string, page = 1, 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 = typePage
|
currentPage.value = page
|
||||||
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)
|
||||||
|
|
@ -171,21 +196,16 @@ async function runSearch(q: string, page = 1, 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, currentPage.value, true)
|
runSearch(query.value, true)
|
||||||
}
|
|
||||||
|
|
||||||
function goToPage(p: number) {
|
|
||||||
activePage.value = p
|
|
||||||
hits.value = []
|
|
||||||
runSearch(query.value, p, false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function retry() {
|
function retry() {
|
||||||
runSearch(query.value, activePage.value, false)
|
runSearch(query.value, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => runSearch(''))
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (timeoutId) clearTimeout(timeoutId)
|
if (timeoutId) clearTimeout(timeoutId)
|
||||||
})
|
})
|
||||||
|
|
@ -194,8 +214,7 @@ watch(debouncedQuery, (q) => {
|
||||||
hits.value = []
|
hits.value = []
|
||||||
total.value = 0
|
total.value = 0
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
activePage.value = 1
|
runSearch(q, false)
|
||||||
runSearch(q, 1, false)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
@ -209,35 +228,13 @@ 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')
|
||||||
|
|
||||||
|
|
@ -250,6 +247,8 @@ 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 {
|
||||||
|
|
@ -267,6 +266,7 @@ 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,10 +284,14 @@ 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
|
||||||
|
|
@ -344,7 +348,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 ref="listEl" class="overflow-y-auto divide-y divide-default flex-1">
|
<div 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"
|
||||||
|
|
@ -373,6 +377,8 @@ 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"
|
||||||
|
|
@ -423,6 +429,9 @@ 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"
|
||||||
|
|
@ -436,9 +445,8 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Infinite scroll: cargando más -->
|
|
||||||
<div
|
<div
|
||||||
v-if="settings.paginationType === 'infinite_scroll' && hasMore && !loading"
|
v-if="hasMore && !loading"
|
||||||
class="p-4 flex justify-center"
|
class="p-4 flex justify-center"
|
||||||
>
|
>
|
||||||
<UButton
|
<UButton
|
||||||
|
|
@ -453,26 +461,12 @@ function highlightedFor(hit: TypesenseHit, field: string): string | null {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else-if="settings.paginationType === 'infinite_scroll' && hits.length && !hasMore && !loading"
|
v-else-if="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) -->
|
||||||
|
|
@ -509,6 +503,9 @@ 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,11 +2,12 @@
|
||||||
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()
|
||||||
|
|
@ -16,12 +17,7 @@ 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 settings = useSettingsStore()
|
const query = ref('')
|
||||||
|
|
||||||
// ── 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)
|
||||||
|
|
@ -53,7 +49,6 @@ 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
|
||||||
|
|
@ -84,31 +79,23 @@ interface TypesenseSearchResponse {
|
||||||
hits?: TypesenseParagraphHit[]
|
hits?: TypesenseParagraphHit[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BrowseItem {
|
|
||||||
docId: string
|
|
||||||
meta: DocMeta
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DisplayGroup {
|
|
||||||
docId: string
|
|
||||||
meta: DocMeta | undefined
|
|
||||||
firstHit: TypesenseParagraphHit | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- State ----------------------------------------------------------------
|
// ---- State ----------------------------------------------------------------
|
||||||
|
|
||||||
// Modo búsqueda (con query): hits de párrafos agrupados por documento
|
// hits: lista plana de párrafos del buscador (sin agrupar)
|
||||||
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)
|
||||||
|
|
||||||
const hasMore = computed(() =>
|
// Progressive display: show 10 groups at a time, reveal more on scroll
|
||||||
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) {
|
||||||
|
|
@ -122,56 +109,7 @@ const groupedHits = computed(() => {
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
const visibleGroups = computed(() =>
|
// Cache de metadatos por document_id
|
||||||
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()
|
||||||
|
|
@ -207,12 +145,12 @@ async function fetchDocumentMeta(docIds: string[]) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Búsqueda de párrafos (con query) -------------------------------------
|
// ---- Búsqueda plana -------------------------------------------------------
|
||||||
|
|
||||||
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, page = 1, append = false) {
|
async function runSearch(q: string, 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
|
||||||
|
|
@ -227,8 +165,7 @@ async function runSearch(q: string, page = 1, append = false) {
|
||||||
}
|
}
|
||||||
}, REQUEST_TIMEOUT_MS)
|
}, REQUEST_TIMEOUT_MS)
|
||||||
|
|
||||||
const isInfinite = settings.paginationType === 'infinite_scroll'
|
const page = append ? currentPage.value + 1 : 1
|
||||||
const typePage = isInfinite ? (append ? currentPage.value + 1 : 1) : page
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const multi = await documentsApi.multiSearch({
|
const multi = await documentsApi.multiSearch({
|
||||||
|
|
@ -239,8 +176,8 @@ async function runSearch(q: string, page = 1, append = false) {
|
||||||
q: q || '*',
|
q: q || '*',
|
||||||
queryBy: QUERY_BY,
|
queryBy: QUERY_BY,
|
||||||
filterBy: filterBy.value,
|
filterBy: filterBy.value,
|
||||||
perPage: settings.pageSize,
|
perPage: PAGE_SIZE,
|
||||||
page: typePage,
|
page,
|
||||||
highlightFullFields: QUERY_BY,
|
highlightFullFields: QUERY_BY,
|
||||||
highlightFields: QUERY_BY,
|
highlightFields: QUERY_BY,
|
||||||
highlightStartTag: '<mark class="search-match">',
|
highlightStartTag: '<mark class="search-match">',
|
||||||
|
|
@ -254,16 +191,17 @@ async function runSearch(q: string, page = 1, 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 = typePage
|
currentPage.value = page
|
||||||
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)
|
||||||
|
|
@ -281,140 +219,38 @@ async function runSearch(q: string, page = 1, 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, currentPage.value, true)
|
runSearch(query.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 (hasMoreVisible.value) {
|
||||||
if (hasMoreBrowse.value && !loadingMore.value && !loading.value) {
|
visibleGroupCount.value += 10
|
||||||
runBrowse(browsePage.value, true)
|
} else if (hasMore.value && !loadingMore.value && !loading.value) {
|
||||||
}
|
loadMore()
|
||||||
} else {
|
|
||||||
if (hasMoreVisible.value) {
|
|
||||||
visibleGroupCount.value += 10
|
|
||||||
} else if (hasMore.value && !loadingMore.value && !loading.value) {
|
|
||||||
loadMore()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function retry() {
|
function retry() {
|
||||||
if (!query.value.trim()) {
|
runSearch(query.value, false)
|
||||||
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
|
hits.value = []
|
||||||
if (!q.trim()) {
|
total.value = 0
|
||||||
hits.value = []
|
currentPage.value = 1
|
||||||
total.value = 0
|
visibleGroupCount.value = 10
|
||||||
currentPage.value = 1
|
runSearch(q, false)
|
||||||
visibleGroupCount.value = 10
|
|
||||||
browseItems.value = []
|
|
||||||
browseTotal.value = 0
|
|
||||||
browsePage.value = 1
|
|
||||||
runBrowse(1, false)
|
|
||||||
} else {
|
|
||||||
browseItems.value = []
|
|
||||||
browseTotal.value = 0
|
|
||||||
browsePage.value = 1
|
|
||||||
hits.value = []
|
|
||||||
total.value = 0
|
|
||||||
currentPage.value = 1
|
|
||||||
visibleGroupCount.value = 10
|
|
||||||
runSearch(q, 1, false)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---- Selección y carga del detalle ----------------------------------------
|
// ---- Selección y carga del detalle ----------------------------------------
|
||||||
|
|
@ -425,7 +261,10 @@ 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) {
|
||||||
|
|
@ -460,7 +299,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 totalParagraphs = 0
|
let total = 0
|
||||||
const all: Array<{ document: ParagraphDoc }> = []
|
const all: Array<{ document: ParagraphDoc }> = []
|
||||||
try {
|
try {
|
||||||
do {
|
do {
|
||||||
|
|
@ -480,10 +319,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) totalParagraphs = result.found ?? 0
|
if (page === 1) total = result.found ?? 0
|
||||||
all.push(...(result.hits ?? []))
|
all.push(...(result.hits ?? []))
|
||||||
page++
|
page++
|
||||||
} while (all.length < totalParagraphs)
|
} while (all.length < total)
|
||||||
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)
|
||||||
|
|
@ -493,12 +332,10 @@ async function fetchDocumentParagraphs(docId: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectGroup(group: DisplayGroup) {
|
async function selectGroup(group: { docId: string, firstHit: TypesenseParagraphHit }) {
|
||||||
selectedDocId.value = group.docId
|
selectedDocId.value = group.docId
|
||||||
selectedHit.value = group.firstHit
|
selectedHit.value = group.firstHit
|
||||||
selectedMatchingHits.value = group.firstHit
|
selectedMatchingHits.value = hits.value.filter(h => h.document.document_id === group.docId)
|
||||||
? hits.value.filter(h => h.document.document_id === group.docId)
|
|
||||||
: []
|
|
||||||
fetchFullDocument(group.docId)
|
fetchFullDocument(group.docId)
|
||||||
fetchDocumentParagraphs(group.docId)
|
fetchDocumentParagraphs(group.docId)
|
||||||
}
|
}
|
||||||
|
|
@ -517,7 +354,7 @@ const isPanelOpen = computed({
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(groupedHits, () => {
|
watch(groupedHits, () => {
|
||||||
if (!selectedDocId.value || !debouncedQuery.value.trim()) return
|
if (!selectedDocId.value) 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
|
||||||
|
|
@ -528,39 +365,6 @@ 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')
|
||||||
|
|
||||||
|
|
@ -614,7 +418,7 @@ function metaLocation(meta: DocMeta | undefined): string {
|
||||||
<UDashboardSidebarCollapse />
|
<UDashboardSidebarCollapse />
|
||||||
</template>
|
</template>
|
||||||
<template #trailing>
|
<template #trailing>
|
||||||
<UBadge :label="displayTotal" variant="subtle" />
|
<UBadge :label="groupedHits.length" variant="subtle" />
|
||||||
</template>
|
</template>
|
||||||
</UDashboardNavbar>
|
</UDashboardNavbar>
|
||||||
|
|
||||||
|
|
@ -641,7 +445,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 && !displayGroups.length"
|
v-if="loading && !groupedHits.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" />
|
||||||
|
|
@ -649,7 +453,7 @@ function metaLocation(meta: DocMeta | undefined): string {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else-if="!displayGroups.length"
|
v-else-if="!groupedHits.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" />
|
||||||
|
|
@ -657,7 +461,7 @@ function metaLocation(meta: DocMeta | undefined): string {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-for="group in displayGroups"
|
v-for="group in visibleGroups"
|
||||||
: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="[
|
||||||
|
|
@ -667,40 +471,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">
|
||||||
{{ group.meta?.title || group.docId }}
|
{{ docCache[group.docId]?.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(group.meta)"
|
v-if="metaDate(docCache[group.docId])"
|
||||||
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(group.meta) }}
|
{{ metaDate(docCache[group.docId]) }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="metaLocation(group.meta)"
|
v-if="metaLocation(docCache[group.docId])"
|
||||||
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(group.meta) }}</span>
|
<span class="truncate">{{ metaLocation(docCache[group.docId]) }}</span>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Snippet con highlight: solo en modo búsqueda -->
|
<!-- Primer párrafo coincidente con highlight -->
|
||||||
<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="settings.paginationType === 'infinite_scroll' && loadingMore"
|
v-if="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" />
|
||||||
|
|
@ -708,26 +512,12 @@ function metaLocation(meta: DocMeta | undefined): string {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else-if="settings.paginationType === 'infinite_scroll' && displayGroups.length && !hasMoreBrowse && !hasMoreVisible && !hasMore && !loading"
|
v-else-if="groupedHits.length && !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) -->
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
|
|
||||||
export type PaginationType = 'infinite_scroll' | 'numbered'
|
|
||||||
|
|
||||||
export const PAGE_SIZE_OPTIONS = [10, 20, 30, 40, 50] as const
|
|
||||||
export type PageSizeOption = typeof PAGE_SIZE_OPTIONS[number]
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'lgcc:settings:v1'
|
|
||||||
|
|
||||||
interface SettingsData {
|
|
||||||
pageSize: PageSizeOption
|
|
||||||
paginationType: PaginationType
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULTS: SettingsData = {
|
|
||||||
pageSize: 10,
|
|
||||||
paginationType: 'infinite_scroll'
|
|
||||||
}
|
|
||||||
|
|
||||||
function readStorage(): SettingsData {
|
|
||||||
if (typeof window === 'undefined') return { ...DEFAULTS }
|
|
||||||
try {
|
|
||||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
|
||||||
if (!raw) return { ...DEFAULTS }
|
|
||||||
const parsed = JSON.parse(raw) as Partial<SettingsData>
|
|
||||||
return {
|
|
||||||
pageSize: (PAGE_SIZE_OPTIONS as readonly number[]).includes(parsed.pageSize as number)
|
|
||||||
? (parsed.pageSize as PageSizeOption)
|
|
||||||
: DEFAULTS.pageSize,
|
|
||||||
paginationType:
|
|
||||||
parsed.paginationType === 'numbered' || parsed.paginationType === 'infinite_scroll'
|
|
||||||
? parsed.paginationType
|
|
||||||
: DEFAULTS.paginationType
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return { ...DEFAULTS }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeStorage(data: SettingsData) {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('No se pudieron guardar los ajustes', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSettingsStore = defineStore('settings', () => {
|
|
||||||
const pageSize = ref<PageSizeOption>(DEFAULTS.pageSize)
|
|
||||||
const paginationType = ref<PaginationType>(DEFAULTS.paginationType)
|
|
||||||
const ready = ref(false)
|
|
||||||
let hydrated = false
|
|
||||||
|
|
||||||
function hydrate() {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
if (hydrated) return
|
|
||||||
const data = readStorage()
|
|
||||||
pageSize.value = data.pageSize
|
|
||||||
paginationType.value = data.paginationType
|
|
||||||
ready.value = true
|
|
||||||
hydrated = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
watch([pageSize, paginationType], () => {
|
|
||||||
if (!hydrated) return
|
|
||||||
writeStorage({ pageSize: pageSize.value, paginationType: paginationType.value })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
pageSize,
|
|
||||||
paginationType,
|
|
||||||
ready,
|
|
||||||
hydrate
|
|
||||||
}
|
|
||||||
})
|
|
||||||
16
lang/en.json
16
lang/en.json
|
|
@ -7,8 +7,7 @@
|
||||||
"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...",
|
||||||
|
|
@ -30,16 +29,5 @@
|
||||||
"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."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
19
lang/es.json
19
lang/es.json
|
|
@ -6,8 +6,7 @@
|
||||||
"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...",
|
||||||
|
|
@ -34,17 +33,7 @@
|
||||||
},
|
},
|
||||||
"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."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
16
lang/fr.json
16
lang/fr.json
|
|
@ -7,8 +7,7 @@
|
||||||
"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"
|
||||||
|
|
@ -18,16 +17,5 @@
|
||||||
"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."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
16
lang/pt.json
16
lang/pt.json
|
|
@ -7,8 +7,7 @@
|
||||||
"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...",
|
||||||
|
|
@ -28,16 +27,5 @@
|
||||||
"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