Merge pull request #18 from Noooste/feat/ui-redesign

UI redesign
This commit is contained in:
Noste
2026-04-19 22:17:57 +02:00
committed by GitHub
46 changed files with 2418 additions and 1671 deletions
+4 -1
View File
@@ -63,4 +63,7 @@ garage.toml
backend/docs/
config.yaml
docs/**/*.md
docs/**/*.md
# Superpowers brainstorm / session scratch
.superpowers/
+93
View File
@@ -0,0 +1,93 @@
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+30 -5
View File
@@ -1,14 +1,19 @@
import { useEffect } from 'react';
import {BrowserRouter, Route, Routes} from 'react-router-dom';
import {BrowserRouter, Navigate, Route, Routes} from 'react-router-dom';
import {QueryClientProvider} from '@tanstack/react-query';
import {ThemeProvider, useTheme} from '@/components/theme-provider';
import {Layout} from '@/components/layout/layout';
import {BucketDetailShell} from '@/components/layout/bucket-detail-shell';
import {Dashboard} from '@/pages/Dashboard';
import {Buckets} from '@/pages/Buckets';
import {BucketObjects} from '@/pages/BucketObjects';
import {ObjectDetailsView} from '@/components/buckets/ObjectDetailsView';
import {BucketPermissions} from '@/pages/BucketPermissions';
import {BucketWebsite} from '@/pages/BucketWebsite';
import {BucketSettings} from '@/pages/BucketSettings';
import {Cluster} from '@/pages/Cluster';
import {AccessControl} from '@/pages/AccessControl';
import {Login} from '@/pages/Login';
import {ObjectDetailsView} from '@/components/buckets/ObjectDetailsView';
import {Toaster} from 'sonner';
import {queryClient} from '@/lib/query-client';
import {useAuthStore} from '@/store/auth-store';
@@ -17,8 +22,21 @@ import {LoadingSpinner} from '@/components/auth/LoadingSpinner';
function ThemedToaster() {
const { theme } = useTheme();
return <Toaster richColors position="bottom-right" theme={theme} />;
return (
<Toaster
richColors
position="bottom-right"
theme={theme}
toastOptions={{
classNames: {
toast:
'rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] font-sans shadow-lg',
title: 'text-[14px] font-medium',
description: 'text-[13px] text-[var(--muted-foreground)]',
},
}}
/>
);
}
function App() {
@@ -49,7 +67,14 @@ function App() {
>
<Route index element={<Dashboard />} />
<Route path="buckets" element={<Buckets />} />
<Route path="buckets/:bucketName/objects/*" element={<ObjectDetailsView />} />
<Route path="buckets/:bucketName" element={<BucketDetailShell />}>
<Route index element={<Navigate to="objects" replace />} />
<Route path="objects" element={<BucketObjects />} />
<Route path="objects/*" element={<ObjectDetailsView />} />
<Route path="permissions" element={<BucketPermissions />} />
<Route path="website" element={<BucketWebsite />} />
<Route path="settings" element={<BucketSettings />} />
</Route>
<Route path="cluster" element={<Cluster />} />
<Route path="access" element={<AccessControl />} />
</Route>
@@ -99,7 +99,7 @@ export function BasicLoginForm({ showOIDC = false, config }: BasicLoginFormProps
</div>
<Button
type="button"
variant="outline"
variant="secondary"
className="w-full"
onClick={loginOIDC}
>
@@ -9,7 +9,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { FolderIcon, Globe, Loader2, MoreVertical, Plus, Search, Settings, Trash2 } from 'lucide-react';
import { FolderIcon, Globe, Loader2, MoreVertical, Search, Settings, Trash2 } from 'lucide-react';
import { formatBytes } from '@/lib/file-utils';
import { formatDate } from '@/lib/utils';
import type { Bucket } from '@/types';
@@ -21,7 +21,6 @@ interface BucketListViewProps {
onSearchChange: (query: string) => void;
onViewBucket: (bucketName: string) => void;
onOpenSettings: (bucket: Bucket) => void;
onCreateBucket: () => void;
onDeleteBucket: (bucket: Bucket) => void;
onWebsiteSettings: (bucket: Bucket) => void;
}
@@ -33,7 +32,6 @@ export function BucketListView({
onSearchChange,
onViewBucket,
onOpenSettings,
onCreateBucket,
onDeleteBucket,
onWebsiteSettings,
}: BucketListViewProps) {
@@ -44,20 +42,14 @@ export function BucketListView({
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 className="relative w-full 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>
{/* Buckets Table */}
@@ -99,14 +91,14 @@ export function BucketListView({
<TableCell className="font-medium max-w-[200px]">
<span className="truncate">{bucket.name}</span>
{bucket.websiteAccess && (
<Badge variant="outline" className="text-xs ml-2">
<Badge variant="neutral" className="text-xs ml-2">
<Globe className="h-3 w-3 mr-1" />
Website
</Badge>
)}
</TableCell>
<TableCell className="hidden sm:table-cell">
<Badge variant="secondary">{bucket.region || 'default'}</Badge>
<Badge variant="neutral">{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>
@@ -1,196 +0,0 @@
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 { useAccessKeys } from '@/hooks/useApi';
import type { 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 { data: availableKeys = [] } = useAccessKeys();
const [selectedAccessKey, setSelectedAccessKey] = useState<string>('');
const [permissionRead, setPermissionRead] = useState(false);
const [permissionWrite, setPermissionWrite] = useState(false);
const [permissionOwner, setPermissionOwner] = useState(false);
useEffect(() => {
if (open && bucket) {
resetForm();
}
}, [open, bucket]);
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>
);
}
@@ -1,133 +0,0 @@
import { useState, useEffect } 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 { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import type { Bucket } from '@/types';
interface BucketWebsiteDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
bucket: Bucket | null;
onSave: (
bucketName: string,
payload: { enabled: boolean; indexDocument?: string; errorDocument?: string }
) => Promise<boolean>;
}
export function BucketWebsiteDialog({
open,
onOpenChange,
bucket,
onSave,
}: BucketWebsiteDialogProps) {
const [enabled, setEnabled] = useState(false);
const [indexDocument, setIndexDocument] = useState('index.html');
const [errorDocument, setErrorDocument] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (open && bucket) {
setEnabled(bucket.websiteAccess);
setIndexDocument(bucket.websiteConfig?.indexDocument ?? 'index.html');
setErrorDocument(bucket.websiteConfig?.errorDocument ?? '');
}
}, [open, bucket]);
const handleSave = async () => {
if (!bucket) return;
setSaving(true);
const success = await onSave(bucket.name, {
enabled,
indexDocument: enabled ? indexDocument : undefined,
errorDocument: enabled && errorDocument ? errorDocument : undefined,
});
setSaving(false);
if (success) onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Website Hosting {bucket?.name}</DialogTitle>
<DialogDescription>
Configure this bucket to serve a static website.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Website access</p>
<p className="text-xs text-muted-foreground mt-0.5">
Allow public HTTP access to bucket objects
</p>
</div>
<div className="flex items-center gap-3">
<Badge variant={enabled ? 'default' : 'secondary'}>
{enabled ? 'Enabled' : 'Disabled'}
</Badge>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
</div>
{enabled && (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">
Index document <span className="text-destructive">*</span>
</label>
<Input
value={indexDocument}
onChange={(e) => setIndexDocument(e.target.value)}
placeholder="index.html"
/>
<p className="text-xs text-muted-foreground">
The file served when a directory is requested (e.g. index.html)
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Error document</label>
<Input
value={errorDocument}
onChange={(e) => setErrorDocument(e.target.value)}
placeholder="404.html (optional)"
/>
<p className="text-xs text-muted-foreground">
The file served when an object is not found (optional)
</p>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleSave}
variant={!enabled && bucket?.websiteAccess ? 'destructive' : 'default'}
disabled={saving || (enabled && !indexDocument)}
>
{saving
? 'Saving...'
: !enabled && bucket?.websiteAccess
? 'Disable Website'
: 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,14 +1,17 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Database } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { IconTile } from '@/components/ui/icon-tile';
import { toast } from 'sonner';
interface CreateBucketDialogProps {
@@ -20,6 +23,8 @@ interface CreateBucketDialogProps {
export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: CreateBucketDialogProps) {
const [bucketName, setBucketName] = useState('');
useEffect(() => { if (!open) setBucketName(''); }, [open]);
const handleCreate = async () => {
if (!bucketName) {
toast.error('Please enter a bucket name');
@@ -37,15 +42,19 @@ export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: Creat
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Bucket</DialogTitle>
<DialogDescription>
Create a new storage bucket for your objects
</DialogDescription>
<IconTile icon={<Database />} tone="primary" size="md" />
<div className="flex-1">
<DialogTitle>Create New Bucket</DialogTitle>
<DialogDescription>
Create a new storage bucket for your objects
</DialogDescription>
</div>
</DialogHeader>
<div className="space-y-4 py-4">
<DialogBody className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Bucket Name</label>
<Input
autoFocus
placeholder="my-bucket-name"
value={bucketName}
onChange={(e) => setBucketName(e.target.value)}
@@ -59,13 +68,13 @@ export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: Creat
Must be unique and follow DNS naming conventions
</p>
</div>
</div>
<DialogFooter className="space-y-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</DialogBody>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant={!bucketName ? 'default_disabled' : 'default'}
variant="primary"
onClick={handleCreate}
disabled={!bucketName}
>
@@ -1,14 +1,17 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { FolderPlus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { IconTile } from '@/components/ui/icon-tile';
import { toast } from 'sonner';
interface CreateDirectoryDialogProps {
@@ -21,6 +24,8 @@ interface CreateDirectoryDialogProps {
export function CreateDirectoryDialog({ open, onOpenChange, currentPath, onCreateDirectory }: CreateDirectoryDialogProps) {
const [dirName, setDirName] = useState('');
useEffect(() => { if (!open) setDirName(''); }, [open]);
const handleCreate = async () => {
if (!dirName) {
toast.error('Please enter a directory name');
@@ -38,15 +43,19 @@ export function CreateDirectoryDialog({ open, onOpenChange, currentPath, onCreat
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Directory</DialogTitle>
<DialogDescription>
Create a new directory in {currentPath || 'the root'}
</DialogDescription>
<IconTile icon={<FolderPlus />} tone="primary" size="md" />
<div className="flex-1">
<DialogTitle>Create Directory</DialogTitle>
<DialogDescription>
Create a new directory in {currentPath || 'the root'}
</DialogDescription>
</div>
</DialogHeader>
<div className="space-y-4 py-4">
<DialogBody className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Directory Name</label>
<Input
autoFocus
placeholder="my-directory"
value={dirName}
onChange={(e) => setDirName(e.target.value)}
@@ -57,9 +66,9 @@ export function CreateDirectoryDialog({ open, onOpenChange, currentPath, onCreat
}}
/>
</div>
</div>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={!dirName}>
@@ -1,49 +0,0 @@
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>
);
}
@@ -1,3 +1,4 @@
import { Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -7,6 +8,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { IconTile } from '@/components/ui/icon-tile';
import type { S3Object } from '@/types';
interface DeleteObjectDialogProps {
@@ -27,16 +29,19 @@ export function DeleteObjectDialog({ open, onOpenChange, object, onDeleteObject
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={onOpenChange} size="destructive">
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Object</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{object?.key}"? This action cannot be undone.
</DialogDescription>
<IconTile icon={<Trash2 />} tone="destructive" size="md" />
<div className="flex-1">
<DialogTitle>Delete Object</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{object?.key}"? This action cannot be undone.
</DialogDescription>
</div>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete}>
@@ -2,7 +2,6 @@ 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';
@@ -170,10 +169,9 @@ export function ObjectBrowserView({
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">
<Button variant="secondary" 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>
@@ -229,7 +227,7 @@ export function ObjectBrowserView({
<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}>
<Button variant="secondary" size="icon" onClick={onRefresh} title="Refresh" disabled={isRefreshing}>
<RotateCwIcon className={`h-4 w-4 transition-transform duration-500 ${isRefreshing ? 'animate-spin' : ''}`} />
</Button>
</div>
@@ -1,21 +1,55 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useNavigate, useParams, Link } from 'react-router-dom';
import { objectsApi } from '@/lib/api';
import type { ObjectMetadata } from '@/types';
import { Header } from '@/components/layout/header';
import { Button } from '@/components/ui/button';
import { ArrowLeft, Download, Trash, Copy, File } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { IconTile } from '@/components/ui/icon-tile';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { ArrowLeft, ChevronRight, Copy, Download, File, Loader2, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { formatBytes } from '@/lib/file-utils';
function formatDate(value: string) {
return new Date(value).toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function CardSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)]">
<div className="border-b border-[var(--border)] px-5 py-3.5">
<h3 className="text-[14px] font-semibold tracking-[-0.01em]">{title}</h3>
</div>
{children}
</section>
);
}
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-1 gap-1 px-5 py-3.5 sm:grid-cols-[200px_1fr] sm:gap-4">
<dt className="text-[12.5px] font-medium text-[var(--muted-foreground)]">{label}</dt>
<dd className="text-[13.5px] text-[var(--foreground)] break-words">{children}</dd>
</div>
);
}
export function ObjectDetailsView() {
const navigate = useNavigate();
const { bucketName, '*': encodedObjectKey } = useParams();
// Decode the object key from the URL
const objectKey = encodedObjectKey ? decodeURIComponent(encodedObjectKey) : undefined;
const [metadata, setMetadata] = useState<ObjectMetadata | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
if (!bucketName || !objectKey) {
@@ -23,7 +57,6 @@ export function ObjectDetailsView() {
setIsLoading(false);
return;
}
const fetchMetadata = async () => {
try {
setIsLoading(true);
@@ -32,240 +65,184 @@ export function ObjectDetailsView() {
setMetadata(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load object metadata');
console.error('Failed to fetch object metadata:', err);
} finally {
setIsLoading(false);
}
};
fetchMetadata();
}, [bucketName, objectKey]);
const parentPath = objectKey?.split('/').slice(0, -1).join('/') ?? '';
const fileName = objectKey?.split('/').pop() || objectKey || '';
const backHref = `/buckets/${bucketName}/objects${parentPath ? `?prefix=${encodeURIComponent(parentPath + '/')}` : ''}`;
const pathSegments = parentPath ? parentPath.split('/').filter(Boolean) : [];
const copy = (text: string, label = 'Copied') => {
navigator.clipboard.writeText(text);
toast.success(label);
};
const handleDownload = async () => {
if (!bucketName || !objectKey) return;
try {
const blob = await objectsApi.get(bucketName, objectKey);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = objectKey.split('/').pop() || 'download';
a.download = fileName || 'download';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('Download started');
} catch (err) {
console.error('Download failed:', err);
} catch {
toast.error('Download failed');
}
};
const handleDelete = async () => {
if (!bucketName || !objectKey) return;
if (!confirm(`Are you sure you want to delete "${objectKey}"?`)) {
return;
}
try {
setDeleting(true);
await objectsApi.delete(bucketName, objectKey);
toast.success('Object deleted successfully');
handleBackNavigation();
} catch (err) {
console.error('Delete failed:', err);
toast.success('Object deleted');
navigate(backHref);
} catch {
toast.error('Delete failed');
} finally {
setDeleting(false);
setDeleteOpen(false);
}
};
const handleBackNavigation = () => {
if (!bucketName) return;
// Navigate back to the bucket explorer with the appropriate prefix
// Extract the folder path from the object key (everything before the last /)
const folderPath = objectKey?.split('/').slice(0, -1).join('/') || '';
const prefix = folderPath ? `${folderPath}/` : '';
// Navigate to the bucket view with the correct prefix
navigate(`/buckets?bucket=${encodeURIComponent(bucketName)}${prefix ? `&prefix=${encodeURIComponent(prefix)}` : ''}`);
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success('Copied to clipboard');
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short',
});
};
if (isLoading) {
return (
<div>
<Header title="Object Details" />
<div className="p-4 sm:p-6">
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">Loading object details...</div>
</div>
</div>
<div className="flex h-64 items-center justify-center gap-2 text-[var(--muted-foreground)]">
<Loader2 className="h-4 w-4 animate-spin" /> Loading object details
</div>
);
}
if (error || !metadata) {
return (
<div>
<Header title="Object Details" />
<div className="p-4 sm:p-6">
<Button variant="outline" onClick={handleBackNavigation} className="mb-4">
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<div className="flex items-center justify-center h-64">
<div className="text-red-500">{error || 'Object not found'}</div>
</div>
<div className="px-7 py-6">
<Button variant="secondary" onClick={() => navigate(backHref)} className="mb-4">
<ArrowLeft className="h-4 w-4" /> Back
</Button>
<div className="rounded-xl border border-[var(--danger-border)] bg-[var(--danger-soft)] px-5 py-4 text-[13.5px] text-[var(--destructive)]">
{error || 'Object not found'}
</div>
</div>
);
}
const fileName = objectKey?.split('/').pop() || objectKey || '';
const pathParts = objectKey?.split('/').filter(part => part) || [];
const parentPath = pathParts.slice(0, -1).join('/');
return (
<div>
<Header title={fileName} />
<div className="p-4 sm:p-6 space-y-6">
{/* Back Button and Actions */}
<div className="flex items-center justify-between">
<Button variant="outline" onClick={handleBackNavigation}>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleDownload}>
<Download className="h-4 w-4" />
Download
</Button>
<Button
variant="outline"
className="border-red-500 text-red-500 hover:bg-red-500/5"
onClick={handleDelete}
>
<Trash className="h-4 w-4" />
Delete
</Button>
</div>
</div>
{/* File Name Header */}
<div className="flex items-start gap-3 p-4 border-b border-border bg-card rounded-t-lg">
<div className="mt-1">
<File className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-4 flex-wrap">
<h2 className="text-lg font-medium text-foreground break-all">
{parentPath && (
<span className="text-muted-foreground font-mono">/{parentPath}/</span>
)}
{fileName}
</h2>
<button
onClick={() => copyToClipboard(metadata.key)}
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 shrink-0"
>
<Copy className="h-3 w-3" />
Copy
</button>
</div>
</div>
</div>
{/* Object Details Section */}
<div className="border border-border rounded-lg bg-card">
<div className="p-6 border-b border-border">
<h3 className="text-base font-semibold text-foreground">Object Details</h3>
</div>
<div className="divide-y divide-border">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
<div className="text-sm font-medium text-muted-foreground">Date Created</div>
<div className="sm:col-span-2 text-sm text-foreground">
{formatDate(metadata.lastModified)}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
<div className="text-sm font-medium text-muted-foreground">Type</div>
<div className="sm:col-span-2 text-sm text-foreground">
{metadata.contentType || 'application/octet-stream'}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
<div className="text-sm font-medium text-muted-foreground">Storage Class</div>
<div className="sm:col-span-2 text-sm text-foreground">
{metadata.storageClass || 'Standard'}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
<div className="text-sm font-medium text-muted-foreground">Size</div>
<div className="sm:col-span-2 text-sm text-foreground">
{formatBytes(metadata.size)}
</div>
</div>
</div>
</div>
{/* Custom Metadata Section */}
{metadata.metadata && Object.keys(metadata.metadata).length > 0 && (
<div className="border border-border rounded-lg bg-card">
<div className="p-6 border-b border-border">
<h3 className="text-base font-semibold text-foreground">Custom Metadata</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/30">
<tr className="border-b border-border">
<th className="px-6 py-3 text-left text-sm font-medium text-muted-foreground">
Key
</th>
<th className="px-6 py-3 text-left text-sm font-medium text-muted-foreground">
Value
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{Object.entries(metadata.metadata).map(([key, value]) => (
<tr key={key} className="hover:bg-muted/30">
<td className="px-6 py-4 text-sm font-medium text-foreground break-all">
{key}
</td>
<td className="px-6 py-4 text-sm text-foreground break-all">{value}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Object Preview Section */}
<div className="border border-border rounded-lg bg-card">
<div className="p-6 border-b border-border">
<h3 className="text-base font-semibold text-foreground">Object Preview</h3>
</div>
<div className="p-6">
<p className="text-sm text-muted-foreground">No preview available</p>
</div>
</div>
<div className="px-7 py-6 space-y-6">
{/* Back + breadcrumb */}
<div className="flex items-center gap-2 text-[13px] text-[var(--muted-foreground)]">
<Link
to={backHref}
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 hover:bg-[var(--accent)] hover:text-[var(--foreground)]"
>
<ArrowLeft className="h-3.5 w-3.5" />
Objects
</Link>
{pathSegments.map((seg, i) => (
<span key={i} className="inline-flex items-center gap-1">
<ChevronRight className="h-3.5 w-3.5 opacity-50" />
<span className="font-mono">{seg}</span>
</span>
))}
<ChevronRight className="h-3.5 w-3.5 opacity-50" />
<span className="truncate font-mono text-[var(--foreground)]">{fileName}</span>
</div>
{/* Hero */}
<section className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<IconTile icon={<File />} tone="primary" size="lg" />
<div className="min-w-0">
<h1 className="truncate text-[22px] font-semibold tracking-[-0.02em]">{fileName}</h1>
<button
type="button"
onClick={() => copy(metadata.key, 'Object key copied')}
title="Copy key"
className="group mt-1 inline-flex max-w-full items-center gap-1.5 truncate font-mono text-[13px] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<span className="truncate">{metadata.key}</span>
<Copy className="h-3 w-3 flex-shrink-0 opacity-60 group-hover:opacity-100" />
</button>
<div className="mt-2 flex flex-wrap gap-1.5">
<Badge>{formatBytes(metadata.size)}</Badge>
<Badge>{metadata.contentType || 'application/octet-stream'}</Badge>
{metadata.storageClass && <Badge>{metadata.storageClass}</Badge>}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button variant="secondary" onClick={handleDownload}>
<Download className="h-4 w-4" /> Download
</Button>
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
<Trash2 className="h-4 w-4" /> Delete
</Button>
</div>
</section>
{/* Details */}
<CardSection title="Details">
<dl className="divide-y divide-[var(--border)]">
<DetailRow label="Size">{formatBytes(metadata.size)}</DetailRow>
<DetailRow label="Content type">{metadata.contentType || 'application/octet-stream'}</DetailRow>
<DetailRow label="Storage class">{metadata.storageClass || 'Standard'}</DetailRow>
<DetailRow label="Last modified">{formatDate(metadata.lastModified)}</DetailRow>
<DetailRow label="ETag">
<button
type="button"
onClick={() => copy(metadata.etag, 'ETag copied')}
className="inline-flex max-w-full items-center gap-1.5 truncate rounded-md bg-[var(--surface-sunken)] px-2 py-0.5 font-mono text-[12.5px] hover:bg-[var(--accent)]"
>
<span className="truncate">{metadata.etag}</span>
<Copy className="h-3 w-3 flex-shrink-0 opacity-60" />
</button>
</DetailRow>
{metadata.versionId && (
<DetailRow label="Version ID">
<span className="font-mono text-[12.5px]">{metadata.versionId}</span>
</DetailRow>
)}
</dl>
</CardSection>
{/* Custom metadata */}
{metadata.metadata && Object.keys(metadata.metadata).length > 0 && (
<CardSection title="Custom metadata">
<dl className="divide-y divide-[var(--border)]">
{Object.entries(metadata.metadata).map(([key, value]) => (
<DetailRow key={key} label={key}>
<span className="font-mono text-[12.5px]">{value}</span>
</DetailRow>
))}
</dl>
</CardSection>
)}
{/* Preview */}
<CardSection title="Preview">
<div className="px-5 py-10 text-center text-[13px] text-[var(--muted-foreground)]">
No preview available for this object.
</div>
</CardSection>
<ConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title={`Delete "${fileName}"?`}
description="Applications referencing this object will no longer be able to read it."
confirmLabel="Delete object"
loading={deleting}
onConfirm={handleDelete}
/>
</div>
);
}
@@ -282,7 +282,7 @@ export function ObjectsTable({
</TableCell>
<TableCell className="hidden md:table-cell">
{obj.storageClass && (
<Badge variant="secondary">{obj.storageClass}</Badge>
<Badge variant="neutral">{obj.storageClass}</Badge>
)}
</TableCell>
<TableCell>{obj.isFolder ? null : formatBytes(obj.size)}</TableCell>
@@ -400,7 +400,7 @@ export function ObjectsTable({
<div className="flex items-center gap-2">
<Button
variant={hasPrevious ? "default": "default_disabled"}
variant="primary"
size="sm"
onClick={handlePreviousPage}
disabled={!hasPrevious}
@@ -411,7 +411,7 @@ export function ObjectsTable({
</Button>
<Button
variant={hasNext ? "default": "default_disabled"}
variant="primary"
size="sm"
onClick={handleNextPage}
disabled={!hasNext}
-2
View File
@@ -2,7 +2,5 @@ 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';
@@ -0,0 +1,105 @@
import { NavLink, Outlet, useParams } from 'react-router-dom';
import { Database, Copy, Upload } from 'lucide-react';
import { IconTile } from '@/components/ui/icon-tile';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { useBuckets } from '@/hooks/useApi';
import { toast } from 'sonner';
interface TabSpec {
to: string;
label: string;
end?: boolean;
}
const tabs: TabSpec[] = [
{ to: 'objects', label: 'Objects' },
{ to: 'permissions', label: 'Permissions' },
{ to: 'website', label: 'Website' },
{ to: 'settings', label: 'Settings' },
];
function formatBytes(n?: number) {
if (n == null) return '';
if (n < 1024) return `${n} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let v = n / 1024;
for (const u of units) {
if (v < 1024) return `${v.toFixed(v >= 10 ? 0 : 1)} ${u}`;
v /= 1024;
}
return `${v.toFixed(0)} PB`;
}
export function BucketDetailShell() {
const { bucketName = '' } = useParams<{ bucketName: string }>();
const { data: buckets = [] } = useBuckets();
const bucket = buckets.find((b) => b.name === bucketName);
const s3Url = `s3://${bucketName}`;
const copyUrl = async () => {
try {
await navigator.clipboard.writeText(s3Url);
toast.success('URL copied');
} catch {
toast.error('Failed to copy');
}
};
return (
<div className="flex flex-col">
{/* Hero */}
<section className="px-7 pt-6 pb-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<IconTile icon={<Database />} tone="primary" size="lg" />
<div className="min-w-0">
<h1 className="truncate text-[26px] font-semibold tracking-[-0.02em]">{bucketName}</h1>
<p className="mt-1 truncate font-mono text-[13.5px] text-[var(--muted-foreground)]">{s3Url}</p>
<div className="mt-2 flex flex-wrap gap-1.5">
<Badge variant="success">Active</Badge>
{bucket?.objectCount != null && <Badge>{bucket.objectCount.toLocaleString()} objects</Badge>}
{bucket?.size != null && <Badge>{formatBytes(bucket.size)}</Badge>}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button variant="secondary" onClick={copyUrl}>
<Copy /> Copy URL
</Button>
<Button variant="primary" onClick={() => document.dispatchEvent(new CustomEvent('bucket:upload'))}>
<Upload /> Upload
</Button>
</div>
</div>
</section>
{/* Tabs */}
<nav className="flex h-12 items-center gap-0 border-b border-[var(--border)] px-7">
{tabs.map((t) => (
<NavLink
key={t.to}
to={t.to}
end={t.end}
className={({ isActive }) =>
cn(
'relative -mb-px inline-flex h-12 items-center px-3.5 text-[14px] font-medium transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] rounded-sm',
isActive
? 'text-[var(--primary)] border-b-2 border-[var(--primary)]'
: 'text-[var(--muted-foreground)] border-b-2 border-transparent hover:text-[var(--foreground)]',
)
}
>
{t.label}
</NavLink>
))}
</nav>
<div className="min-w-0">
<Outlet />
</div>
</div>
);
}
+42 -11
View File
@@ -1,36 +1,67 @@
import { Outlet } from 'react-router-dom';
import { Outlet, useLocation, useParams } from 'react-router-dom';
import { Sidebar } from './sidebar';
import { useState } from 'react';
import { TopBar } from './top-bar';
import { useState, useMemo } from 'react';
import { Menu } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { BreadcrumbItem } from '@/components/ui/breadcrumb';
function useCrumbs(): BreadcrumbItem[] {
const location = useLocation();
const params = useParams();
return useMemo(() => {
const path = location.pathname;
if (path === '/') return [{ label: 'Dashboard' }];
if (path === '/cluster') return [{ label: 'Cluster' }];
if (path === '/access') return [{ label: 'Access Control' }];
if (path === '/buckets') return [{ label: 'Buckets' }];
if (path.startsWith('/buckets/')) {
const bucketName = (params as { bucketName?: string }).bucketName ?? path.split('/')[2];
const crumbs: BreadcrumbItem[] = [
{ label: 'Buckets', to: '/buckets' },
{ label: bucketName, to: `/buckets/${bucketName}/objects` },
];
const segs = path.split('/').slice(3); // after /buckets/:name
if (segs[0] && segs[0] !== 'objects') {
const tabLabel = segs[0][0].toUpperCase() + segs[0].slice(1);
crumbs.push({ label: tabLabel });
}
return crumbs;
}
return [];
}, [location.pathname, params]);
}
export function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const crumbs = useCrumbs();
return (
<div className="flex h-screen overflow-hidden">
{/* Mobile menu button */}
<div className="flex h-screen overflow-hidden bg-[var(--background)]">
<Button
variant="ghost"
size="icon"
className="fixed top-4 left-4 z-50 md:hidden"
className="fixed left-3 top-3 z-50 md:hidden"
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label="Toggle navigation"
>
<Menu className="h-6 w-6" />
<Menu className="h-5 w-5" />
</Button>
{/* Mobile overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 md:hidden"
className="fixed inset-0 z-40 bg-black/50 md:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<main className="flex-1 overflow-y-auto">
<Outlet />
</main>
<div className="flex min-w-0 flex-1 flex-col">
<TopBar crumbs={crumbs} />
<main className="flex-1 overflow-y-auto scrollbar-thin">
<Outlet />
</main>
</div>
</div>
);
}
+84 -90
View File
@@ -1,8 +1,7 @@
import {Link, useLocation} from 'react-router-dom';
import {cn} from '@/lib/utils';
import {Database, Key, LayoutDashboard, LogOut, Server, User} from 'lucide-react';
import {useAuthStore} from '@/store/auth-store';
import {Button} from '@/components/ui/button';
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { BookOpen, Database, Key, LayoutDashboard, Server } from 'lucide-react';
import { useAuthStore } from '@/store/auth-store';
import { useQuery } from '@tanstack/react-query';
import { healthApi, garageApi } from '@/lib/api';
@@ -12,26 +11,25 @@ interface NavItem {
icon: React.ComponentType<{ className?: string }>;
}
const navItems: NavItem[] = [
interface NavGroup {
label?: string;
items: NavItem[];
}
const navGroups: NavGroup[] = [
{
title: 'Dashboard',
href: '/',
icon: LayoutDashboard,
items: [{ title: 'Dashboard', href: '/', icon: LayoutDashboard }],
},
{
title: 'Buckets',
href: '/buckets',
icon: Database,
label: 'Storage',
items: [{ title: 'Buckets', href: '/buckets', icon: Database }],
},
{
title: 'Cluster',
href: '/cluster',
icon: Server,
},
{
title: 'Access Control',
href: '/access',
icon: Key,
label: 'Cluster',
items: [
{ title: 'Cluster', href: '/cluster', icon: Server },
{ title: 'Access Control', href: '/access', icon: Key },
],
},
];
@@ -42,11 +40,7 @@ interface SidebarProps {
export function Sidebar({ isOpen, onClose }: SidebarProps) {
const location = useLocation();
const { user, config, logout } = useAuthStore();
const handleLogout = () => {
logout();
};
const { config } = useAuthStore();
const { data: uiVersion } = useQuery({
queryKey: ['ui-version'],
@@ -60,80 +54,80 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
queryFn: () => garageApi.getNodeInfo('self'),
staleTime: 5 * 60 * 1000,
retry: false,
enabled: !!(config && (config.admin.enabled || config.oidc.enabled)),
});
const garageVersion = nodeInfo
? Object.values(nodeInfo.success)[0]?.garageVersion
: undefined;
const garageVersion = nodeInfo ? Object.values(nodeInfo.success)[0]?.garageVersion : undefined;
const isActive = (href: string) =>
href === '/'
? location.pathname === '/'
: location.pathname === href || location.pathname.startsWith(href + '/');
return (
<div
<aside
className={cn(
'flex h-full w-64 flex-col border-r transition-transform duration-300 ease-in-out md:translate-x-0',
'flex h-full w-64 flex-col border-r border-[var(--border)] bg-[var(--background)] transition-transform duration-300 ease-in-out md:translate-x-0',
'fixed md:static z-50',
isOpen ? 'translate-x-0' : '-translate-x-full'
isOpen ? 'translate-x-0' : '-translate-x-full',
)}
style={{ backgroundColor: 'var(--background)' }}
>
<div className="flex h-16 items-center border-b px-6">
<img src="/garage.png" alt="Garage UI Logo" className="h-8 w-8 mr-2" />
<span className="text-lg font-semibold">Garage UI</span>
<div className="flex h-16 items-center gap-2 border-b border-[var(--border)] px-4">
<img src="/garage.png" alt="" className="h-8 w-8" />
<span className="text-[18px] font-semibold tracking-tight">Garage UI</span>
</div>
<nav className="flex-1 space-y-1 p-4">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = location.pathname === item.href;
return (
<Link
key={item.href}
to={item.href}
onClick={onClose}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary shadow-sm'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
style={isActive ? { backgroundColor: 'var(--primary)', color: '#000000' } : undefined}
>
<Icon className="h-5 w-5" />
{item.title}
</Link>
);
})}
</nav>
{config && (config.admin.enabled || config.oidc.enabled) && user && (
<div className="border-t p-4 space-y-2">
<div className="flex items-center gap-3 rounded-lg bg-muted px-3 py-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold">
<User className="h-4 w-4" />
</div>
<div className="flex-1 overflow-hidden">
<p className="text-sm font-medium truncate">{user.name || user.username}</p>
{user.email && (
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
)}
</div>
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-5 scrollbar-thin">
{navGroups.map((group, gi) => (
<div key={gi}>
{group.label && (
<div className="px-2 pb-1.5 text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--muted-foreground)]">
{group.label}
</div>
)}
<ul className="space-y-0.5">
{group.items.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
return (
<li key={item.href}>
<Link
to={item.href}
onClick={onClose}
className={cn(
'flex h-9 items-center gap-2 rounded-md px-2.5 text-[14px] transition-colors',
active
? 'bg-[var(--primary)] font-medium text-[var(--primary-foreground)]'
: 'text-[var(--muted-foreground)] hover:bg-[var(--accent)] hover:text-[var(--foreground)]',
)}
>
<Icon className="h-4 w-4" />
{item.title}
</Link>
</li>
);
})}
</ul>
</div>
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={handleLogout}
>
<LogOut className="mr-2 h-4 w-4" />
Logout
</Button>
</div>
)}
{(uiVersion || garageVersion) && (
<div className="px-4 pb-3 text-xs text-muted-foreground text-center">
{uiVersion && `UI ${uiVersion}`}
{uiVersion && garageVersion && ' | '}
{garageVersion && `Garage ${garageVersion}`}
</div>
)}
</div>
))}
</nav>
<div className="px-3 py-3 flex flex-col items-center gap-1.5">
<a
href="https://garagehq.deuxfleurs.fr/documentation/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[12.5px] text-[var(--muted-foreground)] transition-colors hover:bg-[var(--accent)] hover:text-[var(--foreground)]"
>
<BookOpen className="h-3.5 w-3.5" />
Documentation
</a>
{(uiVersion || garageVersion) && (
<div className="flex items-center gap-1.5 border-t border-[var(--border)] pt-2 w-full justify-center text-[12px] text-[var(--muted-foreground)]">
{uiVersion && <span>UI {uiVersion}</span>}
{uiVersion && garageVersion && <span className="opacity-40"></span>}
{garageVersion && <span>Garage {garageVersion}</span>}
</div>
)}
</div>
</aside>
);
}
@@ -14,7 +14,7 @@ export function ThemeToggle() {
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="outline" size="icon">
<Button variant="secondary" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
@@ -0,0 +1,97 @@
import * as React from 'react';
import { User, LogOut, Monitor, Moon, Sun } from 'lucide-react';
import { Breadcrumb, type BreadcrumbItem } from '@/components/ui/breadcrumb';
import { useTheme } from '@/components/theme-provider';
import { useAuthStore } from '@/store/auth-store';
import { cn } from '@/lib/utils';
interface TopBarProps {
crumbs: BreadcrumbItem[];
}
export function TopBar({ crumbs }: TopBarProps) {
const { theme, setTheme } = useTheme();
const { user, config, logout } = useAuthStore();
const [menuOpen, setMenuOpen] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!menuOpen) return;
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [menuOpen]);
const hasUser = !!(config && (config.admin.enabled || config.oidc.enabled) && user);
return (
<div
className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-[var(--border)] bg-[var(--surface-sunken)] px-4 backdrop-blur"
>
<div className="min-w-0 flex-1 pl-8 md:pl-0">
<Breadcrumb items={crumbs} />
</div>
<div className="flex items-center gap-1">
<ThemeMiniToggle theme={theme} setTheme={setTheme} />
{hasUser && (
<div ref={menuRef} className="relative">
<button
type="button"
onClick={() => setMenuOpen((o) => !o)}
className="flex h-8 items-center gap-2 rounded-md px-2 text-[13.5px] text-[var(--foreground)] hover:bg-[var(--accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]"
>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[var(--primary)] text-[var(--primary-foreground)]">
<User className="h-3.5 w-3.5" />
</span>
<span className="hidden max-w-[140px] truncate sm:inline">{user?.name || user?.username}</span>
</button>
{menuOpen && (
<div className="absolute right-0 mt-1 w-56 overflow-hidden rounded-md border border-[var(--border)] bg-[var(--popover)] shadow-lg">
<div className="border-b border-[var(--border)] px-3 py-2">
<div className="truncate text-[14px] font-medium">{user?.name || user?.username}</div>
{user?.email && (
<div className="truncate text-[12.5px] text-[var(--muted-foreground)]">{user.email}</div>
)}
</div>
<button
type="button"
onClick={() => { setMenuOpen(false); logout(); }}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-[14px] hover:bg-[var(--accent)]"
>
<LogOut className="h-3.5 w-3.5" /> Logout
</button>
</div>
)}
</div>
)}
</div>
</div>
);
}
function ThemeMiniToggle({
theme,
setTheme,
}: {
theme: 'light' | 'dark' | 'system';
setTheme: (t: 'light' | 'dark' | 'system') => void;
}) {
const next = theme === 'dark' ? 'light' : theme === 'light' ? 'system' : 'dark';
const Icon = theme === 'dark' ? Moon : theme === 'light' ? Sun : Monitor;
return (
<button
type="button"
onClick={() => setTheme(next)}
aria-label={`Switch theme (current: ${theme})`}
className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--muted-foreground)]',
'hover:bg-[var(--accent)] hover:text-[var(--foreground)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]',
)}
>
<Icon className="h-4 w-4" />
</button>
);
}
+14 -12
View File
@@ -3,21 +3,23 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'inline-flex items-center rounded-md border px-2 py-0.5 text-[12px] font-medium tracking-tight',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive',
outline: 'text-foreground',
neutral:
'bg-[var(--card)] border-[var(--border)] text-[var(--muted-foreground)]',
success:
'bg-[var(--success-soft)] border-transparent text-[color:#2ca02c] dark:text-[color:#73bf69]',
warning:
'bg-[var(--accent-primary-soft)] border-[var(--accent-primary-border)] text-[var(--primary)]',
danger:
'bg-[var(--danger-soft)] border-[var(--danger-border)] text-[var(--destructive)]',
primary:
'bg-[var(--primary)] border-transparent text-[var(--primary-foreground)]',
},
},
defaultVariants: {
variant: 'default',
},
defaultVariants: { variant: 'neutral' },
}
);
@@ -25,8 +27,8 @@ export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
export function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
export { badgeVariants };
+44
View File
@@ -0,0 +1,44 @@
import * as React from 'react';
import { Link } from 'react-router-dom';
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface BreadcrumbItem {
label: string;
to?: string;
}
interface BreadcrumbProps extends React.HTMLAttributes<HTMLElement> {
items: BreadcrumbItem[];
}
export function Breadcrumb({ items, className, ...props }: BreadcrumbProps) {
return (
<nav
aria-label="Breadcrumb"
className={cn('flex items-center gap-1.5 text-[13.5px] text-[var(--muted-foreground)]', className)}
{...props}
>
{items.map((item, idx) => {
const isLast = idx === items.length - 1;
return (
<React.Fragment key={`${item.label}-${idx}`}>
{item.to && !isLast ? (
<Link
to={item.to}
className="rounded-sm px-1 text-[var(--foreground)]/80 hover:text-[var(--foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]"
>
{item.label}
</Link>
) : (
<span className={cn('px-1', isLast && 'font-medium text-[var(--foreground)]')}>
{item.label}
</span>
)}
{!isLast && <ChevronRight className="h-3.5 w-3.5 text-[var(--muted-foreground)]/60" />}
</React.Fragment>
);
})}
</nav>
);
}
+28 -19
View File
@@ -3,28 +3,39 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none',
[
'inline-flex items-center justify-center gap-2 whitespace-nowrap',
'rounded-md text-[14px] font-medium tracking-tight',
'transition-colors ring-offset-background',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
'[&_svg]:h-3.5 [&_svg]:w-3.5 [&_svg]:shrink-0',
].join(' '),
{
variants: {
variant: {
default: 'bg-[#ff9329] text-black hover:bg-[#e58625] cursor-pointer',
default_disabled: 'bg-[#ff9329] text-black opacity-50 cursor-not-allowed',
secondary: 'border border-[#ff9329] text-[#ff9329] cursor-pointer',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive cursor-pointer',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground cursor-pointer',
outline_disabled: 'border border-input bg-background text-muted-foreground opacity-50 cursor-not-allowed',
ghost: 'hover:bg-accent hover:text-accent-foreground cursor-pointer',
link: 'text-primary underline-offset-4 hover:underline cursor-pointer',
primary:
'bg-[var(--primary)] text-[var(--primary-foreground)] font-semibold hover:brightness-[1.04] cursor-pointer',
secondary:
'bg-transparent border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--accent)] cursor-pointer',
ghost:
'bg-transparent text-[var(--muted-foreground)] hover:bg-[var(--accent)] hover:text-[var(--foreground)] cursor-pointer',
destructive:
'bg-[var(--destructive)] text-[var(--destructive-foreground)] font-semibold hover:brightness-[1.05] cursor-pointer',
link:
'text-[var(--primary)] underline-offset-4 hover:underline cursor-pointer',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
sm: 'h-8 px-3',
default: 'h-[38px] px-4',
lg: 'h-11 px-5',
'icon-sm': 'h-8 w-8 p-0',
icon: 'h-[38px] w-[38px] p-0',
'icon-lg': 'h-11 w-11 p-0',
},
},
defaultVariants: {
variant: 'default',
variant: 'primary',
size: 'default',
},
}
@@ -35,11 +46,9 @@ export interface ButtonProps
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
}
({ className, variant, size, ...props }, ref) => (
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
)
);
Button.displayName = 'Button';
@@ -0,0 +1,69 @@
import * as React from 'react';
import { Trash2, AlertTriangle } from 'lucide-react';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './dialog';
import { IconTile } from './icon-tile';
import { Button } from './button';
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: React.ReactNode;
confirmLabel?: string;
cancelLabel?: string;
icon?: React.ReactNode;
tone?: 'destructive' | 'primary';
loading?: boolean;
onConfirm: () => void | Promise<void>;
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = 'Delete',
cancelLabel = 'Cancel',
icon,
tone = 'destructive',
loading = false,
onConfirm,
}: ConfirmDialogProps) {
const defaultIcon = tone === 'destructive' ? <Trash2 /> : <AlertTriangle />;
return (
<Dialog open={open} onOpenChange={onOpenChange} size="destructive">
<DialogContent>
<DialogHeader>
<IconTile icon={icon ?? defaultIcon} tone={tone} size="md" />
<div className="flex-1">
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</div>
</DialogHeader>
<DialogBody>
<p className="text-[13.5px] text-[var(--muted-foreground)]">This action cannot be undone.</p>
</DialogBody>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)} disabled={loading}>
{cancelLabel}
</Button>
<Button
variant={tone === 'destructive' ? 'destructive' : 'primary'}
onClick={() => onConfirm()}
disabled={loading}
>
{loading ? 'Working…' : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,91 @@
import * as React from 'react';
import { Trash2 } from 'lucide-react';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './dialog';
import { IconTile } from './icon-tile';
import { Button } from './button';
import { Input } from './input';
interface DangerousConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: React.ReactNode;
/** Exact string the user must type to enable the confirm button. */
confirmationText: string;
confirmLabel?: string;
cancelLabel?: string;
icon?: React.ReactNode;
loading?: boolean;
onConfirm: () => void | Promise<void>;
}
export function DangerousConfirmDialog({
open,
onOpenChange,
title,
description,
confirmationText,
confirmLabel = 'Delete',
cancelLabel = 'Cancel',
icon,
loading = false,
onConfirm,
}: DangerousConfirmDialogProps) {
const [value, setValue] = React.useState('');
React.useEffect(() => { if (!open) setValue(''); }, [open]);
const matches = value === confirmationText;
const submit = () => { if (matches && !loading) onConfirm(); };
return (
<Dialog open={open} onOpenChange={onOpenChange} size="destructive">
<DialogContent>
<DialogHeader>
<IconTile icon={icon ?? <Trash2 />} tone="destructive" size="md" />
<div className="flex-1">
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</div>
</DialogHeader>
<DialogBody className="space-y-3">
<p className="text-[13.5px] text-[var(--muted-foreground)]">
This action cannot be undone. To confirm, type{' '}
<code className="rounded bg-[var(--surface-sunken)] px-1 py-0.5 font-mono text-[13px] text-[var(--foreground)]">
{confirmationText}
</code>{' '}
below.
</p>
<Input
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submit()}
placeholder={confirmationText}
aria-label={`Type ${confirmationText} to confirm`}
/>
</DialogBody>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)} disabled={loading}>
{cancelLabel}
</Button>
<Button
variant="destructive"
onClick={submit}
disabled={!matches || loading}
>
{loading ? 'Working…' : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+132 -79
View File
@@ -1,149 +1,201 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
type DialogSize = 'standard' | 'form' | 'destructive';
interface DialogContextValue {
open: boolean;
onOpenChange: (open: boolean) => void;
size: DialogSize;
}
const DialogContext = React.createContext<DialogContextValue | undefined>(undefined);
function useDialog() {
const context = React.useContext(DialogContext);
if (!context) {
throw new Error('useDialog must be used within a Dialog');
}
return context;
const ctx = React.useContext(DialogContext);
if (!ctx) throw new Error('useDialog must be used within a Dialog');
return ctx;
}
interface DialogProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
size?: DialogSize;
children: React.ReactNode;
}
const Dialog: React.FC<DialogProps> = ({ open = false, onOpenChange, children }) => {
return (
<DialogContext.Provider value={{ open, onOpenChange: onOpenChange || (() => {}) }}>
{children}
</DialogContext.Provider>
);
};
const Dialog: React.FC<DialogProps> = ({ open = false, onOpenChange, size = 'standard', children }) => (
<DialogContext.Provider value={{ open, onOpenChange: onOpenChange || (() => {}), size }}>
{children}
</DialogContext.Provider>
);
const DialogTrigger = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ onClick, ...props }, ref) => {
const { onOpenChange } = useDialog();
return (
<button
ref={ref}
onClick={(e) => {
onOpenChange(true);
onClick?.(e);
}}
{...props}
/>
);
});
DialogTrigger.displayName = 'DialogTrigger';
const DialogPortal: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { open } = useDialog();
if (!open) return null;
return <>{children}</>;
};
const DialogOverlay = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const DialogTrigger = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
({ onClick, ...props }, ref) => {
const { onOpenChange } = useDialog();
return (
<div
<button
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
onClick={() => onOpenChange(false)}
onClick={(e) => { onOpenChange(true); onClick?.(e); }}
{...props}
/>
);
}
);
DialogOverlay.displayName = 'DialogOverlay';
DialogTrigger.displayName = 'DialogTrigger';
const widthClass: Record<DialogSize, string> = {
standard: 'max-w-[480px]',
form: 'max-w-[600px]',
destructive: 'max-w-[440px]',
};
const DialogOverlay: React.FC = () => {
const { onOpenChange } = useDialog();
return (
<div
className="fixed inset-0 z-50 bg-black/55 backdrop-blur-[8px]"
onClick={() => onOpenChange(false)}
/>
);
};
const DialogContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => {
const { onOpenChange } = useDialog();
return (
<DialogPortal>
const { open, onOpenChange, size } = useDialog();
const containerRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!open) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
const keyHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
onOpenChange(false);
return;
}
if (e.key !== 'Tab' || !containerRef.current) return;
const focusables = containerRef.current.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', keyHandler);
setTimeout(() => {
const first = containerRef.current?.querySelector<HTMLElement>(
'input:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
first?.focus();
}, 0);
return () => {
document.removeEventListener('keydown', keyHandler);
previouslyFocused?.focus?.();
};
}, [open, onOpenChange]);
if (!open) return null;
return createPortal(
<>
<DialogOverlay />
<div className="fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%] w-[calc(100%-2rem)] sm:w-full max-w-lg">
<div
ref={containerRef}
role="dialog"
aria-modal="true"
className={cn(
'fixed left-1/2 top-1/2 z-50 w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2',
widthClass[size],
)}
>
<div
ref={ref}
style={{ backgroundColor: 'var(--background)' }}
className={cn(
'relative p-4 sm:p-6 shadow-lg duration-200 rounded-lg border',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
className
'relative overflow-hidden rounded-xl border border-[var(--border)]',
'bg-[var(--card)] text-[var(--card-foreground)]',
'shadow-[0_20px_40px_rgba(0,0,0,0.3)]',
className,
)}
{...props}
>
{children}
<button
type="button"
onClick={() => onOpenChange(false)}
className="absolute right-4 top-4 rounded-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer"
aria-label="Close"
className="absolute right-3 top-3 inline-flex h-7 w-7 items-center justify-center rounded-md text-[var(--muted-foreground)] hover:bg-[var(--accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
</div>
</div>
</DialogPortal>
</>,
document.body,
);
}
);
DialogContent.displayName = 'DialogContent';
const DialogHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => <div className={cn('flex flex-col space-y-1.5 text-center sm:text-left bg-background', className)} {...props} />;
const DialogHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div
className={cn(
'flex items-start gap-3 border-b border-[var(--border)] px-6 py-5',
className,
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => (
const DialogBody: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div className={cn('px-6 py-5', className)} {...props} />
);
DialogBody.displayName = 'DialogBody';
const DialogFooter: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end space-y-2 space-y-reverse sm:space-y-0 sm:space-x-2', className)}
className={cn(
'flex justify-end gap-2 border-t border-[var(--border)] bg-[var(--surface-sunken)] px-6 py-3.5',
className,
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
const DialogTitleText = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h2
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
className={cn('text-[20px] font-semibold tracking-[-0.015em] leading-tight', className)}
{...props}
/>
)
);
DialogTitle.displayName = 'DialogTitle';
DialogTitleText.displayName = 'DialogTitle';
const DialogDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-xs text-muted-foreground', className)} {...props} />
));
const DialogDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('mt-1 text-[13.5px] leading-[1.45] text-[var(--muted-foreground)]', className)}
{...props}
/>
)
);
DialogDescription.displayName = 'DialogDescription';
export {
@@ -151,7 +203,8 @@ export {
DialogTrigger,
DialogContent,
DialogHeader,
DialogBody,
DialogFooter,
DialogTitle,
DialogTitleText as DialogTitle,
DialogDescription,
};
@@ -0,0 +1,32 @@
import * as React from 'react';
import { IconTile } from './icon-tile';
import { cn } from '@/lib/utils';
interface EmptyStateProps extends React.HTMLAttributes<HTMLDivElement> {
icon: React.ReactNode;
title: string;
description?: string;
action?: React.ReactNode;
tone?: 'primary' | 'neutral' | 'destructive';
}
export function EmptyState({ icon, title, description, action, tone = 'primary', className, ...props }: EmptyStateProps) {
return (
<div
className={cn(
'flex flex-col items-center justify-center gap-3 rounded-xl border border-[var(--border)] bg-[var(--card)] px-6 py-12 text-center',
className,
)}
{...props}
>
<IconTile icon={icon} tone={tone} size="lg" />
<div className="space-y-1">
<h3 className="text-[17px] font-semibold tracking-tight">{title}</h3>
{description && (
<p className="max-w-sm text-[13.5px] text-[var(--muted-foreground)]">{description}</p>
)}
</div>
{action && <div className="pt-1">{action}</div>}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { cva, type VariantProps } from 'class-variance-authority';
const iconTileVariants = cva(
'inline-flex items-center justify-center shrink-0 border',
{
variants: {
tone: {
primary: 'bg-[var(--accent-primary-soft)] border-[var(--accent-primary-border)] text-[var(--primary)]',
destructive: 'bg-[var(--danger-soft)] border-[var(--danger-border)] text-[var(--destructive)]',
neutral: 'bg-muted border-border text-muted-foreground',
},
size: {
sm: 'h-8 w-8 rounded-md [&>svg]:h-4 [&>svg]:w-4',
md: 'h-10 w-10 rounded-lg [&>svg]:h-5 [&>svg]:w-5',
lg: 'h-14 w-14 rounded-xl [&>svg]:h-7 [&>svg]:w-7',
},
},
defaultVariants: { tone: 'primary', size: 'md' },
}
);
export interface IconTileProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof iconTileVariants> {
icon: React.ReactNode;
}
export function IconTile({ icon, tone, size, className, ...props }: IconTileProps) {
return (
<div className={cn(iconTileVariants({ tone, size }), className)} {...props}>
{icon}
</div>
);
}
+16 -14
View File
@@ -1,22 +1,24 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed',
className
)}
ref={ref}
{...props}
/>
);
}
({ className, type, ...props }, ref) => (
<input
type={type}
ref={ref}
className={cn(
'flex h-[38px] w-full rounded-md border border-[var(--input)] bg-[var(--background)] px-3 py-1 text-[15px]',
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
'placeholder:text-[var(--muted-foreground)]',
'focus-visible:outline-none focus-visible:border-[var(--primary)] focus-visible:ring-2 focus-visible:ring-[var(--ring)]',
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
)
);
Input.displayName = 'Input';
@@ -0,0 +1,28 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
interface PageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
title: string;
subtitle?: React.ReactNode;
actions?: React.ReactNode;
}
export function PageHeader({ title, subtitle, actions, className, ...props }: PageHeaderProps) {
return (
<div
className={cn(
'flex flex-col gap-4 border-b border-[var(--border)] px-6 py-5 sm:flex-row sm:items-start sm:justify-between sm:gap-6',
className,
)}
{...props}
>
<div className="min-w-0">
<h1 className="text-[26px] font-semibold tracking-[-0.02em] leading-tight truncate">{title}</h1>
{subtitle && (
<div className="mt-1 text-[13.5px] text-[var(--muted-foreground)]">{subtitle}</div>
)}
</div>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</div>
);
}
+6 -5
View File
@@ -53,7 +53,7 @@ const TabsList = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivEl
<div
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
'flex h-12 items-center gap-0 border-b border-[var(--border)] text-[14px]',
className
)}
{...props}
@@ -70,15 +70,16 @@ const TabsTrigger = React.forwardRef<HTMLButtonElement, TabsTriggerProps>(
({ className, value: triggerValue, onClick, ...props }, ref) => {
const { value, onValueChange } = useTabs();
const isActive = value === triggerValue;
return (
<button
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none',
'relative h-12 px-3.5 -mb-px inline-flex items-center justify-center',
'text-[14px] font-medium transition-colors cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] rounded-sm',
isActive
? 'bg-background text-foreground shadow-sm'
: 'hover:bg-accent hover:text-accent-foreground',
? 'text-[var(--primary)] border-b-2 border-[var(--primary)]'
: 'text-[var(--muted-foreground)] border-b-2 border-transparent hover:text-[var(--foreground)]',
className
)}
onClick={(e) => {
+81 -22
View File
@@ -1,30 +1,71 @@
@import 'tailwindcss';
@font-face {
font-family: 'Geist Sans';
src: url('/fonts/geist-sans-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Sans';
src: url('/fonts/geist-sans-500.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Sans';
src: url('/fonts/geist-sans-600.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('/fonts/geist-mono-400.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Geist Mono';
src: url('/fonts/geist-mono-500.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@layer base {
:root {
/* Light Theme Colors */
--background: #ffffff;
--foreground: #0a0a0f;
--card: #ffffff;
--card-foreground: #0a0a0f;
--popover: #fafafa;
--popover-foreground: #0a0a0f;
--background: #faf7f2;
--foreground: #1c1917;
--card: #fffdf9;
--card-foreground: #1c1917;
--popover: #fffdf9;
--popover-foreground: #1c1917;
--primary: #ff9447;
--primary-foreground: #ffffff;
--secondary: #f4f4f5;
--secondary-foreground: #18181b;
--muted: #f4f4f5;
--muted-foreground: #71717a;
--accent: #f4f4f5;
--accent-foreground: #18181b;
--secondary: #f3efe8;
--secondary-foreground: #1c1917;
--muted: #f3efe8;
--muted-foreground: #78716c;
--accent: #f3efe8;
--accent-foreground: #1c1917;
--destructive: #ef4444;
--destructive-foreground: #fafafa;
--border: #e4e4e7;
--input: #e4e4e7;
--destructive-foreground: #ffffff;
--border: #e7e2d8;
--input: #e7e2d8;
--ring: #ff9447;
--radius: 0.5rem;
/* Grafana Chart Colors */
--accent-primary-soft: rgba(255, 148, 71, 0.10);
--accent-primary-border: rgba(255, 148, 71, 0.22);
--danger-soft: rgba(239, 68, 68, 0.10);
--danger-border: rgba(239, 68, 68, 0.28);
--success-soft: rgba(34, 197, 94, 0.10);
--surface-sunken: rgba(120, 90, 40, 0.05);
--chart-blue: #1f77b4;
--chart-orange: #ff7f0e;
--chart-green: #2ca02c;
@@ -38,7 +79,6 @@
}
.dark {
/* Dark Theme Colors - VS Code Inspired */
--background: #1a1d29;
--foreground: #e8eaed;
--card: #252834;
@@ -46,7 +86,7 @@
--popover: #2d3142;
--popover-foreground: #e8eaed;
--primary: #ff9447;
--primary-foreground: #ffffff;
--primary-foreground: #1a1d29;
--secondary: #3a3f52;
--secondary-foreground: #e8eaed;
--muted: #4a5064;
@@ -54,12 +94,18 @@
--accent: #3a3f52;
--accent-foreground: #e8eaed;
--destructive: #ef5350;
--destructive-foreground: #e8eaed;
--destructive-foreground: #1a1d29;
--border: #3a3f52;
--input: #3a3f52;
--ring: #ff9447;
/* Grafana Chart Colors */
--accent-primary-soft: rgba(255, 148, 71, 0.12);
--accent-primary-border: rgba(255, 148, 71, 0.22);
--danger-soft: rgba(239, 83, 80, 0.14);
--danger-border: rgba(239, 83, 80, 0.32);
--success-soft: rgba(115, 191, 105, 0.10);
--surface-sunken: rgba(0, 0, 0, 0.18);
--chart-blue: #3eb0ff;
--chart-orange: #ff9830;
--chart-green: #73bf69;
@@ -76,10 +122,23 @@
border-color: var(--border);
}
html {
font-family: 'Geist Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
font-size: 15px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
background-color: var(--background);
color: var(--foreground);
font-feature-settings: 'rlig' 1, 'calt' 1;
font-feature-settings: 'rlig' 1, 'calt' 1, 'ss01' 1;
}
code, pre, kbd, samp, .font-mono {
font-family: 'Geist Mono', ui-monospace, 'SF Mono', 'Menlo', Consolas, monospace;
}
}
+277 -309
View File
@@ -1,17 +1,20 @@
import {useEffect, useMemo, useState} from 'react';
import {Header} from '@/components/layout/header';
import {cn} from '@/lib/utils';
import {PageHeader} from '@/components/ui/page-header';
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 {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { IconTile } from '@/components/ui/icon-tile';
import {
DropdownMenu,
DropdownMenuContent,
@@ -26,9 +29,82 @@ import {Select, SelectOption} from '@/components/ui/select';
import {accessApi, bucketsApi} from '@/lib/api';
import {formatDate} from '@/lib/utils';
import type {AccessKey, Bucket, BucketPermission} from '@/types';
import {Copy, Edit, Key, Loader2, MoreVertical, Plus, Search, ShieldCheck, ShieldX, Trash2,} from 'lucide-react';
import {AlertTriangle, Calendar, Check, Copy, Database, Edit, Eye, EyeOff, Key, KeyRound, Loader2, MoreVertical, Plus, Search, ShieldCheck, ShieldX, Trash2,} from 'lucide-react';
import {toast} from 'sonner';
function CredentialField({
label,
value,
mono = true,
breakAll = false,
maskable = false,
loading = false,
}: {
label: string;
value: string;
mono?: boolean;
breakAll?: boolean;
maskable?: boolean;
loading?: boolean;
}) {
const [copied, setCopied] = useState(false);
const [revealed, setRevealed] = useState(!maskable);
const copy = () => {
if (!value) return;
navigator.clipboard.writeText(value);
setCopied(true);
toast.success(`${label} copied`);
setTimeout(() => setCopied(false), 1600);
};
const display = loading ? '' : revealed || !maskable ? value : '•'.repeat(Math.min(40, value.length || 40));
return (
<div className="space-y-1.5">
<label className="text-[12px] font-medium uppercase tracking-[0.06em] text-[var(--muted-foreground)]">
{label}
</label>
<div className="flex items-stretch gap-2">
<button
type="button"
onClick={copy}
disabled={loading || !value}
title="Click to copy"
className={cn(
'flex-1 min-w-0 rounded-md border border-[var(--border)] bg-[var(--surface-sunken)]',
'px-3 py-2 text-left text-[13.5px] transition-colors hover:bg-[var(--accent)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]',
'disabled:cursor-not-allowed disabled:opacity-70 disabled:hover:bg-[var(--surface-sunken)]',
mono && 'font-mono',
breakAll ? 'break-all' : 'truncate',
)}
>
{loading ? (
<span className="inline-flex items-center gap-2 text-[var(--muted-foreground)]">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Loading
</span>
) : (
display
)}
</button>
{maskable && (
<Button
variant="secondary"
size="icon"
onClick={() => setRevealed((r) => !r)}
aria-label={revealed ? 'Hide' : 'Reveal'}
disabled={loading || !value}
>
{revealed ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
)}
<Button variant="secondary" size="icon" onClick={copy} aria-label={`Copy ${label}`} disabled={loading || !value}>
{copied ? <Check className="h-4 w-4 text-[var(--primary)]" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
);
}
export function AccessControl() {
const [keys, setKeys] = useState<AccessKey[]>([]);
const [searchQuery, setSearchQuery] = useState('');
@@ -363,9 +439,7 @@ export function AccessControl() {
return (
<div>
<Header
title="Access Control"
/>
<PageHeader title="Access control" subtitle="Access keys and per-bucket permissions" />
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
<Tabs defaultValue="keys">
<TabsContent value="keys" className="space-y-4 sm:space-y-6 mt-4 sm:mt-6">
@@ -488,7 +562,7 @@ export function AccessControl() {
</div>
</TableCell>
<TableCell>
<Badge variant={key.status === 'active' ? 'default' : 'secondary'}>
<Badge variant={key.status === 'active' ? 'primary' : 'neutral'}>
{key.status}
</Badge>
</TableCell>
@@ -496,12 +570,12 @@ export function AccessControl() {
<TableCell className="hidden md:table-cell">
<div className="flex flex-wrap gap-1">
{key.permissions.slice(0, 2).map((perm, idx) => (
<Badge key={idx} variant="outline" className="text-xs">
<Badge key={idx} variant="neutral" className="text-xs">
{perm.bucketName}: {formatPermissions(perm)}
</Badge>
))}
{key.permissions.length > 2 && (
<Badge variant="outline" className="text-xs">
<Badge variant="neutral" className="text-xs">
+{key.permissions.length - 2} more
</Badge>
)}
@@ -566,123 +640,82 @@ export function AccessControl() {
</div>
{/* Create Key Dialog */}
<Dialog open={createDialogOpen} onOpenChange={handleCloseCreateDialog}>
<DialogContent className="max-w-2xl">
<Dialog open={createDialogOpen} onOpenChange={handleCloseCreateDialog} size="form">
<DialogContent>
{newlyCreatedKey ? (
// Success state - show the secret key
<>
<DialogHeader>
<DialogTitle>API Key Created Successfully</DialogTitle>
<DialogDescription>
Save your secret access key now. You won't be able to see it again.
</DialogDescription>
<IconTile icon={<ShieldCheck />} tone="primary" size="md" />
<div className="min-w-0 flex-1">
<DialogTitle>API key created</DialogTitle>
<DialogDescription>
Copy your secret access key now this is the only time it will be shown.
</DialogDescription>
</div>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Key Name</label>
<div className="text-sm text-muted-foreground">{newlyCreatedKey.name}</div>
<DialogBody className="space-y-5">
<div className="space-y-1.5">
<label className="text-[12px] font-medium uppercase tracking-[0.06em] text-[var(--muted-foreground)]">
Key name
</label>
<div className="text-[14px] font-medium">{newlyCreatedKey.name}</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Access Key ID</label>
<div className="flex items-center gap-2">
<code
className="text-sm bg-muted px-3 py-2 rounded flex-1 cursor-pointer hover:bg-muted/80 transition-colors"
onClick={() => {
navigator.clipboard.writeText(newlyCreatedKey.accessKeyId);
toast.success('Access Key ID copied to clipboard');
}}
>
{newlyCreatedKey.accessKeyId}
</code>
<Button
variant="outline"
size="sm"
onClick={() => {
navigator.clipboard.writeText(newlyCreatedKey.accessKeyId);
toast.success('Access Key ID copied to clipboard');
}}
>
<Copy className="h-4 w-4" />
</Button>
<CredentialField label="Access Key ID" value={newlyCreatedKey.accessKeyId} />
<CredentialField
label="Secret Access Key"
value={newlyCreatedKey.secretKey || ''}
breakAll
/>
<div className="flex gap-3 rounded-lg border border-[var(--accent-primary-border)] bg-[var(--accent-primary-soft)] px-3.5 py-3">
<AlertTriangle className="h-4 w-4 flex-shrink-0 text-[var(--primary)] mt-0.5" />
<div className="space-y-0.5">
<p className="text-[13.5px] font-medium text-[var(--foreground)]">
Save this key now
</p>
<p className="text-[12.5px] leading-[1.5] text-[var(--muted-foreground)]">
The secret access key cannot be retrieved again. If lost, you'll need to create a new key.
</p>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Secret Access Key</label>
<div className="flex items-center gap-2">
<code
className="text-sm bg-muted px-3 py-2 rounded flex-1 break-all cursor-pointer hover:bg-muted/80 transition-colors"
onClick={() => {
if (newlyCreatedKey.secretKey) {
navigator.clipboard.writeText(newlyCreatedKey.secretKey);
toast.success('Secret Access Key copied to clipboard');
}
}}
>
{newlyCreatedKey.secretKey}
</code>
<Button
variant="outline"
size="sm"
onClick={() => {
if (newlyCreatedKey.secretKey) {
navigator.clipboard.writeText(newlyCreatedKey.secretKey);
toast.success('Secret Access Key copied to clipboard');
}
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<div className="border rounded-lg p-4 bg-orange-100 border-orange-300 dark:bg-orange-950/20 dark:border-orange-900">
<div className="flex gap-2">
<ShieldX className="h-5 w-5 text-orange-700 dark:text-orange-500 flex-shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium text-orange-950 dark:text-orange-200">
Important: Save This Key Now
</p>
<p className="text-xs text-orange-900 dark:text-orange-300">
This is the only time you'll see the secret access key. Make sure to copy and save it securely.
If you lose it, you'll need to create a new key.
</p>
</div>
</div>
</div>
</div>
</DialogBody>
<DialogFooter>
<Button onClick={handleCloseCreateDialog}>
Done
</Button>
<Button onClick={handleCloseCreateDialog}>Done</Button>
</DialogFooter>
</>
) : (
// Creation form
<>
<DialogHeader>
<DialogTitle>Create API Key</DialogTitle>
<DialogDescription>
Create a new API key with optional bucket permissions
</DialogDescription>
<IconTile icon={<KeyRound />} tone="primary" size="md" />
<div className="min-w-0 flex-1">
<DialogTitle>Create API key</DialogTitle>
<DialogDescription>
Generate a new access key pair. You can optionally grant bucket permissions in the same step.
</DialogDescription>
</div>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Key Name</label>
<DialogBody className="space-y-6">
<div className="space-y-1.5">
<label htmlFor="new-key-name" className="text-[13px] font-medium">
Key name
</label>
<Input
placeholder="My Application Key"
id="new-key-name"
placeholder="e.g. backups-prod"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
autoFocus
/>
<p className="text-xs text-muted-foreground">
A friendly name to identify this API key
<p className="text-[12.5px] text-[var(--muted-foreground)]">
A friendly name to identify this key in the console.
</p>
</div>
{/* Optional: Grant permissions during creation */}
<div className="space-y-3 border-t pt-4">
<label className="flex items-center space-x-2 cursor-pointer">
<div className="space-y-3 rounded-lg border border-[var(--border)] p-4">
<label className="flex cursor-pointer items-start gap-3">
<Checkbox
id="grant-permissions-on-create"
checked={createGrantPermissions}
className="mt-0.5"
onCheckedChange={(checked) => {
setCreateGrantPermissions(checked as boolean);
if (!checked) {
@@ -693,22 +726,23 @@ export function AccessControl() {
}
}}
/>
<span className="text-sm font-medium">Grant bucket permissions now</span>
<div className="flex-1">
<div className="text-[13.5px] font-medium">Grant bucket permissions now</div>
<p className="mt-0.5 text-[12.5px] text-[var(--muted-foreground)]">
Optional — you can also do this later from the key's edit menu.
</p>
</div>
</label>
<p className="text-xs text-muted-foreground">
You can also grant permissions later from the Edit Permissions menu
</p>
{createGrantPermissions && (
<div className="space-y-4 pl-6 pt-2">
{/* Bucket Selection */}
<div className="space-y-2">
<label className="text-sm font-medium">Select Bucket</label>
<div className="space-y-4 border-t border-[var(--border)] pt-4">
<div className="space-y-1.5">
<label className="text-[13px] font-medium">Bucket</label>
<Select
value={createSelectedBucket}
onChange={(value) => setCreateSelectedBucket(value)}
>
<SelectOption value="">-- Select a bucket --</SelectOption>
<SelectOption value="">Select a bucket</SelectOption>
{createAvailableBuckets.map((bucket) => (
<SelectOption key={bucket.name} value={bucket.name}>
{bucket.name}
@@ -717,59 +751,65 @@ export function AccessControl() {
</Select>
</div>
{/* Permissions */}
{createSelectedBucket && (
<div className="space-y-3">
<label className="text-sm font-medium">Permissions</label>
<div className="space-y-2 border rounded-lg p-3">
<label className="flex items-center space-x-2 cursor-pointer">
<Checkbox
id="create-permission-read"
checked={createPermissionRead}
onCheckedChange={(checked) => setCreatePermissionRead(checked as boolean)}
/>
<div>
<span className="text-sm font-medium">Read</span>
<p className="text-xs text-muted-foreground">GetObject, HeadObject, ListObjects</p>
</div>
</label>
<label className="flex items-center space-x-2 cursor-pointer">
<Checkbox
id="create-permission-write"
checked={createPermissionWrite}
onCheckedChange={(checked) => setCreatePermissionWrite(checked as boolean)}
/>
<div>
<span className="text-sm font-medium">Write</span>
<p className="text-xs text-muted-foreground">PutObject, DeleteObject</p>
</div>
</label>
<label className="flex items-center space-x-2 cursor-pointer">
<Checkbox
id="create-permission-owner"
checked={createPermissionOwner}
onCheckedChange={(checked) => setCreatePermissionOwner(checked as boolean)}
/>
<div>
<span className="text-sm font-medium">Owner</span>
<p className="text-xs text-muted-foreground">DeleteBucket, PutBucketPolicy</p>
</div>
</label>
<div className="space-y-1.5">
<label className="text-[13px] font-medium">Permissions</label>
<div className="divide-y divide-[var(--border)] rounded-md border border-[var(--border)]">
{[
{
id: 'create-permission-read',
label: 'Read',
desc: 'GetObject, HeadObject, ListObjects',
checked: createPermissionRead,
setChecked: setCreatePermissionRead,
},
{
id: 'create-permission-write',
label: 'Write',
desc: 'PutObject, DeleteObject',
checked: createPermissionWrite,
setChecked: setCreatePermissionWrite,
},
{
id: 'create-permission-owner',
label: 'Owner',
desc: 'DeleteBucket, PutBucketPolicy',
checked: createPermissionOwner,
setChecked: setCreatePermissionOwner,
},
].map((p) => (
<label
key={p.id}
htmlFor={p.id}
className="flex cursor-pointer items-start gap-3 px-3.5 py-3 transition-colors hover:bg-[var(--accent)]"
>
<Checkbox
id={p.id}
checked={p.checked}
className="mt-0.5"
onCheckedChange={(checked) => p.setChecked(checked as boolean)}
/>
<div className="flex-1">
<div className="text-[13.5px] font-medium">{p.label}</div>
<p className="mt-0.5 font-mono text-[12px] text-[var(--muted-foreground)]">
{p.desc}
</p>
</div>
</label>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={handleCloseCreateDialog}>
<Button variant="secondary" onClick={handleCloseCreateDialog}>
Cancel
</Button>
<Button onClick={handleCreateKey} disabled={!newKeyName}>
Create Key
Create key
</Button>
</DialogFooter>
</>
@@ -788,7 +828,7 @@ export function AccessControl() {
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
<Button variant="secondary" onClick={() => setDeleteDialogOpen(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDeleteKey}>
@@ -827,7 +867,7 @@ export function AccessControl() {
{selectedKey?.accessKeyId}
</code>
<Button
variant="outline"
variant="secondary"
size="sm"
onClick={() => {
if (selectedKey?.accessKeyId) {
@@ -862,7 +902,7 @@ export function AccessControl() {
{revealedSecretKey}
</code>
<Button
variant="outline"
variant="secondary"
size="sm"
onClick={() => {
if (revealedSecretKey) {
@@ -960,7 +1000,7 @@ export function AccessControl() {
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSettingsDialogOpen(false)}>
<Button variant="secondary" onClick={() => setSettingsDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSaveKeySettings}>
@@ -971,185 +1011,113 @@ export function AccessControl() {
</Dialog>
{/* Key Details Dialog */}
<Dialog open={keyDetailsDialogOpen} onOpenChange={setKeyDetailsDialogOpen}>
<DialogContent className="max-w-2xl">
<Dialog open={keyDetailsDialogOpen} onOpenChange={setKeyDetailsDialogOpen} size="form">
<DialogContent>
<DialogHeader>
<DialogTitle>API Key Details</DialogTitle>
<DialogDescription>
View and manage your API key credentials and permissions
</DialogDescription>
<IconTile icon={<KeyRound />} tone="primary" size="md" />
<div className="min-w-0 flex-1">
<DialogTitle className="truncate">{viewingKey?.name || 'API key'}</DialogTitle>
<DialogDescription>View credentials and bucket permissions for this key.</DialogDescription>
</div>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Key Name and Status */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Key Name</label>
<div className="text-sm text-muted-foreground">{viewingKey?.name}</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Status</label>
<div>
<Badge variant={viewingKey?.status === 'active' ? 'default' : 'secondary'}>
{viewingKey?.status}
</Badge>
</div>
</div>
</div>
{/* Access Key ID */}
<div className="space-y-2">
<label className="text-sm font-medium">Access Key ID</label>
<DialogBody className="space-y-5">
{/* Meta strip */}
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 rounded-lg border border-[var(--border)] bg-[var(--surface-sunken)] px-4 py-3">
<div className="flex items-center gap-2">
<code
className="text-sm bg-muted px-3 py-2 rounded flex-1 break-all cursor-pointer hover:bg-muted/80 transition-colors"
onClick={() => {
if (viewingKey?.accessKeyId) {
navigator.clipboard.writeText(viewingKey.accessKeyId);
setCopiedAccessKeyId(true);
setTimeout(() => setCopiedAccessKeyId(false), 2000);
toast.success('Access Key ID copied to clipboard');
}
}}
>
{viewingKey?.accessKeyId}
</code>
<Button
variant="outline"
size="sm"
onClick={() => {
if (viewingKey?.accessKeyId) {
navigator.clipboard.writeText(viewingKey.accessKeyId);
setCopiedAccessKeyId(true);
setTimeout(() => setCopiedAccessKeyId(false), 2000);
toast.success('Access Key ID copied to clipboard');
}
}}
>
{copiedAccessKeyId ? 'Copied' : <Copy className="h-4 w-4" />}
</Button>
<span className="text-[11px] font-medium uppercase tracking-[0.06em] text-[var(--muted-foreground)]">
Status
</span>
<Badge variant={viewingKey?.status === 'active' ? 'success' : 'neutral'}>
{viewingKey?.status}
</Badge>
</div>
</div>
{/* Secret Access Key */}
<div className="space-y-2">
<label className="text-sm font-medium">Secret Access Key</label>
<div className="flex items-center gap-2">
{isLoadingDetailsSecretKey ? (
<div className="flex items-center gap-2 text-muted-foreground flex-1 bg-muted px-3 py-2 rounded">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Loading secret key...</span>
</div>
) : (
<>
<code
className="text-sm bg-muted px-3 py-2 rounded flex-1 break-all cursor-pointer hover:bg-muted/80 transition-colors"
onClick={() => {
if (detailsSecretKey) {
navigator.clipboard.writeText(detailsSecretKey);
setCopiedSecretKey(true);
setTimeout(() => setCopiedSecretKey(false), 2000);
toast.success('Secret Access Key copied to clipboard');
}
}}
>
{''.repeat(40)}
</code>
<Button
variant="outline"
size="sm"
onClick={() => {
if (detailsSecretKey) {
navigator.clipboard.writeText(detailsSecretKey);
setCopiedSecretKey(true);
setTimeout(() => setCopiedSecretKey(false), 2000);
toast.success('Secret Access Key copied to clipboard');
}
}}
disabled={!detailsSecretKey}
>
{copiedSecretKey ? 'Copied' : <Copy className="h-4 w-4" />}
</Button>
</>
)}
</div>
</div>
{/* Metadata */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Created</label>
<div className="text-sm text-muted-foreground">{viewingKey && formatDate(viewingKey.createdAt)}</div>
<div className="flex items-center gap-2 text-[13px] text-[var(--muted-foreground)]">
<Calendar className="h-3.5 w-3.5" />
<span className="text-[11px] font-medium uppercase tracking-[0.06em]">Created</span>
<span className="text-[var(--foreground)]">
{viewingKey && formatDate(viewingKey.createdAt)}
</span>
</div>
{viewingKey?.expiration && (
<div className="space-y-2">
<label className="text-sm font-medium">Expiration</label>
<div className="text-sm text-muted-foreground">{formatDate(viewingKey.expiration)}</div>
<div className="flex items-center gap-2 text-[13px] text-[var(--muted-foreground)]">
<Calendar className="h-3.5 w-3.5" />
<span className="text-[11px] font-medium uppercase tracking-[0.06em]">Expires</span>
<span className="text-[var(--foreground)]">{formatDate(viewingKey.expiration)}</span>
</div>
)}
</div>
{/* Credentials */}
<div className="space-y-4">
<CredentialField
label="Access Key ID"
value={viewingKey?.accessKeyId || ''}
breakAll
/>
<CredentialField
label="Secret Access Key"
value={detailsSecretKey}
breakAll
maskable
loading={isLoadingDetailsSecretKey}
/>
</div>
{/* Bucket Permissions */}
<div className="space-y-3">
<label className="text-sm font-medium">Bucket Permissions</label>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-[12px] font-medium uppercase tracking-[0.06em] text-[var(--muted-foreground)]">
Bucket permissions
</label>
{viewingKey && viewingKey.permissions.length > 0 && (
<span className="text-[12px] text-[var(--muted-foreground)]">
{viewingKey.permissions.length} bucket
{viewingKey.permissions.length === 1 ? '' : 's'}
</span>
)}
</div>
{viewingKey && viewingKey.permissions.length > 0 ? (
<div className="border rounded-lg divide-y">
<div className="divide-y divide-[var(--border)] overflow-hidden rounded-lg border border-[var(--border)]">
{viewingKey.permissions.map((perm, idx) => (
<div key={idx} className="p-3 flex items-center justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">{perm.bucketName}</div>
<div className="text-xs text-muted-foreground">
{formatPermissions(perm)}
</div>
<div
key={idx}
className="flex items-center justify-between gap-3 px-3.5 py-2.5"
>
<div className="flex min-w-0 items-center gap-2.5">
<Database className="h-3.5 w-3.5 flex-shrink-0 text-[var(--muted-foreground)]" />
<span className="truncate font-mono text-[13px]">{perm.bucketName}</span>
</div>
<div className="flex gap-1">
{perm.read && (
<Badge variant="outline" className="text-xs">
Read
</Badge>
)}
{perm.write && (
<Badge variant="outline" className="text-xs">
Write
</Badge>
)}
{perm.owner && (
<Badge variant="outline" className="text-xs">
Owner
</Badge>
)}
<div className="flex flex-shrink-0 gap-1">
{perm.read && <Badge variant="neutral">Read</Badge>}
{perm.write && <Badge variant="neutral">Write</Badge>}
{perm.owner && <Badge variant="warning">Owner</Badge>}
</div>
</div>
))}
</div>
) : (
<div className="border rounded-lg p-6 text-center">
<p className="text-sm text-muted-foreground">
This key has no bucket permissions yet
<div className="rounded-lg border border-dashed border-[var(--border)] px-4 py-6 text-center">
<p className="text-[13px] text-[var(--muted-foreground)]">
No bucket permissions yet.
</p>
</div>
)}
</div>
</div>
<DialogFooter className="flex-col sm:flex-row gap-2">
</DialogBody>
<DialogFooter>
<Button
variant="outline"
variant="secondary"
onClick={() => {
setKeyDetailsDialogOpen(false);
if (viewingKey) {
handleOpenEditPermissions(viewingKey);
}
}}
className="w-full sm:w-auto"
>
<Edit className="h-4 w-4" />
Edit Permissions
</Button>
<Button
onClick={() => setKeyDetailsDialogOpen(false)}
className="w-full sm:w-auto"
>
Close
Edit permissions
</Button>
<Button onClick={() => setKeyDetailsDialogOpen(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -1266,13 +1234,13 @@ export function AccessControl() {
</p>
<div className="flex flex-wrap gap-2">
{bucketPermission.read && (
<Badge variant="secondary">Read</Badge>
<Badge variant="neutral">Read</Badge>
)}
{bucketPermission.write && (
<Badge variant="secondary">Write</Badge>
<Badge variant="neutral">Write</Badge>
)}
{bucketPermission.owner && (
<Badge variant="secondary">Owner</Badge>
<Badge variant="neutral">Owner</Badge>
)}
</div>
<p className="text-xs text-muted-foreground mt-2">
@@ -1302,9 +1270,9 @@ export function AccessControl() {
<div key={idx} className="flex items-center justify-between text-sm p-2 bg-muted/30 rounded">
<span className="font-medium">{perm.bucketName}</span>
<div className="flex gap-1">
{perm.read && <Badge variant="outline" className="text-xs">R</Badge>}
{perm.write && <Badge variant="outline" className="text-xs">W</Badge>}
{perm.owner && <Badge variant="outline" className="text-xs">O</Badge>}
{perm.read && <Badge variant="neutral" className="text-xs">R</Badge>}
{perm.write && <Badge variant="neutral" className="text-xs">W</Badge>}
{perm.owner && <Badge variant="neutral" className="text-xs">O</Badge>}
</div>
</div>
))}
@@ -1314,7 +1282,7 @@ export function AccessControl() {
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditPermissionsDialogOpen(false)}>
<Button variant="secondary" onClick={() => setEditPermissionsDialogOpen(false)}>
Cancel
</Button>
<Button
+122
View File
@@ -0,0 +1,122 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ObjectBrowserView } from '@/components/buckets/ObjectBrowserView';
import { useBucketObjects } from '@/hooks/useBucketObjects';
export function BucketObjects() {
const { bucketName = '' } = useParams<{ bucketName: string }>();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [currentPath, setCurrentPath] = useState(searchParams.get('prefix') ?? '');
const [searchQuery, setSearchQuery] = useState('');
const [initialPageToken, setInitialPageToken] = useState<string | undefined>(
searchParams.get('page') ?? undefined,
);
const [initialItemsPerPage, setInitialItemsPerPage] = useState<number>(
parseInt(searchParams.get('limit') ?? '25', 10),
);
useEffect(() => {
const prefix = searchParams.get('prefix') ?? '';
if (prefix !== currentPath) setCurrentPath(prefix);
setInitialPageToken(searchParams.get('page') ?? undefined);
setInitialItemsPerPage(parseInt(searchParams.get('limit') ?? '25', 10));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
const {
objects,
isLoading,
isRefreshing,
isNavigating,
isTruncated,
nextContinuationToken,
itemsPerPage,
setItemsPerPage,
uploadFiles,
uploadTasks,
deleteObject,
deleteMultipleObjects,
createDirectory,
fetchObjects,
} = useBucketObjects(bucketName, currentPath);
const handleNavigateToFolder = (path: string) => {
setCurrentPath(path);
const next = new URLSearchParams();
if (path) next.set('prefix', path);
setSearchParams(next);
};
const handlePageChange = (token?: string) => {
fetchObjects(token);
const next = new URLSearchParams();
if (currentPath) next.set('prefix', currentPath);
if (token) next.set('page', token);
if (itemsPerPage !== 25) next.set('limit', String(itemsPerPage));
setSearchParams(next);
};
const handleItemsPerPageChange = (count: number) => {
setItemsPerPage(count);
const next = new URLSearchParams();
if (currentPath) next.set('prefix', currentPath);
if (count !== 25) next.set('limit', String(count));
setSearchParams(next);
};
const handleRefresh = async () => {
await fetchObjects(undefined, true);
};
const handleBackToBuckets = () => navigate('/buckets');
// CustomEvent bridge for the Upload button in BucketDetailShell hero.
const uploadInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const handler = () => uploadInputRef.current?.click();
document.addEventListener('bucket:upload', handler);
return () => document.removeEventListener('bucket:upload', handler);
}, []);
return (
<>
<input
ref={uploadInputRef}
type="file"
multiple
hidden
onChange={async (e) => {
const files = Array.from(e.target.files ?? []);
if (files.length > 0) await uploadFiles(files);
e.target.value = '';
}}
/>
<ObjectBrowserView
bucketName={bucketName}
objects={objects}
currentPath={currentPath}
searchQuery={searchQuery}
isLoading={isLoading}
isTruncated={isTruncated}
nextContinuationToken={nextContinuationToken}
itemsPerPage={itemsPerPage}
onSearchChange={setSearchQuery}
onNavigateToFolder={handleNavigateToFolder}
onBackToBuckets={handleBackToBuckets}
onUploadFiles={uploadFiles}
uploadTasks={uploadTasks}
onDeleteObject={deleteObject}
onDeleteMultipleObjects={deleteMultipleObjects}
onCreateDirectory={createDirectory}
onRefresh={handleRefresh}
onPageChange={handlePageChange}
onItemsPerPageChange={handleItemsPerPageChange}
isRefreshing={isRefreshing}
isNavigating={isNavigating}
initialPageToken={initialPageToken}
initialItemsPerPage={initialItemsPerPage}
/>
</>
);
}
+183
View File
@@ -0,0 +1,183 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { KeyRound, ShieldCheck } from 'lucide-react';
import { useAccessKeys, useGrantBucketPermission } from '@/hooks/useApi';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectOption } from '@/components/ui/select';
import { EmptyState } from '@/components/ui/empty-state';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
export function BucketPermissions() {
const { bucketName = '' } = useParams<{ bucketName: string }>();
const { data: availableKeys = [] } = useAccessKeys();
const grant = useGrantBucketPermission();
const [selectedKey, setSelectedKey] = useState('');
const [read, setRead] = useState(false);
const [write, setWrite] = useState(false);
const [owner, setOwner] = useState(false);
useEffect(() => {
if (!selectedKey) {
setRead(false); setWrite(false); setOwner(false);
return;
}
const key = availableKeys.find((k) => k.accessKeyId === selectedKey);
const existing = key?.permissions.find(
(p) => p.bucketName === bucketName || p.bucketId === bucketName,
);
setRead(existing?.read ?? false);
setWrite(existing?.write ?? false);
setOwner(existing?.owner ?? false);
}, [selectedKey, availableKeys, bucketName]);
const canSubmit = !!selectedKey && (read || write || owner) && !grant.isPending;
const onGrant = async () => {
if (!selectedKey) { toast.error('Please select an access key'); return; }
if (!read && !write && !owner) { toast.error('Please select at least one permission'); return; }
try {
await grant.mutateAsync({
bucketName,
accessKeyId: selectedKey,
permissions: { read, write, owner },
});
toast.success('Permissions granted');
setSelectedKey(''); setRead(false); setWrite(false); setOwner(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Grant failed');
}
};
const granted = availableKeys
.map((k) => ({
key: k,
perm: k.permissions.find(
(p) => p.bucketName === bucketName || p.bucketId === bucketName,
),
}))
.filter((x) => !!x.perm);
return (
<div className="space-y-6 px-7 py-6">
<section className="rounded-xl border border-[var(--border)] bg-[var(--card)]">
<header className="flex items-center gap-2 border-b border-[var(--border)] px-5 py-3">
<ShieldCheck className="h-4 w-4 text-[var(--primary)]" />
<h2 className="text-[15px] font-semibold">Grant access</h2>
</header>
<div className="space-y-5 px-5 py-5">
<div className="space-y-1.5">
<label className="text-[13.5px] font-medium">Access key</label>
<Select value={selectedKey} onChange={(v) => setSelectedKey(v)}>
<SelectOption value="">-- Select an access key --</SelectOption>
{availableKeys.map((k) => (
<SelectOption key={k.accessKeyId} value={k.accessKeyId}>
{k.name} ({k.accessKeyId})
</SelectOption>
))}
</Select>
<p className="text-[12.5px] text-[var(--muted-foreground)]">
Choose which access key should have permissions on this bucket. Current permissions pre-fill below.
</p>
</div>
<div className="space-y-1.5">
<div className="text-[13.5px] font-medium">Permissions</div>
<div className="space-y-3 rounded-lg border border-[var(--border)] p-4">
<PermRow
id="perm-read"
checked={read}
onChange={setRead}
title="Read"
description="Allows reading objects from the bucket (GetObject, HeadObject, ListObjects)"
/>
<PermRow
id="perm-write"
checked={write}
onChange={setWrite}
title="Write"
description="Allows writing and deleting objects in the bucket (PutObject, DeleteObject)"
/>
<PermRow
id="perm-owner"
checked={owner}
onChange={setOwner}
title="Owner"
description="Allows managing bucket settings and policies (DeleteBucket, PutBucketPolicy)"
/>
</div>
</div>
<div className="pt-1">
<Button onClick={onGrant} disabled={!canSubmit}>
{grant.isPending ? 'Granting…' : 'Grant access'}
</Button>
</div>
</div>
</section>
<section className="rounded-xl border border-[var(--border)] bg-[var(--card)]">
<header className="flex items-center gap-2 border-b border-[var(--border)] px-5 py-3">
<KeyRound className="h-4 w-4 text-[var(--primary)]" />
<h2 className="text-[15px] font-semibold">Granted</h2>
</header>
{granted.length === 0 ? (
<div className="p-5">
<EmptyState
icon={<KeyRound />}
tone="neutral"
title="No access granted"
description="Grant at least one access key to make this bucket usable."
/>
</div>
) : (
<ul className="divide-y divide-[var(--border)]">
{granted.map(({ key, perm }) => (
<li key={key.accessKeyId} className="flex items-center gap-4 px-5 py-3">
<div className="min-w-0 flex-1">
<div className="truncate text-[14px] font-medium">{key.name}</div>
<div className="truncate font-mono text-[12.5px] text-[var(--muted-foreground)]">
{key.accessKeyId}
</div>
</div>
<div className="flex items-center gap-1">
{perm!.read && <Badge variant="success">Read</Badge>}
{perm!.write && <Badge variant="warning">Write</Badge>}
{perm!.owner && <Badge variant="primary">Owner</Badge>}
</div>
</li>
))}
</ul>
)}
</section>
</div>
);
}
function PermRow({
id,
checked,
onChange,
title,
description,
}: {
id: string;
checked: boolean;
onChange: (v: boolean) => void;
title: string;
description: string;
}) {
return (
<div className="flex items-start gap-3">
<Checkbox id={id} checked={checked} onCheckedChange={(c) => onChange(c as boolean)} />
<div className="flex-1">
<label htmlFor={id} className="text-[14px] font-medium leading-none cursor-pointer">
{title}
</label>
<p className="mt-1 text-[12.5px] text-[var(--muted-foreground)]">{description}</p>
</div>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { AlertTriangle, Info } from 'lucide-react';
import { useBuckets, useDeleteBucket } from '@/hooks/useApi';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { EmptyState } from '@/components/ui/empty-state';
import { DangerousConfirmDialog } from '@/components/ui/dangerous-confirm-dialog';
import { toast } from 'sonner';
function formatBytes(n?: number) {
if (n == null) return '—';
if (n < 1024) return `${n} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let v = n / 1024;
for (const u of units) {
if (v < 1024) return `${v.toFixed(v >= 10 ? 0 : 1)} ${u}`;
v /= 1024;
}
return `${v.toFixed(0)} PB`;
}
function formatDate(iso?: string) {
if (!iso) return '—';
try {
return new Date(iso).toLocaleString();
} catch {
return iso;
}
}
export function BucketSettings() {
const { bucketName = '' } = useParams<{ bucketName: string }>();
const navigate = useNavigate();
const { data: buckets = [], isLoading } = useBuckets();
const bucket = buckets.find((b) => b.name === bucketName);
const deleteMutation = useDeleteBucket();
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
if (isLoading) {
return <div className="px-7 py-6 text-[13.5px] text-[var(--muted-foreground)]">Loading</div>;
}
if (!bucket) {
return (
<div className="px-7 py-6">
<EmptyState
icon={<AlertTriangle />}
tone="neutral"
title="Bucket not found"
description="The bucket you're looking for doesn't exist or you don't have access."
/>
</div>
);
}
const confirmDelete = async () => {
setDeleting(true);
try {
await deleteMutation.mutateAsync(bucket.name);
toast.success('Bucket deleted');
navigate('/buckets');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Delete failed');
setDeleting(false);
}
};
return (
<div className="space-y-6 px-7 py-6">
{/* Info */}
<section className="rounded-xl border border-[var(--border)] bg-[var(--card)]">
<header className="flex items-center gap-2 border-b border-[var(--border)] px-5 py-3">
<Info className="h-4 w-4 text-[var(--primary)]" />
<h2 className="text-[15px] font-semibold">Bucket info</h2>
</header>
<dl className="grid grid-cols-1 gap-x-6 gap-y-4 px-5 py-5 sm:grid-cols-2">
<Field label="Name" value={<span className="font-mono text-[13.5px]">{bucket.name}</span>} />
<Field label="Region" value={bucket.region ?? '—'} />
<Field label="Created" value={formatDate(bucket.creationDate)} />
<Field label="Objects" value={bucket.objectCount != null ? bucket.objectCount.toLocaleString() : '—'} />
<Field label="Size" value={formatBytes(bucket.size)} />
<Field
label="Website"
value={
<Badge variant={bucket.websiteAccess ? 'success' : 'neutral'}>
{bucket.websiteAccess ? 'Enabled' : 'Disabled'}
</Badge>
}
/>
</dl>
</section>
{/* Danger zone */}
<section className="rounded-xl border border-[var(--danger-border)] bg-[var(--card)]">
<header className="border-b border-[var(--danger-border)] px-5 py-3">
<h2 className="text-[15px] font-semibold text-[var(--destructive)]">Danger zone</h2>
<p className="mt-0.5 text-[13.5px] text-[var(--muted-foreground)]">
Destructive actions for this bucket.
</p>
</header>
<div className="flex items-center justify-between gap-4 px-5 py-4">
<div className="min-w-0">
<div className="text-[14px] font-medium">Delete bucket</div>
<div className="text-[13.5px] text-[var(--muted-foreground)]">
All objects in this bucket will be permanently removed.
</div>
</div>
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
Delete bucket
</Button>
</div>
</section>
<DangerousConfirmDialog
open={deleteOpen}
onOpenChange={(o) => {
if (!o && !deleting) setDeleteOpen(false);
}}
title={`Delete bucket "${bucket.name}"?`}
description="This action cannot be undone."
confirmationText={bucket.name}
confirmLabel="Delete bucket"
loading={deleting}
onConfirm={confirmDelete}
/>
</div>
);
}
function Field({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div>
<dt className="text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--muted-foreground)]">{label}</dt>
<dd className="mt-1 text-[14px]">{value}</dd>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, Globe } from 'lucide-react';
import { useBuckets } from '@/hooks/useApi';
import { bucketsApi } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { EmptyState } from '@/components/ui/empty-state';
import { toast } from 'sonner';
export function BucketWebsite() {
const { bucketName = '' } = useParams<{ bucketName: string }>();
const queryClient = useQueryClient();
const { data: buckets = [], isLoading } = useBuckets();
const bucket = buckets.find((b) => b.name === bucketName);
const [enabled, setEnabled] = useState(false);
const [indexDocument, setIndexDocument] = useState('index.html');
const [errorDocument, setErrorDocument] = useState('');
const [saving, setSaving] = useState(false);
// Sync local form state whenever the underlying bucket changes.
useEffect(() => {
if (!bucket) return;
setEnabled(bucket.websiteAccess);
setIndexDocument(bucket.websiteConfig?.indexDocument ?? 'index.html');
setErrorDocument(bucket.websiteConfig?.errorDocument ?? '');
}, [bucket?.name, bucket?.websiteAccess, bucket?.websiteConfig?.indexDocument, bucket?.websiteConfig?.errorDocument]);
if (isLoading) {
return <div className="px-7 py-6 text-[13.5px] text-[var(--muted-foreground)]">Loading</div>;
}
if (!bucket) {
return (
<div className="px-7 py-6">
<EmptyState
icon={<AlertTriangle />}
tone="neutral"
title="Bucket not found"
description="The bucket you're looking for doesn't exist or you don't have access."
/>
</div>
);
}
const wasEnabled = bucket.websiteAccess;
const disabling = wasEnabled && !enabled;
const handleReset = () => {
setEnabled(bucket.websiteAccess);
setIndexDocument(bucket.websiteConfig?.indexDocument ?? 'index.html');
setErrorDocument(bucket.websiteConfig?.errorDocument ?? '');
};
const handleSave = async () => {
setSaving(true);
try {
await bucketsApi.updateBucketWebsite(bucketName, {
enabled,
indexDocument: enabled ? indexDocument : undefined,
errorDocument: enabled && errorDocument ? errorDocument : undefined,
});
await queryClient.invalidateQueries({ queryKey: ['buckets'] });
toast.success(disabling ? 'Website disabled' : 'Website configuration updated');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Save failed');
} finally {
setSaving(false);
}
};
const saveDisabled = saving || (enabled && !indexDocument);
return (
<div className="px-7 py-6">
<section className="rounded-xl border border-[var(--border)] bg-[var(--card)]">
<header className="flex items-center gap-2 border-b border-[var(--border)] px-5 py-3">
<Globe className="h-4 w-4 text-[var(--primary)]" />
<h2 className="text-[15px] font-semibold">Static website hosting</h2>
</header>
<div className="space-y-6 px-5 py-5">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-[14px] font-medium">Website access</p>
<p className="mt-0.5 text-[12.5px] text-[var(--muted-foreground)]">
Allow public HTTP access to bucket objects
</p>
</div>
<div className="flex items-center gap-3">
<Badge variant={enabled ? 'primary' : 'neutral'}>
{enabled ? 'Enabled' : 'Disabled'}
</Badge>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
</div>
{enabled && (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-[13.5px] font-medium">
Index document <span className="text-[var(--destructive)]">*</span>
</label>
<Input
value={indexDocument}
onChange={(e) => setIndexDocument(e.target.value)}
placeholder="index.html"
/>
<p className="text-[12.5px] text-[var(--muted-foreground)]">
The file served when a directory is requested (e.g. index.html)
</p>
</div>
<div className="space-y-2">
<label className="text-[13.5px] font-medium">Error document</label>
<Input
value={errorDocument}
onChange={(e) => setErrorDocument(e.target.value)}
placeholder="404.html (optional)"
/>
<p className="text-[12.5px] text-[var(--muted-foreground)]">
The file served when an object is not found (optional)
</p>
</div>
</div>
)}
</div>
<footer className="flex justify-end gap-2 border-t border-[var(--border)] bg-[var(--surface-sunken)] px-5 py-3">
<Button variant="secondary" onClick={handleReset} disabled={saving}>Reset</Button>
<Button
onClick={handleSave}
variant={disabling ? 'destructive' : 'primary'}
disabled={saveDisabled}
>
{saving ? 'Saving…' : disabling ? 'Disable website' : 'Save changes'}
</Button>
</footer>
</section>
</div>
);
}
+53 -252
View File
@@ -1,289 +1,90 @@
import { useState, useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import { Header } from '@/components/layout/header';
import { useBuckets, useCreateBucket, useDeleteBucket, useGrantBucketPermission } from '@/hooks/useApi';
import { useBucketObjects } from '@/hooks/useBucketObjects';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus } from 'lucide-react';
import { useBuckets, useCreateBucket, useDeleteBucket } from '@/hooks/useApi';
import { BucketListView } from '@/components/buckets/BucketListView';
import { ObjectBrowserView } from '@/components/buckets/ObjectBrowserView';
import { CreateBucketDialog } from '@/components/buckets/CreateBucketDialog';
import { DeleteBucketDialog } from '@/components/buckets/DeleteBucketDialog';
import { BucketSettingsDialog } from '@/components/buckets/BucketSettingsDialog';
import { BucketWebsiteDialog } from '@/components/buckets/BucketWebsiteDialog';
import { DangerousConfirmDialog } from '@/components/ui/dangerous-confirm-dialog';
import { PageHeader } from '@/components/ui/page-header';
import { Button } from '@/components/ui/button';
import type { Bucket } from '@/types';
import { toast } from 'sonner';
import { bucketsApi } from '@/lib/api';
export function Buckets() {
const [searchParams, setSearchParams] = useSearchParams();
// Bucket state
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState('');
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [deleteBucketDialogOpen, setDeleteBucketDialogOpen] = useState(false);
const [selectedBucket, setSelectedBucket] = useState<Bucket | null>(null);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const [settingsBucket, setSettingsBucket] = useState<Bucket | null>(null);
const [websiteDialogOpen, setWebsiteDialogOpen] = useState(false);
const [websiteBucket, setWebsiteBucket] = useState<Bucket | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Bucket | null>(null);
const [deleting, setDeleting] = useState(false);
// Object browser state - initialize from URL params
const [viewingBucket, setViewingBucket] = useState<string | null>(searchParams.get('bucket'));
const [currentPath, setCurrentPath] = useState<string>(searchParams.get('prefix') || '');
const [objectSearchQuery, setObjectSearchQuery] = useState('');
const [initialPageToken, setInitialPageToken] = useState<string | undefined>(
searchParams.get('page') || undefined
);
const [initialItemsPerPage, setInitialItemsPerPage] = useState<number>(
parseInt(searchParams.get('limit') || '25', 10)
);
const { data: buckets = [], isLoading } = useBuckets();
const createMutation = useCreateBucket();
const deleteMutation = useDeleteBucket();
// Sync URL params with state on mount and when URL changes
useEffect(() => {
const bucketParam = searchParams.get('bucket');
const prefixParam = searchParams.get('prefix') || '';
const pageParam = searchParams.get('page') || undefined;
const limitParam = parseInt(searchParams.get('limit') || '25', 10);
if (bucketParam !== viewingBucket) {
setViewingBucket(bucketParam);
}
if (prefixParam !== currentPath) {
setCurrentPath(prefixParam);
}
setInitialPageToken(pageParam);
setInitialItemsPerPage(limitParam);
}, [searchParams]);
// Custom hooks
const queryClient = useQueryClient();
const { data: buckets = [], isLoading: bucketsLoading } = useBuckets();
const createBucketMutation = useCreateBucket();
const deleteBucketMutation = useDeleteBucket();
const grantPermissionMutation = useGrantBucketPermission();
const {
objects,
isLoading: objectsLoading,
isRefreshing,
isNavigating,
isTruncated,
nextContinuationToken,
itemsPerPage,
setItemsPerPage,
uploadFiles,
uploadTasks,
deleteObject,
deleteMultipleObjects,
createDirectory,
fetchObjects
} = useBucketObjects(
viewingBucket,
currentPath
);
const handleViewBucket = (bucketName: string) => {
setViewingBucket(bucketName);
setCurrentPath('');
setObjectSearchQuery('');
setSearchParams({ bucket: bucketName });
};
const handleBackToBuckets = () => {
setViewingBucket(null);
setCurrentPath('');
setObjectSearchQuery('');
setSearchParams({});
};
const handleNavigateToFolder = (path: string) => {
setCurrentPath(path);
if (viewingBucket) {
const params: Record<string, string> = { bucket: viewingBucket };
if (path) {
params.prefix = path;
}
// Reset pagination when navigating to a new folder
setSearchParams(params);
}
};
const handlePageChange = (token?: string) => {
fetchObjects(token);
// Update URL with page token
if (viewingBucket) {
const params: Record<string, string> = { bucket: viewingBucket };
if (currentPath) {
params.prefix = currentPath;
}
if (token) {
params.page = token;
}
if (itemsPerPage !== 25) {
params.limit = itemsPerPage.toString();
}
setSearchParams(params);
}
};
const handleItemsPerPageChange = (count: number) => {
setItemsPerPage(count);
// Update URL with new limit
if (viewingBucket) {
const params: Record<string, string> = { bucket: viewingBucket };
if (currentPath) {
params.prefix = currentPath;
}
if (count !== 25) {
params.limit = count.toString();
}
setSearchParams(params);
}
};
const handleOpenSettings = (bucket: Bucket) => {
setSettingsBucket(bucket);
setSettingsDialogOpen(true);
};
const handleOpenWebsiteSettings = (bucket: Bucket) => {
setWebsiteBucket(bucket);
setWebsiteDialogOpen(true);
};
const handleSaveWebsite = async (
bucketName: string,
payload: { enabled: boolean; indexDocument?: string; errorDocument?: string }
): Promise<boolean> => {
try {
await bucketsApi.updateBucketWebsite(bucketName, payload);
queryClient.invalidateQueries({ queryKey: ['buckets'] });
toast.success('Website configuration updated');
return true;
} catch {
return false;
}
};
const handleRefreshObjects = async () => {
if (isRefreshing) return;
try {
await fetchObjects(undefined, true);
toast.success('Objects refreshed successfully');
} catch (error) {
console.error('Refresh error:', error);
}
};
// Wrapper functions for mutations to match dialog APIs
const createBucket = async (name: string, region?: string) => {
try {
await createBucketMutation.mutateAsync({ name, region });
await createMutation.mutateAsync({ name, region });
toast.success(`Bucket "${name}" created`);
return true;
} catch (error) {
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Create failed');
return false;
}
};
const deleteBucket = async (name: string) => {
const confirmDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
await deleteBucketMutation.mutateAsync(name);
return true;
} catch (error) {
return false;
await deleteMutation.mutateAsync(deleteTarget.name);
toast.success('Bucket deleted');
setDeleteTarget(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Delete failed');
} finally {
setDeleting(false);
}
};
const grantPermission = async (
bucketName: string,
accessKeyId: string,
permissions: { read: boolean; write: boolean; owner: boolean }
) => {
try {
await grantPermissionMutation.mutateAsync({ bucketName, accessKeyId, permissions });
return true;
} catch (error) {
return false;
}
};
// If viewing a bucket's objects, show the object browser view
if (viewingBucket) {
return (
<ObjectBrowserView
bucketName={viewingBucket}
objects={objects}
currentPath={currentPath}
searchQuery={objectSearchQuery}
isLoading={objectsLoading}
isTruncated={isTruncated}
nextContinuationToken={nextContinuationToken}
itemsPerPage={itemsPerPage}
onSearchChange={setObjectSearchQuery}
onNavigateToFolder={handleNavigateToFolder}
onBackToBuckets={handleBackToBuckets}
onUploadFiles={uploadFiles}
uploadTasks={uploadTasks}
onDeleteObject={deleteObject}
onDeleteMultipleObjects={deleteMultipleObjects}
onCreateDirectory={createDirectory}
onRefresh={handleRefreshObjects}
onPageChange={handlePageChange}
onItemsPerPageChange={handleItemsPerPageChange}
isRefreshing={isRefreshing}
isNavigating={isNavigating}
initialPageToken={initialPageToken}
initialItemsPerPage={initialItemsPerPage}
/>
);
}
// Default view: show buckets list
return (
<div>
<Header title="Buckets" />
<PageHeader
title="Buckets"
subtitle={`${buckets.length} bucket${buckets.length === 1 ? '' : 's'}`}
actions={
<Button onClick={() => setCreateOpen(true)}>
<Plus /> Create bucket
</Button>
}
/>
<div className="p-4 sm:p-6">
<BucketListView
buckets={buckets}
searchQuery={searchQuery}
isLoading={bucketsLoading}
isLoading={isLoading}
onSearchChange={setSearchQuery}
onViewBucket={handleViewBucket}
onOpenSettings={handleOpenSettings}
onWebsiteSettings={handleOpenWebsiteSettings}
onCreateBucket={() => setCreateDialogOpen(true)}
onDeleteBucket={(bucket) => {
setSelectedBucket(bucket);
setDeleteBucketDialogOpen(true);
}}
onViewBucket={(name) => navigate(`/buckets/${name}/objects`)}
onOpenSettings={(b) => navigate(`/buckets/${b.name}/settings`)}
onWebsiteSettings={(b) => navigate(`/buckets/${b.name}/website`)}
onDeleteBucket={(b) => setDeleteTarget(b)}
/>
</div>
{/* Dialogs */}
<CreateBucketDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
open={createOpen}
onOpenChange={setCreateOpen}
onCreateBucket={createBucket}
/>
<DeleteBucketDialog
open={deleteBucketDialogOpen}
onOpenChange={setDeleteBucketDialogOpen}
bucket={selectedBucket}
onDeleteBucket={deleteBucket}
/>
<BucketSettingsDialog
open={settingsDialogOpen}
onOpenChange={setSettingsDialogOpen}
bucket={settingsBucket}
onGrantPermission={grantPermission}
/>
<BucketWebsiteDialog
open={websiteDialogOpen}
onOpenChange={setWebsiteDialogOpen}
bucket={websiteBucket}
onSave={handleSaveWebsite}
<DangerousConfirmDialog
open={!!deleteTarget}
onOpenChange={(o) => !o && setDeleteTarget(null)}
title={deleteTarget ? `Delete bucket "${deleteTarget.name}"?` : ''}
description="All objects in this bucket will be permanently removed."
confirmationText={deleteTarget?.name ?? ''}
confirmLabel="Delete bucket"
loading={deleting}
onConfirm={confirmDelete}
/>
</div>
);
+5 -5
View File
@@ -1,5 +1,5 @@
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {Header} from '@/components/layout/header';
import {PageHeader} from '@/components/ui/page-header';
import {formatBytes} from '@/lib/file-utils';
import {Activity, AlertCircle, CheckCircle2, Clock, Cpu, Database, Info, Network, Server, XCircle,} from 'lucide-react';
import {useQuery} from '@tanstack/react-query';
@@ -83,7 +83,7 @@ export function Cluster() {
if (isLoading) {
return (
<div>
<Header title="Cluster" />
<PageHeader title="Cluster" />
<div className="p-4 sm:p-6 flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"></div>
@@ -96,7 +96,7 @@ export function Cluster() {
return (
<div>
<Header title="Cluster Management" />
<PageHeader title="Cluster management" subtitle="Node layout, partitions, and health" />
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
{/* Cluster Health Overview */}
<div className="grid gap-3 sm:gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
@@ -215,7 +215,7 @@ export function Cluster() {
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 pt-2">
<div>
<div className="text-xs text-muted-foreground">Status</div>
<Badge variant={node.isUp ? 'default' : 'destructive'} className="mt-1">
<Badge variant={node.isUp ? 'primary' : 'danger'} className="mt-1">
{nodeStatus.label}
</Badge>
</div>
@@ -393,7 +393,7 @@ export function Cluster() {
<div className="text-xs text-muted-foreground mb-2">Garage Features</div>
<div className="flex flex-wrap gap-2">
{(info as LocalNodeInfo).garageFeatures!.map((feature) => (
<Badge key={feature} variant="secondary">
<Badge key={feature} variant="neutral">
{feature}
</Badge>
))}
+213 -220
View File
@@ -1,244 +1,237 @@
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {Header} from '@/components/layout/header';
import {formatBytes} from '@/lib/file-utils';
import {AlertCircle, Database, FolderOpen, HardDrive, Server, Zap} from 'lucide-react';
import {BucketUsageChart} from '@/components/charts/BucketUsageChart';
import {useDashboardData} from '@/hooks/useApi';
import type {ClusterHealth} from '@/types';
import { AlertCircle, Database, FolderOpen, HardDrive, Server, Zap } from 'lucide-react';
import { PageHeader } from '@/components/ui/page-header';
import { IconTile } from '@/components/ui/icon-tile';
import { EmptyState } from '@/components/ui/empty-state';
import { BucketUsageChart } from '@/components/charts/BucketUsageChart';
import { useDashboardData } from '@/hooks/useApi';
import { formatBytes } from '@/lib/file-utils';
import type { ClusterHealth } from '@/types';
type StatTone = 'primary' | 'destructive' | 'neutral';
type HealthLabel = 'Healthy' | 'Degraded' | 'Unhealthy' | 'Unknown';
function deriveHealth(health: ClusterHealth | null): { label: HealthLabel; tone: StatTone } {
if (!health) return { label: 'Unknown', tone: 'neutral' };
if (
health.storageNodesUp === health.storageNodes &&
health.partitionsAllOk === health.partitions &&
health.connectedNodes === health.knownNodes
) return { label: 'Healthy', tone: 'primary' };
if (health.storageNodesUp > 0 && health.partitionsQuorum > 0) return { label: 'Degraded', tone: 'primary' };
return { label: 'Unhealthy', tone: 'destructive' };
}
export function Dashboard() {
const { metrics: metricsQuery, buckets: bucketsQuery, health: healthQuery, isLoading } = useDashboardData();
const metrics = metricsQuery.data;
const buckets = bucketsQuery.data || [];
const clusterHealth = healthQuery.data;
const getHealthStatus = (health: ClusterHealth | null) => {
if (!health) return { color: 'text-gray-500', label: 'Unknown', icon: AlertCircle };
if (
health.storageNodesUp === health.storageNodes &&
health.partitionsAllOk === health.partitions &&
health.connectedNodes === health.knownNodes
) {
return { color: 'text-green-500', label: 'Healthy', icon: Zap };
}
if (
health.storageNodesUp > 0 &&
health.partitionsQuorum > 0
) {
return { color: 'text-yellow-500', label: 'Degraded', icon: AlertCircle };
}
return { color: 'text-red-500', label: 'Unhealthy', icon: AlertCircle };
};
const healthStatus = getHealthStatus(clusterHealth ?? null);
if (isLoading) {
return (
<div>
<Header title="Dashboard" />
<div className="p-4 sm:p-6 flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"></div>
<p className="mt-2 text-sm text-muted-foreground">Loading dashboard...</p>
</div>
</div>
</div>
);
}
const buckets = bucketsQuery.data ?? [];
const clusterHealth = healthQuery.data ?? null;
const health = deriveHealth(clusterHealth);
return (
<div>
<Header title="Dashboard" />
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
{/* Top Stats Grid */}
<div className="grid gap-3 sm:gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Storage</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{metrics ? formatBytes(metrics.totalSize) : '---'}
</div>
<p className="text-xs text-muted-foreground">
Across {metrics?.bucketCount || 0} buckets
</p>
</CardContent>
</Card>
<PageHeader
title="Dashboard"
subtitle={
clusterHealth
? `${clusterHealth.connectedNodes}/${clusterHealth.knownNodes} nodes connected`
: 'Loading cluster status…'
}
/>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Objects</CardTitle>
<FolderOpen className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{metrics?.objectCount.toLocaleString() || '---'}
</div>
<p className="text-xs text-muted-foreground">
Files and folders
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Buckets</CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{metrics?.bucketCount || '---'}</div>
<p className="text-xs text-muted-foreground">
Active storage buckets
</p>
</CardContent>
</Card>
{isLoading ? (
<div className="flex min-h-[360px] items-center justify-center">
<div className="text-center">
<div className="inline-block h-6 w-6 animate-spin rounded-full border-2 border-[var(--primary)] border-r-transparent" />
<p className="mt-3 text-[13.5px] text-[var(--muted-foreground)]">Loading dashboard</p>
</div>
</div>
) : (
<div className="space-y-6 px-6 py-5">
{/* KPI row */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
label="Total storage"
value={metrics ? formatBytes(metrics.totalSize) : '—'}
sub={`across ${metrics?.bucketCount ?? 0} bucket${metrics?.bucketCount === 1 ? '' : 's'}`}
icon={<HardDrive />}
/>
<StatCard
label="Objects"
value={metrics?.objectCount.toLocaleString() ?? '—'}
sub="files and folders"
icon={<FolderOpen />}
/>
<StatCard
label="Buckets"
value={metrics?.bucketCount.toLocaleString() ?? '—'}
sub="active storage buckets"
icon={<Database />}
/>
<StatCard
label="Cluster"
value={health.label}
valueTone={health.tone}
sub={
clusterHealth
? `${clusterHealth.storageNodesUp}/${clusterHealth.storageNodes} storage nodes`
: '—'
}
icon={health.label === 'Unhealthy' ? <AlertCircle /> : <Zap />}
iconTone={health.tone}
/>
</div>
{/* Cluster Status */}
<div className="grid gap-3 sm:gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Cluster Status</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<div className={`text-2xl font-bold ${healthStatus.color}`}>
{healthStatus.label}
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">
{clusterHealth?.connectedNodes || 0}/{clusterHealth?.knownNodes || 0} nodes connected
</p>
</CardContent>
</Card>
{/* Cluster row */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
<StatCard
label="Storage nodes"
value={clusterHealth ? `${clusterHealth.storageNodesUp}/${clusterHealth.storageNodes}` : '—'}
sub="healthy"
icon={<Server />}
/>
<StatCard
label="Partitions"
value={clusterHealth ? `${clusterHealth.partitionsAllOk}/${clusterHealth.partitions}` : '—'}
sub="healthy"
icon={<Zap />}
/>
<StatCard
label="Connected nodes"
value={clusterHealth ? `${clusterHealth.connectedNodes}/${clusterHealth.knownNodes}` : '—'}
sub="cluster membership"
icon={<Server />}
/>
</div>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Storage Nodes</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{clusterHealth?.storageNodesUp || 0}/{clusterHealth?.storageNodes || 0}
</div>
<p className="text-xs text-muted-foreground">
Healthy storage nodes
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Partitions</CardTitle>
<Zap className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{clusterHealth?.partitionsAllOk || 0}/{clusterHealth?.partitions || 0}
</div>
<p className="text-xs text-muted-foreground">
Healthy partitions
</p>
</CardContent>
</Card>
</div>
{/* Charts Row 1 */}
<div className="grid gap-3 sm:gap-4 grid-cols-1 lg:grid-cols-2">
{/* Bucket Usage Chart */}
<Card>
<CardHeader>
<CardTitle>Storage Usage by Bucket</CardTitle>
<CardDescription>Distribution of storage across buckets</CardDescription>
</CardHeader>
<CardContent>
{/* Charts */}
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<Card title="Storage usage by bucket" description="Distribution of storage across buckets">
{metrics?.usageByBucket && metrics.usageByBucket.length > 0 ? (
<BucketUsageChart data={metrics.usageByBucket} />
) : (
<div className="text-center py-8 text-muted-foreground">No data available</div>
<div className="py-8 text-center text-[13.5px] text-[var(--muted-foreground)]">No data available</div>
)}
</CardContent>
</Card>
</Card>
{/* Bucket Details Table */}
<Card>
<CardHeader>
<CardTitle>Storage Usage by Bucket (Table)</CardTitle>
<CardDescription>Detailed breakdown of storage across all buckets</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{metrics?.usageByBucket && metrics.usageByBucket.length > 0 ? (
metrics.usageByBucket.map((bucket) => (
<div key={bucket.bucketName} className="space-y-2">
<div className="flex items-center justify-between text-sm flex-wrap gap-2">
<span className="font-medium">{bucket.bucketName}</span>
<div className="flex items-center gap-2 sm:gap-4 text-xs sm:text-sm">
<span className="text-muted-foreground">
{bucket.objectCount.toLocaleString()} objects
</span>
<span className="font-medium">{formatBytes(bucket.size)}</span>
<span className="text-muted-foreground w-12 text-right">
{bucket.percentage.toFixed(1)}%
</span>
</div>
</div>
<div className="h-2 rounded-full bg-secondary overflow-hidden">
<div
className="h-full bg-primary transition-all"
style={{ width: `${bucket.percentage}%` }}
/>
</div>
<Card title="Breakdown" description="Detailed breakdown of storage across all buckets">
{metrics?.usageByBucket && metrics.usageByBucket.length > 0 ? (
<div className="space-y-4">
{metrics.usageByBucket.map((bucket) => (
<div key={bucket.bucketName} className="space-y-1.5">
<div className="flex items-center justify-between gap-2 text-[13.5px]">
<span className="truncate font-medium">{bucket.bucketName}</span>
<div className="flex items-center gap-3 text-[13px] text-[var(--muted-foreground)]">
<span>{bucket.objectCount.toLocaleString()} objects</span>
<span className="font-medium text-[var(--foreground)]">{formatBytes(bucket.size)}</span>
<span className="w-10 text-right">{bucket.percentage.toFixed(1)}%</span>
</div>
))
) : (
<div className="text-center py-8 text-muted-foreground">No buckets available</div>
)}
</div>
</CardContent>
</Card>
</div>
{/* Recent Buckets */}
<Card>
<CardHeader>
<CardTitle>Recent Buckets</CardTitle>
<CardDescription>Your most recently created buckets</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{buckets.slice(0, 5).map((bucket) => (
<div
key={bucket.name}
className="flex items-center justify-between py-3 border-b last:border-0 gap-3"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
<Database className="h-5 w-5 text-primary" />
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-[var(--muted)]">
<div
className="h-full bg-[var(--primary)] transition-all"
style={{ width: `${bucket.percentage}%` }}
/>
</div>
</div>
<div className="min-w-0">
<p className="font-medium truncate">{bucket.name}</p>
<p className="text-xs sm:text-sm text-muted-foreground">
))}
</div>
) : (
<div className="py-8 text-center text-[13.5px] text-[var(--muted-foreground)]">No buckets available</div>
)}
</Card>
</div>
{/* Recent buckets */}
<Card title="Recent buckets" description="Your most recently created buckets">
{buckets.length === 0 ? (
<EmptyState
icon={<Database />}
title="No buckets yet"
description="Create your first bucket from the Buckets page to start storing objects."
tone="neutral"
/>
) : (
<ul className="divide-y divide-[var(--border)]">
{buckets.slice(0, 5).map((bucket) => (
<li key={bucket.name} className="flex items-center gap-3 py-3">
<IconTile icon={<Database />} tone="primary" size="md" />
<div className="min-w-0 flex-1">
<p className="truncate text-[14px] font-medium">{bucket.name}</p>
<p className="truncate text-[12.5px] text-[var(--muted-foreground)]">
Created {new Date(bucket.creationDate).toLocaleDateString()}
</p>
</div>
</div>
<div className="text-right flex-shrink-0">
<p className="font-medium text-sm sm:text-base">{bucket.objectCount?.toLocaleString()} objects</p>
<p className="text-xs sm:text-sm text-muted-foreground">
{bucket.size ? formatBytes(bucket.size) : '---'}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
<div className="text-right">
<p className="text-[14px] font-medium">{bucket.objectCount?.toLocaleString() ?? '—'} objects</p>
<p className="text-[12.5px] text-[var(--muted-foreground)]">
{bucket.size ? formatBytes(bucket.size) : '—'}
</p>
</div>
</li>
))}
</ul>
)}
</Card>
</div>
)}
</div>
);
}
function StatCard({
label,
value,
sub,
icon,
iconTone = 'primary',
valueTone = 'neutral',
}: {
label: string;
value: string;
sub?: string;
icon: React.ReactNode;
iconTone?: StatTone;
valueTone?: StatTone;
}) {
const valueColor =
valueTone === 'primary'
? 'text-[var(--primary)]'
: valueTone === 'destructive'
? 'text-[var(--destructive)]'
: 'text-[var(--foreground)]';
return (
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-4">
<div className="flex items-start justify-between gap-3">
<span className="text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--muted-foreground)]">
{label}
</span>
<IconTile icon={icon} tone={iconTone} size="sm" />
</div>
<div className={`mt-2 text-[26px] font-semibold tracking-[-0.02em] leading-none ${valueColor}`}>
{value}
</div>
{sub && <div className="mt-1.5 text-[13px] text-[var(--muted-foreground)]">{sub}</div>}
</div>
);
}
function Card({
title,
description,
children,
}: {
title: string;
description?: string;
children: React.ReactNode;
}) {
return (
<section className="rounded-xl border border-[var(--border)] bg-[var(--card)]">
<header className="border-b border-[var(--border)] px-5 py-3">
<h2 className="text-[15px] font-semibold">{title}</h2>
{description && <p className="mt-0.5 text-[12.5px] text-[var(--muted-foreground)]">{description}</p>}
</header>
<div className="px-5 py-5">{children}</div>
</section>
);
}
+12
View File
@@ -7,6 +7,10 @@ export default {
],
theme: {
extend: {
fontFamily: {
sans: ['Geist Sans', '-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'system-ui', 'sans-serif'],
mono: ['Geist Mono', 'ui-monospace', '"SF Mono"', 'Menlo', 'Consolas', 'monospace'],
},
colors: {
border: 'var(--border)',
input: 'var(--input)',
@@ -16,6 +20,8 @@ export default {
primary: {
DEFAULT: 'var(--primary)',
foreground: 'var(--primary-foreground)',
soft: 'var(--accent-primary-soft)',
softBorder: 'var(--accent-primary-border)',
},
secondary: {
DEFAULT: 'var(--secondary)',
@@ -24,6 +30,11 @@ export default {
destructive: {
DEFAULT: 'var(--destructive)',
foreground: 'var(--destructive-foreground)',
soft: 'var(--danger-soft)',
softBorder: 'var(--danger-border)',
},
success: {
soft: 'var(--success-soft)',
},
muted: {
DEFAULT: 'var(--muted)',
@@ -41,6 +52,7 @@ export default {
DEFAULT: 'var(--card)',
foreground: 'var(--card-foreground)',
},
sunken: 'var(--surface-sunken)',
},
borderRadius: {
lg: 'var(--radius)',