mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-08-25 03:46:47 +00:00
feat: implement UI redesign with updated button styles and new components
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
This commit is contained in:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user