mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-22 21:28:12 +02:00
refactor: enhance DocumentsTableShell with infinite scroll support and update DocumentsSidebar for improved search functionality
This commit is contained in:
parent
b8f52946fd
commit
0feb17cb75
2 changed files with 201 additions and 92 deletions
|
|
@ -298,6 +298,9 @@ export function DocumentsTableShell({
|
||||||
onSortChange,
|
onSortChange,
|
||||||
deleteDocument,
|
deleteDocument,
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
|
hasMore = false,
|
||||||
|
loadingMore = false,
|
||||||
|
onLoadMore,
|
||||||
}: {
|
}: {
|
||||||
documents: Document[];
|
documents: Document[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
|
@ -310,6 +313,9 @@ export function DocumentsTableShell({
|
||||||
onSortChange: (key: SortKey) => void;
|
onSortChange: (key: SortKey) => void;
|
||||||
deleteDocument: (id: number) => Promise<boolean>;
|
deleteDocument: (id: number) => Promise<boolean>;
|
||||||
searchSpaceId: string;
|
searchSpaceId: string;
|
||||||
|
hasMore?: boolean;
|
||||||
|
loadingMore?: boolean;
|
||||||
|
onLoadMore?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("documents");
|
const t = useTranslations("documents");
|
||||||
const { openDialog } = useDocumentUploadDialog();
|
const { openDialog } = useDocumentUploadDialog();
|
||||||
|
|
@ -321,6 +327,31 @@ export function DocumentsTableShell({
|
||||||
const [deleteDoc, setDeleteDoc] = useState<Document | null>(null);
|
const [deleteDoc, setDeleteDoc] = useState<Document | null>(null);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
const desktopSentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mobileSentinelRef = useRef<HTMLDivElement>(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) => {
|
const handleViewDocument = useCallback(async (doc: Document) => {
|
||||||
setViewingDoc(doc);
|
setViewingDoc(doc);
|
||||||
if (doc.content) {
|
if (doc.content) {
|
||||||
|
|
@ -410,7 +441,7 @@ export function DocumentsTableShell({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="bg-background overflow-hidden select-none border-t border-border/50"
|
className="bg-background overflow-hidden select-none border-t border-border/50 flex-1 flex flex-col min-h-0"
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.2 }}
|
transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.2 }}
|
||||||
|
|
@ -418,7 +449,7 @@ export function DocumentsTableShell({
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
{/* Desktop Skeleton */}
|
{/* Desktop Skeleton */}
|
||||||
<div className="hidden md:flex md:flex-col">
|
<div className="hidden md:flex md:flex-col flex-1 min-h-0">
|
||||||
<Table className="table-fixed w-full">
|
<Table className="table-fixed w-full">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="hover:bg-transparent border-b border-border/50">
|
<TableRow className="hover:bg-transparent border-b border-border/50">
|
||||||
|
|
@ -439,7 +470,7 @@ export function DocumentsTableShell({
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
</Table>
|
</Table>
|
||||||
<div className="h-[50vh] overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<Table className="table-fixed w-full">
|
<Table className="table-fixed w-full">
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{[65, 80, 45, 72, 55, 88, 40, 60, 50, 75].map((widthPercent, index) => (
|
{[65, 80, 45, 72, 55, 88, 40, 60, 50, 75].map((widthPercent, index) => (
|
||||||
|
|
@ -468,7 +499,7 @@ export function DocumentsTableShell({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Mobile Skeleton */}
|
{/* Mobile Skeleton */}
|
||||||
<div className="md:hidden divide-y divide-border/50 h-[50vh] overflow-auto">
|
<div className="md:hidden divide-y divide-border/50 flex-1 overflow-auto">
|
||||||
{[70, 85, 55, 78, 62, 90].map((widthPercent, index) => (
|
{[70, 85, 55, 78, 62, 90].map((widthPercent, index) => (
|
||||||
<div key={`skeleton-mobile-${index}`} className="px-3 py-2">
|
<div key={`skeleton-mobile-${index}`} className="px-3 py-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
@ -486,14 +517,14 @@ export function DocumentsTableShell({
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="flex h-[50vh] w-full items-center justify-center">
|
<div className="flex flex-1 w-full items-center justify-center">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||||
<p className="text-sm text-destructive">{t("error_loading")}</p>
|
<p className="text-sm text-destructive">{t("error_loading")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : sorted.length === 0 ? (
|
) : sorted.length === 0 ? (
|
||||||
<div className="flex h-[50vh] w-full items-center justify-center">
|
<div className="flex flex-1 w-full items-center justify-center">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
|
@ -518,7 +549,7 @@ export function DocumentsTableShell({
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Desktop Table View */}
|
{/* Desktop Table View */}
|
||||||
<div className="hidden md:flex md:flex-col">
|
<div className="hidden md:flex md:flex-col flex-1 min-h-0">
|
||||||
<Table className="table-fixed w-full">
|
<Table className="table-fixed w-full">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="hover:bg-transparent border-b border-border/50">
|
<TableRow className="hover:bg-transparent border-b border-border/50">
|
||||||
|
|
@ -556,7 +587,7 @@ export function DocumentsTableShell({
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
</Table>
|
</Table>
|
||||||
<div className="h-[50vh] overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<Table className="table-fixed w-full">
|
<Table className="table-fixed w-full">
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{sorted.map((doc, index) => {
|
{sorted.map((doc, index) => {
|
||||||
|
|
@ -571,14 +602,9 @@ export function DocumentsTableShell({
|
||||||
searchSpaceId={searchSpaceId}
|
searchSpaceId={searchSpaceId}
|
||||||
>
|
>
|
||||||
<motion.tr
|
<motion.tr
|
||||||
initial={{ opacity: 0 }}
|
initial={index < 20 ? { opacity: 0 } : false}
|
||||||
animate={{
|
animate={{ opacity: 1 }}
|
||||||
opacity: 1,
|
transition={index < 20 ? { duration: 0.15, delay: index * 0.02 } : { duration: 0 }}
|
||||||
transition: {
|
|
||||||
duration: 0.2,
|
|
||||||
delay: index * 0.02,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
className={`border-b border-border/50 transition-colors ${
|
className={`border-b border-border/50 transition-colors ${
|
||||||
isSelected
|
isSelected
|
||||||
? "bg-primary/5 hover:bg-primary/8"
|
? "bg-primary/5 hover:bg-primary/8"
|
||||||
|
|
@ -632,11 +658,16 @@ export function DocumentsTableShell({
|
||||||
})}
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
{hasMore && (
|
||||||
|
<div ref={desktopSentinelRef} className="flex items-center justify-center py-3">
|
||||||
|
{loadingMore && <Spinner size="sm" className="text-muted-foreground" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Mobile Card View */}
|
{/* Mobile Card View */}
|
||||||
<div className="md:hidden divide-y divide-border/50 h-[50vh] overflow-auto">
|
<div className="md:hidden divide-y divide-border/50 flex-1 overflow-auto">
|
||||||
{sorted.map((doc, index) => {
|
{sorted.map((doc, index) => {
|
||||||
const isSelected = selectedIds.has(doc.id);
|
const isSelected = selectedIds.has(doc.id);
|
||||||
const canSelect = isSelectable(doc);
|
const canSelect = isSelectable(doc);
|
||||||
|
|
@ -649,8 +680,9 @@ export function DocumentsTableShell({
|
||||||
searchSpaceId={searchSpaceId}
|
searchSpaceId={searchSpaceId}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={index < 20 ? { opacity: 0 } : false}
|
||||||
animate={{ opacity: 1, transition: { delay: index * 0.03 } }}
|
animate={{ opacity: 1 }}
|
||||||
|
transition={index < 20 ? { duration: 0.15, delay: index * 0.03 } : { duration: 0 }}
|
||||||
className={`px-3 py-2 transition-colors ${
|
className={`px-3 py-2 transition-colors ${
|
||||||
isSelected ? "bg-primary/5" : "hover:bg-muted/20"
|
isSelected ? "bg-primary/5" : "hover:bg-muted/20"
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -696,7 +728,12 @@ export function DocumentsTableShell({
|
||||||
</RowContextMenu>
|
</RowContextMenu>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
{hasMore && (
|
||||||
|
<div ref={mobileSentinelRef} className="flex items-center justify-center py-3">
|
||||||
|
{loadingMore && <Spinner size="sm" className="text-muted-foreground" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { ChevronLeft, SquareLibrary } from "lucide-react";
|
import { ChevronLeft, SquareLibrary } from "lucide-react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
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 { toast } from "sonner";
|
||||||
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { useDocuments } from "@/hooks/use-documents";
|
||||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
|
||||||
import {
|
import {
|
||||||
DocumentsFilters,
|
DocumentsFilters,
|
||||||
} from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters";
|
} from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters";
|
||||||
|
|
@ -21,13 +19,12 @@ import {
|
||||||
DocumentsTableShell,
|
DocumentsTableShell,
|
||||||
type SortKey,
|
type SortKey,
|
||||||
} from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell";
|
} 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 type { ColumnVisibility } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/types";
|
||||||
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
||||||
|
|
||||||
|
const INITIAL_LOAD_SIZE = 20;
|
||||||
|
const SCROLL_LOAD_SIZE = 5;
|
||||||
|
|
||||||
function useDebounced<T>(value: T, delay = 250) {
|
function useDebounced<T>(value: T, delay = 250) {
|
||||||
const [debounced, setDebounced] = useState(value);
|
const [debounced, setDebounced] = useState(value);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -58,7 +55,6 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
|
||||||
created_at: true,
|
created_at: true,
|
||||||
status: true,
|
status: true,
|
||||||
});
|
});
|
||||||
const [pageIndex, setPageIndex] = useState(0);
|
|
||||||
const [sortKey, setSortKey] = useState<SortKey>("created_at");
|
const [sortKey, setSortKey] = useState<SortKey>("created_at");
|
||||||
const [sortDesc, setSortDesc] = useState(true);
|
const [sortDesc, setSortDesc] = useState(true);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
|
|
@ -73,28 +69,24 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
|
||||||
|
|
||||||
const isSearchMode = !!debouncedSearch.trim();
|
const isSearchMode = !!debouncedSearch.trim();
|
||||||
|
|
||||||
const searchQueryParams = useMemo(
|
// --- Infinite scroll state ---
|
||||||
() => ({
|
const [visibleCount, setVisibleCount] = useState(INITIAL_LOAD_SIZE);
|
||||||
search_space_id: searchSpaceId,
|
const [searchItems, setSearchItems] = useState<Array<{
|
||||||
page: pageIndex,
|
id: number;
|
||||||
page_size: PAGE_SIZE,
|
search_space_id: number;
|
||||||
title: debouncedSearch.trim(),
|
document_type: string;
|
||||||
...(activeTypes.length > 0 && { document_types: activeTypes }),
|
title: string;
|
||||||
}),
|
created_by_id: string | null;
|
||||||
[searchSpaceId, pageIndex, activeTypes, debouncedSearch]
|
created_by_name: string | null;
|
||||||
);
|
created_by_email: string | null;
|
||||||
|
created_at: string;
|
||||||
const {
|
status: { state: "ready" | "pending" | "processing" | "failed"; reason?: string };
|
||||||
data: searchResponse,
|
}>>([]);
|
||||||
isLoading: isSearchLoading,
|
const [searchTotal, setSearchTotal] = useState(0);
|
||||||
refetch: refetchSearch,
|
const [searchPageIndex, setSearchPageIndex] = useState(0);
|
||||||
error: searchError,
|
const [searchLoadingMore, setSearchLoadingMore] = useState(false);
|
||||||
} = useQuery({
|
const [searchInitialLoading, setSearchInitialLoading] = useState(false);
|
||||||
queryKey: cacheKeys.documents.globalQueryParams(searchQueryParams),
|
const searchQueryRef = useRef(debouncedSearch);
|
||||||
queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }),
|
|
||||||
staleTime: 30 * 1000,
|
|
||||||
enabled: !!searchSpaceId && isSearchMode && open,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedRealtimeDocuments = useMemo(() => {
|
const sortedRealtimeDocuments = useMemo(() => {
|
||||||
const docs = [...realtimeDocuments];
|
const docs = [...realtimeDocuments];
|
||||||
|
|
@ -112,14 +104,82 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
|
||||||
return docs;
|
return docs;
|
||||||
}, [realtimeDocuments, sortKey, sortDesc]);
|
}, [realtimeDocuments, sortKey, sortDesc]);
|
||||||
|
|
||||||
const paginatedRealtimeDocuments = useMemo(() => {
|
// Reset visible count when sort/filter changes
|
||||||
const start = pageIndex * PAGE_SIZE;
|
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional reset
|
||||||
const end = start + PAGE_SIZE;
|
useEffect(() => {
|
||||||
return sortedRealtimeDocuments.slice(start, end);
|
setVisibleCount(INITIAL_LOAD_SIZE);
|
||||||
}, [sortedRealtimeDocuments, pageIndex]);
|
}, [sortKey, sortDesc, activeTypes]);
|
||||||
|
|
||||||
const displayDocs = isSearchMode
|
// Initial search fetch when search query changes
|
||||||
? (searchResponse?.items || []).map((item) => ({
|
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,
|
id: item.id,
|
||||||
search_space_id: item.search_space_id,
|
search_space_id: item.search_space_id,
|
||||||
document_type: item.document_type,
|
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" | "pending" | "processing" | "failed"; reason?: string };
|
||||||
}
|
}
|
||||||
).status ?? { state: "ready" as const },
|
).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;
|
// Load more for realtime (client-side, just increase visible count)
|
||||||
const loading = isSearchMode ? isSearchLoading : realtimeLoading;
|
const loadMoreRealtime = useCallback(() => {
|
||||||
const error = isSearchMode ? searchError : realtimeError;
|
setVisibleCount((prev) => Math.min(prev + SCROLL_LOAD_SIZE, sortedRealtimeDocuments.length));
|
||||||
const pageEnd = Math.min((pageIndex + 1) * PAGE_SIZE, displayTotal);
|
}, [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) => {
|
const onToggleType = (type: DocumentTypeEnum, checked: boolean) => {
|
||||||
setActiveTypes((prev) => {
|
setActiveTypes((prev) => {
|
||||||
|
|
@ -148,7 +233,6 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
|
||||||
}
|
}
|
||||||
return prev.filter((t) => t !== type);
|
return prev.filter((t) => t !== type);
|
||||||
});
|
});
|
||||||
setPageIndex(0);
|
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -159,10 +243,7 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
|
||||||
}
|
}
|
||||||
|
|
||||||
const allDocs = isSearchMode
|
const allDocs = isSearchMode
|
||||||
? (searchResponse?.items || []).map((item) => ({
|
? searchItems.map((item) => ({ id: item.id, status: item.status }))
|
||||||
id: item.id,
|
|
||||||
status: (item as { status?: { state: string } }).status,
|
|
||||||
}))
|
|
||||||
: sortedRealtimeDocuments.map((doc) => ({ id: doc.id, status: doc.status }));
|
: sortedRealtimeDocuments.map((doc) => ({ id: doc.id, status: doc.status }));
|
||||||
|
|
||||||
const selectedDocs = allDocs.filter((doc) => selectedIds.has(doc.id));
|
const selectedDocs = allDocs.filter((doc) => selectedIds.has(doc.id));
|
||||||
|
|
@ -203,7 +284,10 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
|
||||||
} else {
|
} else {
|
||||||
toast.error(t("delete_partial_failed"));
|
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());
|
setSelectedIds(new Set());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
|
@ -216,14 +300,17 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
|
||||||
try {
|
try {
|
||||||
await deleteDocumentMutation({ id });
|
await deleteDocumentMutation({ id });
|
||||||
toast.success(t("delete_success") || "Document deleted");
|
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;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error deleting document:", e);
|
console.error("Error deleting document:", e);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[deleteDocumentMutation, isSearchMode, refetchSearch, t]
|
[deleteDocumentMutation, isSearchMode, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSortChange = useCallback((key: SortKey) => {
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const panelWidth = isMobile ? window.innerWidth : 720;
|
const panelWidth = isMobile ? window.innerWidth : 720;
|
||||||
|
|
@ -281,7 +363,7 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pt-0">
|
<div className="flex-1 min-h-0 overflow-x-hidden pt-0 flex flex-col">
|
||||||
<div className="px-4 pb-2">
|
<div className="px-4 pb-2">
|
||||||
<DocumentsFilters
|
<DocumentsFilters
|
||||||
typeCounts={realtimeTypeCounts}
|
typeCounts={realtimeTypeCounts}
|
||||||
|
|
@ -306,20 +388,10 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
|
||||||
onSortChange={handleSortChange}
|
onSortChange={handleSortChange}
|
||||||
deleteDocument={handleDeleteDocument}
|
deleteDocument={handleDeleteDocument}
|
||||||
searchSpaceId={String(searchSpaceId)}
|
searchSpaceId={String(searchSpaceId)}
|
||||||
|
hasMore={hasMore}
|
||||||
|
loadingMore={loadingMore}
|
||||||
|
onLoadMore={onLoadMore}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="px-4 py-2">
|
|
||||||
<PaginationControls
|
|
||||||
pageIndex={pageIndex}
|
|
||||||
total={displayTotal}
|
|
||||||
onFirst={() => 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}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue