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:
Noooste
2026-04-19 22:11:22 +02:00
parent 38cc1dded0
commit bfed48421b
46 changed files with 2418 additions and 1671 deletions
@@ -99,7 +99,7 @@ export function BasicLoginForm({ showOIDC = false, config }: BasicLoginFormProps
</div>
<Button
type="button"
variant="outline"
variant="secondary"
className="w-full"
onClick={loginOIDC}
>
@@ -9,7 +9,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { FolderIcon, Globe, Loader2, MoreVertical, Plus, Search, Settings, Trash2 } from 'lucide-react';
import { FolderIcon, Globe, Loader2, MoreVertical, Search, Settings, Trash2 } from 'lucide-react';
import { formatBytes } from '@/lib/file-utils';
import { formatDate } from '@/lib/utils';
import type { Bucket } from '@/types';
@@ -21,7 +21,6 @@ interface BucketListViewProps {
onSearchChange: (query: string) => void;
onViewBucket: (bucketName: string) => void;
onOpenSettings: (bucket: Bucket) => void;
onCreateBucket: () => void;
onDeleteBucket: (bucket: Bucket) => void;
onWebsiteSettings: (bucket: Bucket) => void;
}
@@ -33,7 +32,6 @@ export function BucketListView({
onSearchChange,
onViewBucket,
onOpenSettings,
onCreateBucket,
onDeleteBucket,
onWebsiteSettings,
}: BucketListViewProps) {
@@ -44,20 +42,14 @@ export function BucketListView({
return (
<div className="space-y-4 sm:space-y-6">
{/* Toolbar */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
<div className="relative flex-1 max-w-full sm:max-w-xs">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search buckets..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-8"
/>
</div>
<Button onClick={onCreateBucket} className="w-full sm:w-auto">
<Plus className="h-4 w-4" />
Create Bucket
</Button>
<div className="relative w-full max-w-xs">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search buckets..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-8"
/>
</div>
{/* Buckets Table */}
@@ -99,14 +91,14 @@ export function BucketListView({
<TableCell className="font-medium max-w-[200px]">
<span className="truncate">{bucket.name}</span>
{bucket.websiteAccess && (
<Badge variant="outline" className="text-xs ml-2">
<Badge variant="neutral" className="text-xs ml-2">
<Globe className="h-3 w-3 mr-1" />
Website
</Badge>
)}
</TableCell>
<TableCell className="hidden sm:table-cell">
<Badge variant="secondary">{bucket.region || 'default'}</Badge>
<Badge variant="neutral">{bucket.region || 'default'}</Badge>
</TableCell>
<TableCell className="hidden md:table-cell">{bucket.objectCount?.toLocaleString() || 0}</TableCell>
<TableCell>{bucket.size ? formatBytes(bucket.size) : '0 B'}</TableCell>
@@ -1,196 +0,0 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectOption } from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useAccessKeys } from '@/hooks/useApi';
import type { Bucket } from '@/types';
import { toast } from 'sonner';
interface BucketSettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
bucket: Bucket | null;
onGrantPermission: (bucketName: string, accessKeyId: string, permissions: { read: boolean; write: boolean; owner: boolean }) => Promise<boolean>;
}
export function BucketSettingsDialog({ open, onOpenChange, bucket, onGrantPermission }: BucketSettingsDialogProps) {
const { data: availableKeys = [] } = useAccessKeys();
const [selectedAccessKey, setSelectedAccessKey] = useState<string>('');
const [permissionRead, setPermissionRead] = useState(false);
const [permissionWrite, setPermissionWrite] = useState(false);
const [permissionOwner, setPermissionOwner] = useState(false);
useEffect(() => {
if (open && bucket) {
resetForm();
}
}, [open, bucket]);
const resetForm = () => {
setSelectedAccessKey('');
setPermissionRead(false);
setPermissionWrite(false);
setPermissionOwner(false);
};
const handleAccessKeyChange = (accessKeyId: string) => {
setSelectedAccessKey(accessKeyId);
if (!accessKeyId) {
setPermissionRead(false);
setPermissionWrite(false);
setPermissionOwner(false);
return;
}
const selectedKey = availableKeys.find(key => key.accessKeyId === accessKeyId);
if (selectedKey && bucket) {
const bucketPermission = selectedKey.permissions.find(
perm => perm.bucketName === bucket.name || perm.bucketId === bucket.name
);
if (bucketPermission) {
setPermissionRead(bucketPermission.read);
setPermissionWrite(bucketPermission.write);
setPermissionOwner(bucketPermission.owner);
} else {
setPermissionRead(false);
setPermissionWrite(false);
setPermissionOwner(false);
}
}
};
const handleGrantPermission = async () => {
if (!bucket || !selectedAccessKey) {
toast.error('Please select an access key');
return;
}
if (!permissionRead && !permissionWrite && !permissionOwner) {
toast.error('Please select at least one permission');
return;
}
const success = await onGrantPermission(bucket.name, selectedAccessKey, {
read: permissionRead,
write: permissionWrite,
owner: permissionOwner,
});
if (success) {
resetForm();
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Bucket Settings - {bucket?.name}</DialogTitle>
<DialogDescription>
Grant access key permissions for this bucket
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Select Access Key</label>
<Select
value={selectedAccessKey}
onChange={(value) => handleAccessKeyChange(value)}
>
<SelectOption value="">-- Select an access key --</SelectOption>
{availableKeys.map((key) => (
<SelectOption key={key.accessKeyId} value={key.accessKeyId}>
{key.name} ({key.accessKeyId})
</SelectOption>
))}
</Select>
<p className="text-xs text-muted-foreground">
Choose which access key should have permissions on this bucket. Current permissions will be displayed when selected.
</p>
</div>
<div className="space-y-3">
<label className="text-sm font-medium">Permissions</label>
<div className="space-y-3 border rounded-lg p-4">
<div className="flex items-start space-x-3">
<Checkbox
id="permission-read"
checked={permissionRead}
onCheckedChange={(checked) => setPermissionRead(checked as boolean)}
/>
<div className="flex-1">
<label
htmlFor="permission-read"
className="text-sm font-medium leading-none cursor-pointer"
>
Read
</label>
<p className="text-xs text-muted-foreground mt-1">
Allows reading objects from the bucket (GetObject, HeadObject, ListObjects)
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<Checkbox
id="permission-write"
checked={permissionWrite}
onCheckedChange={(checked) => setPermissionWrite(checked as boolean)}
/>
<div className="flex-1">
<label
htmlFor="permission-write"
className="text-sm font-medium leading-none cursor-pointer"
>
Write
</label>
<p className="text-xs text-muted-foreground mt-1">
Allows writing and deleting objects in the bucket (PutObject, DeleteObject)
</p>
</div>
</div>
<div className="flex items-start space-x-3">
<Checkbox
id="permission-owner"
checked={permissionOwner}
onCheckedChange={(checked) => setPermissionOwner(checked as boolean)}
/>
<div className="flex-1">
<label
htmlFor="permission-owner"
className="text-sm font-medium leading-none cursor-pointer"
>
Owner
</label>
<p className="text-xs text-muted-foreground mt-1">
Allows managing bucket settings and policies (DeleteBucket, PutBucketPolicy)
</p>
</div>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleGrantPermission} disabled={!selectedAccessKey}>
Grant Permission
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,133 +0,0 @@
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import type { Bucket } from '@/types';
interface BucketWebsiteDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
bucket: Bucket | null;
onSave: (
bucketName: string,
payload: { enabled: boolean; indexDocument?: string; errorDocument?: string }
) => Promise<boolean>;
}
export function BucketWebsiteDialog({
open,
onOpenChange,
bucket,
onSave,
}: BucketWebsiteDialogProps) {
const [enabled, setEnabled] = useState(false);
const [indexDocument, setIndexDocument] = useState('index.html');
const [errorDocument, setErrorDocument] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (open && bucket) {
setEnabled(bucket.websiteAccess);
setIndexDocument(bucket.websiteConfig?.indexDocument ?? 'index.html');
setErrorDocument(bucket.websiteConfig?.errorDocument ?? '');
}
}, [open, bucket]);
const handleSave = async () => {
if (!bucket) return;
setSaving(true);
const success = await onSave(bucket.name, {
enabled,
indexDocument: enabled ? indexDocument : undefined,
errorDocument: enabled && errorDocument ? errorDocument : undefined,
});
setSaving(false);
if (success) onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Website Hosting {bucket?.name}</DialogTitle>
<DialogDescription>
Configure this bucket to serve a static website.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Website access</p>
<p className="text-xs text-muted-foreground mt-0.5">
Allow public HTTP access to bucket objects
</p>
</div>
<div className="flex items-center gap-3">
<Badge variant={enabled ? 'default' : 'secondary'}>
{enabled ? 'Enabled' : 'Disabled'}
</Badge>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
</div>
{enabled && (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">
Index document <span className="text-destructive">*</span>
</label>
<Input
value={indexDocument}
onChange={(e) => setIndexDocument(e.target.value)}
placeholder="index.html"
/>
<p className="text-xs text-muted-foreground">
The file served when a directory is requested (e.g. index.html)
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Error document</label>
<Input
value={errorDocument}
onChange={(e) => setErrorDocument(e.target.value)}
placeholder="404.html (optional)"
/>
<p className="text-xs text-muted-foreground">
The file served when an object is not found (optional)
</p>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleSave}
variant={!enabled && bucket?.websiteAccess ? 'destructive' : 'default'}
disabled={saving || (enabled && !indexDocument)}
>
{saving
? 'Saving...'
: !enabled && bucket?.websiteAccess
? 'Disable Website'
: 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,14 +1,17 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Database } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { IconTile } from '@/components/ui/icon-tile';
import { toast } from 'sonner';
interface CreateBucketDialogProps {
@@ -20,6 +23,8 @@ interface CreateBucketDialogProps {
export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: CreateBucketDialogProps) {
const [bucketName, setBucketName] = useState('');
useEffect(() => { if (!open) setBucketName(''); }, [open]);
const handleCreate = async () => {
if (!bucketName) {
toast.error('Please enter a bucket name');
@@ -37,15 +42,19 @@ export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: Creat
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Bucket</DialogTitle>
<DialogDescription>
Create a new storage bucket for your objects
</DialogDescription>
<IconTile icon={<Database />} tone="primary" size="md" />
<div className="flex-1">
<DialogTitle>Create New Bucket</DialogTitle>
<DialogDescription>
Create a new storage bucket for your objects
</DialogDescription>
</div>
</DialogHeader>
<div className="space-y-4 py-4">
<DialogBody className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Bucket Name</label>
<Input
autoFocus
placeholder="my-bucket-name"
value={bucketName}
onChange={(e) => setBucketName(e.target.value)}
@@ -59,13 +68,13 @@ export function CreateBucketDialog({ open, onOpenChange, onCreateBucket }: Creat
Must be unique and follow DNS naming conventions
</p>
</div>
</div>
<DialogFooter className="space-y-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</DialogBody>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant={!bucketName ? 'default_disabled' : 'default'}
variant="primary"
onClick={handleCreate}
disabled={!bucketName}
>
@@ -1,14 +1,17 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { FolderPlus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { IconTile } from '@/components/ui/icon-tile';
import { toast } from 'sonner';
interface CreateDirectoryDialogProps {
@@ -21,6 +24,8 @@ interface CreateDirectoryDialogProps {
export function CreateDirectoryDialog({ open, onOpenChange, currentPath, onCreateDirectory }: CreateDirectoryDialogProps) {
const [dirName, setDirName] = useState('');
useEffect(() => { if (!open) setDirName(''); }, [open]);
const handleCreate = async () => {
if (!dirName) {
toast.error('Please enter a directory name');
@@ -38,15 +43,19 @@ export function CreateDirectoryDialog({ open, onOpenChange, currentPath, onCreat
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Directory</DialogTitle>
<DialogDescription>
Create a new directory in {currentPath || 'the root'}
</DialogDescription>
<IconTile icon={<FolderPlus />} tone="primary" size="md" />
<div className="flex-1">
<DialogTitle>Create Directory</DialogTitle>
<DialogDescription>
Create a new directory in {currentPath || 'the root'}
</DialogDescription>
</div>
</DialogHeader>
<div className="space-y-4 py-4">
<DialogBody className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Directory Name</label>
<Input
autoFocus
placeholder="my-directory"
value={dirName}
onChange={(e) => setDirName(e.target.value)}
@@ -57,9 +66,9 @@ export function CreateDirectoryDialog({ open, onOpenChange, currentPath, onCreat
}}
/>
</div>
</div>
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={!dirName}>
@@ -1,49 +0,0 @@
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { Bucket } from '@/types';
interface DeleteBucketDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
bucket: Bucket | null;
onDeleteBucket: (name: string) => Promise<boolean>;
}
export function DeleteBucketDialog({ open, onOpenChange, bucket, onDeleteBucket }: DeleteBucketDialogProps) {
const handleDelete = async () => {
if (!bucket) return;
const success = await onDeleteBucket(bucket.name);
if (success) {
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Bucket</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{bucket?.name}"? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,3 +1,4 @@
import { Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -7,6 +8,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { IconTile } from '@/components/ui/icon-tile';
import type { S3Object } from '@/types';
interface DeleteObjectDialogProps {
@@ -27,16 +29,19 @@ export function DeleteObjectDialog({ open, onOpenChange, object, onDeleteObject
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={onOpenChange} size="destructive">
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Object</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{object?.key}"? This action cannot be undone.
</DialogDescription>
<IconTile icon={<Trash2 />} tone="destructive" size="md" />
<div className="flex-1">
<DialogTitle>Delete Object</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{object?.key}"? This action cannot be undone.
</DialogDescription>
</div>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete}>
@@ -2,7 +2,6 @@ import {useState} from 'react';
import {useDropzone} from 'react-dropzone';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Header} from '@/components/layout/header';
import {ObjectsTable} from './ObjectsTable';
import {CreateDirectoryDialog} from './CreateDirectoryDialog';
import {DeleteObjectDialog} from './DeleteObjectDialog';
@@ -170,10 +169,9 @@ export function ObjectBrowserView({
return (
<div>
<Header title={`Objects in ${bucketName}`} />
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
{/* Back Button */}
<Button variant="outline" onClick={onBackToBuckets} className="text-sm sm:text-base">
<Button variant="secondary" onClick={onBackToBuckets} className="text-sm sm:text-base">
<ArrowLeft className="h-4 w-4" />
<span className="hidden sm:inline">Back to Buckets</span>
<span className="sm:hidden">Back</span>
@@ -229,7 +227,7 @@ export function ObjectBrowserView({
<FolderPlus className="h-4 w-4" />
<span className="hidden sm:inline">Add Directory</span>
</Button>
<Button variant="outline" size="icon" onClick={onRefresh} title="Refresh" disabled={isRefreshing}>
<Button variant="secondary" size="icon" onClick={onRefresh} title="Refresh" disabled={isRefreshing}>
<RotateCwIcon className={`h-4 w-4 transition-transform duration-500 ${isRefreshing ? 'animate-spin' : ''}`} />
</Button>
</div>
@@ -1,21 +1,55 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useNavigate, useParams, Link } from 'react-router-dom';
import { objectsApi } from '@/lib/api';
import type { ObjectMetadata } from '@/types';
import { Header } from '@/components/layout/header';
import { Button } from '@/components/ui/button';
import { ArrowLeft, Download, Trash, Copy, File } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { IconTile } from '@/components/ui/icon-tile';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { ArrowLeft, ChevronRight, Copy, Download, File, Loader2, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { formatBytes } from '@/lib/file-utils';
function formatDate(value: string) {
return new Date(value).toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function CardSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)]">
<div className="border-b border-[var(--border)] px-5 py-3.5">
<h3 className="text-[14px] font-semibold tracking-[-0.01em]">{title}</h3>
</div>
{children}
</section>
);
}
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-1 gap-1 px-5 py-3.5 sm:grid-cols-[200px_1fr] sm:gap-4">
<dt className="text-[12.5px] font-medium text-[var(--muted-foreground)]">{label}</dt>
<dd className="text-[13.5px] text-[var(--foreground)] break-words">{children}</dd>
</div>
);
}
export function ObjectDetailsView() {
const navigate = useNavigate();
const { bucketName, '*': encodedObjectKey } = useParams();
// Decode the object key from the URL
const objectKey = encodedObjectKey ? decodeURIComponent(encodedObjectKey) : undefined;
const [metadata, setMetadata] = useState<ObjectMetadata | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
if (!bucketName || !objectKey) {
@@ -23,7 +57,6 @@ export function ObjectDetailsView() {
setIsLoading(false);
return;
}
const fetchMetadata = async () => {
try {
setIsLoading(true);
@@ -32,240 +65,184 @@ export function ObjectDetailsView() {
setMetadata(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load object metadata');
console.error('Failed to fetch object metadata:', err);
} finally {
setIsLoading(false);
}
};
fetchMetadata();
}, [bucketName, objectKey]);
const parentPath = objectKey?.split('/').slice(0, -1).join('/') ?? '';
const fileName = objectKey?.split('/').pop() || objectKey || '';
const backHref = `/buckets/${bucketName}/objects${parentPath ? `?prefix=${encodeURIComponent(parentPath + '/')}` : ''}`;
const pathSegments = parentPath ? parentPath.split('/').filter(Boolean) : [];
const copy = (text: string, label = 'Copied') => {
navigator.clipboard.writeText(text);
toast.success(label);
};
const handleDownload = async () => {
if (!bucketName || !objectKey) return;
try {
const blob = await objectsApi.get(bucketName, objectKey);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = objectKey.split('/').pop() || 'download';
a.download = fileName || 'download';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('Download started');
} catch (err) {
console.error('Download failed:', err);
} catch {
toast.error('Download failed');
}
};
const handleDelete = async () => {
if (!bucketName || !objectKey) return;
if (!confirm(`Are you sure you want to delete "${objectKey}"?`)) {
return;
}
try {
setDeleting(true);
await objectsApi.delete(bucketName, objectKey);
toast.success('Object deleted successfully');
handleBackNavigation();
} catch (err) {
console.error('Delete failed:', err);
toast.success('Object deleted');
navigate(backHref);
} catch {
toast.error('Delete failed');
} finally {
setDeleting(false);
setDeleteOpen(false);
}
};
const handleBackNavigation = () => {
if (!bucketName) return;
// Navigate back to the bucket explorer with the appropriate prefix
// Extract the folder path from the object key (everything before the last /)
const folderPath = objectKey?.split('/').slice(0, -1).join('/') || '';
const prefix = folderPath ? `${folderPath}/` : '';
// Navigate to the bucket view with the correct prefix
navigate(`/buckets?bucket=${encodeURIComponent(bucketName)}${prefix ? `&prefix=${encodeURIComponent(prefix)}` : ''}`);
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success('Copied to clipboard');
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short',
});
};
if (isLoading) {
return (
<div>
<Header title="Object Details" />
<div className="p-4 sm:p-6">
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">Loading object details...</div>
</div>
</div>
<div className="flex h-64 items-center justify-center gap-2 text-[var(--muted-foreground)]">
<Loader2 className="h-4 w-4 animate-spin" /> Loading object details
</div>
);
}
if (error || !metadata) {
return (
<div>
<Header title="Object Details" />
<div className="p-4 sm:p-6">
<Button variant="outline" onClick={handleBackNavigation} className="mb-4">
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<div className="flex items-center justify-center h-64">
<div className="text-red-500">{error || 'Object not found'}</div>
</div>
<div className="px-7 py-6">
<Button variant="secondary" onClick={() => navigate(backHref)} className="mb-4">
<ArrowLeft className="h-4 w-4" /> Back
</Button>
<div className="rounded-xl border border-[var(--danger-border)] bg-[var(--danger-soft)] px-5 py-4 text-[13.5px] text-[var(--destructive)]">
{error || 'Object not found'}
</div>
</div>
);
}
const fileName = objectKey?.split('/').pop() || objectKey || '';
const pathParts = objectKey?.split('/').filter(part => part) || [];
const parentPath = pathParts.slice(0, -1).join('/');
return (
<div>
<Header title={fileName} />
<div className="p-4 sm:p-6 space-y-6">
{/* Back Button and Actions */}
<div className="flex items-center justify-between">
<Button variant="outline" onClick={handleBackNavigation}>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleDownload}>
<Download className="h-4 w-4" />
Download
</Button>
<Button
variant="outline"
className="border-red-500 text-red-500 hover:bg-red-500/5"
onClick={handleDelete}
>
<Trash className="h-4 w-4" />
Delete
</Button>
</div>
</div>
{/* File Name Header */}
<div className="flex items-start gap-3 p-4 border-b border-border bg-card rounded-t-lg">
<div className="mt-1">
<File className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-4 flex-wrap">
<h2 className="text-lg font-medium text-foreground break-all">
{parentPath && (
<span className="text-muted-foreground font-mono">/{parentPath}/</span>
)}
{fileName}
</h2>
<button
onClick={() => copyToClipboard(metadata.key)}
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 shrink-0"
>
<Copy className="h-3 w-3" />
Copy
</button>
</div>
</div>
</div>
{/* Object Details Section */}
<div className="border border-border rounded-lg bg-card">
<div className="p-6 border-b border-border">
<h3 className="text-base font-semibold text-foreground">Object Details</h3>
</div>
<div className="divide-y divide-border">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
<div className="text-sm font-medium text-muted-foreground">Date Created</div>
<div className="sm:col-span-2 text-sm text-foreground">
{formatDate(metadata.lastModified)}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
<div className="text-sm font-medium text-muted-foreground">Type</div>
<div className="sm:col-span-2 text-sm text-foreground">
{metadata.contentType || 'application/octet-stream'}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
<div className="text-sm font-medium text-muted-foreground">Storage Class</div>
<div className="sm:col-span-2 text-sm text-foreground">
{metadata.storageClass || 'Standard'}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-6">
<div className="text-sm font-medium text-muted-foreground">Size</div>
<div className="sm:col-span-2 text-sm text-foreground">
{formatBytes(metadata.size)}
</div>
</div>
</div>
</div>
{/* Custom Metadata Section */}
{metadata.metadata && Object.keys(metadata.metadata).length > 0 && (
<div className="border border-border rounded-lg bg-card">
<div className="p-6 border-b border-border">
<h3 className="text-base font-semibold text-foreground">Custom Metadata</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/30">
<tr className="border-b border-border">
<th className="px-6 py-3 text-left text-sm font-medium text-muted-foreground">
Key
</th>
<th className="px-6 py-3 text-left text-sm font-medium text-muted-foreground">
Value
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{Object.entries(metadata.metadata).map(([key, value]) => (
<tr key={key} className="hover:bg-muted/30">
<td className="px-6 py-4 text-sm font-medium text-foreground break-all">
{key}
</td>
<td className="px-6 py-4 text-sm text-foreground break-all">{value}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Object Preview Section */}
<div className="border border-border rounded-lg bg-card">
<div className="p-6 border-b border-border">
<h3 className="text-base font-semibold text-foreground">Object Preview</h3>
</div>
<div className="p-6">
<p className="text-sm text-muted-foreground">No preview available</p>
</div>
</div>
<div className="px-7 py-6 space-y-6">
{/* Back + breadcrumb */}
<div className="flex items-center gap-2 text-[13px] text-[var(--muted-foreground)]">
<Link
to={backHref}
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 hover:bg-[var(--accent)] hover:text-[var(--foreground)]"
>
<ArrowLeft className="h-3.5 w-3.5" />
Objects
</Link>
{pathSegments.map((seg, i) => (
<span key={i} className="inline-flex items-center gap-1">
<ChevronRight className="h-3.5 w-3.5 opacity-50" />
<span className="font-mono">{seg}</span>
</span>
))}
<ChevronRight className="h-3.5 w-3.5 opacity-50" />
<span className="truncate font-mono text-[var(--foreground)]">{fileName}</span>
</div>
{/* Hero */}
<section className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<IconTile icon={<File />} tone="primary" size="lg" />
<div className="min-w-0">
<h1 className="truncate text-[22px] font-semibold tracking-[-0.02em]">{fileName}</h1>
<button
type="button"
onClick={() => copy(metadata.key, 'Object key copied')}
title="Copy key"
className="group mt-1 inline-flex max-w-full items-center gap-1.5 truncate font-mono text-[13px] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<span className="truncate">{metadata.key}</span>
<Copy className="h-3 w-3 flex-shrink-0 opacity-60 group-hover:opacity-100" />
</button>
<div className="mt-2 flex flex-wrap gap-1.5">
<Badge>{formatBytes(metadata.size)}</Badge>
<Badge>{metadata.contentType || 'application/octet-stream'}</Badge>
{metadata.storageClass && <Badge>{metadata.storageClass}</Badge>}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button variant="secondary" onClick={handleDownload}>
<Download className="h-4 w-4" /> Download
</Button>
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
<Trash2 className="h-4 w-4" /> Delete
</Button>
</div>
</section>
{/* Details */}
<CardSection title="Details">
<dl className="divide-y divide-[var(--border)]">
<DetailRow label="Size">{formatBytes(metadata.size)}</DetailRow>
<DetailRow label="Content type">{metadata.contentType || 'application/octet-stream'}</DetailRow>
<DetailRow label="Storage class">{metadata.storageClass || 'Standard'}</DetailRow>
<DetailRow label="Last modified">{formatDate(metadata.lastModified)}</DetailRow>
<DetailRow label="ETag">
<button
type="button"
onClick={() => copy(metadata.etag, 'ETag copied')}
className="inline-flex max-w-full items-center gap-1.5 truncate rounded-md bg-[var(--surface-sunken)] px-2 py-0.5 font-mono text-[12.5px] hover:bg-[var(--accent)]"
>
<span className="truncate">{metadata.etag}</span>
<Copy className="h-3 w-3 flex-shrink-0 opacity-60" />
</button>
</DetailRow>
{metadata.versionId && (
<DetailRow label="Version ID">
<span className="font-mono text-[12.5px]">{metadata.versionId}</span>
</DetailRow>
)}
</dl>
</CardSection>
{/* Custom metadata */}
{metadata.metadata && Object.keys(metadata.metadata).length > 0 && (
<CardSection title="Custom metadata">
<dl className="divide-y divide-[var(--border)]">
{Object.entries(metadata.metadata).map(([key, value]) => (
<DetailRow key={key} label={key}>
<span className="font-mono text-[12.5px]">{value}</span>
</DetailRow>
))}
</dl>
</CardSection>
)}
{/* Preview */}
<CardSection title="Preview">
<div className="px-5 py-10 text-center text-[13px] text-[var(--muted-foreground)]">
No preview available for this object.
</div>
</CardSection>
<ConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title={`Delete "${fileName}"?`}
description="Applications referencing this object will no longer be able to read it."
confirmLabel="Delete object"
loading={deleting}
onConfirm={handleDelete}
/>
</div>
);
}
@@ -282,7 +282,7 @@ export function ObjectsTable({
</TableCell>
<TableCell className="hidden md:table-cell">
{obj.storageClass && (
<Badge variant="secondary">{obj.storageClass}</Badge>
<Badge variant="neutral">{obj.storageClass}</Badge>
)}
</TableCell>
<TableCell>{obj.isFolder ? null : formatBytes(obj.size)}</TableCell>
@@ -400,7 +400,7 @@ export function ObjectsTable({
<div className="flex items-center gap-2">
<Button
variant={hasPrevious ? "default": "default_disabled"}
variant="primary"
size="sm"
onClick={handlePreviousPage}
disabled={!hasPrevious}
@@ -411,7 +411,7 @@ export function ObjectsTable({
</Button>
<Button
variant={hasNext ? "default": "default_disabled"}
variant="primary"
size="sm"
onClick={handleNextPage}
disabled={!hasNext}
-2
View File
@@ -2,7 +2,5 @@ export { BucketListView } from './BucketListView';
export { ObjectBrowserView } from './ObjectBrowserView';
export { ObjectsTable } from './ObjectsTable';
export { CreateBucketDialog } from './CreateBucketDialog';
export { DeleteBucketDialog } from './DeleteBucketDialog';
export { BucketSettingsDialog } from './BucketSettingsDialog';
export { CreateDirectoryDialog } from './CreateDirectoryDialog';
export { DeleteObjectDialog } from './DeleteObjectDialog';
@@ -0,0 +1,105 @@
import { NavLink, Outlet, useParams } from 'react-router-dom';
import { Database, Copy, Upload } from 'lucide-react';
import { IconTile } from '@/components/ui/icon-tile';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { useBuckets } from '@/hooks/useApi';
import { toast } from 'sonner';
interface TabSpec {
to: string;
label: string;
end?: boolean;
}
const tabs: TabSpec[] = [
{ to: 'objects', label: 'Objects' },
{ to: 'permissions', label: 'Permissions' },
{ to: 'website', label: 'Website' },
{ to: 'settings', label: 'Settings' },
];
function formatBytes(n?: number) {
if (n == null) return '';
if (n < 1024) return `${n} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let v = n / 1024;
for (const u of units) {
if (v < 1024) return `${v.toFixed(v >= 10 ? 0 : 1)} ${u}`;
v /= 1024;
}
return `${v.toFixed(0)} PB`;
}
export function BucketDetailShell() {
const { bucketName = '' } = useParams<{ bucketName: string }>();
const { data: buckets = [] } = useBuckets();
const bucket = buckets.find((b) => b.name === bucketName);
const s3Url = `s3://${bucketName}`;
const copyUrl = async () => {
try {
await navigator.clipboard.writeText(s3Url);
toast.success('URL copied');
} catch {
toast.error('Failed to copy');
}
};
return (
<div className="flex flex-col">
{/* Hero */}
<section className="px-7 pt-6 pb-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<IconTile icon={<Database />} tone="primary" size="lg" />
<div className="min-w-0">
<h1 className="truncate text-[26px] font-semibold tracking-[-0.02em]">{bucketName}</h1>
<p className="mt-1 truncate font-mono text-[13.5px] text-[var(--muted-foreground)]">{s3Url}</p>
<div className="mt-2 flex flex-wrap gap-1.5">
<Badge variant="success">Active</Badge>
{bucket?.objectCount != null && <Badge>{bucket.objectCount.toLocaleString()} objects</Badge>}
{bucket?.size != null && <Badge>{formatBytes(bucket.size)}</Badge>}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button variant="secondary" onClick={copyUrl}>
<Copy /> Copy URL
</Button>
<Button variant="primary" onClick={() => document.dispatchEvent(new CustomEvent('bucket:upload'))}>
<Upload /> Upload
</Button>
</div>
</div>
</section>
{/* Tabs */}
<nav className="flex h-12 items-center gap-0 border-b border-[var(--border)] px-7">
{tabs.map((t) => (
<NavLink
key={t.to}
to={t.to}
end={t.end}
className={({ isActive }) =>
cn(
'relative -mb-px inline-flex h-12 items-center px-3.5 text-[14px] font-medium transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] rounded-sm',
isActive
? 'text-[var(--primary)] border-b-2 border-[var(--primary)]'
: 'text-[var(--muted-foreground)] border-b-2 border-transparent hover:text-[var(--foreground)]',
)
}
>
{t.label}
</NavLink>
))}
</nav>
<div className="min-w-0">
<Outlet />
</div>
</div>
);
}
+42 -11
View File
@@ -1,36 +1,67 @@
import { Outlet } from 'react-router-dom';
import { Outlet, useLocation, useParams } from 'react-router-dom';
import { Sidebar } from './sidebar';
import { useState } from 'react';
import { TopBar } from './top-bar';
import { useState, useMemo } from 'react';
import { Menu } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { BreadcrumbItem } from '@/components/ui/breadcrumb';
function useCrumbs(): BreadcrumbItem[] {
const location = useLocation();
const params = useParams();
return useMemo(() => {
const path = location.pathname;
if (path === '/') return [{ label: 'Dashboard' }];
if (path === '/cluster') return [{ label: 'Cluster' }];
if (path === '/access') return [{ label: 'Access Control' }];
if (path === '/buckets') return [{ label: 'Buckets' }];
if (path.startsWith('/buckets/')) {
const bucketName = (params as { bucketName?: string }).bucketName ?? path.split('/')[2];
const crumbs: BreadcrumbItem[] = [
{ label: 'Buckets', to: '/buckets' },
{ label: bucketName, to: `/buckets/${bucketName}/objects` },
];
const segs = path.split('/').slice(3); // after /buckets/:name
if (segs[0] && segs[0] !== 'objects') {
const tabLabel = segs[0][0].toUpperCase() + segs[0].slice(1);
crumbs.push({ label: tabLabel });
}
return crumbs;
}
return [];
}, [location.pathname, params]);
}
export function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const crumbs = useCrumbs();
return (
<div className="flex h-screen overflow-hidden">
{/* Mobile menu button */}
<div className="flex h-screen overflow-hidden bg-[var(--background)]">
<Button
variant="ghost"
size="icon"
className="fixed top-4 left-4 z-50 md:hidden"
className="fixed left-3 top-3 z-50 md:hidden"
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label="Toggle navigation"
>
<Menu className="h-6 w-6" />
<Menu className="h-5 w-5" />
</Button>
{/* Mobile overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 md:hidden"
className="fixed inset-0 z-40 bg-black/50 md:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<main className="flex-1 overflow-y-auto">
<Outlet />
</main>
<div className="flex min-w-0 flex-1 flex-col">
<TopBar crumbs={crumbs} />
<main className="flex-1 overflow-y-auto scrollbar-thin">
<Outlet />
</main>
</div>
</div>
);
}
+84 -90
View File
@@ -1,8 +1,7 @@
import {Link, useLocation} from 'react-router-dom';
import {cn} from '@/lib/utils';
import {Database, Key, LayoutDashboard, LogOut, Server, User} from 'lucide-react';
import {useAuthStore} from '@/store/auth-store';
import {Button} from '@/components/ui/button';
import { Link, useLocation } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { BookOpen, Database, Key, LayoutDashboard, Server } from 'lucide-react';
import { useAuthStore } from '@/store/auth-store';
import { useQuery } from '@tanstack/react-query';
import { healthApi, garageApi } from '@/lib/api';
@@ -12,26 +11,25 @@ interface NavItem {
icon: React.ComponentType<{ className?: string }>;
}
const navItems: NavItem[] = [
interface NavGroup {
label?: string;
items: NavItem[];
}
const navGroups: NavGroup[] = [
{
title: 'Dashboard',
href: '/',
icon: LayoutDashboard,
items: [{ title: 'Dashboard', href: '/', icon: LayoutDashboard }],
},
{
title: 'Buckets',
href: '/buckets',
icon: Database,
label: 'Storage',
items: [{ title: 'Buckets', href: '/buckets', icon: Database }],
},
{
title: 'Cluster',
href: '/cluster',
icon: Server,
},
{
title: 'Access Control',
href: '/access',
icon: Key,
label: 'Cluster',
items: [
{ title: 'Cluster', href: '/cluster', icon: Server },
{ title: 'Access Control', href: '/access', icon: Key },
],
},
];
@@ -42,11 +40,7 @@ interface SidebarProps {
export function Sidebar({ isOpen, onClose }: SidebarProps) {
const location = useLocation();
const { user, config, logout } = useAuthStore();
const handleLogout = () => {
logout();
};
const { config } = useAuthStore();
const { data: uiVersion } = useQuery({
queryKey: ['ui-version'],
@@ -60,80 +54,80 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
queryFn: () => garageApi.getNodeInfo('self'),
staleTime: 5 * 60 * 1000,
retry: false,
enabled: !!(config && (config.admin.enabled || config.oidc.enabled)),
});
const garageVersion = nodeInfo
? Object.values(nodeInfo.success)[0]?.garageVersion
: undefined;
const garageVersion = nodeInfo ? Object.values(nodeInfo.success)[0]?.garageVersion : undefined;
const isActive = (href: string) =>
href === '/'
? location.pathname === '/'
: location.pathname === href || location.pathname.startsWith(href + '/');
return (
<div
<aside
className={cn(
'flex h-full w-64 flex-col border-r transition-transform duration-300 ease-in-out md:translate-x-0',
'flex h-full w-64 flex-col border-r border-[var(--border)] bg-[var(--background)] transition-transform duration-300 ease-in-out md:translate-x-0',
'fixed md:static z-50',
isOpen ? 'translate-x-0' : '-translate-x-full'
isOpen ? 'translate-x-0' : '-translate-x-full',
)}
style={{ backgroundColor: 'var(--background)' }}
>
<div className="flex h-16 items-center border-b px-6">
<img src="/garage.png" alt="Garage UI Logo" className="h-8 w-8 mr-2" />
<span className="text-lg font-semibold">Garage UI</span>
<div className="flex h-16 items-center gap-2 border-b border-[var(--border)] px-4">
<img src="/garage.png" alt="" className="h-8 w-8" />
<span className="text-[18px] font-semibold tracking-tight">Garage UI</span>
</div>
<nav className="flex-1 space-y-1 p-4">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = location.pathname === item.href;
return (
<Link
key={item.href}
to={item.href}
onClick={onClose}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary shadow-sm'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
style={isActive ? { backgroundColor: 'var(--primary)', color: '#000000' } : undefined}
>
<Icon className="h-5 w-5" />
{item.title}
</Link>
);
})}
</nav>
{config && (config.admin.enabled || config.oidc.enabled) && user && (
<div className="border-t p-4 space-y-2">
<div className="flex items-center gap-3 rounded-lg bg-muted px-3 py-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground text-xs font-semibold">
<User className="h-4 w-4" />
</div>
<div className="flex-1 overflow-hidden">
<p className="text-sm font-medium truncate">{user.name || user.username}</p>
{user.email && (
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
)}
</div>
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-5 scrollbar-thin">
{navGroups.map((group, gi) => (
<div key={gi}>
{group.label && (
<div className="px-2 pb-1.5 text-[11px] font-medium uppercase tracking-[0.08em] text-[var(--muted-foreground)]">
{group.label}
</div>
)}
<ul className="space-y-0.5">
{group.items.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
return (
<li key={item.href}>
<Link
to={item.href}
onClick={onClose}
className={cn(
'flex h-9 items-center gap-2 rounded-md px-2.5 text-[14px] transition-colors',
active
? 'bg-[var(--primary)] font-medium text-[var(--primary-foreground)]'
: 'text-[var(--muted-foreground)] hover:bg-[var(--accent)] hover:text-[var(--foreground)]',
)}
>
<Icon className="h-4 w-4" />
{item.title}
</Link>
</li>
);
})}
</ul>
</div>
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={handleLogout}
>
<LogOut className="mr-2 h-4 w-4" />
Logout
</Button>
</div>
)}
{(uiVersion || garageVersion) && (
<div className="px-4 pb-3 text-xs text-muted-foreground text-center">
{uiVersion && `UI ${uiVersion}`}
{uiVersion && garageVersion && ' | '}
{garageVersion && `Garage ${garageVersion}`}
</div>
)}
</div>
))}
</nav>
<div className="px-3 py-3 flex flex-col items-center gap-1.5">
<a
href="https://garagehq.deuxfleurs.fr/documentation/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[12.5px] text-[var(--muted-foreground)] transition-colors hover:bg-[var(--accent)] hover:text-[var(--foreground)]"
>
<BookOpen className="h-3.5 w-3.5" />
Documentation
</a>
{(uiVersion || garageVersion) && (
<div className="flex items-center gap-1.5 border-t border-[var(--border)] pt-2 w-full justify-center text-[12px] text-[var(--muted-foreground)]">
{uiVersion && <span>UI {uiVersion}</span>}
{uiVersion && garageVersion && <span className="opacity-40"></span>}
{garageVersion && <span>Garage {garageVersion}</span>}
</div>
)}
</div>
</aside>
);
}
@@ -14,7 +14,7 @@ export function ThemeToggle() {
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="outline" size="icon">
<Button variant="secondary" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
@@ -0,0 +1,97 @@
import * as React from 'react';
import { User, LogOut, Monitor, Moon, Sun } from 'lucide-react';
import { Breadcrumb, type BreadcrumbItem } from '@/components/ui/breadcrumb';
import { useTheme } from '@/components/theme-provider';
import { useAuthStore } from '@/store/auth-store';
import { cn } from '@/lib/utils';
interface TopBarProps {
crumbs: BreadcrumbItem[];
}
export function TopBar({ crumbs }: TopBarProps) {
const { theme, setTheme } = useTheme();
const { user, config, logout } = useAuthStore();
const [menuOpen, setMenuOpen] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!menuOpen) return;
const handler = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [menuOpen]);
const hasUser = !!(config && (config.admin.enabled || config.oidc.enabled) && user);
return (
<div
className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-[var(--border)] bg-[var(--surface-sunken)] px-4 backdrop-blur"
>
<div className="min-w-0 flex-1 pl-8 md:pl-0">
<Breadcrumb items={crumbs} />
</div>
<div className="flex items-center gap-1">
<ThemeMiniToggle theme={theme} setTheme={setTheme} />
{hasUser && (
<div ref={menuRef} className="relative">
<button
type="button"
onClick={() => setMenuOpen((o) => !o)}
className="flex h-8 items-center gap-2 rounded-md px-2 text-[13.5px] text-[var(--foreground)] hover:bg-[var(--accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]"
>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-[var(--primary)] text-[var(--primary-foreground)]">
<User className="h-3.5 w-3.5" />
</span>
<span className="hidden max-w-[140px] truncate sm:inline">{user?.name || user?.username}</span>
</button>
{menuOpen && (
<div className="absolute right-0 mt-1 w-56 overflow-hidden rounded-md border border-[var(--border)] bg-[var(--popover)] shadow-lg">
<div className="border-b border-[var(--border)] px-3 py-2">
<div className="truncate text-[14px] font-medium">{user?.name || user?.username}</div>
{user?.email && (
<div className="truncate text-[12.5px] text-[var(--muted-foreground)]">{user.email}</div>
)}
</div>
<button
type="button"
onClick={() => { setMenuOpen(false); logout(); }}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-[14px] hover:bg-[var(--accent)]"
>
<LogOut className="h-3.5 w-3.5" /> Logout
</button>
</div>
)}
</div>
)}
</div>
</div>
);
}
function ThemeMiniToggle({
theme,
setTheme,
}: {
theme: 'light' | 'dark' | 'system';
setTheme: (t: 'light' | 'dark' | 'system') => void;
}) {
const next = theme === 'dark' ? 'light' : theme === 'light' ? 'system' : 'dark';
const Icon = theme === 'dark' ? Moon : theme === 'light' ? Sun : Monitor;
return (
<button
type="button"
onClick={() => setTheme(next)}
aria-label={`Switch theme (current: ${theme})`}
className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--muted-foreground)]',
'hover:bg-[var(--accent)] hover:text-[var(--foreground)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]',
)}
>
<Icon className="h-4 w-4" />
</button>
);
}
+14 -12
View File
@@ -3,21 +3,23 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'inline-flex items-center rounded-md border px-2 py-0.5 text-[12px] font-medium tracking-tight',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive',
outline: 'text-foreground',
neutral:
'bg-[var(--card)] border-[var(--border)] text-[var(--muted-foreground)]',
success:
'bg-[var(--success-soft)] border-transparent text-[color:#2ca02c] dark:text-[color:#73bf69]',
warning:
'bg-[var(--accent-primary-soft)] border-[var(--accent-primary-border)] text-[var(--primary)]',
danger:
'bg-[var(--danger-soft)] border-[var(--danger-border)] text-[var(--destructive)]',
primary:
'bg-[var(--primary)] border-transparent text-[var(--primary-foreground)]',
},
},
defaultVariants: {
variant: 'default',
},
defaultVariants: { variant: 'neutral' },
}
);
@@ -25,8 +27,8 @@ export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
export function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
export { badgeVariants };
+44
View File
@@ -0,0 +1,44 @@
import * as React from 'react';
import { Link } from 'react-router-dom';
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface BreadcrumbItem {
label: string;
to?: string;
}
interface BreadcrumbProps extends React.HTMLAttributes<HTMLElement> {
items: BreadcrumbItem[];
}
export function Breadcrumb({ items, className, ...props }: BreadcrumbProps) {
return (
<nav
aria-label="Breadcrumb"
className={cn('flex items-center gap-1.5 text-[13.5px] text-[var(--muted-foreground)]', className)}
{...props}
>
{items.map((item, idx) => {
const isLast = idx === items.length - 1;
return (
<React.Fragment key={`${item.label}-${idx}`}>
{item.to && !isLast ? (
<Link
to={item.to}
className="rounded-sm px-1 text-[var(--foreground)]/80 hover:text-[var(--foreground)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]"
>
{item.label}
</Link>
) : (
<span className={cn('px-1', isLast && 'font-medium text-[var(--foreground)]')}>
{item.label}
</span>
)}
{!isLast && <ChevronRight className="h-3.5 w-3.5 text-[var(--muted-foreground)]/60" />}
</React.Fragment>
);
})}
</nav>
);
}
+28 -19
View File
@@ -3,28 +3,39 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none',
[
'inline-flex items-center justify-center gap-2 whitespace-nowrap',
'rounded-md text-[14px] font-medium tracking-tight',
'transition-colors ring-offset-background',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
'[&_svg]:h-3.5 [&_svg]:w-3.5 [&_svg]:shrink-0',
].join(' '),
{
variants: {
variant: {
default: 'bg-[#ff9329] text-black hover:bg-[#e58625] cursor-pointer',
default_disabled: 'bg-[#ff9329] text-black opacity-50 cursor-not-allowed',
secondary: 'border border-[#ff9329] text-[#ff9329] cursor-pointer',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive cursor-pointer',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground cursor-pointer',
outline_disabled: 'border border-input bg-background text-muted-foreground opacity-50 cursor-not-allowed',
ghost: 'hover:bg-accent hover:text-accent-foreground cursor-pointer',
link: 'text-primary underline-offset-4 hover:underline cursor-pointer',
primary:
'bg-[var(--primary)] text-[var(--primary-foreground)] font-semibold hover:brightness-[1.04] cursor-pointer',
secondary:
'bg-transparent border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--accent)] cursor-pointer',
ghost:
'bg-transparent text-[var(--muted-foreground)] hover:bg-[var(--accent)] hover:text-[var(--foreground)] cursor-pointer',
destructive:
'bg-[var(--destructive)] text-[var(--destructive-foreground)] font-semibold hover:brightness-[1.05] cursor-pointer',
link:
'text-[var(--primary)] underline-offset-4 hover:underline cursor-pointer',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
sm: 'h-8 px-3',
default: 'h-[38px] px-4',
lg: 'h-11 px-5',
'icon-sm': 'h-8 w-8 p-0',
icon: 'h-[38px] w-[38px] p-0',
'icon-lg': 'h-11 w-11 p-0',
},
},
defaultVariants: {
variant: 'default',
variant: 'primary',
size: 'default',
},
}
@@ -35,11 +46,9 @@ export interface ButtonProps
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
}
({ className, variant, size, ...props }, ref) => (
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
)
);
Button.displayName = 'Button';
@@ -0,0 +1,69 @@
import * as React from 'react';
import { Trash2, AlertTriangle } from 'lucide-react';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './dialog';
import { IconTile } from './icon-tile';
import { Button } from './button';
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: React.ReactNode;
confirmLabel?: string;
cancelLabel?: string;
icon?: React.ReactNode;
tone?: 'destructive' | 'primary';
loading?: boolean;
onConfirm: () => void | Promise<void>;
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = 'Delete',
cancelLabel = 'Cancel',
icon,
tone = 'destructive',
loading = false,
onConfirm,
}: ConfirmDialogProps) {
const defaultIcon = tone === 'destructive' ? <Trash2 /> : <AlertTriangle />;
return (
<Dialog open={open} onOpenChange={onOpenChange} size="destructive">
<DialogContent>
<DialogHeader>
<IconTile icon={icon ?? defaultIcon} tone={tone} size="md" />
<div className="flex-1">
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</div>
</DialogHeader>
<DialogBody>
<p className="text-[13.5px] text-[var(--muted-foreground)]">This action cannot be undone.</p>
</DialogBody>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)} disabled={loading}>
{cancelLabel}
</Button>
<Button
variant={tone === 'destructive' ? 'destructive' : 'primary'}
onClick={() => onConfirm()}
disabled={loading}
>
{loading ? 'Working…' : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,91 @@
import * as React from 'react';
import { Trash2 } from 'lucide-react';
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './dialog';
import { IconTile } from './icon-tile';
import { Button } from './button';
import { Input } from './input';
interface DangerousConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: React.ReactNode;
/** Exact string the user must type to enable the confirm button. */
confirmationText: string;
confirmLabel?: string;
cancelLabel?: string;
icon?: React.ReactNode;
loading?: boolean;
onConfirm: () => void | Promise<void>;
}
export function DangerousConfirmDialog({
open,
onOpenChange,
title,
description,
confirmationText,
confirmLabel = 'Delete',
cancelLabel = 'Cancel',
icon,
loading = false,
onConfirm,
}: DangerousConfirmDialogProps) {
const [value, setValue] = React.useState('');
React.useEffect(() => { if (!open) setValue(''); }, [open]);
const matches = value === confirmationText;
const submit = () => { if (matches && !loading) onConfirm(); };
return (
<Dialog open={open} onOpenChange={onOpenChange} size="destructive">
<DialogContent>
<DialogHeader>
<IconTile icon={icon ?? <Trash2 />} tone="destructive" size="md" />
<div className="flex-1">
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</div>
</DialogHeader>
<DialogBody className="space-y-3">
<p className="text-[13.5px] text-[var(--muted-foreground)]">
This action cannot be undone. To confirm, type{' '}
<code className="rounded bg-[var(--surface-sunken)] px-1 py-0.5 font-mono text-[13px] text-[var(--foreground)]">
{confirmationText}
</code>{' '}
below.
</p>
<Input
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submit()}
placeholder={confirmationText}
aria-label={`Type ${confirmationText} to confirm`}
/>
</DialogBody>
<DialogFooter>
<Button variant="secondary" onClick={() => onOpenChange(false)} disabled={loading}>
{cancelLabel}
</Button>
<Button
variant="destructive"
onClick={submit}
disabled={!matches || loading}
>
{loading ? 'Working…' : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+132 -79
View File
@@ -1,149 +1,201 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
type DialogSize = 'standard' | 'form' | 'destructive';
interface DialogContextValue {
open: boolean;
onOpenChange: (open: boolean) => void;
size: DialogSize;
}
const DialogContext = React.createContext<DialogContextValue | undefined>(undefined);
function useDialog() {
const context = React.useContext(DialogContext);
if (!context) {
throw new Error('useDialog must be used within a Dialog');
}
return context;
const ctx = React.useContext(DialogContext);
if (!ctx) throw new Error('useDialog must be used within a Dialog');
return ctx;
}
interface DialogProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
size?: DialogSize;
children: React.ReactNode;
}
const Dialog: React.FC<DialogProps> = ({ open = false, onOpenChange, children }) => {
return (
<DialogContext.Provider value={{ open, onOpenChange: onOpenChange || (() => {}) }}>
{children}
</DialogContext.Provider>
);
};
const Dialog: React.FC<DialogProps> = ({ open = false, onOpenChange, size = 'standard', children }) => (
<DialogContext.Provider value={{ open, onOpenChange: onOpenChange || (() => {}), size }}>
{children}
</DialogContext.Provider>
);
const DialogTrigger = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ onClick, ...props }, ref) => {
const { onOpenChange } = useDialog();
return (
<button
ref={ref}
onClick={(e) => {
onOpenChange(true);
onClick?.(e);
}}
{...props}
/>
);
});
DialogTrigger.displayName = 'DialogTrigger';
const DialogPortal: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { open } = useDialog();
if (!open) return null;
return <>{children}</>;
};
const DialogOverlay = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const DialogTrigger = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
({ onClick, ...props }, ref) => {
const { onOpenChange } = useDialog();
return (
<div
<button
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
onClick={() => onOpenChange(false)}
onClick={(e) => { onOpenChange(true); onClick?.(e); }}
{...props}
/>
);
}
);
DialogOverlay.displayName = 'DialogOverlay';
DialogTrigger.displayName = 'DialogTrigger';
const widthClass: Record<DialogSize, string> = {
standard: 'max-w-[480px]',
form: 'max-w-[600px]',
destructive: 'max-w-[440px]',
};
const DialogOverlay: React.FC = () => {
const { onOpenChange } = useDialog();
return (
<div
className="fixed inset-0 z-50 bg-black/55 backdrop-blur-[8px]"
onClick={() => onOpenChange(false)}
/>
);
};
const DialogContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, children, ...props }, ref) => {
const { onOpenChange } = useDialog();
return (
<DialogPortal>
const { open, onOpenChange, size } = useDialog();
const containerRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!open) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
const keyHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
onOpenChange(false);
return;
}
if (e.key !== 'Tab' || !containerRef.current) return;
const focusables = containerRef.current.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', keyHandler);
setTimeout(() => {
const first = containerRef.current?.querySelector<HTMLElement>(
'input:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
first?.focus();
}, 0);
return () => {
document.removeEventListener('keydown', keyHandler);
previouslyFocused?.focus?.();
};
}, [open, onOpenChange]);
if (!open) return null;
return createPortal(
<>
<DialogOverlay />
<div className="fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%] w-[calc(100%-2rem)] sm:w-full max-w-lg">
<div
ref={containerRef}
role="dialog"
aria-modal="true"
className={cn(
'fixed left-1/2 top-1/2 z-50 w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2',
widthClass[size],
)}
>
<div
ref={ref}
style={{ backgroundColor: 'var(--background)' }}
className={cn(
'relative p-4 sm:p-6 shadow-lg duration-200 rounded-lg border',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
className
'relative overflow-hidden rounded-xl border border-[var(--border)]',
'bg-[var(--card)] text-[var(--card-foreground)]',
'shadow-[0_20px_40px_rgba(0,0,0,0.3)]',
className,
)}
{...props}
>
{children}
<button
type="button"
onClick={() => onOpenChange(false)}
className="absolute right-4 top-4 rounded-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer"
aria-label="Close"
className="absolute right-3 top-3 inline-flex h-7 w-7 items-center justify-center rounded-md text-[var(--muted-foreground)] hover:bg-[var(--accent)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]"
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</button>
</div>
</div>
</DialogPortal>
</>,
document.body,
);
}
);
DialogContent.displayName = 'DialogContent';
const DialogHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => <div className={cn('flex flex-col space-y-1.5 text-center sm:text-left bg-background', className)} {...props} />;
const DialogHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div
className={cn(
'flex items-start gap-3 border-b border-[var(--border)] px-6 py-5',
className,
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
className,
...props
}) => (
const DialogBody: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div className={cn('px-6 py-5', className)} {...props} />
);
DialogBody.displayName = 'DialogBody';
const DialogFooter: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({ className, ...props }) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end space-y-2 space-y-reverse sm:space-y-0 sm:space-x-2', className)}
className={cn(
'flex justify-end gap-2 border-t border-[var(--border)] bg-[var(--surface-sunken)] px-6 py-3.5',
className,
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
const DialogTitleText = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h2
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
className={cn('text-[20px] font-semibold tracking-[-0.015em] leading-tight', className)}
{...props}
/>
)
);
DialogTitle.displayName = 'DialogTitle';
DialogTitleText.displayName = 'DialogTitle';
const DialogDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-xs text-muted-foreground', className)} {...props} />
));
const DialogDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('mt-1 text-[13.5px] leading-[1.45] text-[var(--muted-foreground)]', className)}
{...props}
/>
)
);
DialogDescription.displayName = 'DialogDescription';
export {
@@ -151,7 +203,8 @@ export {
DialogTrigger,
DialogContent,
DialogHeader,
DialogBody,
DialogFooter,
DialogTitle,
DialogTitleText as DialogTitle,
DialogDescription,
};
@@ -0,0 +1,32 @@
import * as React from 'react';
import { IconTile } from './icon-tile';
import { cn } from '@/lib/utils';
interface EmptyStateProps extends React.HTMLAttributes<HTMLDivElement> {
icon: React.ReactNode;
title: string;
description?: string;
action?: React.ReactNode;
tone?: 'primary' | 'neutral' | 'destructive';
}
export function EmptyState({ icon, title, description, action, tone = 'primary', className, ...props }: EmptyStateProps) {
return (
<div
className={cn(
'flex flex-col items-center justify-center gap-3 rounded-xl border border-[var(--border)] bg-[var(--card)] px-6 py-12 text-center',
className,
)}
{...props}
>
<IconTile icon={icon} tone={tone} size="lg" />
<div className="space-y-1">
<h3 className="text-[17px] font-semibold tracking-tight">{title}</h3>
{description && (
<p className="max-w-sm text-[13.5px] text-[var(--muted-foreground)]">{description}</p>
)}
</div>
{action && <div className="pt-1">{action}</div>}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { cva, type VariantProps } from 'class-variance-authority';
const iconTileVariants = cva(
'inline-flex items-center justify-center shrink-0 border',
{
variants: {
tone: {
primary: 'bg-[var(--accent-primary-soft)] border-[var(--accent-primary-border)] text-[var(--primary)]',
destructive: 'bg-[var(--danger-soft)] border-[var(--danger-border)] text-[var(--destructive)]',
neutral: 'bg-muted border-border text-muted-foreground',
},
size: {
sm: 'h-8 w-8 rounded-md [&>svg]:h-4 [&>svg]:w-4',
md: 'h-10 w-10 rounded-lg [&>svg]:h-5 [&>svg]:w-5',
lg: 'h-14 w-14 rounded-xl [&>svg]:h-7 [&>svg]:w-7',
},
},
defaultVariants: { tone: 'primary', size: 'md' },
}
);
export interface IconTileProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof iconTileVariants> {
icon: React.ReactNode;
}
export function IconTile({ icon, tone, size, className, ...props }: IconTileProps) {
return (
<div className={cn(iconTileVariants({ tone, size }), className)} {...props}>
{icon}
</div>
);
}
+16 -14
View File
@@ -1,22 +1,24 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed',
className
)}
ref={ref}
{...props}
/>
);
}
({ className, type, ...props }, ref) => (
<input
type={type}
ref={ref}
className={cn(
'flex h-[38px] w-full rounded-md border border-[var(--input)] bg-[var(--background)] px-3 py-1 text-[15px]',
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
'placeholder:text-[var(--muted-foreground)]',
'focus-visible:outline-none focus-visible:border-[var(--primary)] focus-visible:ring-2 focus-visible:ring-[var(--ring)]',
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
)
);
Input.displayName = 'Input';
@@ -0,0 +1,28 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
interface PageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
title: string;
subtitle?: React.ReactNode;
actions?: React.ReactNode;
}
export function PageHeader({ title, subtitle, actions, className, ...props }: PageHeaderProps) {
return (
<div
className={cn(
'flex flex-col gap-4 border-b border-[var(--border)] px-6 py-5 sm:flex-row sm:items-start sm:justify-between sm:gap-6',
className,
)}
{...props}
>
<div className="min-w-0">
<h1 className="text-[26px] font-semibold tracking-[-0.02em] leading-tight truncate">{title}</h1>
{subtitle && (
<div className="mt-1 text-[13.5px] text-[var(--muted-foreground)]">{subtitle}</div>
)}
</div>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</div>
);
}
+6 -5
View File
@@ -53,7 +53,7 @@ const TabsList = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivEl
<div
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
'flex h-12 items-center gap-0 border-b border-[var(--border)] text-[14px]',
className
)}
{...props}
@@ -70,15 +70,16 @@ const TabsTrigger = React.forwardRef<HTMLButtonElement, TabsTriggerProps>(
({ className, value: triggerValue, onClick, ...props }, ref) => {
const { value, onValueChange } = useTabs();
const isActive = value === triggerValue;
return (
<button
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none',
'relative h-12 px-3.5 -mb-px inline-flex items-center justify-center',
'text-[14px] font-medium transition-colors cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] rounded-sm',
isActive
? 'bg-background text-foreground shadow-sm'
: 'hover:bg-accent hover:text-accent-foreground',
? 'text-[var(--primary)] border-b-2 border-[var(--primary)]'
: 'text-[var(--muted-foreground)] border-b-2 border-transparent hover:text-[var(--foreground)]',
className
)}
onClick={(e) => {