import {useEffect, useMemo, useState} from 'react'; import {useNavigate} from 'react-router-dom'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; import {Checkbox} from '@/components/ui/checkbox'; import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@/components/ui/table'; import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@/components/ui/tooltip'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import {ChevronLeft, ChevronRight, Download, Eye, FileIcon, FolderIcon, Loader2, MoreVertical, Trash2} from 'lucide-react'; import {Select, SelectOption} from '@/components/ui/select'; import {downloadObject, formatBytes, formatRelativeTime} from '@/lib/file-utils'; import type {S3Object} from '@/types'; interface ObjectsTableProps { bucketName: string; objects: S3Object[]; currentPath: string; searchQuery: string; filterQuery: string; deepSearch: boolean; selectedFileKeys: Set; isDragActive: boolean; isLoading?: boolean; isTruncated?: boolean; nextContinuationToken?: string; itemsPerPage: number; onNavigateToFolder: (key: string) => void; onDeleteObject?: (object: S3Object) => void; onToggleFileSelection: (key: string) => void; onSelectAllFiles: () => void; onPageChange: (token?: string) => void; onItemsPerPageChange: (count: number) => void; initialPageToken?: string; initialItemsPerPage?: number; } type SortColumn = 'name' | 'size' | 'modified'; type SortDirection = 'asc' | 'desc'; export function ObjectsTable({ bucketName, objects, currentPath, searchQuery, filterQuery, deepSearch, selectedFileKeys, isDragActive, isLoading = false, isTruncated = false, nextContinuationToken, itemsPerPage, onNavigateToFolder, onDeleteObject, onToggleFileSelection, onSelectAllFiles, onPageChange, onItemsPerPageChange, initialPageToken, initialItemsPerPage, }: ObjectsTableProps) { const navigate = useNavigate(); const canDelete = Boolean(onDeleteObject); const [sortColumn, setSortColumn] = useState('name'); const [sortDirection, setSortDirection] = useState('asc'); // Store tokens for each page: [undefined (page 1), token1 (page 2), token2 (page 3), ...] const [pageTokens, setPageTokens] = useState<(string | undefined)[]>([undefined]); const [currentPageIndex, setCurrentPageIndex] = useState(0); const [initialized, setInitialized] = useState(false); // Initialize from URL params on first load useEffect(() => { if (!initialized && initialItemsPerPage && initialItemsPerPage !== itemsPerPage) { onItemsPerPageChange(initialItemsPerPage); setInitialized(true); } if (!initialized && initialPageToken && initialPageToken !== nextContinuationToken) { // If we have an initial page token, trigger page change onPageChange(initialPageToken); setInitialized(true); } if (!initialized && !initialPageToken && !initialItemsPerPage) { setInitialized(true); } }, [initialized, initialPageToken, initialItemsPerPage, itemsPerPage, nextContinuationToken, onPageChange, onItemsPerPageChange]); const filteredObjects = useMemo(() => { // Filter on the debounced query, not the raw input, so the list only // updates once typing pauses (matches the debounced server request). const query = filterQuery.toLowerCase(); const filtered = objects.filter((obj) => obj.key.toLowerCase().includes(query)); return [...filtered].sort((a, b) => { const aIsFolder = a.isFolder ? 1 : 0; const bIsFolder = b.isFolder ? 1 : 0; if (aIsFolder !== bIsFolder) return bIsFolder - aIsFolder; let compareValue = 0; switch (sortColumn) { case 'name': { const aName = a.key.replace(currentPath, '').replace(/\/$/, '').toLowerCase(); const bName = b.key.replace(currentPath, '').replace(/\/$/, '').toLowerCase(); compareValue = aName.localeCompare(bName); break; } case 'size': compareValue = a.size - b.size; break; case 'modified': { const aDate = new Date(a.lastModified).getTime(); const bDate = new Date(b.lastModified).getTime(); compareValue = aDate - bDate; break; } } return sortDirection === 'asc' ? compareValue : -compareValue; }); }, [objects, filterQuery, sortColumn, sortDirection, currentPath]); // Effect 2: Reset pagination on path navigation or when a search begins/ends. // Search results are a single flat list, so page-token state must not leak // across the search/browse boundary. useEffect(() => { setPageTokens([undefined]); setCurrentPageIndex(0); }, [currentPath, searchQuery, deepSearch]); // Update page tokens when we get a new next token useEffect(() => { if (nextContinuationToken && isTruncated) { setPageTokens(prev => { const newTokens = [...prev]; // Only add the token if we don't have it yet const nextIndex = currentPageIndex + 1; if (nextIndex >= newTokens.length) { newTokens[nextIndex] = nextContinuationToken; } return newTokens; }); } }, [nextContinuationToken, isTruncated, currentPageIndex]); // Prefix search and normal browsing are server-paginated (query folded into // the prefix; continuation tokens for pages). Deep search loads the whole // capped result set in one response, so we paginate that on the client by // itemsPerPage instead of dumping every match at once. const isDeepSearching = deepSearch && searchQuery.trim().length > 0; const clientPaginated = isDeepSearching; const totalPages = clientPaginated ? Math.max(1, Math.ceil(filteredObjects.length / itemsPerPage)) : 1; // Clamp during render (not via a setState effect) so a shrinking result set // or a larger page size can't strand us on an out-of-range page. const pageIndex = clientPaginated ? Math.min(currentPageIndex, totalPages - 1) : currentPageIndex; const pageObjects = clientPaginated ? filteredObjects.slice(pageIndex * itemsPerPage, (pageIndex + 1) * itemsPerPage) : filteredObjects; const hasPrevious = pageIndex > 0; const hasNext = clientPaginated ? pageIndex < totalPages - 1 : isTruncated; const handleNextPage = () => { if (!hasNext) return; // Client-paginated (deep search): just advance the slice, no server fetch. if (clientPaginated) { setCurrentPageIndex(pageIndex + 1); window.scrollTo({ top: 0, behavior: 'smooth' }); return; } if (nextContinuationToken) { const nextIndex = currentPageIndex + 1; setCurrentPageIndex(nextIndex); onPageChange(nextContinuationToken); window.scrollTo({ top: 0, behavior: 'smooth' }); } }; const handlePreviousPage = () => { if (!hasPrevious) return; if (clientPaginated) { setCurrentPageIndex(pageIndex - 1); window.scrollTo({ top: 0, behavior: 'smooth' }); return; } const prevIndex = currentPageIndex - 1; setCurrentPageIndex(prevIndex); onPageChange(pageTokens[prevIndex]); window.scrollTo({ top: 0, behavior: 'smooth' }); }; const handleItemsPerPageChange = (value: string) => { onItemsPerPageChange(Number(value)); setPageTokens([undefined]); // Reset to first page setCurrentPageIndex(0); }; const handleSort = (column: SortColumn) => { if (sortColumn === column) { setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); } else { setSortColumn(column); setSortDirection('asc'); } }; return ( <>
{canDelete && ( !obj.isFolder).length > 0 && selectedFileKeys.size === filteredObjects.filter(obj => !obj.isFolder).length } onCheckedChange={onSelectAllFiles} aria-label="Select all files" /> )} handleSort('name')} > Objects {sortColumn === 'name' && (sortDirection === 'asc' ? '↑' : '↓')} Type Storage Class handleSort('size')} > Size {sortColumn === 'size' && (sortDirection === 'asc' ? '↑' : '↓')} handleSort('modified')} > Modified {sortColumn === 'modified' && (sortDirection === 'asc' ? '↑' : '↓')} {isLoading ? (
Loading objects...
) : filteredObjects.length === 0 ? ( {searchQuery ? 'No objects found matching your search' : isDragActive ? 'Drop files or folders here' : 'No objects in this location'} ) : ( pageObjects.map((obj) => ( {canDelete && ( {obj.isFolder ? ( ) : ( onToggleFileSelection(obj.key)} aria-label={`Select file ${obj.key}`} /> )} )}
{obj.isFolder ? ( ) : ( )} {obj.isFolder ? ( ) : ( )}
{obj.isFolder ? 'Directory' : (obj.contentType || 'application/octet-stream')} {obj.storageClass && ( {obj.storageClass} )} {obj.isFolder ? null : formatBytes(obj.size)} {obj.lastModified ? (() => { const d = new Date(obj.lastModified); return (
{d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', })} {d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, })} CET
UTC {d.toLocaleString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: 'UTC', })} UTC
Relative {formatRelativeTime(d)}
Timestamp {d.toISOString()}
); })() : null}
{!obj.isFolder && ( navigate(`/buckets/${bucketName}/objects/${encodeURIComponent(obj.key)}`)}> View Details downloadObject(bucketName, obj.key)}> Download {onDeleteObject && ( <> onDeleteObject(obj)} > Delete )} )}
)) )}
{/* Pagination Controls */} {(filteredObjects.length > 0 || hasPrevious) && (
{/* Items per page selector */}
Items per page:
{/* Pagination info and controls */}
{isDeepSearching ? `Page ${pageIndex + 1} of ${totalPages} • ${filteredObjects.length} match${filteredObjects.length !== 1 ? 'es' : ''}${isTruncated ? ' (capped, refine to narrow)' : ''}` : `Page ${pageIndex + 1} • Showing ${pageObjects.length} item${pageObjects.length !== 1 ? 's' : ''}`}
)} ); }