chore: import clean Agent Desk snapshot (6/7)
This commit is contained in:
234
web/components/load-more.tsx
Normal file
234
web/components/load-more.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { PageData } from "@/lib/api/types"
|
||||
|
||||
type LoadMoreLabels = {
|
||||
loadMore: string
|
||||
noMore: string
|
||||
loading?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
type LoadMoreRequest = {
|
||||
cursor: string
|
||||
force: boolean
|
||||
}
|
||||
|
||||
export function LoadMore<T>({
|
||||
initialItems = [],
|
||||
initialCursor,
|
||||
initialHasMore,
|
||||
initialLoad = false,
|
||||
resetKey,
|
||||
labels,
|
||||
loadPage,
|
||||
renderItems,
|
||||
renderEmpty,
|
||||
alwaysShowButton = false,
|
||||
autoLoadOnScroll = false,
|
||||
}: {
|
||||
initialItems?: T[] | null
|
||||
initialCursor?: string
|
||||
initialHasMore: boolean
|
||||
initialLoad?: boolean
|
||||
resetKey?: string
|
||||
labels: LoadMoreLabels
|
||||
loadPage: (request: LoadMoreRequest) => Promise<PageData<T>>
|
||||
renderItems: (items: T[]) => React.ReactNode
|
||||
renderEmpty?: () => React.ReactNode
|
||||
alwaysShowButton?: boolean
|
||||
autoLoadOnScroll?: boolean
|
||||
}) {
|
||||
const safeInitialItems = Array.isArray(initialItems) ? initialItems : []
|
||||
const contentKey = React.useMemo(
|
||||
() =>
|
||||
resetKey ||
|
||||
JSON.stringify({
|
||||
initialCursor: initialCursor || "",
|
||||
initialHasMore,
|
||||
initialLoad,
|
||||
initialItemsLength: safeInitialItems.length,
|
||||
}),
|
||||
[initialCursor, initialHasMore, initialLoad, resetKey, safeInitialItems.length]
|
||||
)
|
||||
|
||||
return (
|
||||
<LoadMoreContent
|
||||
key={contentKey}
|
||||
initialItems={safeInitialItems}
|
||||
initialCursor={initialCursor}
|
||||
initialHasMore={initialHasMore}
|
||||
initialLoad={initialLoad}
|
||||
labels={labels}
|
||||
loadPage={loadPage}
|
||||
renderItems={renderItems}
|
||||
renderEmpty={renderEmpty}
|
||||
alwaysShowButton={alwaysShowButton}
|
||||
autoLoadOnScroll={autoLoadOnScroll}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadMoreContent<T>({
|
||||
initialItems,
|
||||
initialCursor,
|
||||
initialHasMore,
|
||||
initialLoad,
|
||||
labels,
|
||||
loadPage,
|
||||
renderItems,
|
||||
renderEmpty,
|
||||
alwaysShowButton,
|
||||
autoLoadOnScroll,
|
||||
}: {
|
||||
initialItems: T[]
|
||||
initialCursor?: string
|
||||
initialHasMore: boolean
|
||||
initialLoad: boolean
|
||||
labels: LoadMoreLabels
|
||||
loadPage: (request: LoadMoreRequest) => Promise<PageData<T>>
|
||||
renderItems: (items: T[]) => React.ReactNode
|
||||
renderEmpty?: () => React.ReactNode
|
||||
alwaysShowButton: boolean
|
||||
autoLoadOnScroll: boolean
|
||||
}) {
|
||||
const [cursor, setCursor] = React.useState(initialCursor || "")
|
||||
const [hasMore, setHasMore] = React.useState(initialHasMore || (initialLoad && initialItems.length === 0))
|
||||
const [items, setItems] = React.useState<T[]>(initialItems)
|
||||
const [loaded, setLoaded] = React.useState(!initialLoad || initialItems.length > 0)
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
const inFlightRef = React.useRef(false)
|
||||
const mountedRef = React.useRef(true)
|
||||
const loadPageRef = React.useRef(loadPage)
|
||||
const sentinelRef = React.useRef<HTMLDivElement | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
loadPageRef.current = loadPage
|
||||
}, [loadPage])
|
||||
|
||||
React.useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadMore = React.useCallback(
|
||||
async (force = false) => {
|
||||
if (inFlightRef.current || (!force && !hasMore)) {
|
||||
return
|
||||
}
|
||||
|
||||
inFlightRef.current = true
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await loadPageRef.current({
|
||||
cursor: force ? "" : cursor,
|
||||
force,
|
||||
})
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
setItems((current) => (force ? data.results || [] : [...current, ...(data.results || [])]))
|
||||
setCursor(data.cursor || "")
|
||||
setHasMore(Boolean(data.hasMore))
|
||||
setLoaded(true)
|
||||
} catch (err) {
|
||||
if (mountedRef.current) {
|
||||
setError(err instanceof Error ? err.message : labels.error || "Could not load more items.")
|
||||
}
|
||||
} finally {
|
||||
inFlightRef.current = false
|
||||
if (mountedRef.current) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[cursor, hasMore, labels.error]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!initialLoad || initialItems.length > 0 || loaded || loading || inFlightRef.current) {
|
||||
return
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
void loadMore(true)
|
||||
}, 0)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [initialItems.length, initialLoad, loadMore, loaded, loading])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!autoLoadOnScroll || !hasMore || loading) {
|
||||
return
|
||||
}
|
||||
const sentinel = sentinelRef.current
|
||||
if (!sentinel || typeof IntersectionObserver === "undefined") {
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) {
|
||||
void loadMore()
|
||||
}
|
||||
},
|
||||
{ rootMargin: "160px 0px" }
|
||||
)
|
||||
observer.observe(sentinel)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [autoLoadOnScroll, hasMore, loadMore, loading])
|
||||
|
||||
async function onLoadMore() {
|
||||
if (inFlightRef.current || !hasMore) {
|
||||
return
|
||||
}
|
||||
await loadMore()
|
||||
}
|
||||
|
||||
const showButton = alwaysShowButton || items.length > 0 || hasMore || loading
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.length ? renderItems(items) : loaded && !loading && !error ? renderEmpty?.() : null}
|
||||
{showButton ? (
|
||||
<LoadMoreButton
|
||||
loading={loading}
|
||||
hasMore={hasMore}
|
||||
loadingLabel={labels.loading}
|
||||
labels={labels}
|
||||
onClick={onLoadMore}
|
||||
/>
|
||||
) : null}
|
||||
{autoLoadOnScroll ? <div ref={sentinelRef} aria-hidden="true" className="h-px" /> : null}
|
||||
{error ? <p className="-mt-4 pb-5 text-center text-xs text-destructive">{error}</p> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function LoadMoreButton({
|
||||
loading,
|
||||
hasMore,
|
||||
loadingLabel,
|
||||
labels,
|
||||
onClick,
|
||||
}: {
|
||||
loading: boolean
|
||||
hasMore: boolean
|
||||
loadingLabel?: string
|
||||
labels: LoadMoreLabels
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="p-5 text-center">
|
||||
<Button type="button" variant="link" disabled={loading || !hasMore} onClick={onClick} className="w-[150px]">
|
||||
{loading ? loadingLabel || labels.loading || labels.loadMore : hasMore ? labels.loadMore : labels.noMore}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user