query params data search

This commit is contained in:
David Ascanio 2026-05-17 10:55:05 -03:00
parent e9117a1adf
commit af88c88aa6
6 changed files with 307 additions and 39 deletions

View File

@ -642,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)
@ -659,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"

View File

@ -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)
}

View File

@ -12,7 +12,10 @@ const REQUEST_TIMEOUT_MS = 15000
const settings = useSettingsStore() 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)
@ -22,7 +25,7 @@ const meili = useMeiliSearchRef()
const hits = ref<SearchHit[]>([]) const hits = ref<SearchHit[]>([])
const total = ref(0) const total = ref(0)
const activePage = ref(1) const activePage = ref(p0)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / settings.pageSize))) const totalPages = computed(() => Math.max(1, Math.ceil(total.value / settings.pageSize)))
@ -100,19 +103,7 @@ function retry() {
runSearch(query.value, activePage.value, false) runSearch(query.value, activePage.value, false)
} }
onMounted(() => runSearch('', 1, false)) // Selección
onBeforeUnmount(() => {
abortController?.abort()
})
watch(debouncedQuery, (q) => {
hits.value = []
total.value = 0
activePage.value = 1
runSearch(q, 1, false)
})
const selectedActivity = ref<SearchHit | null>(null) const selectedActivity = ref<SearchHit | null>(null)
const isActivityPanelOpen = computed({ const isActivityPanelOpen = computed({
@ -120,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)
@ -176,6 +204,7 @@ useDetailHistory(isActivityPanelOpen, isMobile)
/> />
<InboxList <InboxList
ref="inboxListRef"
v-model="selectedActivity" v-model="selectedActivity"
:activities="hits" :activities="hits"
:query="debouncedQuery" :query="debouncedQuery"

View File

@ -12,7 +12,10 @@ const REQUEST_TIMEOUT_MS = 15000
const settings = useSettingsStore() 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)
@ -22,7 +25,7 @@ const meili = useMeiliSearchRef()
const hits = ref<SearchHit[]>([]) const hits = ref<SearchHit[]>([])
const total = ref(0) const total = ref(0)
const activePage = ref(1) const activePage = ref(p0)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / settings.pageSize))) const totalPages = computed(() => Math.max(1, Math.ceil(total.value / settings.pageSize)))
@ -100,19 +103,7 @@ function retry() {
runSearch(query.value, activePage.value, false) runSearch(query.value, activePage.value, false)
} }
onMounted(() => runSearch('', 1, false)) // Selección
onBeforeUnmount(() => {
abortController?.abort()
})
watch(debouncedQuery, (q) => {
hits.value = []
total.value = 0
activePage.value = 1
runSearch(q, 1, false)
})
const selected = ref<SearchHit | null>(null) const selected = ref<SearchHit | null>(null)
const isPanelOpen = computed({ const isPanelOpen = computed({
@ -120,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)
@ -180,6 +208,7 @@ useDetailHistory(isPanelOpen, isMobile)
/> />
<InboxList <InboxList
ref="inboxListRef"
v-model="selected" v-model="selected"
:activities="hits" :activities="hits"
:query="debouncedQuery" :query="debouncedQuery"

View File

@ -33,7 +33,10 @@ const REQUEST_TIMEOUT_MS = 15000
const settings = useSettingsStore() 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)
@ -84,7 +87,7 @@ 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(1) const activePage = ref(p0)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / settings.pageSize))) const totalPages = computed(() => Math.max(1, Math.ceil(total.value / settings.pageSize)))
@ -183,8 +186,6 @@ function retry() {
runSearch(query.value, activePage.value, false) runSearch(query.value, activePage.value, false)
} }
onMounted(() => runSearch('', 1, false))
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (timeoutId) clearTimeout(timeoutId) if (timeoutId) clearTimeout(timeoutId)
}) })
@ -214,6 +215,29 @@ watch(hits, () => {
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')
@ -320,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"

View File

@ -18,7 +18,10 @@ const REQUEST_TIMEOUT_MS = 15000
const settings = useSettingsStore() 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)
@ -158,7 +161,7 @@ const displayGroups = computed((): DisplayGroup[] => {
}) })
// Paginación activa (compartida entre browse y search) // Paginación activa (compartida entre browse y search)
const activePage = ref(1) const activePage = ref(p0)
const displayTotal = computed(() => const displayTotal = computed(() =>
debouncedQuery.value.trim() ? total.value : browseTotal.value debouncedQuery.value.trim() ? total.value : browseTotal.value
@ -389,7 +392,6 @@ function retry() {
} }
} }
onMounted(() => runBrowse(1, false))
onBeforeUnmount(() => { if (timeoutId) clearTimeout(timeoutId) }) onBeforeUnmount(() => { if (timeoutId) clearTimeout(timeoutId) })
watch(debouncedQuery, (q) => { watch(debouncedQuery, (q) => {
@ -526,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')