mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 07:48:13 +00:00
@@ -64,3 +64,6 @@ garage.toml
|
||||
backend/docs/
|
||||
config.yaml
|
||||
docs/**/*.md
|
||||
|
||||
# Superpowers brainstorm / session scratch
|
||||
.superpowers/
|
||||
@@ -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
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)',
|
||||
|
||||
Reference in New Issue
Block a user