query params data search
This commit is contained in:
parent
e9117a1adf
commit
af88c88aa6
|
|
@ -642,6 +642,11 @@ const rows = computed<RowVm[]>(() => {
|
|||
return result
|
||||
})
|
||||
|
||||
// ---- Scroll element (exposed for parent scroll tracking / restoration) ---
|
||||
|
||||
const listEl = ref<HTMLElement | null>(null)
|
||||
defineExpose({ listEl })
|
||||
|
||||
// ---- Infinite scroll ---------------------------------------------------
|
||||
|
||||
const sentinel = ref<HTMLElement | null>(null)
|
||||
|
|
@ -659,7 +664,7 @@ useIntersectionObserver(
|
|||
</script>
|
||||
|
||||
<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
|
||||
v-if="loading && !rows.length"
|
||||
class="flex items-center justify-center gap-2 py-16 text-sm text-muted"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -12,7 +12,10 @@ 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 loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
|
|
@ -22,7 +25,7 @@ const meili = useMeiliSearchRef()
|
|||
|
||||
const hits = ref<SearchHit[]>([])
|
||||
const total = ref(0)
|
||||
const activePage = ref(1)
|
||||
const activePage = ref(p0)
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / settings.pageSize)))
|
||||
|
||||
|
|
@ -100,19 +103,7 @@ function retry() {
|
|||
runSearch(query.value, activePage.value, false)
|
||||
}
|
||||
|
||||
onMounted(() => runSearch('', 1, false))
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
abortController?.abort()
|
||||
})
|
||||
|
||||
watch(debouncedQuery, (q) => {
|
||||
hits.value = []
|
||||
total.value = 0
|
||||
activePage.value = 1
|
||||
runSearch(q, 1, false)
|
||||
})
|
||||
|
||||
// ── Selección ──────────────────────────────────────────────────────────────
|
||||
const selectedActivity = ref<SearchHit | null>(null)
|
||||
|
||||
const isActivityPanelOpen = computed({
|
||||
|
|
@ -120,6 +111,43 @@ const isActivityPanelOpen = computed({
|
|||
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, () => {
|
||||
if (!selectedActivity.value) return
|
||||
const stillThere = hits.value.find(h => h._id === selectedActivity.value?._id)
|
||||
|
|
@ -176,6 +204,7 @@ useDetailHistory(isActivityPanelOpen, isMobile)
|
|||
/>
|
||||
|
||||
<InboxList
|
||||
ref="inboxListRef"
|
||||
v-model="selectedActivity"
|
||||
:activities="hits"
|
||||
:query="debouncedQuery"
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ 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 loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
|
|
@ -22,7 +25,7 @@ const meili = useMeiliSearchRef()
|
|||
|
||||
const hits = ref<SearchHit[]>([])
|
||||
const total = ref(0)
|
||||
const activePage = ref(1)
|
||||
const activePage = ref(p0)
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / settings.pageSize)))
|
||||
|
||||
|
|
@ -100,19 +103,7 @@ function retry() {
|
|||
runSearch(query.value, activePage.value, false)
|
||||
}
|
||||
|
||||
onMounted(() => runSearch('', 1, false))
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
abortController?.abort()
|
||||
})
|
||||
|
||||
watch(debouncedQuery, (q) => {
|
||||
hits.value = []
|
||||
total.value = 0
|
||||
activePage.value = 1
|
||||
runSearch(q, 1, false)
|
||||
})
|
||||
|
||||
// ── Selección ──────────────────────────────────────────────────────────────
|
||||
const selected = ref<SearchHit | null>(null)
|
||||
|
||||
const isPanelOpen = computed({
|
||||
|
|
@ -120,6 +111,43 @@ const isPanelOpen = computed({
|
|||
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, () => {
|
||||
if (!selected.value) return
|
||||
const stillThere = hits.value.find(h => h._id === selected.value?._id)
|
||||
|
|
@ -180,6 +208,7 @@ useDetailHistory(isPanelOpen, isMobile)
|
|||
/>
|
||||
|
||||
<InboxList
|
||||
ref="inboxListRef"
|
||||
v-model="selected"
|
||||
:activities="hits"
|
||||
:query="debouncedQuery"
|
||||
|
|
|
|||
|
|
@ -33,7 +33,10 @@ 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 loading = ref(false)
|
||||
|
|
@ -84,7 +87,7 @@ interface TypesenseSearchResponse {
|
|||
const hits = ref<TypesenseHit[]>([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const activePage = ref(1)
|
||||
const activePage = ref(p0)
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / settings.pageSize)))
|
||||
|
||||
|
|
@ -183,8 +186,6 @@ function retry() {
|
|||
runSearch(query.value, activePage.value, false)
|
||||
}
|
||||
|
||||
onMounted(() => runSearch('', 1, false))
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
})
|
||||
|
|
@ -214,6 +215,29 @@ watch(hits, () => {
|
|||
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 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 }]"
|
||||
/>
|
||||
|
||||
<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
|
||||
v-if="loading && !hits.length"
|
||||
class="flex items-center justify-center gap-2 py-16 text-sm text-muted"
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ 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 loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
|
|
@ -158,7 +161,7 @@ const displayGroups = computed((): DisplayGroup[] => {
|
|||
})
|
||||
|
||||
// Paginación activa (compartida entre browse y search)
|
||||
const activePage = ref(1)
|
||||
const activePage = ref(p0)
|
||||
|
||||
const displayTotal = computed(() =>
|
||||
debouncedQuery.value.trim() ? total.value : browseTotal.value
|
||||
|
|
@ -389,7 +392,6 @@ function retry() {
|
|||
}
|
||||
}
|
||||
|
||||
onMounted(() => runBrowse(1, false))
|
||||
onBeforeUnmount(() => { if (timeoutId) clearTimeout(timeoutId) })
|
||||
|
||||
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 isMobile = breakpoints.smaller('lg')
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue