Arcadia wiring: - home: real Overview dashboard (tenants/users/audit/health probe) replacing the inherited Vibespace welcome tiles; skeleton loaders, refresh button, registers admin context - profile: split into Account (synced via getUser/updateUser of session user) and local Preferences; updateSessionUser keeps the appbar in sync after edits - session: drop unused signIn mock, add updateSessionUser, refresh tests - profile schema: drop redundant Profile.name/email (session is the source of truth) - routes: delete orphaned resources route + lib Auth flows that previously 404'd: - /signup, /login/forgot, /login/reset, /login/2fa wired via @crema/arcadia-auth-ui - shared AuthShell + AuthBrand wrapper Assistant tools (admin-tools.ts): - +10 tools: deactivate_tenant, set_user_status, delete_user, list_memberships, list_roles, revoke_api_key, create_user, update_user, assign_role, remove_role - list_memberships gains user_id filter for "tenants this user belongs to" queries - search_kb / read_chunk: new token resolution (window override → VITE_ARCADIA_SEARCH_TOKEN service token → operator session JWT → "dev"); on 401/403 emit a tailored hint based on which token was used UI consistency: - new PageHeader component - AppShell.title was unrendered — dropped; first-child padding on #main-content keeps the floating actions pill from colliding with header content - removed dead "Sign in required" fallback cards from 14 routes (AppShell already redirects) - stripped p-6 from outer wrappers across 14 routes (was double-padding under AppShell's own p-6) - migrated home + tenants to PageHeader arcadia-search ergonomics: - scripts/mint-search-token.mjs + `npm run mint:search-token` mints HS512 JWT with required tenant_id claim, upserts VITE_ARCADIA_SEARCH_TOKEN into .env.local - README/.env document the new VITE_ARCADIA_SEARCH_URL / VITE_ARCADIA_SEARCH_TOKEN knobs - .env.local now gitignored Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1471 lines
44 KiB
TypeScript
1471 lines
44 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react"
|
|
import {
|
|
ArrowLeft,
|
|
Boxes,
|
|
CheckCircle2,
|
|
Copy,
|
|
Database,
|
|
Eye,
|
|
ExternalLink,
|
|
FolderOpen,
|
|
Grid3x3,
|
|
List as ListIcon,
|
|
Plus,
|
|
RefreshCw,
|
|
Settings as SettingsIcon,
|
|
Shield,
|
|
Trash2,
|
|
} from "lucide-react"
|
|
|
|
import { ArcadiaError, useArcadiaClient } from "@crema/arcadia-client"
|
|
import {
|
|
ActionsCell,
|
|
DataTable,
|
|
DateCell,
|
|
Pagination,
|
|
useTable,
|
|
type ActionItem,
|
|
type Column,
|
|
} from "@crema/table-ui"
|
|
import { SearchInput } from "@crema/search-ui"
|
|
import { AlertBanner, ConfirmDialog, EmptyState, LoadingOverlay } from "@crema/feedback-ui"
|
|
import { FileGrid, FileList, formatBytes, type FileItem } from "@crema/file-ui"
|
|
import { KpiTile, formatCompact } from "@crema/dashboard-ui"
|
|
|
|
import { AppShell } from "~/components/layout/app-shell"
|
|
import { Badge } from "~/components/ui/badge"
|
|
import { Button } from "~/components/ui/button"
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "~/components/ui/card"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "~/components/ui/dialog"
|
|
import { Input } from "~/components/ui/input"
|
|
import { Label } from "~/components/ui/label"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "~/components/ui/select"
|
|
import { Switch } from "~/components/ui/switch"
|
|
import { Textarea } from "~/components/ui/textarea"
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"
|
|
import {
|
|
configureCors,
|
|
configurePolicy,
|
|
configureVersioning,
|
|
createBucket,
|
|
deleteBucket,
|
|
generateConfirmationCode,
|
|
getCors,
|
|
getPresignedUrl,
|
|
listBuckets,
|
|
listObjects,
|
|
listRegions,
|
|
type Bucket,
|
|
type BucketObject,
|
|
type CorsRule,
|
|
} from "~/lib/arcadia/buckets"
|
|
import {
|
|
listStorageConfigs,
|
|
type StorageConfig,
|
|
} from "~/lib/arcadia/storage-configs"
|
|
import { pageTitle } from "~/lib/page-meta"
|
|
import { useSession } from "~/lib/session"
|
|
import { useRegisterAdminContext } from "~/lib/admin-context"
|
|
|
|
export const meta = () => pageTitle("Buckets")
|
|
|
|
const SELECTED_CONFIG_KEY = "crema.buckets.selected-config"
|
|
|
|
type View =
|
|
| { kind: "list" }
|
|
| { kind: "objects"; bucket: Bucket }
|
|
|
|
type Editor =
|
|
| { kind: "create" }
|
|
| { kind: "configure"; bucket: Bucket }
|
|
| null
|
|
|
|
export default function BucketsRoute() {
|
|
const session = useSession()
|
|
const arcadia = useArcadiaClient()
|
|
|
|
const [configs, setConfigs] = useState<StorageConfig[]>([])
|
|
const [configId, setConfigId] = useState<string>(() =>
|
|
typeof window !== "undefined"
|
|
? (localStorage.getItem(SELECTED_CONFIG_KEY) ?? "")
|
|
: "",
|
|
)
|
|
const [buckets, setBuckets] = useState<Bucket[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [info, setInfo] = useState<string | null>(null)
|
|
const [view, setView] = useState<View>({ kind: "list" })
|
|
const [editor, setEditor] = useState<Editor>(null)
|
|
const [pendingDelete, setPendingDelete] = useState<Bucket | null>(null)
|
|
|
|
const activeConfig = useMemo(
|
|
() => configs.find((c) => c.id === configId),
|
|
[configs, configId],
|
|
)
|
|
|
|
// Load storage configs on mount; pick the default if no choice was persisted.
|
|
useEffect(() => {
|
|
if (!session) return
|
|
let mounted = true
|
|
listStorageConfigs(arcadia)
|
|
.then((rows) => {
|
|
if (!mounted) return
|
|
setConfigs(rows)
|
|
if (!configId) {
|
|
const def = rows.find((r) => r.is_default) ?? rows[0]
|
|
if (def) {
|
|
setConfigId(def.id)
|
|
localStorage.setItem(SELECTED_CONFIG_KEY, def.id)
|
|
}
|
|
} else if (!rows.find((r) => r.id === configId)) {
|
|
// Stored config no longer exists.
|
|
const def = rows.find((r) => r.is_default) ?? rows[0]
|
|
setConfigId(def?.id ?? "")
|
|
}
|
|
})
|
|
.catch((err) =>
|
|
setError(
|
|
err instanceof ArcadiaError ? err.message : "Failed to load storage configs.",
|
|
),
|
|
)
|
|
return () => {
|
|
mounted = false
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [session, arcadia])
|
|
|
|
useEffect(() => {
|
|
if (configId) localStorage.setItem(SELECTED_CONFIG_KEY, configId)
|
|
}, [configId])
|
|
|
|
const refresh = useCallback(async () => {
|
|
if (!configId) {
|
|
setBuckets([])
|
|
return
|
|
}
|
|
setError(null)
|
|
setLoading(true)
|
|
try {
|
|
setBuckets(await listBuckets(arcadia, configId))
|
|
} catch (err) {
|
|
setError(err instanceof ArcadiaError ? err.message : "Failed to load buckets.")
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [arcadia, configId])
|
|
|
|
useEffect(() => {
|
|
refresh()
|
|
}, [refresh])
|
|
|
|
const summary = useMemo(
|
|
() => ({
|
|
storage_config: activeConfig
|
|
? { id: activeConfig.id, name: activeConfig.name, backend: activeConfig.backend_type }
|
|
: null,
|
|
total_buckets: buckets.length,
|
|
total_objects: buckets.reduce((a, b) => a + (b.object_count ?? 0), 0),
|
|
total_size_bytes: buckets.reduce((a, b) => a + (b.size_bytes ?? 0), 0),
|
|
buckets: buckets.map((b) => ({
|
|
name: b.name,
|
|
region: b.region,
|
|
size_bytes: b.size_bytes,
|
|
object_count: b.object_count,
|
|
})),
|
|
}),
|
|
[activeConfig, buckets],
|
|
)
|
|
useRegisterAdminContext("buckets", summary)
|
|
|
|
return (
|
|
<AppShell>
|
|
<div className="flex flex-col gap-4">
|
|
<header className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">
|
|
{view.kind === "list"
|
|
? "Buckets"
|
|
: `${view.bucket.name}`}
|
|
</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
{view.kind === "list"
|
|
? "Storage backends, buckets, and their contents. Configure CORS, versioning, and policies per bucket."
|
|
: `Objects in ${view.bucket.name}.`}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{view.kind === "objects" ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setView({ kind: "list" })}
|
|
data-action="buckets-back"
|
|
>
|
|
<ArrowLeft className="size-4" />
|
|
Buckets
|
|
</Button>
|
|
) : (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={refresh}
|
|
disabled={loading || !configId}
|
|
data-action="buckets-refresh"
|
|
>
|
|
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
|
Refresh
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => setEditor({ kind: "create" })}
|
|
disabled={!configId}
|
|
data-action="buckets-create"
|
|
>
|
|
<Plus className="size-4" />
|
|
New bucket
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
{error ? (
|
|
<AlertBanner variant="error" dismissible onDismiss={() => setError(null)}>
|
|
{error}
|
|
</AlertBanner>
|
|
) : null}
|
|
{info ? (
|
|
<AlertBanner variant="success" dismissible onDismiss={() => setInfo(null)}>
|
|
{info}
|
|
</AlertBanner>
|
|
) : null}
|
|
|
|
{view.kind === "list" ? (
|
|
<>
|
|
<Card>
|
|
<CardHeader className="flex flex-row flex-wrap items-end gap-3">
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="buckets-config" className="text-xs">
|
|
Storage configuration
|
|
</Label>
|
|
<Select value={configId} onValueChange={setConfigId}>
|
|
<SelectTrigger
|
|
id="buckets-config"
|
|
className="w-72"
|
|
data-action="buckets-config-select"
|
|
>
|
|
<SelectValue placeholder="Pick a storage config" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{configs.length === 0 ? (
|
|
<SelectItem value="__none" disabled>
|
|
No storage configs yet — create one in Storage
|
|
</SelectItem>
|
|
) : (
|
|
configs.map((c) => (
|
|
<SelectItem key={c.id} value={c.id}>
|
|
{c.name}{" "}
|
|
<span className="text-xs text-muted-foreground">
|
|
({c.backend_type}
|
|
{c.is_default ? " · default" : ""})
|
|
</span>
|
|
</SelectItem>
|
|
))
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="ml-auto grid grid-cols-3 gap-3 min-w-0">
|
|
<KpiTile
|
|
label="Buckets"
|
|
value={formatCompact(buckets.length)}
|
|
size="sm"
|
|
/>
|
|
<KpiTile
|
|
label="Objects"
|
|
value={formatCompact(summary.total_objects)}
|
|
size="sm"
|
|
/>
|
|
<KpiTile
|
|
label="Size"
|
|
value={formatBytes(summary.total_size_bytes ?? 0)}
|
|
size="sm"
|
|
/>
|
|
</div>
|
|
</CardHeader>
|
|
</Card>
|
|
|
|
<BucketsTable
|
|
buckets={buckets}
|
|
loading={loading}
|
|
hasConfig={!!configId}
|
|
onOpen={(b) => setView({ kind: "objects", bucket: b })}
|
|
onConfigure={(b) => setEditor({ kind: "configure", bucket: b })}
|
|
onDelete={(b) => setPendingDelete(b)}
|
|
/>
|
|
</>
|
|
) : (
|
|
<ObjectBrowser
|
|
storageConfigId={configId}
|
|
bucket={view.bucket}
|
|
onError={setError}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Create */}
|
|
<CreateBucketDialog
|
|
open={editor?.kind === "create"}
|
|
configId={configId}
|
|
onClose={() => setEditor(null)}
|
|
onCreated={async (msg) => {
|
|
setEditor(null)
|
|
if (msg) setInfo(msg)
|
|
await refresh()
|
|
}}
|
|
onError={setError}
|
|
/>
|
|
|
|
{/* Configure (versioning / CORS / policy) */}
|
|
<ConfigureBucketDialog
|
|
state={editor?.kind === "configure" ? editor : null}
|
|
configId={configId}
|
|
onClose={() => setEditor(null)}
|
|
onChanged={async (msg) => {
|
|
if (msg) setInfo(msg)
|
|
await refresh()
|
|
}}
|
|
onError={setError}
|
|
/>
|
|
|
|
{/* Delete */}
|
|
<DeleteBucketFlow
|
|
bucket={pendingDelete}
|
|
configId={configId}
|
|
onClose={() => setPendingDelete(null)}
|
|
onDeleted={async (msg) => {
|
|
setPendingDelete(null)
|
|
if (msg) setInfo(msg)
|
|
await refresh()
|
|
}}
|
|
onError={setError}
|
|
/>
|
|
</AppShell>
|
|
)
|
|
}
|
|
|
|
// --- Buckets table -----------------------------------------------------
|
|
|
|
function BucketsTable({
|
|
buckets,
|
|
loading,
|
|
hasConfig,
|
|
onOpen,
|
|
onConfigure,
|
|
onDelete,
|
|
}: {
|
|
buckets: Bucket[]
|
|
loading: boolean
|
|
hasConfig: boolean
|
|
onOpen: (b: Bucket) => void
|
|
onConfigure: (b: Bucket) => void
|
|
onDelete: (b: Bucket) => void
|
|
}) {
|
|
const [search, setSearch] = useState("")
|
|
|
|
const columns = useMemo<Column<Bucket>[]>(
|
|
() => [
|
|
{
|
|
id: "name",
|
|
header: "Name",
|
|
accessor: "name",
|
|
sortable: true,
|
|
cell: (b) => (
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpen(b)}
|
|
data-action={`bucket-${b.name}-open`}
|
|
className="flex items-center gap-2 font-medium text-left hover:underline"
|
|
>
|
|
<Database className="size-4 text-muted-foreground" />
|
|
{b.name}
|
|
</button>
|
|
),
|
|
},
|
|
{
|
|
id: "region",
|
|
header: "Region",
|
|
accessor: "region",
|
|
sortable: true,
|
|
cell: (b) =>
|
|
b.region ? (
|
|
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">{b.region}</code>
|
|
) : (
|
|
<span className="text-muted-foreground">—</span>
|
|
),
|
|
},
|
|
{
|
|
id: "objects",
|
|
header: "Objects",
|
|
accessor: "object_count",
|
|
sortable: true,
|
|
cell: (b) =>
|
|
b.object_count != null ? (
|
|
<span className="font-mono text-xs">{b.object_count.toLocaleString()}</span>
|
|
) : (
|
|
<span className="text-muted-foreground">—</span>
|
|
),
|
|
},
|
|
{
|
|
id: "size",
|
|
header: "Size",
|
|
accessor: "size_bytes",
|
|
sortable: true,
|
|
cell: (b) =>
|
|
b.size_bytes != null ? (
|
|
<span className="text-xs">{formatBytes(b.size_bytes)}</span>
|
|
) : (
|
|
<span className="text-muted-foreground">—</span>
|
|
),
|
|
},
|
|
{
|
|
id: "created",
|
|
header: "Created",
|
|
accessor: "created_at",
|
|
sortable: true,
|
|
cell: (b) =>
|
|
b.created_at ? (
|
|
<DateCell value={b.created_at} format="short" />
|
|
) : (
|
|
<span className="text-muted-foreground">—</span>
|
|
),
|
|
},
|
|
{
|
|
id: "actions",
|
|
header: "",
|
|
align: "right",
|
|
cell: (b) => {
|
|
const items: ActionItem[] = [
|
|
{
|
|
id: "open",
|
|
label: "Browse objects",
|
|
icon: <FolderOpen className="size-4" />,
|
|
dataAction: `bucket-${b.name}-browse`,
|
|
onSelect: () => onOpen(b),
|
|
},
|
|
{
|
|
id: "configure",
|
|
label: "Configure",
|
|
icon: <SettingsIcon className="size-4" />,
|
|
dataAction: `bucket-${b.name}-configure`,
|
|
onSelect: () => onConfigure(b),
|
|
},
|
|
{
|
|
id: "delete",
|
|
label: "Delete",
|
|
icon: <Trash2 className="size-4" />,
|
|
destructive: true,
|
|
dataAction: `bucket-${b.name}-delete`,
|
|
onSelect: () => onDelete(b),
|
|
},
|
|
]
|
|
return (
|
|
<ActionsCell items={items} triggerDataAction={`bucket-${b.name}-actions`} />
|
|
)
|
|
},
|
|
},
|
|
],
|
|
[onOpen, onConfigure, onDelete],
|
|
)
|
|
|
|
const table = useTable<Bucket>({
|
|
data: buckets,
|
|
columns,
|
|
getRowId: (b) => b.name,
|
|
initialPageSize: 25,
|
|
initialSearch: search,
|
|
})
|
|
useEffect(() => {
|
|
table.setSearch(search)
|
|
}, [search, table])
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center gap-3">
|
|
<SearchInput
|
|
value={search}
|
|
onValueChange={setSearch}
|
|
placeholder="Search by name or region"
|
|
data-action="buckets-search"
|
|
className="max-w-sm flex-1"
|
|
/>
|
|
<div className="ml-auto text-xs text-muted-foreground">
|
|
{table.total} of {buckets.length}
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent className="relative p-0">
|
|
<LoadingOverlay active={loading && buckets.length === 0} label="Loading buckets…" />
|
|
{!hasConfig ? (
|
|
<EmptyState
|
|
icon={<Boxes className="size-6" />}
|
|
title="Pick a storage configuration"
|
|
description="Buckets are scoped to a credential. Add one under Storage if you don't have any yet."
|
|
className="py-12"
|
|
/>
|
|
) : table.total === 0 && !loading ? (
|
|
<EmptyState
|
|
icon={<Boxes className="size-6" />}
|
|
title={search ? "No buckets match." : "No buckets in this account."}
|
|
description={search ? "Try a different search." : "Create your first bucket."}
|
|
className="py-12"
|
|
/>
|
|
) : (
|
|
<>
|
|
<DataTable
|
|
columns={columns}
|
|
rows={table.pageRows}
|
|
getRowId={(b) => b.name}
|
|
sort={table.sort}
|
|
onSortToggle={table.toggleSort}
|
|
loading={loading && buckets.length > 0}
|
|
stickyHeader
|
|
/>
|
|
<Pagination
|
|
page={table.page}
|
|
pageSize={table.pageSize}
|
|
total={table.total}
|
|
onPageChange={table.setPage}
|
|
onPageSizeChange={table.setPageSize}
|
|
/>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
// --- Object browser ----------------------------------------------------
|
|
|
|
function ObjectBrowser({
|
|
storageConfigId,
|
|
bucket,
|
|
onError,
|
|
}: {
|
|
storageConfigId: string
|
|
bucket: Bucket
|
|
onError: (msg: string | null) => void
|
|
}) {
|
|
const arcadia = useArcadiaClient()
|
|
const [objects, setObjects] = useState<BucketObject[]>([])
|
|
const [prefix, setPrefix] = useState("")
|
|
const [loading, setLoading] = useState(true)
|
|
const [layout, setLayout] = useState<"grid" | "list">("list")
|
|
const [previewUrl, setPreviewUrl] = useState<{ url: string; key: string } | null>(null)
|
|
const [search, setSearch] = useState("")
|
|
|
|
const refresh = useCallback(async () => {
|
|
setLoading(true)
|
|
onError(null)
|
|
try {
|
|
const res = await listObjects(arcadia, {
|
|
storage_config_id: storageConfigId,
|
|
bucket_name: bucket.name,
|
|
prefix: prefix || undefined,
|
|
max_keys: 1000,
|
|
})
|
|
setObjects(res.objects ?? [])
|
|
} catch (err) {
|
|
onError(err instanceof ArcadiaError ? err.message : "Failed to load objects.")
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [arcadia, storageConfigId, bucket.name, prefix, onError])
|
|
|
|
useEffect(() => {
|
|
refresh()
|
|
}, [refresh])
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = search.trim().toLowerCase()
|
|
if (!q) return objects
|
|
return objects.filter((o) => o.key.toLowerCase().includes(q))
|
|
}, [objects, search])
|
|
|
|
const fileItems = useMemo<FileItem[]>(
|
|
() =>
|
|
filtered.map((o) => ({
|
|
id: o.key,
|
|
name: o.key.split("/").pop() || o.key,
|
|
size: o.size,
|
|
mime: guessMime(o.key),
|
|
modified: o.last_modified ?? undefined,
|
|
})),
|
|
[filtered],
|
|
)
|
|
|
|
const openPresigned = useCallback(
|
|
async (key: string) => {
|
|
try {
|
|
const res = await getPresignedUrl(arcadia, {
|
|
storage_config_id: storageConfigId,
|
|
bucket_name: bucket.name,
|
|
key,
|
|
expires_in: 3600,
|
|
})
|
|
setPreviewUrl({ url: res.url, key })
|
|
} catch (err) {
|
|
onError(err instanceof ArcadiaError ? err.message : "Presign failed.")
|
|
}
|
|
},
|
|
[arcadia, storageConfigId, bucket.name, onError],
|
|
)
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:items-end">
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="objects-prefix" className="text-xs">
|
|
Prefix / folder
|
|
</Label>
|
|
<Input
|
|
id="objects-prefix"
|
|
value={prefix}
|
|
onChange={(e) => setPrefix(e.target.value)}
|
|
placeholder="path/to/folder/"
|
|
className="w-64 font-mono text-xs"
|
|
data-action="objects-prefix"
|
|
/>
|
|
</div>
|
|
<SearchInput
|
|
value={search}
|
|
onValueChange={setSearch}
|
|
placeholder="Filter by name"
|
|
data-action="objects-search"
|
|
className="max-w-sm flex-1"
|
|
/>
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<Button
|
|
variant={layout === "list" ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => setLayout("list")}
|
|
data-action="objects-layout-list"
|
|
aria-label="List view"
|
|
>
|
|
<ListIcon className="size-4" />
|
|
</Button>
|
|
<Button
|
|
variant={layout === "grid" ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => setLayout("grid")}
|
|
data-action="objects-layout-grid"
|
|
aria-label="Grid view"
|
|
>
|
|
<Grid3x3 className="size-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={refresh}
|
|
disabled={loading}
|
|
data-action="objects-refresh"
|
|
>
|
|
<RefreshCw className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent className="relative p-4">
|
|
<LoadingOverlay active={loading && objects.length === 0} label="Loading objects…" />
|
|
{fileItems.length === 0 && !loading ? (
|
|
<EmptyState
|
|
icon={<FolderOpen className="size-6" />}
|
|
title={search || prefix ? "No matches." : "Empty bucket."}
|
|
description={
|
|
search || prefix
|
|
? "Adjust the filter or prefix."
|
|
: "Upload an object via your application; this view is read-only for now."
|
|
}
|
|
className="py-12"
|
|
/>
|
|
) : layout === "list" ? (
|
|
<FileList
|
|
files={fileItems}
|
|
onItemClick={(f) => openPresigned(f.id)}
|
|
renderAction={(f) => (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
openPresigned(f.id)
|
|
}}
|
|
data-action={`object-${f.id}-presign`}
|
|
>
|
|
<ExternalLink className="size-3.5" />
|
|
Link
|
|
</Button>
|
|
)}
|
|
/>
|
|
) : (
|
|
<FileGrid
|
|
files={fileItems}
|
|
onItemClick={(f) => openPresigned(f.id)}
|
|
minItemWidth={180}
|
|
/>
|
|
)}
|
|
</CardContent>
|
|
|
|
<PresignDialog reveal={previewUrl} onClose={() => setPreviewUrl(null)} />
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function PresignDialog({
|
|
reveal,
|
|
onClose,
|
|
}: {
|
|
reveal: { url: string; key: string } | null
|
|
onClose: () => void
|
|
}) {
|
|
const [copied, setCopied] = useState(false)
|
|
useEffect(() => {
|
|
if (!reveal) setCopied(false)
|
|
}, [reveal])
|
|
|
|
if (!reveal) return null
|
|
|
|
return (
|
|
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
|
<DialogContent className="sm:max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<ExternalLink className="size-5 text-primary" />
|
|
Presigned URL
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
<code className="font-mono text-xs">{reveal.key}</code> — valid for 1 hour.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex flex-col gap-2 rounded-md border bg-muted/30 p-3">
|
|
<code className="select-all break-all font-mono text-xs">{reveal.url}</code>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(reveal.url)
|
|
setCopied(true)
|
|
} catch {}
|
|
}}
|
|
data-action="presign-copy"
|
|
>
|
|
{copied ? <CheckCircle2 className="size-3.5" /> : <Copy className="size-3.5" />}
|
|
{copied ? "Copied" : "Copy"}
|
|
</Button>
|
|
<Button asChild size="sm" data-action="presign-open">
|
|
<a href={reveal.url} target="_blank" rel="noopener noreferrer">
|
|
<ExternalLink className="size-3.5" />
|
|
Open
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button onClick={onClose} variant="outline" data-action="presign-close">
|
|
Close
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
// --- Create / configure / delete dialogs --------------------------------
|
|
|
|
function CreateBucketDialog({
|
|
open,
|
|
configId,
|
|
onClose,
|
|
onCreated,
|
|
onError,
|
|
}: {
|
|
open: boolean
|
|
configId: string
|
|
onClose: () => void
|
|
onCreated: (msg?: string) => Promise<void>
|
|
onError: (msg: string | null) => void
|
|
}) {
|
|
const arcadia = useArcadiaClient()
|
|
const [name, setName] = useState("")
|
|
const [region, setRegion] = useState("")
|
|
const [acl, setAcl] = useState<"private" | "public-read">("private")
|
|
const [versioning, setVersioning] = useState(false)
|
|
const [regions, setRegions] = useState<string[]>([])
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setName("")
|
|
setRegion("")
|
|
setAcl("private")
|
|
setVersioning(false)
|
|
return
|
|
}
|
|
if (configId) {
|
|
listRegions(arcadia, configId)
|
|
.then(setRegions)
|
|
.catch(() => setRegions([]))
|
|
}
|
|
}, [open, arcadia, configId])
|
|
|
|
const submit = async () => {
|
|
onError(null)
|
|
setSaving(true)
|
|
try {
|
|
await createBucket(arcadia, {
|
|
storage_config_id: configId,
|
|
bucket_name: name,
|
|
region: region || undefined,
|
|
acl,
|
|
versioning,
|
|
})
|
|
await onCreated(`Bucket ${name} created.`)
|
|
} catch (err) {
|
|
onError(
|
|
err instanceof ArcadiaError
|
|
? err.message
|
|
: err instanceof Error
|
|
? err.message
|
|
: "Create failed.",
|
|
)
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>New bucket</DialogTitle>
|
|
<DialogDescription>
|
|
Bucket names must be globally unique on the provider and follow DNS rules
|
|
(lowercase, no underscores).
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="new-bucket-name">Name</Label>
|
|
<Input
|
|
id="new-bucket-name"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="my-app-uploads"
|
|
data-action="bucket-form-name"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="new-bucket-region">Region</Label>
|
|
{regions.length > 0 ? (
|
|
<Select value={region} onValueChange={setRegion}>
|
|
<SelectTrigger id="new-bucket-region" data-action="bucket-form-region">
|
|
<SelectValue placeholder="Pick a region" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{regions.map((r) => (
|
|
<SelectItem key={r} value={r}>
|
|
{r}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<Input
|
|
id="new-bucket-region"
|
|
value={region}
|
|
onChange={(e) => setRegion(e.target.value)}
|
|
placeholder="us-east-1"
|
|
data-action="bucket-form-region"
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label>ACL</Label>
|
|
<Select value={acl} onValueChange={(v) => setAcl(v as "private" | "public-read")}>
|
|
<SelectTrigger data-action="bucket-form-acl">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="private">Private</SelectItem>
|
|
<SelectItem value="public-read">Public read</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-center justify-between rounded-md border px-3 py-2">
|
|
<div>
|
|
<div className="text-sm font-medium">Versioning</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Keep object history. Can be enabled later.
|
|
</div>
|
|
</div>
|
|
<Switch
|
|
checked={versioning}
|
|
onCheckedChange={setVersioning}
|
|
data-action="bucket-form-versioning"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={onClose} disabled={saving} data-action="bucket-form-cancel">
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={submit} disabled={saving || !name} data-action="bucket-form-save">
|
|
{saving ? <RefreshCw className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
|
Create
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
function ConfigureBucketDialog({
|
|
state,
|
|
configId,
|
|
onClose,
|
|
onChanged,
|
|
onError,
|
|
}: {
|
|
state: { kind: "configure"; bucket: Bucket } | null
|
|
configId: string
|
|
onClose: () => void
|
|
onChanged: (msg?: string) => Promise<void>
|
|
onError: (msg: string | null) => void
|
|
}) {
|
|
const arcadia = useArcadiaClient()
|
|
const [versioningOn, setVersioningOn] = useState(false)
|
|
const [versioningSaving, setVersioningSaving] = useState(false)
|
|
|
|
const [corsRules, setCorsRules] = useState<CorsRule[]>([])
|
|
const [corsSaving, setCorsSaving] = useState(false)
|
|
const [corsLoading, setCorsLoading] = useState(false)
|
|
|
|
const [policyText, setPolicyText] = useState("")
|
|
const [policySaving, setPolicySaving] = useState(false)
|
|
|
|
const open = state !== null
|
|
|
|
useEffect(() => {
|
|
if (!open || !state) return
|
|
setCorsLoading(true)
|
|
getCors(arcadia, configId, state.bucket.name)
|
|
.then((res) => {
|
|
setCorsRules(res?.rules ?? [])
|
|
})
|
|
.catch(() => setCorsRules([]))
|
|
.finally(() => setCorsLoading(false))
|
|
}, [open, state, arcadia, configId])
|
|
|
|
if (!state) return null
|
|
const { bucket } = state
|
|
|
|
const saveVersioning = async () => {
|
|
setVersioningSaving(true)
|
|
onError(null)
|
|
try {
|
|
await configureVersioning(arcadia, {
|
|
storage_config_id: configId,
|
|
bucket_name: bucket.name,
|
|
enabled: versioningOn,
|
|
})
|
|
await onChanged(`Versioning ${versioningOn ? "enabled" : "suspended"}.`)
|
|
} catch (err) {
|
|
onError(err instanceof ArcadiaError ? err.message : "Save failed.")
|
|
} finally {
|
|
setVersioningSaving(false)
|
|
}
|
|
}
|
|
|
|
const saveCors = async () => {
|
|
setCorsSaving(true)
|
|
onError(null)
|
|
try {
|
|
await configureCors(arcadia, {
|
|
storage_config_id: configId,
|
|
bucket_name: bucket.name,
|
|
rules: corsRules,
|
|
})
|
|
await onChanged("CORS rules saved.")
|
|
} catch (err) {
|
|
onError(err instanceof ArcadiaError ? err.message : "Save failed.")
|
|
} finally {
|
|
setCorsSaving(false)
|
|
}
|
|
}
|
|
|
|
const savePolicy = async () => {
|
|
setPolicySaving(true)
|
|
onError(null)
|
|
try {
|
|
const policy = policyText.trim() === "" ? {} : JSON.parse(policyText)
|
|
await configurePolicy(arcadia, {
|
|
storage_config_id: configId,
|
|
bucket_name: bucket.name,
|
|
policy,
|
|
})
|
|
await onChanged("Bucket policy saved.")
|
|
} catch (err) {
|
|
onError(
|
|
err instanceof ArcadiaError
|
|
? err.message
|
|
: err instanceof Error
|
|
? `Invalid JSON or save failed: ${err.message}`
|
|
: "Save failed.",
|
|
)
|
|
} finally {
|
|
setPolicySaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
|
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Configure {bucket.name}</DialogTitle>
|
|
<DialogDescription>
|
|
Each section saves independently — changes to versioning don't touch CORS, etc.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<Tabs defaultValue="versioning">
|
|
<TabsList>
|
|
<TabsTrigger value="versioning" data-action="configure-tab-versioning">
|
|
Versioning
|
|
</TabsTrigger>
|
|
<TabsTrigger value="cors" data-action="configure-tab-cors">
|
|
CORS
|
|
</TabsTrigger>
|
|
<TabsTrigger value="policy" data-action="configure-tab-policy">
|
|
Policy
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="versioning" className="pt-4">
|
|
<div className="flex items-center justify-between rounded-md border px-3 py-2">
|
|
<div>
|
|
<div className="text-sm font-medium">Versioning enabled</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
When on, overwrites and deletes are kept as previous versions.
|
|
</div>
|
|
</div>
|
|
<Switch
|
|
checked={versioningOn}
|
|
onCheckedChange={setVersioningOn}
|
|
data-action="configure-versioning-toggle"
|
|
/>
|
|
</div>
|
|
<div className="mt-3 flex justify-end">
|
|
<Button
|
|
onClick={saveVersioning}
|
|
disabled={versioningSaving}
|
|
data-action="configure-versioning-save"
|
|
>
|
|
{versioningSaving ? (
|
|
<RefreshCw className="size-4 animate-spin" />
|
|
) : (
|
|
<CheckCircle2 className="size-4" />
|
|
)}
|
|
Save versioning
|
|
</Button>
|
|
</div>
|
|
<p className="mt-2 text-xs text-muted-foreground">
|
|
Note: this UI shows your intended state, not the current bucket setting (the
|
|
backend's GET endpoint isn't exposed). Submit to apply.
|
|
</p>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="cors" className="pt-4">
|
|
{corsLoading ? (
|
|
<p className="py-4 text-center text-sm text-muted-foreground">
|
|
<RefreshCw className="mr-1 inline size-3.5 animate-spin" /> Loading rules…
|
|
</p>
|
|
) : (
|
|
<CorsEditor rules={corsRules} onChange={setCorsRules} />
|
|
)}
|
|
<div className="mt-3 flex justify-end gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() =>
|
|
setCorsRules([
|
|
...corsRules,
|
|
{
|
|
allowed_origins: ["*"],
|
|
allowed_methods: ["GET"],
|
|
allowed_headers: [],
|
|
max_age_seconds: 3000,
|
|
},
|
|
])
|
|
}
|
|
data-action="configure-cors-add"
|
|
>
|
|
<Plus className="size-3.5" />
|
|
Add rule
|
|
</Button>
|
|
<Button
|
|
onClick={saveCors}
|
|
disabled={corsSaving}
|
|
data-action="configure-cors-save"
|
|
>
|
|
{corsSaving ? <RefreshCw className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
|
Save CORS
|
|
</Button>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="policy" className="pt-4">
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="bucket-policy" className="text-xs">
|
|
Bucket policy (JSON, AWS S3 format)
|
|
</Label>
|
|
<Textarea
|
|
id="bucket-policy"
|
|
value={policyText}
|
|
onChange={(e) => setPolicyText(e.target.value)}
|
|
rows={14}
|
|
placeholder='{\n "Version": "2012-10-17",\n "Statement": [...]\n}'
|
|
className="font-mono text-xs"
|
|
data-action="configure-policy-text"
|
|
spellCheck={false}
|
|
/>
|
|
</div>
|
|
<div className="mt-3 flex justify-end">
|
|
<Button
|
|
onClick={savePolicy}
|
|
disabled={policySaving}
|
|
data-action="configure-policy-save"
|
|
>
|
|
{policySaving ? (
|
|
<RefreshCw className="size-4 animate-spin" />
|
|
) : (
|
|
<Shield className="size-4" />
|
|
)}
|
|
Save policy
|
|
</Button>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={onClose} data-action="configure-close">
|
|
Close
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
function CorsEditor({
|
|
rules,
|
|
onChange,
|
|
}: {
|
|
rules: CorsRule[]
|
|
onChange: (next: CorsRule[]) => void
|
|
}) {
|
|
if (rules.length === 0) {
|
|
return (
|
|
<p className="rounded-md border bg-muted/30 p-4 text-center text-sm text-muted-foreground">
|
|
No CORS rules. Click "Add rule" to allow a browser origin to call this bucket.
|
|
</p>
|
|
)
|
|
}
|
|
return (
|
|
<ul className="flex flex-col gap-3">
|
|
{rules.map((r, i) => (
|
|
<li key={i} className="flex flex-col gap-2 rounded-md border p-3">
|
|
<div className="flex items-center justify-between">
|
|
<Badge variant="secondary" className="font-mono text-xs">
|
|
Rule {i + 1}
|
|
</Badge>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onChange(rules.filter((_, idx) => idx !== i))}
|
|
data-action={`configure-cors-rule-${i}-delete`}
|
|
>
|
|
<Trash2 className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<Label className="text-xs">Allowed origins (comma-separated)</Label>
|
|
<Input
|
|
value={r.allowed_origins.join(", ")}
|
|
onChange={(e) =>
|
|
onChange(
|
|
rules.map((rr, idx) =>
|
|
idx === i ? { ...rr, allowed_origins: csv(e.target.value) } : rr,
|
|
),
|
|
)
|
|
}
|
|
placeholder="https://example.com"
|
|
data-action={`configure-cors-rule-${i}-origins`}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">Allowed methods</Label>
|
|
<Input
|
|
value={r.allowed_methods.join(", ")}
|
|
onChange={(e) =>
|
|
onChange(
|
|
rules.map((rr, idx) =>
|
|
idx === i ? { ...rr, allowed_methods: csv(e.target.value) } : rr,
|
|
),
|
|
)
|
|
}
|
|
placeholder="GET, PUT, POST"
|
|
data-action={`configure-cors-rule-${i}-methods`}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">Allowed headers</Label>
|
|
<Input
|
|
value={(r.allowed_headers ?? []).join(", ")}
|
|
onChange={(e) =>
|
|
onChange(
|
|
rules.map((rr, idx) =>
|
|
idx === i ? { ...rr, allowed_headers: csv(e.target.value) } : rr,
|
|
),
|
|
)
|
|
}
|
|
placeholder="*"
|
|
data-action={`configure-cors-rule-${i}-headers`}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">Max age (seconds)</Label>
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
value={r.max_age_seconds ?? ""}
|
|
onChange={(e) =>
|
|
onChange(
|
|
rules.map((rr, idx) =>
|
|
idx === i
|
|
? {
|
|
...rr,
|
|
max_age_seconds:
|
|
e.target.value === "" ? undefined : Number(e.target.value),
|
|
}
|
|
: rr,
|
|
),
|
|
)
|
|
}
|
|
data-action={`configure-cors-rule-${i}-max-age`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)
|
|
}
|
|
|
|
function DeleteBucketFlow({
|
|
bucket,
|
|
configId,
|
|
onClose,
|
|
onDeleted,
|
|
onError,
|
|
}: {
|
|
bucket: Bucket | null
|
|
configId: string
|
|
onClose: () => void
|
|
onDeleted: (msg?: string) => Promise<void>
|
|
onError: (msg: string | null) => void
|
|
}) {
|
|
const arcadia = useArcadiaClient()
|
|
const [code, setCode] = useState("")
|
|
const [forceEmpty, setForceEmpty] = useState(false)
|
|
const [issuingCode, setIssuingCode] = useState(false)
|
|
const [deleting, setDeleting] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (!bucket) {
|
|
setCode("")
|
|
setForceEmpty(false)
|
|
}
|
|
}, [bucket])
|
|
|
|
const requestCode = async () => {
|
|
if (!bucket) return
|
|
setIssuingCode(true)
|
|
onError(null)
|
|
try {
|
|
const res = await generateConfirmationCode(arcadia, configId, bucket.name)
|
|
setCode(res.code ?? "")
|
|
} catch (err) {
|
|
onError(
|
|
err instanceof ArcadiaError ? err.message : "Failed to generate confirmation code.",
|
|
)
|
|
} finally {
|
|
setIssuingCode(false)
|
|
}
|
|
}
|
|
|
|
const doDelete = async () => {
|
|
if (!bucket) return
|
|
setDeleting(true)
|
|
onError(null)
|
|
try {
|
|
await deleteBucket(arcadia, {
|
|
storage_config_id: configId,
|
|
bucket_name: bucket.name,
|
|
confirmation_code: code,
|
|
force_empty: forceEmpty,
|
|
dry_run: false,
|
|
})
|
|
await onDeleted(`${bucket.name} deleted.`)
|
|
} catch (err) {
|
|
onError(err instanceof ArcadiaError ? err.message : "Delete failed.")
|
|
} finally {
|
|
setDeleting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={bucket !== null} onOpenChange={(o) => !o && onClose()}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-destructive">
|
|
{bucket ? `Delete ${bucket.name}?` : ""}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
Destructive action. Generate the 6-digit confirmation code and paste it back to
|
|
authorize the delete.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-end gap-2">
|
|
<div className="flex-1">
|
|
<Label htmlFor="delete-code" className="text-xs">
|
|
Confirmation code
|
|
</Label>
|
|
<Input
|
|
id="delete-code"
|
|
value={code}
|
|
onChange={(e) => setCode(e.target.value)}
|
|
placeholder="6 digits"
|
|
className="font-mono"
|
|
data-action="bucket-delete-code"
|
|
/>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
onClick={requestCode}
|
|
disabled={issuingCode}
|
|
data-action="bucket-delete-issue-code"
|
|
>
|
|
{issuingCode ? (
|
|
<RefreshCw className="size-4 animate-spin" />
|
|
) : (
|
|
<Shield className="size-4" />
|
|
)}
|
|
Issue code
|
|
</Button>
|
|
</div>
|
|
<div className="flex items-center justify-between rounded-md border px-3 py-2">
|
|
<div>
|
|
<div className="text-sm font-medium">Force empty</div>
|
|
<div className="text-xs text-destructive">
|
|
Delete all objects first. Cannot be undone.
|
|
</div>
|
|
</div>
|
|
<Switch
|
|
checked={forceEmpty}
|
|
onCheckedChange={setForceEmpty}
|
|
data-action="bucket-delete-force-empty"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={onClose} disabled={deleting}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={doDelete}
|
|
disabled={deleting || code.length === 0}
|
|
data-action="bucket-delete-confirm"
|
|
>
|
|
{deleting ? <RefreshCw className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
|
|
Delete bucket
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
// --- helpers --------------------------------------------------------------
|
|
|
|
function csv(s: string): string[] {
|
|
return s
|
|
.split(",")
|
|
.map((x) => x.trim())
|
|
.filter(Boolean)
|
|
}
|
|
|
|
function guessMime(key: string): string {
|
|
const ext = key.split(".").pop()?.toLowerCase() ?? ""
|
|
const m: Record<string, string> = {
|
|
jpg: "image/jpeg",
|
|
jpeg: "image/jpeg",
|
|
png: "image/png",
|
|
gif: "image/gif",
|
|
webp: "image/webp",
|
|
svg: "image/svg+xml",
|
|
pdf: "application/pdf",
|
|
mp4: "video/mp4",
|
|
mov: "video/quicktime",
|
|
mp3: "audio/mpeg",
|
|
wav: "audio/wav",
|
|
json: "application/json",
|
|
csv: "text/csv",
|
|
txt: "text/plain",
|
|
md: "text/markdown",
|
|
html: "text/html",
|
|
zip: "application/zip",
|
|
}
|
|
return m[ext] ?? "application/octet-stream"
|
|
}
|