2026-07-31 16:04:30 +08:00
|
|
|
import * as React from "react"
|
|
|
|
|
|
2026-09-20 15:52:09 +08:00
|
|
|
import type { SearchPage, SearchResultGroup } from "./types"
|
2026-07-31 16:04:30 +08:00
|
|
|
import { useSearchContext } from "./context"
|
|
|
|
|
|
|
|
|
|
interface UseSearchOptions {
|
|
|
|
|
active: boolean
|
|
|
|
|
query: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface SearchState {
|
|
|
|
|
error: unknown
|
|
|
|
|
isLoading: boolean
|
|
|
|
|
isLoadingMore: boolean
|
|
|
|
|
pages: readonly SearchPage[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const INITIAL_STATE: SearchState = {
|
|
|
|
|
error: null,
|
|
|
|
|
isLoading: false,
|
|
|
|
|
isLoadingMore: false,
|
|
|
|
|
pages: [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useSearch({ active, query }: UseSearchOptions) {
|
|
|
|
|
const { adapter } = useSearchContext()
|
|
|
|
|
const normalizedQuery = query.trim()
|
|
|
|
|
const [state, setState] = React.useState<SearchState>(INITIAL_STATE)
|
|
|
|
|
const [retryKey, setRetryKey] = React.useState(0)
|
|
|
|
|
const requestId = React.useRef(0)
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!active || !normalizedQuery) {
|
|
|
|
|
setState(INITIAL_STATE)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
const currentRequest = ++requestId.current
|
|
|
|
|
setState({ error: null, isLoading: true, isLoadingMore: false, pages: [] })
|
|
|
|
|
|
|
|
|
|
void adapter
|
|
|
|
|
.search({ query: normalizedQuery, signal: controller.signal })
|
|
|
|
|
.then((page) => {
|
|
|
|
|
if (currentRequest !== requestId.current || controller.signal.aborted)
|
|
|
|
|
return
|
|
|
|
|
setState({
|
|
|
|
|
error: null,
|
|
|
|
|
isLoading: false,
|
|
|
|
|
isLoadingMore: false,
|
|
|
|
|
pages: [page],
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.catch((error: unknown) => {
|
|
|
|
|
if (controller.signal.aborted || currentRequest !== requestId.current)
|
|
|
|
|
return
|
|
|
|
|
setState({ error, isLoading: false, isLoadingMore: false, pages: [] })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return () => controller.abort()
|
|
|
|
|
}, [active, adapter, normalizedQuery, retryKey])
|
|
|
|
|
|
|
|
|
|
const groups = React.useMemo(() => mergeGroups(state.pages), [state.pages])
|
|
|
|
|
const cursor = state.pages.at(-1)?.nextCursor ?? null
|
|
|
|
|
|
|
|
|
|
const loadMore = React.useCallback(() => {
|
|
|
|
|
if (!active || !normalizedQuery || cursor === null || state.isLoadingMore)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
const currentRequest = ++requestId.current
|
|
|
|
|
setState((current) => ({ ...current, error: null, isLoadingMore: true }))
|
|
|
|
|
|
|
|
|
|
void adapter
|
|
|
|
|
.search({
|
|
|
|
|
cursor,
|
|
|
|
|
query: normalizedQuery,
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
})
|
|
|
|
|
.then((page) => {
|
|
|
|
|
if (currentRequest !== requestId.current || controller.signal.aborted)
|
|
|
|
|
return
|
|
|
|
|
setState((current) => ({
|
|
|
|
|
error: null,
|
|
|
|
|
isLoading: false,
|
|
|
|
|
isLoadingMore: false,
|
|
|
|
|
pages: [...current.pages, page],
|
|
|
|
|
}))
|
|
|
|
|
})
|
|
|
|
|
.catch((error: unknown) => {
|
|
|
|
|
if (currentRequest !== requestId.current || controller.signal.aborted)
|
|
|
|
|
return
|
|
|
|
|
setState((current) => ({ ...current, error, isLoadingMore: false }))
|
|
|
|
|
})
|
|
|
|
|
}, [active, adapter, cursor, normalizedQuery, state.isLoadingMore])
|
|
|
|
|
|
|
|
|
|
const retry = React.useCallback(() => setRetryKey((value) => value + 1), [])
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
error: state.error,
|
|
|
|
|
groups,
|
|
|
|
|
hasMore: cursor !== null,
|
|
|
|
|
isLoading: state.isLoading,
|
|
|
|
|
isLoadingMore: state.isLoadingMore,
|
|
|
|
|
retry,
|
|
|
|
|
loadMore,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mergeGroups(
|
|
|
|
|
pages: readonly SearchPage[]
|
|
|
|
|
): readonly SearchResultGroup[] {
|
|
|
|
|
const groups = new Map<string, SearchResultGroup>()
|
|
|
|
|
|
|
|
|
|
for (const page of pages) {
|
|
|
|
|
for (const group of page.groups) {
|
|
|
|
|
const existing = groups.get(group.id)
|
|
|
|
|
if (!existing) {
|
|
|
|
|
groups.set(group.id, { ...group, items: [...group.items] })
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const items = new Map(existing.items.map((item) => [item.id, item]))
|
|
|
|
|
for (const item of group.items) items.set(item.id, item)
|
|
|
|
|
groups.set(group.id, {
|
|
|
|
|
...existing,
|
|
|
|
|
...group,
|
|
|
|
|
items: [...items.values()],
|
|
|
|
|
total: group.total ?? existing.total,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [...groups.values()]
|
|
|
|
|
}
|