feat(ui): add infinite scroll for notebook sources
Previously, notebook sources were limited to 50 items due to API default. This adds pagination with infinite scroll to the sources column: - Add useNotebookSources hook with useInfiniteQuery for paginated fetching - Update SourcesColumn with scroll detection to load more sources - Fetch 30 sources per page, loading more as user scrolls near bottom
This commit is contained in:
parent
53cbea6809
commit
e5eeb90341
3 changed files with 113 additions and 11 deletions
|
|
@ -8,7 +8,7 @@ import { SourcesColumn } from '../components/SourcesColumn'
|
|||
import { NotesColumn } from '../components/NotesColumn'
|
||||
import { ChatColumn } from '../components/ChatColumn'
|
||||
import { useNotebook } from '@/lib/hooks/use-notebooks'
|
||||
import { useSources } from '@/lib/hooks/use-sources'
|
||||
import { useNotebookSources } from '@/lib/hooks/use-sources'
|
||||
import { useNotes } from '@/lib/hooks/use-notes'
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||
import { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store'
|
||||
|
|
@ -31,7 +31,14 @@ export default function NotebookPage() {
|
|||
const notebookId = decodeURIComponent(params.id as string)
|
||||
|
||||
const { data: notebook, isLoading: notebookLoading } = useNotebook(notebookId)
|
||||
const { data: sources, isLoading: sourcesLoading, refetch: refetchSources } = useSources(notebookId)
|
||||
const {
|
||||
sources,
|
||||
isLoading: sourcesLoading,
|
||||
refetch: refetchSources,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
} = useNotebookSources(notebookId)
|
||||
const { data: notes, isLoading: notesLoading } = useNotes(notebookId)
|
||||
|
||||
// Get collapse states for dynamic layout
|
||||
|
|
@ -153,6 +160,9 @@ export default function NotebookPage() {
|
|||
onRefresh={refetchSources}
|
||||
contextSelections={contextSelections.sources}
|
||||
onContextModeChange={(sourceId, mode) => handleContextModeChange(sourceId, mode, 'source')}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
/>
|
||||
)}
|
||||
{mobileActiveTab === 'notes' && (
|
||||
|
|
@ -192,6 +202,9 @@ export default function NotebookPage() {
|
|||
onRefresh={refetchSources}
|
||||
contextSelections={contextSelections.sources}
|
||||
onContextModeChange={(sourceId, mode) => handleContextModeChange(sourceId, mode, 'source')}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
|
||||
import { SourceListResponse } from '@/lib/types/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
|
|
@ -10,7 +10,7 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Plus, FileText, Link2, ChevronDown } from 'lucide-react'
|
||||
import { Plus, FileText, Link2, ChevronDown, Loader2 } from 'lucide-react'
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { AddSourceDialog } from '@/components/sources/AddSourceDialog'
|
||||
|
|
@ -31,6 +31,10 @@ interface SourcesColumnProps {
|
|||
onRefresh?: () => void
|
||||
contextSelections?: Record<string, ContextMode>
|
||||
onContextModeChange?: (sourceId: string, mode: ContextMode) => void
|
||||
// Pagination props
|
||||
hasNextPage?: boolean
|
||||
isFetchingNextPage?: boolean
|
||||
fetchNextPage?: () => void
|
||||
}
|
||||
|
||||
export function SourcesColumn({
|
||||
|
|
@ -39,7 +43,10 @@ export function SourcesColumn({
|
|||
notebookId,
|
||||
onRefresh,
|
||||
contextSelections,
|
||||
onContextModeChange
|
||||
onContextModeChange,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
}: SourcesColumnProps) {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||
|
|
@ -60,6 +67,30 @@ export function SourcesColumn({
|
|||
() => createCollapseButton(toggleSources, 'Sources'),
|
||||
[toggleSources]
|
||||
)
|
||||
|
||||
// Scroll container ref for infinite scroll
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Handle scroll for infinite loading
|
||||
const handleScroll = useCallback(() => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container || !hasNextPage || isFetchingNextPage || !fetchNextPage) return
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container
|
||||
// Load more when user scrolls within 200px of the bottom
|
||||
if (scrollHeight - scrollTop - clientHeight < 200) {
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
|
||||
|
||||
// Attach scroll listener
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container) return
|
||||
|
||||
container.addEventListener('scroll', handleScroll)
|
||||
return () => container.removeEventListener('scroll', handleScroll)
|
||||
}, [handleScroll])
|
||||
|
||||
const handleDeleteClick = (sourceId: string) => {
|
||||
setSourceToDelete(sourceId)
|
||||
|
|
@ -149,7 +180,7 @@ export function SourcesColumn({
|
|||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 overflow-y-auto min-h-0">
|
||||
<CardContent ref={scrollContainerRef} className="flex-1 overflow-y-auto min-h-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<LoadingSpinner />
|
||||
|
|
@ -179,6 +210,12 @@ export function SourcesColumn({
|
|||
}
|
||||
/>
|
||||
))}
|
||||
{/* Loading indicator for infinite scroll */}
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { useCallback } from 'react'
|
||||
import { sourcesApi } from '@/lib/api/sources'
|
||||
import { QUERY_KEYS } from '@/lib/api/query-client'
|
||||
import { useToast } from '@/lib/hooks/use-toast'
|
||||
import {
|
||||
CreateSourceRequest,
|
||||
UpdateSourceRequest,
|
||||
import {
|
||||
CreateSourceRequest,
|
||||
UpdateSourceRequest,
|
||||
SourceResponse,
|
||||
SourceStatusResponse
|
||||
SourceStatusResponse,
|
||||
SourceListResponse
|
||||
} from '@/lib/types/api'
|
||||
|
||||
const NOTEBOOK_SOURCES_PAGE_SIZE = 30
|
||||
|
||||
export function useSources(notebookId?: string) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.sources(notebookId),
|
||||
|
|
@ -19,6 +23,54 @@ export function useSources(notebookId?: string) {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching notebook sources with infinite scroll pagination.
|
||||
* Returns flattened sources array and pagination controls.
|
||||
*/
|
||||
export function useNotebookSources(notebookId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ['notebookSources', notebookId],
|
||||
queryFn: async ({ pageParam = 0 }) => {
|
||||
const data = await sourcesApi.list({
|
||||
notebook_id: notebookId,
|
||||
limit: NOTEBOOK_SOURCES_PAGE_SIZE,
|
||||
offset: pageParam,
|
||||
sort_by: 'updated',
|
||||
sort_order: 'desc',
|
||||
})
|
||||
return {
|
||||
sources: data,
|
||||
nextOffset: data.length === NOTEBOOK_SOURCES_PAGE_SIZE ? pageParam + data.length : undefined,
|
||||
}
|
||||
},
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage) => lastPage.nextOffset,
|
||||
enabled: !!notebookId,
|
||||
staleTime: 5 * 1000,
|
||||
refetchOnWindowFocus: true,
|
||||
})
|
||||
|
||||
// Flatten all pages into a single array
|
||||
const sources: SourceListResponse[] = query.data?.pages.flatMap(page => page.sources) ?? []
|
||||
|
||||
// Refetch function that resets to first page
|
||||
const refetch = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notebookSources', notebookId] })
|
||||
}, [queryClient, notebookId])
|
||||
|
||||
return {
|
||||
sources,
|
||||
isLoading: query.isLoading,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
hasNextPage: query.hasNextPage,
|
||||
fetchNextPage: query.fetchNextPage,
|
||||
refetch,
|
||||
error: query.error,
|
||||
}
|
||||
}
|
||||
|
||||
export function useSource(id: string) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.source(id),
|
||||
|
|
|
|||
Loading…
Reference in a new issue