import React, { useState, useEffect, useRef, useCallback, type DragEvent } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { Upload } from 'lucide-react'; import PencilIcon from '@/assets/icons/pencil.svg?react'; import RefreshIcon from '@/assets/icons/refresh.svg?react'; import { useStorage } from '@/features/storage/hooks/useStorage'; import { StorageSidebar } from '@/features/storage/components/StorageSidebar'; import { StorageManager } from '@/features/storage/components/StorageManager'; import { BucketFormDialog } from '@/features/storage/components/BucketFormDialog'; import { useConfirm } from '@/lib/hooks/useConfirm'; import { useToast } from '@/lib/hooks/useToast'; import { useUploadToast } from '@/features/storage/components/UploadToast'; import { SearchInput, SelectionClearButton, DeleteActionButton, Alert, AlertDescription, Button, ConfirmDialog, EmptyState, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components'; import { DataUpdatePayload, DataUpdateResourceType, ServerEvents, SocketMessage, useSocket, } from '@/lib/contexts/SocketContext'; interface BucketFormState { mode: 'create' | 'edit'; name: string | null; isPublic: boolean; } export default function StoragePage() { const [selectedBucket, setSelectedBucket] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [isDragging, setIsDragging] = useState(false); const [isUploading, setIsUploading] = useState(false); const [selectedFiles, setSelectedFiles] = useState>(new Set()); const [isRefreshing, setIsRefreshing] = useState(false); // Bucket form state const [bucketFormOpen, setBucketFormOpen] = useState(false); const [bucketFormState, setBucketFormState] = useState({ mode: 'create', name: null, isPublic: false, }); const { confirm, confirmDialogProps } = useConfirm(); const { showToast } = useToast(); const { showUploadToast, updateUploadProgress, cancelUpload } = useUploadToast(); const queryClient = useQueryClient(); const fileInputRef = useRef(null); const uploadAbortControllerRef = useRef(null); const { socket, isConnected } = useSocket(); const { buckets, isLoadingBuckets: isLoading, bucketsError, refetchBuckets, useBucketStats, uploadObject, deleteObjects, deleteBucket, } = useStorage(); const { data: bucketStats } = useBucketStats(); // Build bucket info map const bucketInfo = React.useMemo(() => { return bucketStats || {}; }, [bucketStats]); useEffect(() => { if (!socket || !isConnected) { return; } const handleDataUpdate = (message: SocketMessage) => { if (message.payload?.resource === DataUpdateResourceType.BUCKETS) { // Invalidate all buckets queries void queryClient.invalidateQueries({ queryKey: ['storage'] }); } }; socket.on(ServerEvents.DATA_UPDATE, handleDataUpdate); return () => { socket.off(ServerEvents.DATA_UPDATE, handleDataUpdate); }; }, [socket, isConnected, queryClient]); // Auto-select first bucket useEffect(() => { if (buckets.length && !selectedBucket) { setSelectedBucket(buckets[0].name); } }, [buckets, selectedBucket]); // Clear selected files when switching buckets useEffect(() => { setSelectedFiles(new Set()); }, [selectedBucket]); const handleRefresh = async () => { setIsRefreshing(true); try { await Promise.all([ refetchBuckets(), queryClient.invalidateQueries({ queryKey: ['storage'] }), ]); } finally { setIsRefreshing(false); } }; // Handle bulk delete files const handleBulkDeleteFiles = async (fileKeys: string[]) => { if (!selectedBucket || !fileKeys.length) { return; } const shouldDelete = await confirm({ title: `Delete ${fileKeys.length} ${fileKeys.length === 1 ? 'file' : 'files'}`, description: `Are you sure you want to delete ${fileKeys.length} ${fileKeys.length === 1 ? 'file' : 'files'}? This action cannot be undone.`, confirmText: 'Delete', destructive: true, }); if (shouldDelete) { deleteObjects({ bucket: selectedBucket, keys: fileKeys }); setSelectedFiles(new Set()); } }; const uploadFiles = async (files: FileList | File[] | null) => { if (!files || !files.length || !selectedBucket) { return; } setIsUploading(true); // Create abort controller for cancellation uploadAbortControllerRef.current = new AbortController(); // Show upload toast const toastId = showUploadToast(files.length, { onCancel: () => { uploadAbortControllerRef.current?.abort(); setIsUploading(false); if (fileInputRef.current) { fileInputRef.current.value = ''; } }, }); let successCount = 0; // Upload files sequentially with individual error handling for (let i = 0; i < files.length; i++) { if (uploadAbortControllerRef.current?.signal.aborted) { break; } // Update progress const progress = Math.round(((i + 1) / files.length) * 100); updateUploadProgress(toastId, progress); try { await uploadObject({ bucket: selectedBucket, objectKey: files[i].name, file: files[i], }); successCount++; } catch (error) { // Handle individual file upload error const fileName = files[i].name; // Show individual file error (but don't stop the overall process) const errorMessage = error instanceof Error ? error.message : 'Upload failed'; showToast(`Failed to upload "${fileName}": ${errorMessage}`, 'error'); } } showToast(`${successCount} files uploaded successfully`, 'success'); // Complete the upload toast cancelUpload(toastId); // Always reset uploading state setIsUploading(false); uploadAbortControllerRef.current = null; // Reset file input if (fileInputRef.current) { fileInputRef.current.value = ''; } }; const handleFileUpload = useCallback(uploadFiles, [ cancelUpload, selectedBucket, showToast, showUploadToast, updateUploadProgress, uploadObject, ]); const handleFileSelect = async (event: React.ChangeEvent) => { await handleFileUpload(event.target.files); }; const handleDeleteBucket = async (bucketName: string) => { const confirmOptions = { title: 'Delete Bucket', description: `Are you sure you want to delete the bucket "${bucketName}"? This will permanently delete all files in this bucket. This action cannot be undone.`, confirmText: 'Delete', destructive: true, }; const shouldDelete = await confirm(confirmOptions); if (shouldDelete) { try { await deleteBucket(bucketName); await refetchBuckets(); // If the deleted bucket was selected, select the first available bucket if (selectedBucket === bucketName) { const updatedBuckets = queryClient.getQueryData(['storage', 'buckets']) || []; setSelectedBucket(updatedBuckets[0]?.name || null); } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to delete bucket'; showToast(errorMessage, 'error'); } } }; const handleEditBucket = (bucketName: string) => { // Get current bucket's public status const info = bucketInfo[bucketName]; setBucketFormState({ mode: 'edit', name: bucketName, isPublic: info?.public ?? false, }); setBucketFormOpen(true); }; const handleDragOver = useCallback((event: DragEvent) => { event.preventDefault(); setIsDragging(true); }, []); const handleDragLeave = useCallback((_event: DragEvent) => { setIsDragging(false); }, []); const handleDrop = useCallback( (event: DragEvent) => { event.preventDefault(); setIsDragging(false); // To support only file uploads (not directories), we filter through // dataTransfer.items instead of directly using dataTransfer.files. // Ref: https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/webkitGetAsEntry const fileItems: File[] = Array.from(event.dataTransfer.items) .filter((item) => item.webkitGetAsEntry()?.isFile) .map((item) => item.getAsFile()) .filter((item) => item !== null); void handleFileUpload(fileItems); }, [handleFileUpload] ); const error = bucketsError; return (
{/* Secondary Sidebar - Bucket List */} { setBucketFormState({ mode: 'create', name: null, isPublic: true, }); setBucketFormOpen(true); }} onEditBucket={handleEditBucket} onDeleteBucket={(bucketName) => void handleDeleteBucket(bucketName)} /> {/* Main Content Area */}
{selectedBucket && ( <> {/* Sticky Header Section */}
{/* Page Header with Breadcrumb */}
{selectedBucket && ( )} {/* Separator */}
{/* Action buttons group */}
{selectedBucket && (

Edit Bucket

)}

{isRefreshing ? 'Refreshing...' : 'Refresh'}

{/* Search Bar and Actions - only show when bucket is selected */} {selectedBucket && (
{selectedFiles.size > 0 ? (
setSelectedFiles(new Set())} /> void handleBulkDeleteFiles(Array.from(selectedFiles))} />
) : ( )}
{selectedFiles.size === 0 && ( <> {/* Upload File Button - moved here when no files selected */} void handleFileSelect(e)} className="hidden" accept="*" style={{ display: 'none' }} /> )}
)}
{/* Content (supports drag-and-drop file upload) */}
{error && ( {String(error)} )}
)} {!selectedBucket && (
)}
{/* Bucket Form (handles both create and edit) */} { void refetchBuckets(); if (bucketFormState.mode === 'create' && bucketName) { setSelectedBucket(bucketName); } }} /> {/* Confirm Dialog */}
); }