diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx index bf9e29a1d..277b73f0f 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx @@ -298,6 +298,9 @@ export function DocumentsTableShell({ onSortChange, deleteDocument, searchSpaceId, + hasMore = false, + loadingMore = false, + onLoadMore, }: { documents: Document[]; loading: boolean; @@ -310,6 +313,9 @@ export function DocumentsTableShell({ onSortChange: (key: SortKey) => void; deleteDocument: (id: number) => Promise; searchSpaceId: string; + hasMore?: boolean; + loadingMore?: boolean; + onLoadMore?: () => void; }) { const t = useTranslations("documents"); const { openDialog } = useDocumentUploadDialog(); @@ -321,6 +327,31 @@ export function DocumentsTableShell({ const [deleteDoc, setDeleteDoc] = useState(null); const [isDeleting, setIsDeleting] = useState(false); + const desktopSentinelRef = useRef(null); + const mobileSentinelRef = useRef(null); + + useEffect(() => { + if (!onLoadMore || !hasMore || loadingMore) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) { + onLoadMore(); + } + }, + { root: null, rootMargin: "200px", threshold: 0 } + ); + + if (desktopSentinelRef.current) { + observer.observe(desktopSentinelRef.current); + } + if (mobileSentinelRef.current) { + observer.observe(mobileSentinelRef.current); + } + + return () => observer.disconnect(); + }, [onLoadMore, hasMore, loadingMore]); + const handleViewDocument = useCallback(async (doc: Document) => { setViewingDoc(doc); if (doc.content) { @@ -410,7 +441,7 @@ export function DocumentsTableShell({ return ( {/* Desktop Skeleton */} -
+
@@ -439,7 +470,7 @@ export function DocumentsTableShell({
-
+
{[65, 80, 45, 72, 55, 88, 40, 60, 50, 75].map((widthPercent, index) => ( @@ -468,7 +499,7 @@ export function DocumentsTableShell({ {/* Mobile Skeleton */} -
+
{[70, 85, 55, 78, 62, 90].map((widthPercent, index) => (
@@ -486,14 +517,14 @@ export function DocumentsTableShell({
) : error ? ( -
+

{t("error_loading")}

) : sorted.length === 0 ? ( -
+
{/* Desktop Table View */} -
+
@@ -556,7 +587,7 @@ export function DocumentsTableShell({
-
+
{sorted.map((doc, index) => { @@ -571,14 +602,9 @@ export function DocumentsTableShell({ searchSpaceId={searchSpaceId} >
-
+ {hasMore && ( +
+ {loadingMore && } +
+ )}
+
- {/* Mobile Card View */} -
+ {/* Mobile Card View */} +
{sorted.map((doc, index) => { const isSelected = selectedIds.has(doc.id); const canSelect = isSelectable(doc); @@ -649,8 +680,9 @@ export function DocumentsTableShell({ searchSpaceId={searchSpaceId} > ); })} -
+ {hasMore && ( +
+ {loadingMore && } +
+ )} +
)} diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index e8029404e..e0feacf3d 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -1,11 +1,10 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; import { ChevronLeft, SquareLibrary } from "lucide-react"; import { useParams } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { Button } from "@/components/ui/button"; @@ -13,7 +12,6 @@ import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import { useDocuments } from "@/hooks/use-documents"; import { documentsApiService } from "@/lib/apis/documents-api.service"; import { useMediaQuery } from "@/hooks/use-media-query"; -import { cacheKeys } from "@/lib/query-client/cache-keys"; import { DocumentsFilters, } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters"; @@ -21,13 +19,12 @@ import { DocumentsTableShell, type SortKey, } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell"; -import { - PAGE_SIZE, - PaginationControls, -} from "@/app/dashboard/[search_space_id]/documents/(manage)/components/PaginationControls"; import type { ColumnVisibility } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/types"; import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; +const INITIAL_LOAD_SIZE = 20; +const SCROLL_LOAD_SIZE = 5; + function useDebounced(value: T, delay = 250) { const [debounced, setDebounced] = useState(value); useEffect(() => { @@ -58,7 +55,6 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) created_at: true, status: true, }); - const [pageIndex, setPageIndex] = useState(0); const [sortKey, setSortKey] = useState("created_at"); const [sortDesc, setSortDesc] = useState(true); const [selectedIds, setSelectedIds] = useState>(new Set()); @@ -73,28 +69,24 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) const isSearchMode = !!debouncedSearch.trim(); - const searchQueryParams = useMemo( - () => ({ - search_space_id: searchSpaceId, - page: pageIndex, - page_size: PAGE_SIZE, - title: debouncedSearch.trim(), - ...(activeTypes.length > 0 && { document_types: activeTypes }), - }), - [searchSpaceId, pageIndex, activeTypes, debouncedSearch] - ); - - const { - data: searchResponse, - isLoading: isSearchLoading, - refetch: refetchSearch, - error: searchError, - } = useQuery({ - queryKey: cacheKeys.documents.globalQueryParams(searchQueryParams), - queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }), - staleTime: 30 * 1000, - enabled: !!searchSpaceId && isSearchMode && open, - }); + // --- Infinite scroll state --- + const [visibleCount, setVisibleCount] = useState(INITIAL_LOAD_SIZE); + const [searchItems, setSearchItems] = useState>([]); + const [searchTotal, setSearchTotal] = useState(0); + const [searchPageIndex, setSearchPageIndex] = useState(0); + const [searchLoadingMore, setSearchLoadingMore] = useState(false); + const [searchInitialLoading, setSearchInitialLoading] = useState(false); + const searchQueryRef = useRef(debouncedSearch); const sortedRealtimeDocuments = useMemo(() => { const docs = [...realtimeDocuments]; @@ -112,14 +104,82 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) return docs; }, [realtimeDocuments, sortKey, sortDesc]); - const paginatedRealtimeDocuments = useMemo(() => { - const start = pageIndex * PAGE_SIZE; - const end = start + PAGE_SIZE; - return sortedRealtimeDocuments.slice(start, end); - }, [sortedRealtimeDocuments, pageIndex]); + // Reset visible count when sort/filter changes + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional reset + useEffect(() => { + setVisibleCount(INITIAL_LOAD_SIZE); + }, [sortKey, sortDesc, activeTypes]); - const displayDocs = isSearchMode - ? (searchResponse?.items || []).map((item) => ({ + // Initial search fetch when search query changes + useEffect(() => { + if (!isSearchMode || !searchSpaceId || !open) { + setSearchItems([]); + setSearchTotal(0); + setSearchPageIndex(0); + return; + } + + searchQueryRef.current = debouncedSearch; + setSearchInitialLoading(true); + + const queryParams = { + search_space_id: searchSpaceId, + page: 0, + page_size: INITIAL_LOAD_SIZE, + title: debouncedSearch.trim(), + ...(activeTypes.length > 0 && { document_types: activeTypes }), + }; + + documentsApiService + .searchDocuments({ queryParams }) + .then((response) => { + if (searchQueryRef.current !== debouncedSearch) return; + const mapped = response.items.map((item) => ({ + id: item.id, + search_space_id: item.search_space_id, + document_type: item.document_type, + title: item.title, + created_by_id: item.created_by_id ?? null, + created_by_name: item.created_by_name ?? null, + created_by_email: item.created_by_email ?? null, + created_at: item.created_at, + status: ( + item as { + status?: { state: "ready" | "pending" | "processing" | "failed"; reason?: string }; + } + ).status ?? { state: "ready" as const }, + })); + setSearchItems(mapped); + setSearchTotal(response.total); + setSearchPageIndex(0); + }) + .catch((err) => { + console.error("[DocumentsSidebar] Search failed:", err); + }) + .finally(() => { + setSearchInitialLoading(false); + }); + }, [debouncedSearch, searchSpaceId, open, isSearchMode, activeTypes]); + + // Load more search results + const loadMoreSearch = useCallback(async () => { + if (searchLoadingMore || !isSearchMode) return; + const nextPage = searchPageIndex + 1; + if (searchItems.length >= searchTotal) return; + + setSearchLoadingMore(true); + try { + const queryParams = { + search_space_id: searchSpaceId, + page: nextPage, + page_size: SCROLL_LOAD_SIZE, + title: debouncedSearch.trim(), + ...(activeTypes.length > 0 && { document_types: activeTypes }), + }; + const response = await documentsApiService.searchDocuments({ queryParams }); + if (searchQueryRef.current !== debouncedSearch) return; + + const mapped = response.items.map((item) => ({ id: item.id, search_space_id: item.search_space_id, document_type: item.document_type, @@ -133,13 +193,38 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) status?: { state: "ready" | "pending" | "processing" | "failed"; reason?: string }; } ).status ?? { state: "ready" as const }, - })) - : paginatedRealtimeDocuments; + })); + setSearchItems((prev) => [...prev, ...mapped]); + setSearchTotal(response.total); + setSearchPageIndex(nextPage); + } catch (err) { + console.error("[DocumentsSidebar] Load more search failed:", err); + } finally { + setSearchLoadingMore(false); + } + }, [searchLoadingMore, isSearchMode, searchPageIndex, searchItems.length, searchTotal, searchSpaceId, debouncedSearch, activeTypes]); - const displayTotal = isSearchMode ? searchResponse?.total || 0 : sortedRealtimeDocuments.length; - const loading = isSearchMode ? isSearchLoading : realtimeLoading; - const error = isSearchMode ? searchError : realtimeError; - const pageEnd = Math.min((pageIndex + 1) * PAGE_SIZE, displayTotal); + // Load more for realtime (client-side, just increase visible count) + const loadMoreRealtime = useCallback(() => { + setVisibleCount((prev) => Math.min(prev + SCROLL_LOAD_SIZE, sortedRealtimeDocuments.length)); + }, [sortedRealtimeDocuments.length]); + + const visibleRealtimeDocs = useMemo( + () => sortedRealtimeDocuments.slice(0, visibleCount), + [sortedRealtimeDocuments, visibleCount] + ); + + const displayDocs = isSearchMode ? searchItems : visibleRealtimeDocs; + const loading = isSearchMode ? searchInitialLoading : realtimeLoading; + const error = isSearchMode ? false : realtimeError; + + const hasMore = isSearchMode + ? searchItems.length < searchTotal + : visibleCount < sortedRealtimeDocuments.length; + + const loadingMore = isSearchMode ? searchLoadingMore : false; + + const onLoadMore = isSearchMode ? loadMoreSearch : loadMoreRealtime; const onToggleType = (type: DocumentTypeEnum, checked: boolean) => { setActiveTypes((prev) => { @@ -148,7 +233,6 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) } return prev.filter((t) => t !== type); }); - setPageIndex(0); setSelectedIds(new Set()); }; @@ -159,10 +243,7 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) } const allDocs = isSearchMode - ? (searchResponse?.items || []).map((item) => ({ - id: item.id, - status: (item as { status?: { state: string } }).status, - })) + ? searchItems.map((item) => ({ id: item.id, status: item.status })) : sortedRealtimeDocuments.map((doc) => ({ id: doc.id, status: doc.status })); const selectedDocs = allDocs.filter((doc) => selectedIds.has(doc.id)); @@ -203,7 +284,10 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) } else { toast.error(t("delete_partial_failed")); } - if (isSearchMode) await refetchSearch(); + if (isSearchMode) { + setSearchItems((prev) => prev.filter((item) => !deletableIds.includes(item.id))); + setSearchTotal((prev) => prev - okCount); + } setSelectedIds(new Set()); } catch (e) { console.error(e); @@ -216,14 +300,17 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) try { await deleteDocumentMutation({ id }); toast.success(t("delete_success") || "Document deleted"); - if (isSearchMode) await refetchSearch(); + if (isSearchMode) { + setSearchItems((prev) => prev.filter((item) => item.id !== id)); + setSearchTotal((prev) => prev - 1); + } return true; } catch (e) { console.error("Error deleting document:", e); return false; } }, - [deleteDocumentMutation, isSearchMode, refetchSearch, t] + [deleteDocumentMutation, isSearchMode, t] ); const handleSortChange = useCallback((key: SortKey) => { @@ -237,11 +324,6 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) }); }, []); - // biome-ignore lint/correctness/useExhaustiveDependencies: Reset page on search change - useEffect(() => { - setPageIndex(0); - }, [debouncedSearch]); - useEffect(() => { if (!open) return; const panelWidth = isMobile ? window.innerWidth : 720; @@ -281,7 +363,7 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
-
+
- -
- setPageIndex(0)} - onPrev={() => setPageIndex((i) => Math.max(0, i - 1))} - onNext={() => setPageIndex((i) => (pageEnd < displayTotal ? i + 1 : i))} - onLast={() => setPageIndex(Math.max(0, Math.ceil(displayTotal / PAGE_SIZE) - 1))} - canPrev={pageIndex > 0} - canNext={pageEnd < displayTotal} - /> -
);