diff --git a/src/interface/web/app/search/page.tsx b/src/interface/web/app/search/page.tsx index 03a59b5f..c7ac9b3c 100644 --- a/src/interface/web/app/search/page.tsx +++ b/src/interface/web/app/search/page.tsx @@ -55,6 +55,16 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; + import { uploadDataForIndexing } from "../common/chatFunctions"; import { Progress } from "@/components/ui/progress"; interface AdditionalData { @@ -88,6 +98,132 @@ function getNoteTypeIcon(source: string) { return ; } +interface FileCardProps { + file: FileObject; + index: number; + setSelectedFile: (file: string) => void; + setSelectedFileFullText: (file: string) => void; + handleDownload: (fileName: string, content: string) => void; + handleDelete: (fileName: string) => void; + isDeleting: boolean; + selectedFileFullText: string | null; +} + +function FileCard({ file, setSelectedFile, setSelectedFileFullText, handleDownload, handleDelete, isDeleting, selectedFileFullText }: FileCardProps) { + return ( + + + + + + { + setSelectedFileFullText( + '', + ); + setSelectedFile( + file.file_name, + ); + }} + > + {file.file_name + .split("/") + .pop()} + + + + + + + {file.file_name + .split("/") + .pop()} + + handleDownload( + file.file_name, + file.raw_text, + ) + } + > + + + + + + + + {!selectedFileFullText && ( + + )} + { + selectedFileFullText + } + + + + + + + + + + + + + { + handleDelete( + file.file_name, + ); + }} + > + + + {isDeleting + ? "Deleting..." + : "Delete"} + + + + + + + + + + + {file.raw_text.slice(0, 100)}... + + + + + + {formatDateTime(file.updated_at)} + + + + ) +} + interface NoteResultProps { note: SearchResult; setFocusSearchResult: (note: SearchResult) => void; @@ -338,6 +474,8 @@ export default function Search() { const [selectedFileFullText, setSelectedFileFullText] = useState(null); const [isDeleting, setIsDeleting] = useState(false); const [uploadedFiles, setUploadedFiles] = useState([]); + const [pageNumber, setPageNumber] = useState(0); + const [numPages, setNumPages] = useState(1); const { toast } = useToast(); @@ -365,15 +503,22 @@ export default function Search() { }); } - const fetchFiles = async () => { + const fetchFiles = async (currentPageNumber: number) => { try { - const response = await fetch("/api/content/all"); + const url = `api/content/all?page=${currentPageNumber}`; + const response = await fetch(url); if (!response.ok) throw new Error("Failed to fetch files"); - const filesList = await response.json(); + const filesData = await response.json(); + const filesList = filesData.files; + const totalPages = filesData.num_pages; + + setNumPages(totalPages); + if (Array.isArray(filesList)) { setFiles(filesList.toSorted()); } + } catch (error) { setError("Failed to load files"); console.error("Error fetching files:", error); @@ -382,6 +527,10 @@ export default function Search() { } }; + useEffect(() => { + fetchFiles(pageNumber); + }, [pageNumber]); + const fetchSpecificFile = async (fileName: string) => { try { const response = await fetch(`/api/content/file?file_name=${fileName}`); @@ -439,12 +588,13 @@ export default function Search() { }, [selectedFile]); useEffect(() => { - fetchFiles(); + fetchFiles(pageNumber); }, []); useEffect(() => { if (uploadedFiles.length > 0) { - fetchFiles(); + setPageNumber(0); + fetchFiles(0); } }, [uploadedFiles]); @@ -462,7 +612,8 @@ export default function Search() { }); // Refresh files list - fetchFiles(); + setPageNumber(0); + fetchFiles(0); } catch (error) { toast({ title: "Error deleting file", @@ -582,119 +733,85 @@ export default function Search() { {files.map((file, index) => ( - - - - - - { - setSelectedFileFullText( - null, - ); - setSelectedFile( - file.file_name, - ); - }} - > - {file.file_name - .split("/") - .pop()} - - - - - - - {file.file_name - .split("/") - .pop()} - - handleDownload( - file.file_name, - file.raw_text, - ) - } - > - - - - - - - - {!selectedFileFullText && ( - - )} - { - selectedFileFullText - } - - - - - - - - - - - - - { - handleDelete( - file.file_name, - ); - }} - > - - - {isDeleting - ? "Deleting..." - : "Delete"} - - - - - - - - - - - {file.raw_text.slice(0, 100)}... - - - - - - {formatDateTime(file.updated_at)} - - - + file={file} + index={index} + setSelectedFile={setSelectedFile} + setSelectedFileFullText={setSelectedFileFullText} + handleDownload={handleDownload} + handleDelete={handleDelete} + isDeleting={isDeleting} + selectedFileFullText={selectedFileFullText} + /> ))} + + + + {/* Show prev button if not on first page */} + {pageNumber > 0 && ( + + setPageNumber(pageNumber - 1)} /> + + )} + + {/* Show first page if not on first two pages */} + {pageNumber > 1 && ( + + setPageNumber(0)}>1 + + )} + + {/* Show ellipsis if there's a gap */} + {pageNumber > 2 && ( + + + + )} + + {/* Show previous page if not on first page */} + {pageNumber > 0 && ( + + setPageNumber(pageNumber - 1)}>{pageNumber} + + )} + + {/* Current page */} + + {pageNumber + 1} + + + {/* Show next page if not on last page */} + {pageNumber < numPages - 1 && ( + + setPageNumber(pageNumber + 1)}>{pageNumber + 2} + + )} + + {/* Show ellipsis if there's a gap before last page */} + {pageNumber < numPages - 3 && ( + + + + )} + + {/* Show last page if not on last two pages */} + {pageNumber < numPages - 2 && ( + + setPageNumber(numPages - 1)}>{numPages} + + )} + + {/* Show next button if not on last page */} + {pageNumber < numPages - 1 && ( + + setPageNumber(pageNumber + 1)} /> + + )} + + + )} {searchResults && searchResults.length === 0 && ( diff --git a/src/interface/web/components/ui/pagination.tsx b/src/interface/web/components/ui/pagination.tsx new file mode 100644 index 00000000..c5c99bea --- /dev/null +++ b/src/interface/web/components/ui/pagination.tsx @@ -0,0 +1,118 @@ +import * as React from "react" +import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" +import { ButtonProps, buttonVariants } from "@/components/ui/button" + +const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( + +) +Pagination.displayName = "Pagination" + +const PaginationContent = React.forwardRef< + HTMLUListElement, + React.ComponentProps<"ul"> +>(({ className, ...props }, ref) => ( + +)) +PaginationContent.displayName = "PaginationContent" + +const PaginationItem = React.forwardRef< + HTMLLIElement, + React.ComponentProps<"li"> +>(({ className, ...props }, ref) => ( + +)) +PaginationItem.displayName = "PaginationItem" + +type PaginationLinkProps = { + isActive?: boolean +} & Pick & + React.ComponentProps<"a"> + +const PaginationLink = ({ + className, + isActive, + size = "icon", + ...props +}: PaginationLinkProps) => ( + +) +PaginationLink.displayName = "PaginationLink" + +const PaginationPrevious = ({ + className, + ...props +}: React.ComponentProps) => ( + + + Previous + +) +PaginationPrevious.displayName = "PaginationPrevious" + +const PaginationNext = ({ + className, + ...props +}: React.ComponentProps) => ( + + Next + + +) +PaginationNext.displayName = "PaginationNext" + +const PaginationEllipsis = ({ + className, + ...props +}: React.ComponentProps<"span">) => ( + + + More pages + +) +PaginationEllipsis.displayName = "PaginationEllipsis" + +export { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} diff --git a/src/khoj/database/adapters/__init__.py b/src/khoj/database/adapters/__init__.py index c3a2eb45..33c1c6b8 100644 --- a/src/khoj/database/adapters/__init__.py +++ b/src/khoj/database/adapters/__init__.py @@ -1485,9 +1485,16 @@ class FileObjectAdapters: return await sync_to_async(list)(FileObject.objects.filter(user=user, file_name__in=file_names)) @staticmethod - @arequire_valid_user - async def aget_all_file_objects(user: KhojUser): - return await sync_to_async(list)(FileObject.objects.filter(user=user).order_by("-updated_at")) + @require_valid_user + async def aget_all_file_objects(user: KhojUser, start: int = 0, limit: int = 10): + query = FileObject.objects.filter(user=user).order_by("-updated_at")[start : start + limit] + return await sync_to_async(list)(query) + + @staticmethod + @require_valid_user + async def aget_number_of_pages(user: KhojUser, limit: int = 10): + count = await FileObject.objects.filter(user=user).acount() + return math.ceil(count / limit) @staticmethod @arequire_valid_user diff --git a/src/khoj/routers/api_content.py b/src/khoj/routers/api_content.py index 3211c154..d898febb 100644 --- a/src/khoj/routers/api_content.py +++ b/src/khoj/routers/api_content.py @@ -332,7 +332,9 @@ def get_content_types(request: Request, client: Optional[str] = None): @api_content.get("/all", response_model=Dict[str, str]) @requires(["authenticated"]) -async def get_all_content(request: Request, client: Optional[str] = None, truncated: Optional[bool] = True): +async def get_all_content( + request: Request, client: Optional[str] = None, truncated: Optional[bool] = True, page: int = 0 +): user = request.user.object update_telemetry_state( @@ -343,7 +345,12 @@ async def get_all_content(request: Request, client: Optional[str] = None, trunca ) files_data = [] - file_objects = await FileObjectAdapters.aget_all_file_objects(user) + page_size = 1 + + file_objects = await FileObjectAdapters.aget_all_file_objects(user, start=page * page_size, limit=page_size) + + num_pages = await FileObjectAdapters.aget_number_of_pages(user, page_size) + for file_object in file_objects: files_data.append( { @@ -353,7 +360,12 @@ async def get_all_content(request: Request, client: Optional[str] = None, trunca } ) - return Response(content=json.dumps(files_data), media_type="application/json", status_code=200) + data_packet = { + "files": files_data, + "num_pages": num_pages, + } + + return Response(content=json.dumps(data_packet), media_type="application/json", status_code=200) @api_content.get("/file", response_model=Dict[str, str])
+ {!selectedFileFullText && ( + + )} + { + selectedFileFullText + } +
+ {file.raw_text.slice(0, 100)}... +
- {!selectedFileFullText && ( - - )} - { - selectedFileFullText - } -
- {file.raw_text.slice(0, 100)}... -