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([]) const [configId, setConfigId] = useState(() => typeof window !== "undefined" ? (localStorage.getItem(SELECTED_CONFIG_KEY) ?? "") : "", ) const [buckets, setBuckets] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [info, setInfo] = useState(null) const [view, setView] = useState({ kind: "list" }) const [editor, setEditor] = useState(null) const [pendingDelete, setPendingDelete] = useState(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 (

{view.kind === "list" ? "Buckets" : `${view.bucket.name}`}

{view.kind === "list" ? "Storage backends, buckets, and their contents. Configure CORS, versioning, and policies per bucket." : `Objects in ${view.bucket.name}.`}

{view.kind === "objects" ? ( ) : ( <> )}
{error ? ( setError(null)}> {error} ) : null} {info ? ( setInfo(null)}> {info} ) : null} {view.kind === "list" ? ( <>
setView({ kind: "objects", bucket: b })} onConfigure={(b) => setEditor({ kind: "configure", bucket: b })} onDelete={(b) => setPendingDelete(b)} /> ) : ( )}
{/* Create */} setEditor(null)} onCreated={async (msg) => { setEditor(null) if (msg) setInfo(msg) await refresh() }} onError={setError} /> {/* Configure (versioning / CORS / policy) */} setEditor(null)} onChanged={async (msg) => { if (msg) setInfo(msg) await refresh() }} onError={setError} /> {/* Delete */} setPendingDelete(null)} onDeleted={async (msg) => { setPendingDelete(null) if (msg) setInfo(msg) await refresh() }} onError={setError} />
) } // --- 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[]>( () => [ { id: "name", header: "Name", accessor: "name", sortable: true, cell: (b) => ( ), }, { id: "region", header: "Region", accessor: "region", sortable: true, cell: (b) => b.region ? ( {b.region} ) : ( ), }, { id: "objects", header: "Objects", accessor: "object_count", sortable: true, cell: (b) => b.object_count != null ? ( {b.object_count.toLocaleString()} ) : ( ), }, { id: "size", header: "Size", accessor: "size_bytes", sortable: true, cell: (b) => b.size_bytes != null ? ( {formatBytes(b.size_bytes)} ) : ( ), }, { id: "created", header: "Created", accessor: "created_at", sortable: true, cell: (b) => b.created_at ? ( ) : ( ), }, { id: "actions", header: "", align: "right", cell: (b) => { const items: ActionItem[] = [ { id: "open", label: "Browse objects", icon: , dataAction: `bucket-${b.name}-browse`, onSelect: () => onOpen(b), }, { id: "configure", label: "Configure", icon: , dataAction: `bucket-${b.name}-configure`, onSelect: () => onConfigure(b), }, { id: "delete", label: "Delete", icon: , destructive: true, dataAction: `bucket-${b.name}-delete`, onSelect: () => onDelete(b), }, ] return ( ) }, }, ], [onOpen, onConfigure, onDelete], ) const table = useTable({ data: buckets, columns, getRowId: (b) => b.name, initialPageSize: 25, initialSearch: search, }) useEffect(() => { table.setSearch(search) }, [search, table]) return (
{table.total} of {buckets.length}
{!hasConfig ? ( } 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 ? ( } title={search ? "No buckets match." : "No buckets in this account."} description={search ? "Try a different search." : "Create your first bucket."} className="py-12" /> ) : ( <> b.name} sort={table.sort} onSortToggle={table.toggleSort} loading={loading && buckets.length > 0} stickyHeader /> )}
) } // --- Object browser ---------------------------------------------------- function ObjectBrowser({ storageConfigId, bucket, onError, }: { storageConfigId: string bucket: Bucket onError: (msg: string | null) => void }) { const arcadia = useArcadiaClient() const [objects, setObjects] = useState([]) 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( () => 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 (
setPrefix(e.target.value)} placeholder="path/to/folder/" className="w-64 font-mono text-xs" data-action="objects-prefix" />
{fileItems.length === 0 && !loading ? ( } 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" ? ( openPresigned(f.id)} renderAction={(f) => ( )} /> ) : ( openPresigned(f.id)} minItemWidth={180} /> )} setPreviewUrl(null)} />
) } 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 ( !o && onClose()}> Presigned URL {reveal.key} — valid for 1 hour.
{reveal.url}
) } // --- Create / configure / delete dialogs -------------------------------- function CreateBucketDialog({ open, configId, onClose, onCreated, onError, }: { open: boolean configId: string onClose: () => void onCreated: (msg?: string) => Promise 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([]) 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 ( !o && onClose()}> New bucket Bucket names must be globally unique on the provider and follow DNS rules (lowercase, no underscores).
setName(e.target.value)} placeholder="my-app-uploads" data-action="bucket-form-name" />
{regions.length > 0 ? ( ) : ( setRegion(e.target.value)} placeholder="us-east-1" data-action="bucket-form-region" /> )}
Versioning
Keep object history. Can be enabled later.
) } function ConfigureBucketDialog({ state, configId, onClose, onChanged, onError, }: { state: { kind: "configure"; bucket: Bucket } | null configId: string onClose: () => void onChanged: (msg?: string) => Promise onError: (msg: string | null) => void }) { const arcadia = useArcadiaClient() const [versioningOn, setVersioningOn] = useState(false) const [versioningSaving, setVersioningSaving] = useState(false) const [corsRules, setCorsRules] = useState([]) 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 ( !o && onClose()}> Configure {bucket.name} Each section saves independently — changes to versioning don't touch CORS, etc. Versioning CORS Policy
Versioning enabled
When on, overwrites and deletes are kept as previous versions.

Note: this UI shows your intended state, not the current bucket setting (the backend's GET endpoint isn't exposed). Submit to apply.

{corsLoading ? (

Loading rules…

) : ( )}