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 { NotesColumn } from '../components/NotesColumn'
|
||||||
import { ChatColumn } from '../components/ChatColumn'
|
import { ChatColumn } from '../components/ChatColumn'
|
||||||
import { useNotebook } from '@/lib/hooks/use-notebooks'
|
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 { useNotes } from '@/lib/hooks/use-notes'
|
||||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||||
import { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store'
|
import { useNotebookColumnsStore } from '@/lib/stores/notebook-columns-store'
|
||||||
|
|
@ -31,7 +31,14 @@ export default function NotebookPage() {
|
||||||
const notebookId = decodeURIComponent(params.id as string)
|
const notebookId = decodeURIComponent(params.id as string)
|
||||||
|
|
||||||
const { data: notebook, isLoading: notebookLoading } = useNotebook(notebookId)
|
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)
|
const { data: notes, isLoading: notesLoading } = useNotes(notebookId)
|
||||||
|
|
||||||
// Get collapse states for dynamic layout
|
// Get collapse states for dynamic layout
|
||||||
|
|
@ -153,6 +160,9 @@ export default function NotebookPage() {
|
||||||
onRefresh={refetchSources}
|
onRefresh={refetchSources}
|
||||||
contextSelections={contextSelections.sources}
|
contextSelections={contextSelections.sources}
|
||||||
onContextModeChange={(sourceId, mode) => handleContextModeChange(sourceId, mode, 'source')}
|
onContextModeChange={(sourceId, mode) => handleContextModeChange(sourceId, mode, 'source')}
|
||||||
|
hasNextPage={hasNextPage}
|
||||||
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
|
fetchNextPage={fetchNextPage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{mobileActiveTab === 'notes' && (
|
{mobileActiveTab === 'notes' && (
|
||||||
|
|
@ -192,6 +202,9 @@ export default function NotebookPage() {
|
||||||
onRefresh={refetchSources}
|
onRefresh={refetchSources}
|
||||||
contextSelections={contextSelections.sources}
|
contextSelections={contextSelections.sources}
|
||||||
onContextModeChange={(sourceId, mode) => handleContextModeChange(sourceId, mode, 'source')}
|
onContextModeChange={(sourceId, mode) => handleContextModeChange(sourceId, mode, 'source')}
|
||||||
|
hasNextPage={hasNextPage}
|
||||||
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
|
fetchNextPage={fetchNextPage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
|
||||||
import { SourceListResponse } from '@/lib/types/api'
|
import { SourceListResponse } from '@/lib/types/api'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} 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 { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||||
import { EmptyState } from '@/components/common/EmptyState'
|
import { EmptyState } from '@/components/common/EmptyState'
|
||||||
import { AddSourceDialog } from '@/components/sources/AddSourceDialog'
|
import { AddSourceDialog } from '@/components/sources/AddSourceDialog'
|
||||||
|
|
@ -31,6 +31,10 @@ interface SourcesColumnProps {
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
contextSelections?: Record<string, ContextMode>
|
contextSelections?: Record<string, ContextMode>
|
||||||
onContextModeChange?: (sourceId: string, mode: ContextMode) => void
|
onContextModeChange?: (sourceId: string, mode: ContextMode) => void
|
||||||
|
// Pagination props
|
||||||
|
hasNextPage?: boolean
|
||||||
|
isFetchingNextPage?: boolean
|
||||||
|
fetchNextPage?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SourcesColumn({
|
export function SourcesColumn({
|
||||||
|
|
@ -39,7 +43,10 @@ export function SourcesColumn({
|
||||||
notebookId,
|
notebookId,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
contextSelections,
|
contextSelections,
|
||||||
onContextModeChange
|
onContextModeChange,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
fetchNextPage,
|
||||||
}: SourcesColumnProps) {
|
}: SourcesColumnProps) {
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||||
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||||
|
|
@ -60,6 +67,30 @@ export function SourcesColumn({
|
||||||
() => createCollapseButton(toggleSources, 'Sources'),
|
() => createCollapseButton(toggleSources, 'Sources'),
|
||||||
[toggleSources]
|
[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) => {
|
const handleDeleteClick = (sourceId: string) => {
|
||||||
setSourceToDelete(sourceId)
|
setSourceToDelete(sourceId)
|
||||||
|
|
@ -149,7 +180,7 @@ export function SourcesColumn({
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="flex-1 overflow-y-auto min-h-0">
|
<CardContent ref={scrollContainerRef} className="flex-1 overflow-y-auto min-h-0">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<LoadingSpinner />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</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 { sourcesApi } from '@/lib/api/sources'
|
||||||
import { QUERY_KEYS } from '@/lib/api/query-client'
|
import { QUERY_KEYS } from '@/lib/api/query-client'
|
||||||
import { useToast } from '@/lib/hooks/use-toast'
|
import { useToast } from '@/lib/hooks/use-toast'
|
||||||
import {
|
import {
|
||||||
CreateSourceRequest,
|
CreateSourceRequest,
|
||||||
UpdateSourceRequest,
|
UpdateSourceRequest,
|
||||||
SourceResponse,
|
SourceResponse,
|
||||||
SourceStatusResponse
|
SourceStatusResponse,
|
||||||
|
SourceListResponse
|
||||||
} from '@/lib/types/api'
|
} from '@/lib/types/api'
|
||||||
|
|
||||||
|
const NOTEBOOK_SOURCES_PAGE_SIZE = 30
|
||||||
|
|
||||||
export function useSources(notebookId?: string) {
|
export function useSources(notebookId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: QUERY_KEYS.sources(notebookId),
|
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) {
|
export function useSource(id: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: QUERY_KEYS.source(id),
|
queryKey: QUERY_KEYS.source(id),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue