mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-09-03 18:55:42 +00:00
feat: implement bucket and object management dialogs, enhance caching, and update theme colors
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { FolderIcon, Loader2, MoreVertical, Plus, Search, Settings, Trash2 } from 'lucide-react';
|
||||
import { formatBytes, formatDate } from '@/lib/utils';
|
||||
import type { Bucket } from '@/types';
|
||||
|
||||
interface BucketListViewProps {
|
||||
buckets: Bucket[];
|
||||
searchQuery: string;
|
||||
isLoading?: boolean;
|
||||
onSearchChange: (query: string) => void;
|
||||
onViewBucket: (bucketName: string) => void;
|
||||
onOpenSettings: (bucket: Bucket) => void;
|
||||
onCreateBucket: () => void;
|
||||
onDeleteBucket: (bucket: Bucket) => void;
|
||||
}
|
||||
|
||||
export function BucketListView({
|
||||
buckets,
|
||||
searchQuery,
|
||||
isLoading = false,
|
||||
onSearchChange,
|
||||
onViewBucket,
|
||||
onOpenSettings,
|
||||
onCreateBucket,
|
||||
onDeleteBucket,
|
||||
}: BucketListViewProps) {
|
||||
const filteredBuckets = buckets.filter((bucket) =>
|
||||
bucket.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||
<div className="relative flex-1 max-w-full sm:max-w-xs">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search buckets..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={onCreateBucket} className="w-full sm:w-auto">
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Bucket
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Buckets Table */}
|
||||
<div className="border rounded-lg overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">Region</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Objects</TableHead>
|
||||
<TableHead>Size</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Created</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-12">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>Loading buckets...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredBuckets.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-12 text-muted-foreground">
|
||||
{searchQuery ? 'No buckets found matching your search' : 'No buckets yet'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredBuckets.map((bucket) => (
|
||||
<TableRow
|
||||
key={bucket.name}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => onViewBucket(bucket.name)}
|
||||
>
|
||||
<TableCell className="font-medium truncate max-w-[200px]">{bucket.name}</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
<Badge variant="secondary">{bucket.region || 'default'}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">{bucket.objectCount?.toLocaleString() || 0}</TableCell>
|
||||
<TableCell>{bucket.size ? formatBytes(bucket.size) : '0 B'}</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">{formatDate(bucket.creationDate)}</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon" className="-m-3 top-1 relative">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewBucket(bucket.name);
|
||||
}}>
|
||||
<FolderIcon className="h-4 w-4" />
|
||||
View Objects
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenSettings(bucket);
|
||||
}}>
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteBucket(bucket);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Select, SelectOption } from '@/components/ui/select';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { accessApi } from '@/lib/api';
|
||||
import type { AccessKey, Bucket } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface BucketSettingsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
bucket: Bucket | null;
|
||||
onGrantPermission: (bucketName: string, accessKeyId: string, permissions: { read: boolean; write: boolean; owner: boolean }) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function BucketSettingsDialog({ open, onOpenChange, bucket, onGrantPermission }: BucketSettingsDialogProps) {
|
||||
const [availableKeys, setAvailableKeys] = useState<AccessKey[]>([]);
|
||||
const [selectedAccessKey, setSelectedAccessKey] = useState<string>('');
|
||||
const [permissionRead, setPermissionRead] = useState(false);
|
||||
const [permissionWrite, setPermissionWrite] = useState(false);
|
||||
const [permissionOwner, setPermissionOwner] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && bucket) {
|
||||
loadAccessKeys();
|
||||
resetForm();
|
||||
}
|
||||
}, [open, bucket]);
|
||||
|
||||
const loadAccessKeys = async () => {
|
||||
try {
|
||||
const keys = await accessApi.listKeys();
|
||||
setAvailableKeys(keys);
|
||||
} catch (error) {
|
||||
console.error('Failed to load access keys:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setSelectedAccessKey('');
|
||||
setPermissionRead(false);
|
||||
setPermissionWrite(false);
|
||||
setPermissionOwner(false);
|
||||
};
|
||||
|
||||
const handleAccessKeyChange = (accessKeyId: string) => {
|
||||
setSelectedAccessKey(accessKeyId);
|
||||
|
||||
if (!accessKeyId) {
|
||||
setPermissionRead(false);
|
||||
setPermissionWrite(false);
|
||||
setPermissionOwner(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedKey = availableKeys.find(key => key.accessKeyId === accessKeyId);
|
||||
if (selectedKey && bucket) {
|
||||
const bucketPermission = selectedKey.permissions.find(
|
||||
perm => perm.bucketName === bucket.name || perm.bucketId === bucket.name
|
||||
);
|
||||
|
||||
if (bucketPermission) {
|
||||
setPermissionRead(bucketPermission.read);
|
||||
setPermissionWrite(bucketPermission.write);
|
||||
setPermissionOwner(bucketPermission.owner);
|
||||
} else {
|
||||
setPermissionRead(false);
|
||||
setPermissionWrite(false);
|
||||
setPermissionOwner(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleGrantPermission = async () => {
|
||||
if (!bucket || !selectedAccessKey) {
|
||||
toast.error('Please select an access key');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!permissionRead && !permissionWrite && !permissionOwner) {
|
||||
toast.error('Please select at least one permission');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await onGrantPermission(bucket.name, selectedAccessKey, {
|
||||
read: permissionRead,
|
||||
write: permissionWrite,
|
||||
owner: permissionOwner,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bucket Settings - {bucket?.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Grant access key permissions for this bucket
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Select Access Key</label>
|
||||
<Select
|
||||
value={selectedAccessKey}
|
||||
onChange={(value) => handleAccessKeyChange(value)}
|
||||
>
|
||||
<SelectOption value="">-- Select an access key --</SelectOption>
|
||||
{availableKeys.map((key) => (
|
||||
<SelectOption key={key.accessKeyId} value={key.accessKeyId}>
|
||||
{key.name} ({key.accessKeyId})
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose which access key should have permissions on this bucket. Current permissions will be displayed when selected.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium">Permissions</label>
|
||||
<div className="space-y-3 border rounded-lg p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="permission-read"
|
||||
checked={permissionRead}
|
||||
onCheckedChange={(checked) => setPermissionRead(checked as boolean)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="permission-read"
|
||||
className="text-sm font-medium leading-none cursor-pointer"
|
||||
>
|
||||
Read
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Allows reading objects from the bucket (GetObject, HeadObject, ListObjects)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="permission-write"
|
||||
checked={permissionWrite}
|
||||
onCheckedChange={(checked) => setPermissionWrite(checked as boolean)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="permission-write"
|
||||
className="text-sm font-medium leading-none cursor-pointer"
|
||||
>
|
||||
Write
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Allows writing and deleting objects in the bucket (PutObject, DeleteObject)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="permission-owner"
|
||||
checked={permissionOwner}
|
||||
onCheckedChange={(checked) => setPermissionOwner(checked as boolean)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="permission-owner"
|
||||
className="text-sm font-medium leading-none cursor-pointer"
|
||||
>
|
||||
Owner
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Allows managing bucket settings and policies (DeleteBucket, PutBucketPolicy)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleGrantPermission} disabled={!selectedAccessKey}>
|
||||
Grant Permission
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface CreateBucketDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreateBucket: (name: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: CreateBucketDialogProps) {
|
||||
const [bucketName, setBucketName] = useState('');
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!bucketName) {
|
||||
toast.error('Please enter a bucket name');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await onCreateBucket(bucketName);
|
||||
if (success) {
|
||||
setBucketName('');
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Bucket</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new storage bucket for your objects
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Bucket Name</label>
|
||||
<Input
|
||||
placeholder="my-bucket-name"
|
||||
value={bucketName}
|
||||
onChange={(e) => setBucketName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCreate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Must be unique and follow DNS naming conventions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="space-y-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={!bucketName ? 'default_disabled' : 'default'}
|
||||
onClick={handleCreate}
|
||||
disabled={!bucketName}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface CreateDirectoryDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentPath: string;
|
||||
onCreateDirectory: (name: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function CreateDirectoryDialog({ open, onOpenChange, currentPath, onCreateDirectory }: CreateDirectoryDialogProps) {
|
||||
const [dirName, setDirName] = useState('');
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!dirName) {
|
||||
toast.error('Please enter a directory name');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await onCreateDirectory(dirName);
|
||||
if (success) {
|
||||
setDirName('');
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Directory</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new directory in {currentPath || 'the root'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Directory Name</label>
|
||||
<Input
|
||||
placeholder="my-directory"
|
||||
value={dirName}
|
||||
onChange={(e) => setDirName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCreate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={!dirName}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import type { Bucket } from '@/types';
|
||||
|
||||
interface DeleteBucketDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
bucket: Bucket | null;
|
||||
onDeleteBucket: (name: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function DeleteBucketDialog({ open, onOpenChange, bucket, onDeleteBucket }: DeleteBucketDialogProps) {
|
||||
const handleDelete = async () => {
|
||||
if (!bucket) return;
|
||||
|
||||
const success = await onDeleteBucket(bucket.name);
|
||||
if (success) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Bucket</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{bucket?.name}"? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import type { S3Object } from '@/types';
|
||||
|
||||
interface DeleteObjectDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
object: S3Object | null;
|
||||
onDeleteObject: (key: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function DeleteObjectDialog({ open, onOpenChange, object, onDeleteObject }: DeleteObjectDialogProps) {
|
||||
const handleDelete = async () => {
|
||||
if (!object) return;
|
||||
|
||||
const success = await onDeleteObject(object.key);
|
||||
if (success) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Object</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{object?.key}"? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import { useState } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Header } from '@/components/layout/header';
|
||||
import { ObjectsTable } from './ObjectsTable';
|
||||
import { CreateDirectoryDialog } from './CreateDirectoryDialog';
|
||||
import { DeleteObjectDialog } from './DeleteObjectDialog';
|
||||
import { ArrowLeft, ChevronRight, FolderPlus, Home, RotateCwIcon, Search, Trash, Upload } from 'lucide-react';
|
||||
import { getBreadcrumbs } from '@/lib/file-utils';
|
||||
import type { S3Object } from '@/types';
|
||||
|
||||
interface ObjectBrowserViewProps {
|
||||
bucketName: string;
|
||||
objects: S3Object[];
|
||||
currentPath: string;
|
||||
searchQuery: string;
|
||||
isLoading?: boolean;
|
||||
isTruncated?: boolean;
|
||||
nextContinuationToken?: string;
|
||||
itemsPerPage: number;
|
||||
onSearchChange: (query: string) => void;
|
||||
onNavigateToFolder: (path: string) => void;
|
||||
onBackToBuckets: () => void;
|
||||
onUploadFiles: (files: File[]) => Promise<boolean>;
|
||||
onDeleteObject: (key: string) => Promise<boolean>;
|
||||
onDeleteMultipleObjects: (keys: string[]) => Promise<boolean>;
|
||||
onCreateDirectory: (name: string) => Promise<boolean>;
|
||||
onRefresh: () => Promise<void>;
|
||||
onPageChange: (token?: string) => void;
|
||||
onItemsPerPageChange: (count: number) => void;
|
||||
isRefreshing: boolean;
|
||||
isNavigating: boolean;
|
||||
initialPageToken?: string;
|
||||
initialItemsPerPage?: number;
|
||||
}
|
||||
|
||||
export function ObjectBrowserView({
|
||||
bucketName,
|
||||
objects,
|
||||
currentPath,
|
||||
searchQuery,
|
||||
isLoading = false,
|
||||
isTruncated = false,
|
||||
nextContinuationToken,
|
||||
itemsPerPage,
|
||||
onSearchChange,
|
||||
onNavigateToFolder,
|
||||
onBackToBuckets,
|
||||
onUploadFiles,
|
||||
onDeleteObject,
|
||||
onDeleteMultipleObjects,
|
||||
onCreateDirectory,
|
||||
onRefresh,
|
||||
onPageChange,
|
||||
onItemsPerPageChange,
|
||||
isRefreshing,
|
||||
isNavigating,
|
||||
initialPageToken,
|
||||
initialItemsPerPage,
|
||||
}: ObjectBrowserViewProps) {
|
||||
const [showUploadZone, setShowUploadZone] = useState(false);
|
||||
const [deleteObjectDialogOpen, setDeleteObjectDialogOpen] = useState(false);
|
||||
const [selectedObject, setSelectedObject] = useState<S3Object | null>(null);
|
||||
const [createDirDialogOpen, setCreateDirDialogOpen] = useState(false);
|
||||
const [selectedFileKeys, setSelectedFileKeys] = useState<Set<string>>(new Set());
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop: async (acceptedFiles, fileRejections, event) => {
|
||||
// Get files with their full paths from DataTransferItems API
|
||||
const filesWithPaths: File[] = [];
|
||||
|
||||
if (event.dataTransfer?.items) {
|
||||
// Use DataTransferItemList API to preserve folder structure
|
||||
const items = Array.from(event.dataTransfer.items);
|
||||
await Promise.all(items.map(async (item) => {
|
||||
if (item.kind === 'file') {
|
||||
const entry = item.webkitGetAsEntry?.();
|
||||
if (entry) {
|
||||
await traverseFileTree(entry, '', filesWithPaths);
|
||||
}
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
// Fallback to standard files
|
||||
filesWithPaths.push(...acceptedFiles);
|
||||
}
|
||||
|
||||
await onUploadFiles(filesWithPaths.length > 0 ? filesWithPaths : acceptedFiles);
|
||||
setShowUploadZone(false);
|
||||
},
|
||||
noClick: true,
|
||||
});
|
||||
|
||||
// Helper function to traverse file/directory tree
|
||||
const traverseFileTree = async (item: any, path: string, files: File[]): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
if (item.isFile) {
|
||||
item.file((file: File) => {
|
||||
// Add the relative path to the file object
|
||||
const fullPath = path + file.name;
|
||||
Object.defineProperty(file, 'webkitRelativePath', {
|
||||
value: fullPath,
|
||||
writable: false
|
||||
});
|
||||
files.push(file);
|
||||
resolve();
|
||||
});
|
||||
} else if (item.isDirectory) {
|
||||
const dirReader = item.createReader();
|
||||
dirReader.readEntries(async (entries: any[]) => {
|
||||
for (const entry of entries) {
|
||||
await traverseFileTree(entry, path + item.name + '/', files);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleFileSelection = (key: string) => {
|
||||
const newSelected = new Set(selectedFileKeys);
|
||||
if (newSelected.has(key)) {
|
||||
newSelected.delete(key);
|
||||
} else {
|
||||
newSelected.add(key);
|
||||
}
|
||||
setSelectedFileKeys(newSelected);
|
||||
};
|
||||
|
||||
const handleSelectAllFiles = () => {
|
||||
const fileKeys = objects
|
||||
.filter(obj => !obj.isFolder)
|
||||
.map(obj => obj.key);
|
||||
|
||||
if (selectedFileKeys.size === fileKeys.length && fileKeys.length > 0) {
|
||||
setSelectedFileKeys(new Set());
|
||||
} else {
|
||||
setSelectedFileKeys(new Set(fileKeys));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDeleteFiles = async () => {
|
||||
if (selectedFileKeys.size === 0) return;
|
||||
|
||||
await onDeleteMultipleObjects(Array.from(selectedFileKeys));
|
||||
setSelectedFileKeys(new Set());
|
||||
};
|
||||
|
||||
const handleDeleteObject = async (key: string): Promise<boolean> => {
|
||||
const success = await onDeleteObject(key);
|
||||
if (success) {
|
||||
setDeleteObjectDialogOpen(false);
|
||||
setSelectedObject(null);
|
||||
}
|
||||
return success;
|
||||
};
|
||||
|
||||
const uploadFiles = async (files: File[]) => {
|
||||
await onUploadFiles(files);
|
||||
setShowUploadZone(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header title={`Objects in ${bucketName}`} />
|
||||
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
|
||||
{/* Back Button */}
|
||||
<Button variant="outline" onClick={onBackToBuckets} className="text-sm sm:text-base">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Back to Buckets</span>
|
||||
<span className="sm:hidden">Back</span>
|
||||
</Button>
|
||||
|
||||
{/* Breadcrumb Navigation */}
|
||||
<div className="flex items-center gap-2 text-xs sm:text-sm overflow-x-auto">
|
||||
<Home className="h-4 w-4 text-muted-foreground" />
|
||||
{getBreadcrumbs(currentPath).map((crumb, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
{index > 0 && <ChevronRight className="h-4 w-4 text-muted-foreground" />}
|
||||
<button
|
||||
onClick={() => onNavigateToFolder(crumb.path)}
|
||||
className={
|
||||
index === getBreadcrumbs(currentPath).length - 1
|
||||
? 'font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}
|
||||
>
|
||||
{crumb.label}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||
<div className="relative flex-1 max-w-full sm:max-w-xs">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search objects..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{selectedFileKeys.size > 0 && (
|
||||
<Button
|
||||
onClick={handleBulkDeleteFiles}
|
||||
title={`Delete ${selectedFileKeys.size} selected file(s)`}
|
||||
className="bg-transparent border border-red-500 text-red-500 hover:bg-red-500/5"
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
Delete {selectedFileKeys.size} file{selectedFileKeys.size !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" onClick={() => setShowUploadZone(!showUploadZone)} className="flex-1 sm:flex-initial">
|
||||
<Upload className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Upload</span>
|
||||
</Button>
|
||||
<Button onClick={() => setCreateDirDialogOpen(true)} className="flex-1 sm:flex-initial">
|
||||
<FolderPlus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Add Directory</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={onRefresh} title="Refresh" disabled={isRefreshing}>
|
||||
<RotateCwIcon className={`h-4 w-4 transition-transform duration-500 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Zone */}
|
||||
{showUploadZone && (
|
||||
<div className="border rounded-lg p-6 bg-muted/30 space-y-4">
|
||||
<div className="flex gap-6">
|
||||
<div className="flex-shrink-0 flex items-center justify-center">
|
||||
<div className="w-20 h-20 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
className="w-12 h-12 text-primary"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-3">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
|
||||
isDragActive
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted-foreground/25 hover:border-muted-foreground/50'
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<p className="text-sm">
|
||||
Drag and drop files/folders or{' '}
|
||||
<label
|
||||
htmlFor="file-input"
|
||||
className="font-medium text-primary hover:underline cursor-pointer"
|
||||
>
|
||||
select files
|
||||
</label>
|
||||
{' / '}
|
||||
<label
|
||||
htmlFor="folder-input"
|
||||
className="font-medium text-primary hover:underline cursor-pointer"
|
||||
>
|
||||
select folder
|
||||
</label>
|
||||
</p>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
if (e.target.files) {
|
||||
const files = Array.from(e.target.files);
|
||||
uploadFiles(files);
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<input
|
||||
id="folder-input"
|
||||
type="file"
|
||||
{...({ webkitdirectory: '', directory: '', mozdirectory: '' } as any)}
|
||||
onChange={(e) => {
|
||||
if (e.target.files) {
|
||||
const files = Array.from(e.target.files);
|
||||
uploadFiles(files);
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Objects Table with Drag & Drop */}
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`relative border rounded-lg transition-all duration-200 overflow-visible ${
|
||||
isDragActive
|
||||
? 'border-primary bg-primary/5 border-2 shadow-lg'
|
||||
: 'border-border'
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
|
||||
{/* Drag & Drop Overlay */}
|
||||
{isDragActive && (
|
||||
<div className="absolute inset-0 z-50 bg-primary/10 backdrop-blur-sm rounded-lg flex items-center justify-center pointer-events-none">
|
||||
<div className="bg-background/95 border-2 border-primary border-dashed rounded-lg p-8 shadow-xl">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="relative">
|
||||
<Upload className="h-16 w-16 text-primary animate-bounce" />
|
||||
<div className="absolute inset-0 h-16 w-16 text-primary opacity-30 animate-ping">
|
||||
<Upload className="h-16 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-lg font-semibold text-primary">Drop files here to upload</p>
|
||||
<p className="text-sm text-muted-foreground">Files will be uploaded to {currentPath || 'root'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ObjectsTable
|
||||
objects={objects}
|
||||
currentPath={currentPath}
|
||||
searchQuery={searchQuery}
|
||||
selectedFileKeys={selectedFileKeys}
|
||||
isDragActive={isDragActive}
|
||||
isLoading={isLoading && !isRefreshing && !isNavigating}
|
||||
isTruncated={isTruncated}
|
||||
nextContinuationToken={nextContinuationToken}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onNavigateToFolder={onNavigateToFolder}
|
||||
onDeleteObject={(obj) => {
|
||||
setSelectedObject(obj);
|
||||
setDeleteObjectDialogOpen(true);
|
||||
}}
|
||||
onToggleFileSelection={handleToggleFileSelection}
|
||||
onSelectAllFiles={handleSelectAllFiles}
|
||||
onPageChange={onPageChange}
|
||||
onItemsPerPageChange={onItemsPerPageChange}
|
||||
initialPageToken={initialPageToken}
|
||||
initialItemsPerPage={initialItemsPerPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Directory Dialog */}
|
||||
<CreateDirectoryDialog
|
||||
open={createDirDialogOpen}
|
||||
onOpenChange={setCreateDirDialogOpen}
|
||||
currentPath={currentPath}
|
||||
onCreateDirectory={onCreateDirectory}
|
||||
/>
|
||||
|
||||
{/* Delete Object Dialog */}
|
||||
<DeleteObjectDialog
|
||||
open={deleteObjectDialogOpen}
|
||||
onOpenChange={setDeleteObjectDialogOpen}
|
||||
object={selectedObject}
|
||||
onDeleteObject={handleDeleteObject}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
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, FileIcon, FolderIcon, Loader2, MoreVertical, Trash2} from 'lucide-react';
|
||||
import {Select, SelectOption} from '@/components/ui/select';
|
||||
import {formatBytes} from '@/lib/utils';
|
||||
import {formatRelativeTime, getFileType} from '@/lib/file-utils';
|
||||
import type {S3Object} from '@/types';
|
||||
|
||||
interface ObjectsTableProps {
|
||||
objects: S3Object[];
|
||||
currentPath: string;
|
||||
searchQuery: string;
|
||||
selectedFileKeys: Set<string>;
|
||||
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({
|
||||
objects,
|
||||
currentPath,
|
||||
searchQuery,
|
||||
selectedFileKeys,
|
||||
isDragActive,
|
||||
isLoading = false,
|
||||
isTruncated = false,
|
||||
nextContinuationToken,
|
||||
itemsPerPage,
|
||||
onNavigateToFolder,
|
||||
onDeleteObject,
|
||||
onToggleFileSelection,
|
||||
onSelectAllFiles,
|
||||
onPageChange,
|
||||
onItemsPerPageChange,
|
||||
initialPageToken,
|
||||
initialItemsPerPage,
|
||||
}: ObjectsTableProps) {
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('name');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
const [filteredObjects, setFilteredObjects] = useState<S3Object[]>([]);
|
||||
// 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 sortObjects = (objList: S3Object[]): S3Object[] => {
|
||||
const sorted = [...objList].sort((a, b) => {
|
||||
// Always put folders before files
|
||||
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;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const filtered = objects.filter((obj) =>
|
||||
obj.key.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
const sorted = sortObjects(filtered);
|
||||
setFilteredObjects(sorted);
|
||||
// Reset pagination when path/search changes
|
||||
setPageTokens([undefined]);
|
||||
setCurrentPageIndex(0);
|
||||
}, [searchQuery, objects, sortColumn, sortDirection, currentPath]);
|
||||
|
||||
// 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]);
|
||||
|
||||
const hasPrevious = currentPageIndex > 0;
|
||||
const hasNext = isTruncated;
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (hasNext && nextContinuationToken) {
|
||||
const nextIndex = currentPageIndex + 1;
|
||||
setCurrentPageIndex(nextIndex);
|
||||
onPageChange(nextContinuationToken);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviousPage = () => {
|
||||
if (hasPrevious) {
|
||||
const prevIndex = currentPageIndex - 1;
|
||||
setCurrentPageIndex(prevIndex);
|
||||
const previousToken = pageTokens[prevIndex];
|
||||
onPageChange(previousToken);
|
||||
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 (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">
|
||||
<Checkbox
|
||||
checked={
|
||||
filteredObjects.filter(obj => !obj.isFolder).length > 0 &&
|
||||
selectedFileKeys.size === filteredObjects.filter(obj => !obj.isFolder).length
|
||||
}
|
||||
onCheckedChange={onSelectAllFiles}
|
||||
aria-label="Select all files"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleSort('name')}
|
||||
>
|
||||
Objects {sortColumn === 'name' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">Type</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Storage Class</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleSort('size')}
|
||||
>
|
||||
Size {sortColumn === 'size' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleSort('modified')}
|
||||
>
|
||||
Modified {sortColumn === 'modified' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-12">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>Loading objects...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredObjects.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-12 text-muted-foreground">
|
||||
{searchQuery
|
||||
? 'No objects found matching your search'
|
||||
: isDragActive
|
||||
? 'Drop files or folders here'
|
||||
: 'No objects in this location'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredObjects.map((obj) => (
|
||||
<TableRow key={obj.key}>
|
||||
<TableCell className="w-[50px]">
|
||||
{obj.isFolder ? (
|
||||
<Checkbox
|
||||
disabled
|
||||
checked={false}
|
||||
className="opacity-50 cursor-not-allowed bg-muted"
|
||||
aria-label="Folders cannot be selected"
|
||||
/>
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={selectedFileKeys.has(obj.key)}
|
||||
onCheckedChange={() => onToggleFileSelection(obj.key)}
|
||||
aria-label={`Select file ${obj.key}`}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{obj.isFolder ? (
|
||||
<FolderIcon className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<FileIcon className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
{obj.isFolder ? (
|
||||
<button
|
||||
onClick={() => onNavigateToFolder(obj.key)}
|
||||
className="font-medium cursor-pointer underline hover:text-primary"
|
||||
>
|
||||
{obj.key.replace(currentPath, '').replace('/', '')}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-medium">
|
||||
{obj.key.replace(currentPath, '')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">
|
||||
{obj.isFolder ? 'Folder' : getFileType(obj.key.replace(currentPath, ''))}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{obj.storageClass && (
|
||||
<Badge variant="secondary">{obj.storageClass}</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{obj.isFolder ? null : formatBytes(obj.size)}</TableCell>
|
||||
<TableCell>
|
||||
{obj.lastModified ?
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="decoration-dashed decoration-1 underline underline-offset-6 cursor-pointer text-muted-foreground hover:text-foreground transition-colors">
|
||||
{new Date(obj.lastModified).toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})} {new Date(obj.lastModified).toLocaleTimeString('en-GB', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
})} CET
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="space-y-1 min-w-max">
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">UTC</span>
|
||||
<span className="text-sm text-white">
|
||||
{new Date(obj.lastModified).toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: 'UTC',
|
||||
})} UTC
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">Relative</span>
|
||||
<span className="text-sm text-white">
|
||||
{formatRelativeTime(new Date(obj.lastModified))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm text-gray-400 w-20 text-right">Timestamp</span>
|
||||
<span className="text-sm text-white font-mono">
|
||||
{new Date(obj.lastModified).toISOString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>: null}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{!obj.isFolder && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button variant="ghost" size="icon" className="-m-6 top-1 relative">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onDeleteObject(obj)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{(filteredObjects.length > 0 || hasPrevious) && (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 px-4 py-4 border-t bg-background">
|
||||
{/* Items per page selector */}
|
||||
<div className="flex items-center gap-2 text-sm relative z-10">
|
||||
<span className="text-muted-foreground">Items per page:</span>
|
||||
<Select value={itemsPerPage.toString()} onChange={handleItemsPerPageChange}>
|
||||
<SelectOption value="10">10</SelectOption>
|
||||
<SelectOption value="25">25</SelectOption>
|
||||
<SelectOption value="50">50</SelectOption>
|
||||
<SelectOption value="100">100</SelectOption>
|
||||
<SelectOption value="200">200</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Pagination info and controls */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {currentPageIndex + 1} • Showing {filteredObjects.length} item{filteredObjects.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={hasPrevious ? "default": "default_disabled"}
|
||||
size="sm"
|
||||
onClick={handlePreviousPage}
|
||||
disabled={!hasPrevious}
|
||||
className="h-8"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={hasNext ? "default": "default_disabled"}
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={!hasNext}
|
||||
className="h-8"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { BucketListView } from './BucketListView';
|
||||
export { ObjectBrowserView } from './ObjectBrowserView';
|
||||
export { ObjectsTable } from './ObjectsTable';
|
||||
export { CreateBucketDialog } from './CreateBucketDialog';
|
||||
export { DeleteBucketDialog } from './DeleteBucketDialog';
|
||||
export { BucketSettingsDialog } from './BucketSettingsDialog';
|
||||
export { CreateDirectoryDialog } from './CreateDirectoryDialog';
|
||||
export { DeleteObjectDialog } from './DeleteObjectDialog';
|
||||
Reference in New Issue
Block a user