mirror of
https://github.com/Nezumi-2711/s3-drive-storage-manage.git
synced 2026-09-22 13:48:31 +00:00
feat: implement ui for manage s3 storage
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
export interface DialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Dialog({ open, onClose, children, className }: DialogProps) {
|
||||
const dialogRef = React.useRef<HTMLDialogElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
const dialog = dialogRef.current
|
||||
if (!dialog) return
|
||||
|
||||
if (open) {
|
||||
if (!dialog.open) {
|
||||
dialog.showModal()
|
||||
}
|
||||
} else {
|
||||
if (dialog.open) {
|
||||
dialog.close()
|
||||
}
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleCancel = (e: React.SyntheticEvent) => {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent<HTMLDialogElement>) => {
|
||||
if (e.target === dialogRef.current) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
onCancel={handleCancel}
|
||||
onClick={handleBackdropClick}
|
||||
className={cn(
|
||||
"backdrop:bg-background/80 backdrop:backdrop-blur-sm p-0 rounded-2xl bg-card border border-border/80 text-card-foreground shadow-2xl max-w-md w-full m-auto focus:outline-hidden",
|
||||
"open:animate-in open:fade-in-0 open:zoom-in-95",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{open ? children : null}
|
||||
</dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
export interface SwitchProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
checked: boolean
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
}
|
||||
|
||||
export function Switch({ checked, onCheckedChange, disabled, className, ...props }: SwitchProps) {
|
||||
const handleClick = () => {
|
||||
if (!disabled && onCheckedChange) {
|
||||
onCheckedChange(!checked)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
|
||||
checked ? "bg-emerald-600" : "bg-muted-foreground/30 hover:bg-muted-foreground/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform",
|
||||
checked ? "translate-x-4" : "translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { apiRequest, IS_MOCK_MODE } from "../../../lib/api-client"
|
||||
import type {
|
||||
BucketRecord,
|
||||
CreateBucketRequest,
|
||||
ImportCandidate,
|
||||
ImportCandidatesResponse,
|
||||
ImportResult,
|
||||
UpdateBucketRequest,
|
||||
} from "./buckets.types"
|
||||
import {
|
||||
mockCreateBucket,
|
||||
mockImportBuckets,
|
||||
mockListImportCandidates,
|
||||
mockUpdateBucket,
|
||||
mockDeleteBucket,
|
||||
} from "./buckets.mock"
|
||||
|
||||
export async function createBucket(data: CreateBucketRequest): Promise<BucketRecord> {
|
||||
if (IS_MOCK_MODE) {
|
||||
return mockCreateBucket(data)
|
||||
}
|
||||
return apiRequest<BucketRecord>("/api/buckets", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateBucket(name: string, data: UpdateBucketRequest): Promise<BucketRecord> {
|
||||
if (IS_MOCK_MODE) {
|
||||
return mockUpdateBucket(name, data)
|
||||
}
|
||||
return apiRequest<BucketRecord>(`/api/buckets/${encodeURIComponent(name)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteBucket(name: string): Promise<void> {
|
||||
if (IS_MOCK_MODE) {
|
||||
return mockDeleteBucket(name)
|
||||
}
|
||||
return apiRequest<void>(`/api/buckets/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchImportCandidates(): Promise<ImportCandidate[]> {
|
||||
if (IS_MOCK_MODE) {
|
||||
return mockListImportCandidates()
|
||||
}
|
||||
const response = await apiRequest<ImportCandidatesResponse>("/api/import-candidates")
|
||||
return response.candidates
|
||||
}
|
||||
|
||||
export async function importBuckets(names: string[]): Promise<ImportResult> {
|
||||
if (IS_MOCK_MODE) {
|
||||
return mockImportBuckets(names)
|
||||
}
|
||||
return apiRequest<ImportResult>("/api/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ names }),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export const bucketKeys = {
|
||||
all: ["buckets"] as const,
|
||||
importCandidates: () => [...bucketKeys.all, "import-candidates"] as const,
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import type { BucketStat, BucketStatsResponse, StatusResponse } from "../../status/api/status.types"
|
||||
import type {
|
||||
BucketRecord,
|
||||
CreateBucketRequest,
|
||||
ImportCandidate,
|
||||
ImportResult,
|
||||
UpdateBucketRequest,
|
||||
} from "./buckets.types"
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
let mockBuckets: BucketStat[] = [
|
||||
{
|
||||
name: "documents",
|
||||
objectCount: 142,
|
||||
totalSize: 1024 * 1024 * 340, // 340 MB
|
||||
lastModified: new Date(Date.now() - 1000 * 60 * 15).toISOString(),
|
||||
truncated: false,
|
||||
publicRead: false,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
name: "media-storage",
|
||||
objectCount: 890,
|
||||
totalSize: 1024 * 1024 * 1024 * 5.2, // 5.2 GB
|
||||
lastModified: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(),
|
||||
truncated: false,
|
||||
publicRead: false,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
name: "backups",
|
||||
objectCount: 24,
|
||||
totalSize: 1024 * 1024 * 850, // 850 MB
|
||||
lastModified: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
|
||||
truncated: false,
|
||||
publicRead: false,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
name: "public-assets",
|
||||
objectCount: 65,
|
||||
totalSize: 1024 * 1024 * 45, // 45 MB
|
||||
lastModified: new Date(Date.now() - 1000 * 60 * 5).toISOString(),
|
||||
truncated: false,
|
||||
publicRead: true,
|
||||
error: null,
|
||||
},
|
||||
]
|
||||
|
||||
let mockImportCandidatesList: ImportCandidate[] = [
|
||||
{
|
||||
name: "archive-2024",
|
||||
folderId: "mock-folder-archive",
|
||||
objectCount: 128,
|
||||
},
|
||||
{
|
||||
name: "user-avatars",
|
||||
folderId: "mock-folder-avatars",
|
||||
objectCount: 450,
|
||||
},
|
||||
]
|
||||
|
||||
export async function mockFetchStatus(): Promise<StatusResponse> {
|
||||
await delay(400)
|
||||
return {
|
||||
gateway: {
|
||||
status: "ok",
|
||||
region: "auto",
|
||||
multipartEnabled: true,
|
||||
etagStyle: "md5",
|
||||
docsEnabled: true,
|
||||
buckets: mockBuckets.map((b) => b.name),
|
||||
publicReadBuckets: mockBuckets.filter((b) => b.publicRead).map((b) => b.name),
|
||||
rootFolder: {
|
||||
name: "s3-storage",
|
||||
id: "mock-root-folder-id",
|
||||
configured: true,
|
||||
},
|
||||
corsOrigins: ["http://localhost:5173"],
|
||||
credentials: {
|
||||
s3Keys: true,
|
||||
googleOAuth: true,
|
||||
dashboardPassword: true,
|
||||
},
|
||||
},
|
||||
drive: {
|
||||
connected: true,
|
||||
account: {
|
||||
email: "demo-storage@example.com",
|
||||
displayName: "S3 Drive Bridge Admin",
|
||||
},
|
||||
quota: {
|
||||
limit: 15 * 1024 * 1024 * 1024, // 15 GB
|
||||
usage: 6.4 * 1024 * 1024 * 1024, // 6.4 GB
|
||||
usageInDrive: 6.2 * 1024 * 1024 * 1024,
|
||||
usageInDriveTrash: 200 * 1024 * 1024,
|
||||
free: 8.6 * 1024 * 1024 * 1024,
|
||||
percentUsed: 42.7,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
checkedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockFetchBucketStats(): Promise<BucketStatsResponse> {
|
||||
await delay(600)
|
||||
const totalCount = mockBuckets.reduce((acc, b) => acc + b.objectCount, 0)
|
||||
const totalSize = mockBuckets.reduce((acc, b) => acc + b.totalSize, 0)
|
||||
|
||||
return {
|
||||
buckets: [...mockBuckets],
|
||||
totals: {
|
||||
buckets: mockBuckets.length,
|
||||
objectCount: totalCount,
|
||||
totalSize,
|
||||
},
|
||||
cachedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockCreateBucket(data: CreateBucketRequest): Promise<BucketRecord> {
|
||||
await delay(500)
|
||||
if (mockBuckets.some((b) => b.name === data.name)) {
|
||||
throw new Error(`Bucket '${data.name}' already exists`)
|
||||
}
|
||||
const newBucket: BucketStat = {
|
||||
name: data.name,
|
||||
objectCount: 0,
|
||||
totalSize: 0,
|
||||
lastModified: new Date().toISOString(),
|
||||
truncated: false,
|
||||
publicRead: Boolean(data.publicRead),
|
||||
error: null,
|
||||
}
|
||||
mockBuckets = [newBucket, ...mockBuckets]
|
||||
return {
|
||||
name: data.name,
|
||||
folderId: `mock-folder-${data.name}`,
|
||||
publicRead: Boolean(data.publicRead),
|
||||
createdTime: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockUpdateBucket(name: string, data: UpdateBucketRequest): Promise<BucketRecord> {
|
||||
await delay(400)
|
||||
const existing = mockBuckets.find((b) => b.name === name)
|
||||
if (!existing) {
|
||||
throw new Error(`Bucket '${name}' not found`)
|
||||
}
|
||||
|
||||
if (data.name && data.name !== name) {
|
||||
if (mockBuckets.some((b) => b.name === data.name)) {
|
||||
throw new Error(`Bucket '${data.name}' already exists`)
|
||||
}
|
||||
existing.name = data.name
|
||||
}
|
||||
|
||||
if (data.publicRead !== undefined) {
|
||||
existing.publicRead = data.publicRead
|
||||
}
|
||||
|
||||
return {
|
||||
name: existing.name,
|
||||
folderId: `mock-folder-${existing.name}`,
|
||||
publicRead: existing.publicRead,
|
||||
createdTime: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockDeleteBucket(name: string): Promise<void> {
|
||||
await delay(400)
|
||||
const existing = mockBuckets.find((b) => b.name === name)
|
||||
if (!existing) {
|
||||
throw new Error(`Bucket '${name}' not found`)
|
||||
}
|
||||
if (existing.objectCount > 0) {
|
||||
throw new Error(`Bucket '${name}' is not empty (${existing.objectCount} objects)`)
|
||||
}
|
||||
mockBuckets = mockBuckets.filter((b) => b.name !== name)
|
||||
}
|
||||
|
||||
export async function mockListImportCandidates(): Promise<ImportCandidate[]> {
|
||||
await delay(400)
|
||||
return [...mockImportCandidatesList]
|
||||
}
|
||||
|
||||
export async function mockImportBuckets(names: string[]): Promise<ImportResult> {
|
||||
await delay(600)
|
||||
const imported: string[] = []
|
||||
const failed: Array<{ name: string; error: string }> = []
|
||||
|
||||
for (const name of names) {
|
||||
const candidate = mockImportCandidatesList.find((c) => c.name === name)
|
||||
if (!candidate) {
|
||||
failed.push({ name, error: "Candidate not found" })
|
||||
continue
|
||||
}
|
||||
mockBuckets.push({
|
||||
name: candidate.name,
|
||||
objectCount: candidate.objectCount,
|
||||
totalSize: candidate.objectCount * 1024 * 1024 * 2,
|
||||
lastModified: new Date().toISOString(),
|
||||
truncated: false,
|
||||
publicRead: false,
|
||||
error: null,
|
||||
})
|
||||
mockImportCandidatesList = mockImportCandidatesList.filter((c) => c.name !== name)
|
||||
imported.push(name)
|
||||
}
|
||||
|
||||
return { imported, failed }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export interface BucketRecord {
|
||||
name: string
|
||||
folderId: string
|
||||
publicRead: boolean
|
||||
createdTime: string | null
|
||||
}
|
||||
|
||||
export interface CreateBucketRequest {
|
||||
name: string
|
||||
publicRead?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateBucketRequest {
|
||||
publicRead?: boolean
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface ImportCandidate {
|
||||
name: string
|
||||
folderId: string
|
||||
objectCount: number
|
||||
}
|
||||
|
||||
export interface ImportCandidatesResponse {
|
||||
candidates: ImportCandidate[]
|
||||
}
|
||||
|
||||
export interface ImportBucketsRequest {
|
||||
names: string[]
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
imported: string[]
|
||||
failed: Array<{
|
||||
name: string
|
||||
error: string
|
||||
}>
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import * as React from "react"
|
||||
import { Dialog } from "../../../components/ui/dialog"
|
||||
import { Button } from "../../../components/ui/button"
|
||||
import { Input } from "../../../components/ui/input"
|
||||
import { Label } from "../../../components/ui/label"
|
||||
import { Switch } from "../../../components/ui/switch"
|
||||
import { useCreateBucket } from "../hooks/useCreateBucket"
|
||||
import { AlertCircle, FolderPlus, X } from "lucide-react"
|
||||
|
||||
interface CreateBucketDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function CreateBucketDialog({ open, onClose }: CreateBucketDialogProps) {
|
||||
const [name, setName] = React.useState("")
|
||||
const [publicRead, setPublicRead] = React.useState(false)
|
||||
const [errorMessage, setErrorMessage] = React.useState<string | null>(null)
|
||||
|
||||
const { mutate: create, isPending } = useCreateBucket()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setName("")
|
||||
setPublicRead(false)
|
||||
setErrorMessage(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setErrorMessage(null)
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
|
||||
create(
|
||||
{ name: trimmed, publicRead },
|
||||
{
|
||||
onSuccess: () => {
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => {
|
||||
setErrorMessage(err.message || "Failed to create bucket")
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} className="overflow-hidden border-border/80 bg-card/95 backdrop-blur-md shadow-2xl">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 pb-4 border-b border-border/60 bg-linear-to-r from-emerald-500/5 via-teal-500/5 to-transparent">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-600 dark:text-emerald-400 shadow-xs">
|
||||
<FolderPlus className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-foreground">Create Bucket</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Create a new storage bucket under root folder</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-5">
|
||||
{errorMessage && (
|
||||
<div className="p-3 rounded-xl bg-destructive/10 border border-destructive/20 flex items-center gap-2.5 text-xs text-destructive">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="bucket-name" className="text-foreground">Bucket Name</Label>
|
||||
<Input
|
||||
id="bucket-name"
|
||||
placeholder="my-new-bucket"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={isPending}
|
||||
className="bg-background/60 border-border/80 rounded-xl h-10"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
3-63 lowercase alphanumeric characters and hyphens.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3.5 rounded-xl bg-background/50 border border-border/70 shadow-xs">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-semibold text-foreground">Public Read</span>
|
||||
<span className="text-[11px] text-muted-foreground">Allow unauthenticated GET / HEAD requests</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={publicRead}
|
||||
onCheckedChange={setPublicRead}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2.5 p-4 sm:px-6 bg-muted/30 border-t border-border/60">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
disabled={isPending}
|
||||
className="h-9 px-3.5 font-medium border-border/80 bg-background/70 hover:bg-muted/80 shadow-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={isPending || !name.trim()}
|
||||
className="h-9 px-4 font-medium shadow-sm shadow-blue-500/25 gap-1.5"
|
||||
>
|
||||
{isPending ? "Creating..." : "Create Bucket"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import * as React from "react"
|
||||
import { Dialog } from "../../../components/ui/dialog"
|
||||
import { Button } from "../../../components/ui/button"
|
||||
import { Input } from "../../../components/ui/input"
|
||||
import { Label } from "../../../components/ui/label"
|
||||
import { useDeleteBucket } from "../hooks/useDeleteBucket"
|
||||
import { AlertCircle, AlertTriangle, X } from "lucide-react"
|
||||
|
||||
interface DeleteBucketDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
bucketName: string | null
|
||||
}
|
||||
|
||||
export function DeleteBucketDialog({ open, onClose, bucketName }: DeleteBucketDialogProps) {
|
||||
const [confirmation, setConfirmation] = React.useState("")
|
||||
const [errorMessage, setErrorMessage] = React.useState<string | null>(null)
|
||||
|
||||
const { mutate: deleteMutate, isPending } = useDeleteBucket()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setConfirmation("")
|
||||
setErrorMessage(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (!bucketName) return null
|
||||
|
||||
const isConfirmed = confirmation === bucketName
|
||||
|
||||
const handleDelete = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!isConfirmed) return
|
||||
|
||||
setErrorMessage(null)
|
||||
deleteMutate(bucketName, {
|
||||
onSuccess: () => {
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => {
|
||||
setErrorMessage(err.message || "Failed to delete bucket")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} className="overflow-hidden border-border/80 bg-card/95 backdrop-blur-md shadow-2xl">
|
||||
<form onSubmit={handleDelete} className="flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 pb-4 border-b border-border/60 bg-linear-to-r from-red-500/5 via-rose-500/5 to-transparent">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-destructive/10 border border-destructive/20 text-destructive shadow-xs">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-foreground">Delete Bucket</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">This action will move the bucket folder to Drive trash</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{errorMessage && (
|
||||
<div className="p-3 rounded-xl bg-destructive/10 border border-destructive/20 flex items-center gap-2.5 text-xs text-destructive">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-3.5 rounded-xl bg-destructive/5 border border-destructive/15 text-xs text-muted-foreground flex flex-col gap-1">
|
||||
<p>
|
||||
Only <strong className="text-foreground font-semibold">empty buckets</strong> can be deleted. If objects exist, the deletion will be rejected.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="confirm-bucket-name" className="text-foreground">
|
||||
Type <span className="font-mono font-bold text-destructive bg-destructive/10 px-1.5 py-0.5 rounded-md border border-destructive/20">{bucketName}</span> to confirm:
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-bucket-name"
|
||||
placeholder={bucketName}
|
||||
value={confirmation}
|
||||
onChange={(e) => setConfirmation(e.target.value)}
|
||||
disabled={isPending}
|
||||
className="bg-background/60 border-border/80 rounded-xl h-10 font-mono text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2.5 p-4 sm:px-6 bg-muted/30 border-t border-border/60">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
disabled={isPending}
|
||||
className="h-9 px-3.5 font-medium border-border/80 bg-background/70 hover:bg-muted/80 shadow-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={!isConfirmed || isPending}
|
||||
className="h-9 px-4 font-medium"
|
||||
>
|
||||
{isPending ? "Deleting..." : "Delete Bucket"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import * as React from "react"
|
||||
import { Dialog } from "../../../components/ui/dialog"
|
||||
import { Button } from "../../../components/ui/button"
|
||||
import { useImportCandidates } from "../hooks/useImportCandidates"
|
||||
import { useImportBuckets } from "../hooks/useImportBuckets"
|
||||
import { AlertCircle, DownloadCloud, Folder, RefreshCw, X, FolderCheck, Check, Info } from "lucide-react"
|
||||
|
||||
interface ImportBucketsDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ImportBucketsDialog({ open, onClose }: ImportBucketsDialogProps) {
|
||||
const [selectedNames, setSelectedNames] = React.useState<Set<string>>(new Set())
|
||||
const [errorMessage, setErrorMessage] = React.useState<string | null>(null)
|
||||
|
||||
const { data: candidates, isLoading, error, refetch, isFetching } = useImportCandidates({ enabled: open })
|
||||
const { mutate: importMutate, isPending } = useImportBuckets()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedNames(new Set())
|
||||
setErrorMessage(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const toggleSelect = (name: string) => {
|
||||
setSelectedNames((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(name)) {
|
||||
next.delete(name)
|
||||
} else {
|
||||
next.add(name)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (!candidates) return
|
||||
if (selectedNames.size === candidates.length) {
|
||||
setSelectedNames(new Set())
|
||||
} else {
|
||||
setSelectedNames(new Set(candidates.map((c) => c.name)))
|
||||
}
|
||||
}
|
||||
|
||||
const handleImport = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (selectedNames.size === 0) return
|
||||
|
||||
setErrorMessage(null)
|
||||
importMutate(Array.from(selectedNames), {
|
||||
onSuccess: (result) => {
|
||||
if (result.failed.length > 0) {
|
||||
const failedDetails = result.failed.map((f) => `${f.name}: ${f.error}`).join(", ")
|
||||
setErrorMessage(`Import completed with errors: ${failedDetails}`)
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
setErrorMessage(err.message || "Failed to import buckets")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const allSelected = Boolean(candidates && candidates.length > 0 && selectedNames.size === candidates.length)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} className="max-w-lg overflow-hidden border-border/80 bg-card/95 backdrop-blur-md shadow-2xl">
|
||||
<form onSubmit={handleImport} className="flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 pb-4 border-b border-border/60 bg-linear-to-r from-blue-500/5 via-indigo-500/5 to-transparent">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-blue-500/10 border border-blue-500/20 text-blue-600 dark:text-blue-400 shadow-xs">
|
||||
<DownloadCloud className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-foreground">Import from Drive</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Move root Drive folders into your managed storage root
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Error messages */}
|
||||
{errorMessage && (
|
||||
<div className="p-3 rounded-xl bg-destructive/10 border border-destructive/20 flex items-center gap-2.5 text-xs text-destructive">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-xl bg-destructive/10 border border-destructive/20 flex items-center gap-2.5 text-xs text-destructive">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span>Failed to load Drive folders: {error.message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subheader Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
|
||||
{candidates ? (
|
||||
<>
|
||||
<span className="font-semibold text-foreground">{candidates.length}</span> candidate folder{candidates.length === 1 ? "" : "s"} found
|
||||
</>
|
||||
) : (
|
||||
"Scanning Google Drive..."
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="h-7.5 text-xs px-2.5 gap-1.5 font-medium border-border/80 bg-background/60 hover:bg-muted/70 shadow-xs"
|
||||
title="Rescan root folders"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${isFetching ? "animate-spin text-blue-500" : ""}`} />
|
||||
<span className="hidden sm:inline">Rescan</span>
|
||||
</Button>
|
||||
{candidates && candidates.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSelectAll}
|
||||
className="h-7.5 text-xs px-2.5 font-medium border-border/80 bg-background/60 hover:bg-muted/70 shadow-xs"
|
||||
>
|
||||
{allSelected ? "Deselect All" : "Select All"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Folder Candidate List */}
|
||||
<div className="max-h-64 overflow-y-auto rounded-xl border border-border/70 bg-background/50 divide-y divide-border/50 shadow-inner">
|
||||
{isLoading ? (
|
||||
<div className="py-12 flex flex-col items-center justify-center gap-2 text-center text-xs text-muted-foreground">
|
||||
<RefreshCw className="w-5 h-5 animate-spin text-blue-500" />
|
||||
<span>Scanning Google Drive root folders...</span>
|
||||
</div>
|
||||
) : !candidates || candidates.length === 0 ? (
|
||||
<div className="py-10 px-4 text-center space-y-1">
|
||||
<div className="p-2.5 rounded-full bg-muted/60 text-muted-foreground w-fit mx-auto mb-2">
|
||||
<FolderCheck className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="text-xs font-semibold text-foreground">No Unmanaged Folders</div>
|
||||
<p className="text-[11px] text-muted-foreground max-w-xs mx-auto">
|
||||
All root folders in Google Drive are already managed or no other top-level folders exist.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
candidates.map((candidate) => {
|
||||
const isSelected = selectedNames.has(candidate.name)
|
||||
return (
|
||||
<label
|
||||
key={candidate.folderId}
|
||||
className={`flex items-center justify-between p-3 cursor-pointer transition-colors select-none ${
|
||||
isSelected
|
||||
? "bg-blue-500/10 dark:bg-blue-500/15"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleSelect(candidate.name)}
|
||||
disabled={isPending}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<div
|
||||
className={`w-4.5 h-4.5 rounded-md border flex items-center justify-center transition-all ${
|
||||
isSelected
|
||||
? "bg-blue-600 border-blue-600 text-white shadow-xs shadow-blue-500/30"
|
||||
: "border-border/80 bg-card hover:border-border"
|
||||
}`}
|
||||
>
|
||||
{isSelected && <Check className="w-3 h-3 stroke-3" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="p-1 rounded-md bg-amber-500/10 text-amber-500 shrink-0">
|
||||
<Folder className="w-4 h-4" />
|
||||
</div>
|
||||
<span className="text-xs sm:text-sm font-semibold text-foreground truncate">
|
||||
{candidate.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-[11px] sm:text-xs font-mono font-medium text-muted-foreground px-2 py-0.5 rounded-md bg-muted/60 shrink-0 border border-border/40 ml-2">
|
||||
{candidate.objectCount} {candidate.objectCount === 1 ? "object" : "objects"}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<Info className="w-3.5 h-3.5 shrink-0 text-blue-500" />
|
||||
<span>Selected folders will be moved under your configured storage root directory.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2.5 p-4 sm:px-6 bg-muted/30 border-t border-border/60">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
disabled={isPending}
|
||||
className="h-9 px-3.5 font-medium border-border/80 bg-background/70 hover:bg-muted/80 shadow-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={selectedNames.size === 0 || isPending}
|
||||
className="h-9 px-4 font-medium shadow-sm shadow-blue-500/25 gap-1.5"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
<span>Importing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DownloadCloud className="w-3.5 h-3.5" />
|
||||
<span>Import {selectedNames.size > 0 ? `(${selectedNames.size})` : ""}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { createBucket } from "../api/buckets.api"
|
||||
import type { CreateBucketRequest } from "../api/buckets.types"
|
||||
import { statusKeys } from "../../status/api/status.keys"
|
||||
|
||||
export function useCreateBucket() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateBucketRequest) => createBucket(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: statusKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { deleteBucket } from "../api/buckets.api"
|
||||
import { statusKeys } from "../../status/api/status.keys"
|
||||
|
||||
export function useDeleteBucket() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (name: string) => deleteBucket(name),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: statusKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { importBuckets } from "../api/buckets.api"
|
||||
import { statusKeys } from "../../status/api/status.keys"
|
||||
import { bucketKeys } from "../api/buckets.keys"
|
||||
|
||||
export function useImportBuckets() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (names: string[]) => importBuckets(names),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: statusKeys.all })
|
||||
queryClient.invalidateQueries({ queryKey: bucketKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { fetchImportCandidates } from "../api/buckets.api"
|
||||
import { bucketKeys } from "../api/buckets.keys"
|
||||
|
||||
export function useImportCandidates(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: bucketKeys.importCandidates(),
|
||||
queryFn: fetchImportCandidates,
|
||||
enabled: options?.enabled ?? true,
|
||||
staleTime: 10_000,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { updateBucket } from "../api/buckets.api"
|
||||
import type { UpdateBucketRequest } from "../api/buckets.types"
|
||||
import { statusKeys } from "../../status/api/status.keys"
|
||||
|
||||
export function useUpdateBucket() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ name, data }: { name: string; data: UpdateBucketRequest }) =>
|
||||
updateBucket(name, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: statusKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,96 +1 @@
|
||||
import type { BucketStatsResponse, StatusResponse } from "./status.types"
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
export async function mockFetchStatus(): Promise<StatusResponse> {
|
||||
await delay(400)
|
||||
return {
|
||||
gateway: {
|
||||
status: "ok",
|
||||
region: "auto",
|
||||
multipartEnabled: true,
|
||||
etagStyle: "md5",
|
||||
docsEnabled: true,
|
||||
buckets: ["documents", "media-storage", "backups", "public-assets"],
|
||||
publicReadBuckets: ["public-assets"],
|
||||
corsOrigins: ["http://localhost:5173"],
|
||||
credentials: {
|
||||
s3Keys: true,
|
||||
googleOAuth: true,
|
||||
dashboardPassword: true,
|
||||
},
|
||||
},
|
||||
drive: {
|
||||
connected: true,
|
||||
account: {
|
||||
email: "demo-storage@example.com",
|
||||
displayName: "S3 Drive Bridge Admin",
|
||||
},
|
||||
quota: {
|
||||
limit: 15 * 1024 * 1024 * 1024, // 15 GB
|
||||
usage: 6.4 * 1024 * 1024 * 1024, // 6.4 GB
|
||||
usageInDrive: 6.2 * 1024 * 1024 * 1024,
|
||||
usageInDriveTrash: 200 * 1024 * 1024,
|
||||
free: 8.6 * 1024 * 1024 * 1024,
|
||||
percentUsed: 42.7,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
checkedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockFetchBucketStats(): Promise<BucketStatsResponse> {
|
||||
await delay(600)
|
||||
const buckets = [
|
||||
{
|
||||
name: "documents",
|
||||
objectCount: 142,
|
||||
totalSize: 1024 * 1024 * 340, // 340 MB
|
||||
lastModified: new Date(Date.now() - 1000 * 60 * 15).toISOString(),
|
||||
truncated: false,
|
||||
publicRead: false,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
name: "media-storage",
|
||||
objectCount: 890,
|
||||
totalSize: 1024 * 1024 * 1024 * 5.2, // 5.2 GB
|
||||
lastModified: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(),
|
||||
truncated: false,
|
||||
publicRead: false,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
name: "backups",
|
||||
objectCount: 24,
|
||||
totalSize: 1024 * 1024 * 850, // 850 MB
|
||||
lastModified: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
|
||||
truncated: false,
|
||||
publicRead: false,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
name: "public-assets",
|
||||
objectCount: 65,
|
||||
totalSize: 1024 * 1024 * 45, // 45 MB
|
||||
lastModified: new Date(Date.now() - 1000 * 60 * 5).toISOString(),
|
||||
truncated: false,
|
||||
publicRead: true,
|
||||
error: null,
|
||||
},
|
||||
]
|
||||
|
||||
const totalCount = buckets.reduce((acc, b) => acc + b.objectCount, 0)
|
||||
const totalSize = buckets.reduce((acc, b) => acc + b.totalSize, 0)
|
||||
|
||||
return {
|
||||
buckets,
|
||||
totals: {
|
||||
buckets: buckets.length,
|
||||
objectCount: totalCount,
|
||||
totalSize,
|
||||
},
|
||||
cachedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
export { mockFetchStatus, mockFetchBucketStats } from "../../buckets/api/buckets.mock"
|
||||
|
||||
@@ -6,6 +6,11 @@ export interface GatewayConfig {
|
||||
docsEnabled: boolean
|
||||
buckets: string[]
|
||||
publicReadBuckets: string[]
|
||||
rootFolder: {
|
||||
name: string | null
|
||||
id: string | null
|
||||
configured: boolean
|
||||
}
|
||||
corsOrigins: string[]
|
||||
credentials: {
|
||||
s3Keys: boolean
|
||||
|
||||
@@ -1,22 +1,36 @@
|
||||
import { useState } from "react"
|
||||
import { useState, useMemo } from "react"
|
||||
import {
|
||||
FolderTree,
|
||||
RotateCw,
|
||||
Sparkles,
|
||||
AlertCircle,
|
||||
Plus,
|
||||
DownloadCloud,
|
||||
Trash2,
|
||||
Files,
|
||||
Search,
|
||||
Globe,
|
||||
Lock,
|
||||
Files,
|
||||
ArrowUpDown,
|
||||
HardDrive,
|
||||
CheckCircle2,
|
||||
RefreshCw,
|
||||
} from "lucide-react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Card, CardContent, CardDescription, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { formatBytes, formatRelativeTime } from "@/lib/format"
|
||||
import { fetchBucketStats } from "../api/status.api"
|
||||
import { statusKeys } from "../api/status.keys"
|
||||
import type { BucketStatsResponse } from "../api/status.types"
|
||||
import { useUpdateBucket } from "../../buckets/hooks/useUpdateBucket"
|
||||
import { CreateBucketDialog } from "../../buckets/components/CreateBucketDialog"
|
||||
import { DeleteBucketDialog } from "../../buckets/components/DeleteBucketDialog"
|
||||
import { ImportBucketsDialog } from "../../buckets/components/ImportBucketsDialog"
|
||||
|
||||
interface BucketStatsTableProps {
|
||||
stats?: BucketStatsResponse
|
||||
@@ -24,9 +38,25 @@ interface BucketStatsTableProps {
|
||||
error?: Error | null
|
||||
}
|
||||
|
||||
type SortField = "name" | "objectCount" | "totalSize" | "lastModified"
|
||||
type SortOrder = "asc" | "desc"
|
||||
type FilterTab = "all" | "public" | "private"
|
||||
|
||||
export function BucketStatsTable({ stats, isLoading, error }: BucketStatsTableProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||
const [isImportOpen, setIsImportOpen] = useState(false)
|
||||
const [deletingBucket, setDeletingBucket] = useState<string | null>(null)
|
||||
const [updatingBucket, setUpdatingBucket] = useState<string | null>(null)
|
||||
|
||||
// Search, filter and sorting states
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [filterTab, setFilterTab] = useState<FilterTab>("all")
|
||||
const [sortField, setSortField] = useState<SortField>("name")
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
|
||||
|
||||
const { mutate: updateBucketMutate } = useUpdateBucket()
|
||||
|
||||
const handleForceRefresh = async () => {
|
||||
try {
|
||||
@@ -38,136 +68,579 @@ export function BucketStatsTable({ stats, isLoading, error }: BucketStatsTablePr
|
||||
}
|
||||
}
|
||||
|
||||
const handleTogglePublicRead = (bucketName: string, currentPublic: boolean) => {
|
||||
setUpdatingBucket(bucketName)
|
||||
updateBucketMutate(
|
||||
{
|
||||
name: bucketName,
|
||||
data: { publicRead: !currentPublic },
|
||||
},
|
||||
{
|
||||
onSettled: () => {
|
||||
setUpdatingBucket(null)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc")
|
||||
} else {
|
||||
setSortField(field)
|
||||
setSortOrder("asc")
|
||||
}
|
||||
}
|
||||
|
||||
const buckets = stats?.buckets || []
|
||||
|
||||
// Count summaries
|
||||
const publicCount = useMemo(() => buckets.filter((b) => b.publicRead).length, [buckets])
|
||||
const privateCount = useMemo(() => buckets.filter((b) => !b.publicRead).length, [buckets])
|
||||
|
||||
// Filtered and sorted bucket list
|
||||
const filteredBuckets = useMemo(() => {
|
||||
return buckets
|
||||
.filter((bucket) => {
|
||||
// Tab filter
|
||||
if (filterTab === "public" && !bucket.publicRead) return false
|
||||
if (filterTab === "private" && bucket.publicRead) return false
|
||||
|
||||
// Search term filter
|
||||
if (searchTerm.trim()) {
|
||||
const term = searchTerm.toLowerCase().trim()
|
||||
return bucket.name.toLowerCase().includes(term)
|
||||
}
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => {
|
||||
let comp = 0
|
||||
if (sortField === "name") {
|
||||
comp = a.name.localeCompare(b.name)
|
||||
} else if (sortField === "objectCount") {
|
||||
comp = (a.objectCount || 0) - (b.objectCount || 0)
|
||||
} else if (sortField === "totalSize") {
|
||||
comp = (a.totalSize || 0) - (b.totalSize || 0)
|
||||
} else if (sortField === "lastModified") {
|
||||
const timeA = a.lastModified ? new Date(a.lastModified).getTime() : 0
|
||||
const timeB = b.lastModified ? new Date(b.lastModified).getTime() : 0
|
||||
comp = timeA - timeB
|
||||
}
|
||||
return sortOrder === "asc" ? comp : -comp
|
||||
})
|
||||
}, [buckets, filterTab, searchTerm, sortField, sortOrder])
|
||||
|
||||
return (
|
||||
<Card className="lg:col-span-2 border-border/80 bg-card/85 backdrop-blur-sm shadow-md flex flex-col justify-between">
|
||||
<div>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-blue-500/10 text-blue-600 dark:text-blue-400">
|
||||
<FolderTree className="h-4 w-4" />
|
||||
<>
|
||||
<Card className="w-full border-border/80 bg-card/90 backdrop-blur-md shadow-lg overflow-hidden flex flex-col transition-all">
|
||||
{/* Top Header Section */}
|
||||
<div className="border-b border-border/70 bg-gradient-to-r from-muted/30 via-background/40 to-muted/20 px-4 py-5 sm:px-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
{/* Title & Stats Badges */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
<div className="p-2 rounded-xl bg-gradient-to-tr from-blue-600/15 via-indigo-600/15 to-cyan-500/15 border border-blue-500/20 text-blue-600 dark:text-blue-400 shadow-xs">
|
||||
<FolderTree className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<CardTitle className="text-lg font-bold tracking-tight text-foreground">
|
||||
Bucket Explorer
|
||||
</CardTitle>
|
||||
{stats && (
|
||||
<Badge variant="neutral" className="text-[11px] font-semibold px-2 py-0.5 border border-border/80">
|
||||
{stats.totals.buckets} {stats.totals.buckets === 1 ? "Bucket" : "Buckets"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="text-xs text-muted-foreground mt-0.5">
|
||||
Explore and manage S3 storage buckets mapped directly to Google Drive folders
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-base font-bold">Bucket Explorer</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{stats && (
|
||||
<span className="text-xs text-muted-foreground hidden sm:inline">
|
||||
{stats.totals.buckets} {stats.totals.buckets === 1 ? "bucket" : "buckets"} · {stats.totals.objectCount} objects · {formatBytes(stats.totals.totalSize)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Main Action Buttons */}
|
||||
<div className="flex items-center gap-2 flex-wrap sm:flex-nowrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsImportOpen(true)}
|
||||
disabled={isLoading}
|
||||
className="h-8 text-xs px-3 rounded-lg gap-1.5 font-medium shadow-xs"
|
||||
title="Import existing Google Drive folders into your storage root"
|
||||
>
|
||||
<DownloadCloud className="h-3.5 w-3.5 text-blue-500" />
|
||||
<span>Import from Drive</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
disabled={isLoading}
|
||||
className="h-8 text-xs px-3.5 rounded-lg gap-1.5 font-medium shadow-sm shadow-blue-500/20"
|
||||
title="Create a new storage bucket"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<span>New Bucket</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleForceRefresh}
|
||||
disabled={isLoading || isRefreshing}
|
||||
className="h-7 text-xs px-2.5 rounded-lg gap-1"
|
||||
title="Bypass KV cache and recalculate bucket statistics"
|
||||
className="h-8 text-xs px-2.5 rounded-lg gap-1.5 font-medium shadow-xs"
|
||||
title="Recalculate bucket stats from live storage"
|
||||
>
|
||||
<RotateCw className={`h-3 w-3 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
<span>{isRefreshing ? "Scanning..." : "Recalculate"}</span>
|
||||
<RotateCw className={`h-3.5 w-3.5 ${isRefreshing ? "animate-spin text-blue-500" : ""}`} />
|
||||
<span className="hidden sm:inline">{isRefreshing ? "Scanning..." : "Recalculate"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription className="text-xs text-muted-foreground">
|
||||
Allowlisted S3 buckets mapped to Google Drive folders with size and object counts.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* Quick Metrics Bar (when stats loaded) */}
|
||||
{stats && stats.buckets.length > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2.5 sm:gap-3 mt-4 pt-4 border-t border-border/50">
|
||||
<div className="flex items-center gap-2.5 p-2.5 rounded-xl bg-background/60 border border-border/60">
|
||||
<div className="p-1.5 rounded-lg bg-blue-500/10 text-blue-500 shrink-0">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-semibold uppercase text-muted-foreground tracking-wider">Total Storage</div>
|
||||
<div className="text-xs font-bold text-foreground font-mono truncate">{formatBytes(stats.totals.totalSize)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 p-2.5 rounded-xl bg-background/60 border border-border/60">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500 shrink-0">
|
||||
<Files className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-semibold uppercase text-muted-foreground tracking-wider">Total Objects</div>
|
||||
<div className="text-xs font-bold text-foreground font-mono truncate">{stats.totals.objectCount.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 p-2.5 rounded-xl bg-background/60 border border-border/60">
|
||||
<div className="p-1.5 rounded-lg bg-emerald-500/10 text-emerald-500 shrink-0">
|
||||
<Globe className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-semibold uppercase text-muted-foreground tracking-wider">Public Buckets</div>
|
||||
<div className="text-xs font-bold text-emerald-600 dark:text-emerald-400 font-mono truncate">{publicCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 p-2.5 rounded-xl bg-background/60 border border-border/60">
|
||||
<div className="p-1.5 rounded-lg bg-zinc-500/10 text-zinc-500 dark:text-zinc-400 shrink-0">
|
||||
<Lock className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-semibold uppercase text-muted-foreground tracking-wider">Private Buckets</div>
|
||||
<div className="text-xs font-bold text-foreground font-mono truncate">{privateCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content Body */}
|
||||
<CardContent className="p-4 sm:p-6 space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full rounded-lg" />
|
||||
<Skeleton className="h-12 w-full rounded-lg" />
|
||||
<Skeleton className="h-12 w-full rounded-lg" />
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-9 w-64 rounded-lg" />
|
||||
<Skeleton className="h-9 w-32 rounded-lg" />
|
||||
</div>
|
||||
<Skeleton className="h-12 w-full rounded-xl" />
|
||||
<Skeleton className="h-12 w-full rounded-xl" />
|
||||
<Skeleton className="h-12 w-full rounded-xl" />
|
||||
</div>
|
||||
) : error || !stats ? (
|
||||
<div className="p-6 rounded-xl border border-destructive/30 bg-destructive/5 text-center space-y-2">
|
||||
<AlertCircle className="h-6 w-6 text-destructive mx-auto" />
|
||||
<div className="text-sm font-semibold text-destructive">Failed to load bucket stats</div>
|
||||
<p className="text-xs text-muted-foreground">{error?.message || "Unknown error occurred"}</p>
|
||||
<div className="p-8 rounded-2xl border border-destructive/30 bg-destructive/5 text-center space-y-3">
|
||||
<div className="p-3 rounded-full bg-destructive/10 text-destructive w-fit mx-auto">
|
||||
<AlertCircle className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-bold text-destructive">Failed to Load Bucket Stats</div>
|
||||
<p className="text-xs text-muted-foreground max-w-md mx-auto">{error?.message || "Unknown error occurred"}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleForceRefresh} className="h-8 text-xs gap-1.5">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
<span>Retry Connection</span>
|
||||
</Button>
|
||||
</div>
|
||||
) : stats.buckets.length === 0 ? (
|
||||
<div className="h-36 rounded-xl border border-dashed border-border flex flex-col items-center justify-center p-6 text-center bg-muted/20">
|
||||
<Files className="h-6 w-6 text-muted-foreground mb-2" />
|
||||
<div className="text-sm font-medium text-foreground">No buckets configured</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Set ALLOWED_BUCKETS in your Worker configuration to expose buckets.
|
||||
</p>
|
||||
<div className="py-12 px-4 rounded-2xl border border-dashed border-border/80 flex flex-col items-center justify-center text-center bg-muted/15 gap-4">
|
||||
<div className="p-4 rounded-2xl bg-gradient-to-tr from-blue-500/10 via-indigo-500/10 to-cyan-500/10 border border-blue-500/20 text-blue-600 dark:text-blue-400">
|
||||
<FolderTree className="h-8 w-8" />
|
||||
</div>
|
||||
<div className="max-w-md space-y-1">
|
||||
<div className="text-base font-bold text-foreground">No Buckets Configured Yet</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create a new storage bucket or discover and import existing Google Drive folders from your configured storage root.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center gap-2.5 pt-1">
|
||||
<Button size="sm" onClick={() => setIsCreateOpen(true)} className="h-8 text-xs px-4 gap-1.5 shadow-sm shadow-blue-500/20 font-medium">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<span>Create Bucket</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setIsImportOpen(true)} className="h-8 text-xs px-4 gap-1.5 font-medium">
|
||||
<DownloadCloud className="h-3.5 w-3.5 text-blue-500" />
|
||||
<span>Import from Google Drive</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-border/70">
|
||||
<table className="w-full text-xs text-left">
|
||||
<thead className="bg-muted/50 text-muted-foreground font-semibold border-b border-border/70">
|
||||
<tr>
|
||||
<th className="py-2.5 px-3">Bucket Name</th>
|
||||
<th className="py-2.5 px-3">Access</th>
|
||||
<th className="py-2.5 px-3 text-right">Objects</th>
|
||||
<th className="py-2.5 px-3 text-right">Total Size</th>
|
||||
<th className="py-2.5 px-3 text-right">Last Modified</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{stats.buckets.map((b) => (
|
||||
<tr key={b.name} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="py-2.5 px-3 font-medium text-foreground flex items-center gap-1.5 font-mono">
|
||||
<span>{b.name}</span>
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
{b.publicRead ? (
|
||||
<Badge variant="warning" className="text-[10px] gap-1 px-1.5 py-0">
|
||||
<Globe className="h-2.5 w-2.5" />
|
||||
Public Read
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="neutral" className="text-[10px] gap-1 px-1.5 py-0">
|
||||
<Lock className="h-2.5 w-2.5" />
|
||||
Private
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-right font-mono text-foreground">
|
||||
{b.error ? (
|
||||
<span className="text-destructive">Error</span>
|
||||
) : (
|
||||
<span>{b.truncated ? `≥ ${b.objectCount}` : b.objectCount}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-right font-mono text-foreground">
|
||||
{b.error ? "—" : formatBytes(b.totalSize)}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-right text-muted-foreground">
|
||||
{b.error ? (
|
||||
<span className="text-destructive text-[11px]" title={b.error}>
|
||||
Failed to scan
|
||||
</span>
|
||||
) : (
|
||||
formatRelativeTime(b.lastModified)
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="space-y-4">
|
||||
{/* Search, Filter Tabs & Sort Controls */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||||
{/* Search Bar */}
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Search buckets by name..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 h-8 text-xs bg-background/70 border-border/70 rounded-lg focus-visible:ring-1"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => setSearchTerm("")}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[10px] text-muted-foreground hover:text-foreground p-0.5 rounded-sm"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex items-center gap-1.5 self-start sm:self-auto p-1 bg-muted/50 rounded-lg border border-border/60">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilterTab("all")}
|
||||
className={`px-2.5 py-1 rounded-md text-xs font-medium transition-all ${
|
||||
filterTab === "all"
|
||||
? "bg-card text-foreground shadow-xs font-semibold"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
All ({buckets.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilterTab("public")}
|
||||
className={`px-2.5 py-1 rounded-md text-xs font-medium transition-all flex items-center gap-1 ${
|
||||
filterTab === "public"
|
||||
? "bg-card text-emerald-600 dark:text-emerald-400 shadow-xs font-semibold"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Globe className="h-3 w-3" />
|
||||
<span>Public ({publicCount})</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilterTab("private")}
|
||||
className={`px-2.5 py-1 rounded-md text-xs font-medium transition-all flex items-center gap-1 ${
|
||||
filterTab === "private"
|
||||
? "bg-card text-foreground shadow-xs font-semibold"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Lock className="h-3 w-3" />
|
||||
<span>Private ({privateCount})</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* No match state */}
|
||||
{filteredBuckets.length === 0 ? (
|
||||
<div className="py-10 text-center rounded-xl border border-dashed border-border/70 bg-muted/10 space-y-2">
|
||||
<Search className="h-6 w-6 text-muted-foreground mx-auto" />
|
||||
<div className="text-xs font-semibold text-foreground">No buckets matched your search</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Try changing your search term or clearing the active filter.
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSearchTerm("")
|
||||
setFilterTab("all")
|
||||
}}
|
||||
className="h-7 text-xs text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
Reset Filters
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop Table View (>= md screens) */}
|
||||
<div className="hidden md:block overflow-hidden rounded-xl border border-border/80 bg-card/40 shadow-xs">
|
||||
<table className="w-full text-xs text-left">
|
||||
<thead className="bg-muted/60 text-muted-foreground font-semibold border-b border-border/70 select-none">
|
||||
<tr>
|
||||
<th className="py-3 px-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("name")}
|
||||
className="flex items-center gap-1.5 hover:text-foreground transition-colors font-semibold"
|
||||
>
|
||||
<span>Bucket Name</span>
|
||||
<ArrowUpDown className={`h-3 w-3 ${sortField === "name" ? "text-blue-500" : "opacity-40"}`} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="py-3 px-4">Access Policy</th>
|
||||
<th className="py-3 px-4 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("objectCount")}
|
||||
className="inline-flex items-center gap-1.5 hover:text-foreground transition-colors font-semibold ml-auto"
|
||||
>
|
||||
<span>Objects</span>
|
||||
<ArrowUpDown className={`h-3 w-3 ${sortField === "objectCount" ? "text-blue-500" : "opacity-40"}`} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="py-3 px-4 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("totalSize")}
|
||||
className="inline-flex items-center gap-1.5 hover:text-foreground transition-colors font-semibold ml-auto"
|
||||
>
|
||||
<span>Total Size</span>
|
||||
<ArrowUpDown className={`h-3 w-3 ${sortField === "totalSize" ? "text-blue-500" : "opacity-40"}`} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="py-3 px-4 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("lastModified")}
|
||||
className="inline-flex items-center gap-1.5 hover:text-foreground transition-colors font-semibold ml-auto"
|
||||
>
|
||||
<span>Last Modified</span>
|
||||
<ArrowUpDown className={`h-3 w-3 ${sortField === "lastModified" ? "text-blue-500" : "opacity-40"}`} />
|
||||
</button>
|
||||
</th>
|
||||
<th className="py-3 px-4 text-center w-20">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{filteredBuckets.map((b) => {
|
||||
const isUpdating = updatingBucket === b.name
|
||||
return (
|
||||
<tr
|
||||
key={b.name}
|
||||
className="hover:bg-muted/40 transition-colors group"
|
||||
>
|
||||
{/* Name column */}
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="p-1.5 rounded-lg bg-blue-500/10 text-blue-600 dark:text-blue-400 group-hover:bg-blue-500/15 transition-colors">
|
||||
<FolderTree className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono font-semibold text-foreground text-xs">{b.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
s3://{b.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Access policy */}
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Switch
|
||||
checked={b.publicRead}
|
||||
disabled={isUpdating}
|
||||
onCheckedChange={() => handleTogglePublicRead(b.name, b.publicRead)}
|
||||
className="data-[state=checked]:bg-emerald-500"
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{b.publicRead ? (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
<Globe className="h-3 w-3" />
|
||||
Public Read
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-medium text-muted-foreground">
|
||||
<Lock className="h-3 w-3" />
|
||||
Private
|
||||
</span>
|
||||
)}
|
||||
{isUpdating && <RotateCw className="h-3 w-3 animate-spin text-blue-500" />}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Object count */}
|
||||
<td className="py-3 px-4 text-right">
|
||||
{b.error ? (
|
||||
<span className="text-destructive font-mono text-[11px]">Error</span>
|
||||
) : (
|
||||
<div className="inline-flex items-center gap-1 font-mono font-medium text-foreground">
|
||||
<span>{b.truncated ? `≥ ${b.objectCount.toLocaleString()}` : b.objectCount.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Size */}
|
||||
<td className="py-3 px-4 text-right">
|
||||
{b.error ? (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
) : (
|
||||
<span className="font-mono font-semibold text-foreground">
|
||||
{formatBytes(b.totalSize)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Last modified */}
|
||||
<td className="py-3 px-4 text-right">
|
||||
{b.error ? (
|
||||
<span className="text-destructive text-[11px] font-medium" title={b.error}>
|
||||
Scan failed
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-[11px]">
|
||||
{formatRelativeTime(b.lastModified)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="py-3 px-4 text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeletingBucket(b.name)}
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors"
|
||||
title="Delete bucket"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile & Tablet Card List View (< md screens) */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 md:hidden">
|
||||
{filteredBuckets.map((b) => {
|
||||
const isUpdating = updatingBucket === b.name
|
||||
return (
|
||||
<div
|
||||
key={b.name}
|
||||
className="p-4 rounded-xl border border-border/80 bg-card/60 backdrop-blur-sm space-y-3 shadow-xs hover:border-border transition-all"
|
||||
>
|
||||
{/* Header of bucket card */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-600 dark:text-blue-400 shrink-0">
|
||||
<FolderTree className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono font-bold text-sm text-foreground truncate">{b.name}</div>
|
||||
<div className="text-[10px] font-mono text-muted-foreground">s3://{b.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeletingBucket(b.name)}
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg shrink-0"
|
||||
title="Delete bucket"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid inside card */}
|
||||
<div className="grid grid-cols-2 gap-2 p-2.5 rounded-lg bg-secondary/40 border border-border/50 text-xs">
|
||||
<div>
|
||||
<span className="text-[10px] uppercase font-semibold text-muted-foreground block">Size</span>
|
||||
<span className="font-mono font-bold text-foreground">
|
||||
{b.error ? "—" : formatBytes(b.totalSize)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] uppercase font-semibold text-muted-foreground block">Objects</span>
|
||||
<span className="font-mono font-bold text-foreground">
|
||||
{b.error ? "Error" : b.truncated ? `≥ ${b.objectCount}` : b.objectCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Access Policy & Last Updated */}
|
||||
<div className="flex items-center justify-between pt-1 border-t border-border/50 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={b.publicRead}
|
||||
disabled={isUpdating}
|
||||
onCheckedChange={() => handleTogglePublicRead(b.name, b.publicRead)}
|
||||
className="data-[state=checked]:bg-emerald-500 scale-90"
|
||||
/>
|
||||
<span className={`text-[11px] font-medium ${b.publicRead ? "text-emerald-600 dark:text-emerald-400 font-semibold" : "text-muted-foreground"}`}>
|
||||
{b.publicRead ? "Public Read" : "Private"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-[10px] text-muted-foreground font-medium">
|
||||
{b.error ? "Scan error" : formatRelativeTime(b.lastModified)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* S3 actions info bar */}
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<span className="text-[11px] font-medium text-muted-foreground mr-1">Supported APIs:</span>
|
||||
{["s3:ListObjectsV2", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:CreateMultipartUpload"].map(
|
||||
(api) => (
|
||||
{/* S3 capabilities bottom bar */}
|
||||
<div className="pt-3 border-t border-border/60 flex flex-col sm:flex-row sm:items-center justify-between gap-2.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-[11px] font-semibold text-muted-foreground mr-1">S3 API Support:</span>
|
||||
{[
|
||||
{ name: "s3:ListObjectsV2", desc: "List bucket objects" },
|
||||
{ name: "s3:GetObject", desc: "Download objects" },
|
||||
{ name: "s3:PutObject", desc: "Upload files" },
|
||||
{ name: "s3:DeleteObject", desc: "Delete files" },
|
||||
{ name: "s3:CreateMultipartUpload", desc: "Multipart uploads" },
|
||||
].map((api) => (
|
||||
<span
|
||||
key={api}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-[11px] font-mono font-medium bg-secondary/80 text-secondary-foreground border border-border/60"
|
||||
key={api.name}
|
||||
title={api.desc}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-md text-[10px] font-mono font-medium bg-secondary/80 text-secondary-foreground border border-border/60 hover:border-border transition-colors cursor-default"
|
||||
>
|
||||
<Sparkles className="h-2.5 w-2.5 text-blue-500" />
|
||||
{api}
|
||||
{api.name}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
|
||||
<span>Direct Google Drive mapping active</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</Card>
|
||||
|
||||
<CreateBucketDialog open={isCreateOpen} onClose={() => setIsCreateOpen(false)} />
|
||||
<DeleteBucketDialog
|
||||
open={Boolean(deletingBucket)}
|
||||
onClose={() => setDeletingBucket(null)}
|
||||
bucketName={deletingBucket}
|
||||
/>
|
||||
<ImportBucketsDialog open={isImportOpen} onClose={() => setIsImportOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ShieldCheck, ArrowUpRight, CheckCircle2, XCircle } from "lucide-react"
|
||||
import { ShieldCheck, ArrowUpRight, CheckCircle2, XCircle, FolderTree } from "lucide-react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -31,6 +31,8 @@ export function GatewayConfigCard({ gateway, isLoading }: GatewayConfigCardProps
|
||||
const hasS3Keys = creds?.s3Keys ?? false
|
||||
const hasOAuth = creds?.googleOAuth ?? false
|
||||
const hasPassword = creds?.dashboardPassword ?? false
|
||||
const rootFolder = gateway?.rootFolder
|
||||
const isRootConfigured = rootFolder?.configured ?? false
|
||||
|
||||
return (
|
||||
<Card className="border-border/80 bg-card/85 backdrop-blur-sm shadow-md flex flex-col justify-between">
|
||||
@@ -40,13 +42,38 @@ export function GatewayConfigCard({ gateway, isLoading }: GatewayConfigCardProps
|
||||
<div className="p-1.5 rounded-md bg-indigo-500/10 text-indigo-600 dark:text-indigo-400">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
</div>
|
||||
<CardTitle className="text-base font-bold">Security & Protocol</CardTitle>
|
||||
<CardTitle className="text-base font-bold">Security & Storage Root</CardTitle>
|
||||
</div>
|
||||
<CardDescription className="text-xs">
|
||||
Authentication and credential verification
|
||||
Authentication, credentials, and storage root folder
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Storage Root Folder */}
|
||||
<div className="p-3 rounded-xl bg-secondary/40 border border-border/60 space-y-1">
|
||||
<div className="text-xs font-semibold text-foreground flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<FolderTree className="h-3.5 w-3.5 text-indigo-400" />
|
||||
<span>Drive Root Folder</span>
|
||||
</span>
|
||||
<Badge variant={isRootConfigured ? "success" : "danger"} className="text-[10px] px-1.5 py-0">
|
||||
{isRootConfigured ? (rootFolder?.name ?? "Configured") : "Not Configured"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground flex items-center gap-1">
|
||||
{isRootConfigured ? (
|
||||
<CheckCircle2 className="h-3 w-3 text-emerald-500 shrink-0" />
|
||||
) : (
|
||||
<XCircle className="h-3 w-3 text-red-500 shrink-0" />
|
||||
)}
|
||||
<span>
|
||||
{isRootConfigured
|
||||
? `All buckets anchored under '/${rootFolder?.name}'`
|
||||
: "DRIVE_ROOT_FOLDER missing in Worker configuration"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* AWS SigV4 */}
|
||||
<div className="p-3 rounded-xl bg-secondary/40 border border-border/60 space-y-1">
|
||||
<div className="text-xs font-semibold text-foreground flex items-center justify-between">
|
||||
|
||||
+14
-7
@@ -1,4 +1,4 @@
|
||||
import { QueryClient, QueryCache } from "@tanstack/react-query"
|
||||
import { QueryClient, QueryCache, MutationCache } from "@tanstack/react-query"
|
||||
import { ApiError } from "./api-client"
|
||||
import { clearAuthToken } from "./auth-storage"
|
||||
|
||||
@@ -8,17 +8,24 @@ import { clearAuthToken } from "./auth-storage"
|
||||
export function createQueryClient(): QueryClient {
|
||||
let client: QueryClient
|
||||
|
||||
const handle401 = (error: unknown) => {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
clearAuthToken()
|
||||
client?.clear()
|
||||
}
|
||||
}
|
||||
|
||||
const queryCache = new QueryCache({
|
||||
onError: (error) => {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
clearAuthToken()
|
||||
client?.clear()
|
||||
}
|
||||
},
|
||||
onError: handle401,
|
||||
})
|
||||
|
||||
const mutationCache = new MutationCache({
|
||||
onError: handle401,
|
||||
})
|
||||
|
||||
client = new QueryClient({
|
||||
queryCache,
|
||||
mutationCache,
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60_000,
|
||||
|
||||
@@ -17,7 +17,6 @@ import { GatewayStatusCard } from "@/features/status/components/GatewayStatusCar
|
||||
import { DriveQuotaCard } from "@/features/status/components/DriveQuotaCard"
|
||||
import { MultipartStatusCard } from "@/features/status/components/MultipartStatusCard"
|
||||
import { BucketStatsTable } from "@/features/status/components/BucketStatsTable"
|
||||
import { GatewayConfigCard } from "@/features/status/components/GatewayConfigCard"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { formatRelativeTime } from "@/lib/format"
|
||||
|
||||
@@ -191,16 +190,12 @@ export default function Dashboard() {
|
||||
</div>
|
||||
|
||||
{/* Feature Capabilities & Specifications */}
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="w-full">
|
||||
<BucketStatsTable
|
||||
stats={bucketStatsData}
|
||||
isLoading={isBucketsLoading}
|
||||
error={bucketsError}
|
||||
/>
|
||||
<GatewayConfigCard
|
||||
gateway={statusData?.gateway}
|
||||
isLoading={isStatusLoading}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user