813 lines
41 KiB
TypeScript
813 lines
41 KiB
TypeScript
import {useEffect, useRef, useState} from 'react'
|
|
import {ChevronLeft, ChevronRight, Copy, Download, Eye, File, Folder, Loader2, RefreshCw, Search, Trash2, Upload, X} from 'lucide-react'
|
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
|
import {
|
|
copyStorageObject,
|
|
createCloudResource,
|
|
deleteStorageObject,
|
|
listCloudResources,
|
|
listStorageObjects,
|
|
storageObjectDownloadUrl,
|
|
uploadStorageObject,
|
|
} from '@/api/cloudProxyClient'
|
|
import {capabilityEnabled, capabilityFor, normalizeCapabilities, withRuntimeState} from '@/lib/capabilities'
|
|
import type {CloudProvider} from '@/types/cloud'
|
|
import type {CloudResource, StorageObject} from '@/types/resource'
|
|
import type {CapabilitySchema, ObjectActionName} from '@/types/schema'
|
|
import {formatBytes, objectIconKind, objectPreviewKind} from '@/lib/format'
|
|
import {ImagePreviewModal} from '@/components/ImagePreviewModal'
|
|
import {ObjectThumbnail} from '@/components/ObjectThumbnail'
|
|
import {CopyValueButton} from '@/components/CopyValueButton'
|
|
|
|
interface StorageObjectBrowserProps {
|
|
cloud: CloudProvider
|
|
resource?: CloudResource
|
|
capabilities?: Array<CapabilitySchema<ObjectActionName> | ObjectActionName>
|
|
runtimeReachable?: boolean
|
|
selectedObjectKey?: string
|
|
onSelectObject: (object?: StorageObject) => void
|
|
}
|
|
|
|
export function StorageObjectBrowser({cloud, resource, capabilities = [], runtimeReachable = false, selectedObjectKey, onSelectObject}: StorageObjectBrowserProps) {
|
|
const qc = useQueryClient()
|
|
const fileRef = useRef<HTMLInputElement | null>(null)
|
|
const [prefix, setPrefix] = useState('')
|
|
const [uploadPrefix, setUploadPrefix] = useState('')
|
|
const [folderName, setFolderName] = useState('')
|
|
const [createFolderOpen, setCreateFolderOpen] = useState(false)
|
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
|
const [deleteFolderConfirm, setDeleteFolderConfirm] = useState<string | null>(null)
|
|
const [emptyBucketConfirm, setEmptyBucketConfirm] = useState(false)
|
|
const [emptyBucketText, setEmptyBucketText] = useState('')
|
|
const [copyObject, setCopyObject] = useState<StorageObject | null>(null)
|
|
const [search, setSearch] = useState('')
|
|
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set())
|
|
const [bulkDeleting, setBulkDeleting] = useState(false)
|
|
const [previewObject, setPreviewObject] = useState<StorageObject | null>(null)
|
|
const [folderUpload, setFolderUpload] = useState<{completed: number; total: number; failed: number} | null>(null)
|
|
const [folderUploadNotice, setFolderUploadNotice] = useState<string | null>(null)
|
|
|
|
const resolvedCapabilities = withRuntimeState(normalizeCapabilities(capabilities), runtimeReachable)
|
|
const uploadCapability = capabilityFor(resolvedCapabilities, 'upload')
|
|
const downloadCapability = capabilityFor(resolvedCapabilities, 'download')
|
|
const deleteCapability = capabilityFor(resolvedCapabilities, 'delete')
|
|
const createFolderCapability = capabilityFor(resolvedCapabilities, 'createFolder')
|
|
const copyCapability = capabilityFor(resolvedCapabilities, 'copy')
|
|
const canUpload = capabilityEnabled(uploadCapability)
|
|
const canDownload = capabilityEnabled(downloadCapability)
|
|
const canDelete = capabilityEnabled(deleteCapability)
|
|
const canCreateFolder = capabilityEnabled(createFolderCapability)
|
|
const canCopy = capabilityEnabled(copyCapability)
|
|
|
|
// Runtime reachability can flip while a preview is already open (it's polled
|
|
// independently of this component). Re-checking canDownload only at the
|
|
// moment the Eye button is clicked isn't enough — close an already-open
|
|
// preview the instant download capability is revoked, so its modal can't
|
|
// keep serving object content (img/video/audio src, the Download link,
|
|
// an in-flight text fetch) after the gate that allowed it closes.
|
|
useEffect(() => {
|
|
if (!canDownload) setPreviewObject(null)
|
|
}, [canDownload])
|
|
|
|
useEffect(() => {
|
|
setPrefix('')
|
|
setUploadPrefix('')
|
|
setFolderName('')
|
|
setCreateFolderOpen(false)
|
|
setDeleteConfirm(null)
|
|
setDeleteFolderConfirm(null)
|
|
setEmptyBucketConfirm(false)
|
|
setEmptyBucketText('')
|
|
setCopyObject(null)
|
|
setSearch('')
|
|
setSelectedKeys(new Set())
|
|
setPreviewObject(null)
|
|
setFolderUpload(null)
|
|
setFolderUploadNotice(null)
|
|
onSelectObject(undefined)
|
|
}, [resource?.id, onSelectObject])
|
|
|
|
const query = useQuery({
|
|
queryKey: ['storage-objects', cloud, resource?.id, prefix],
|
|
queryFn: ({signal}) => listStorageObjects(cloud, resource?.id ?? '', prefix, signal),
|
|
enabled: !!resource,
|
|
})
|
|
|
|
const uploadMut = useMutation({
|
|
mutationFn: async (file: File) => {
|
|
if (!resource) return
|
|
const key = `${uploadPrefix || prefix}${file.name}`
|
|
await uploadStorageObject(cloud, resource.id, key, file)
|
|
},
|
|
onSuccess: () => qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]}),
|
|
})
|
|
|
|
const folderUploadMut = useMutation({
|
|
mutationFn: async (files: File[]) => {
|
|
if (!resource) return
|
|
const basePrefix = uploadPrefix || prefix
|
|
setFolderUpload({completed: 0, total: files.length, failed: 0})
|
|
let completed = 0
|
|
let failed = 0
|
|
for (const file of files) {
|
|
const relativePath = (file as File & {webkitRelativePath?: string}).webkitRelativePath || file.name
|
|
try {
|
|
await uploadStorageObject(cloud, resource.id, `${basePrefix}${relativePath}`, file)
|
|
completed++
|
|
} catch {
|
|
failed++
|
|
}
|
|
setFolderUpload({completed, total: files.length, failed})
|
|
}
|
|
},
|
|
onSuccess: () => qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]}),
|
|
})
|
|
|
|
const createFolderMut = useMutation({
|
|
mutationFn: async (name: string) => {
|
|
if (!resource) return
|
|
const folderKey = `${prefix}${normalizeFolderName(name)}`
|
|
await uploadStorageObject(cloud, resource.id, folderKey, new Blob([], {type: 'application/x-directory'}))
|
|
},
|
|
onSuccess: () => {
|
|
setFolderName('')
|
|
setCreateFolderOpen(false)
|
|
void qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]})
|
|
},
|
|
})
|
|
|
|
const deleteMut = useMutation({
|
|
mutationFn: async (object: StorageObject) => {
|
|
if (!resource) return
|
|
await deleteStorageObject(cloud, resource.id, object.key)
|
|
},
|
|
onSuccess: (_, deleted) => {
|
|
if (selectedObjectKey === deleted.key) onSelectObject(undefined)
|
|
qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]})
|
|
},
|
|
})
|
|
|
|
const deleteFolderMut = useMutation({
|
|
mutationFn: async (folder: StorageObject) => {
|
|
if (!resource) return
|
|
await deleteFolderContents(cloud, resource.id, folder.key)
|
|
},
|
|
onSuccess: (_, folder) => {
|
|
if (selectedObjectKey?.startsWith(folder.key)) onSelectObject(undefined)
|
|
setDeleteFolderConfirm(null)
|
|
void qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]})
|
|
},
|
|
})
|
|
|
|
const emptyBucketMut = useMutation({
|
|
mutationFn: async () => {
|
|
if (!resource) return
|
|
await emptyBucket(cloud, resource.id)
|
|
},
|
|
onSuccess: () => {
|
|
setEmptyBucketConfirm(false)
|
|
setEmptyBucketText('')
|
|
onSelectObject(undefined)
|
|
void qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]})
|
|
},
|
|
})
|
|
|
|
const moveCopyMut = useMutation({
|
|
mutationFn: async ({srcKey, destKey, destResourceId, mode}: {
|
|
srcKey: string; destKey: string; destResourceId?: string; mode: 'move' | 'copy'
|
|
}) => {
|
|
if (!resource) return
|
|
await copyStorageObject(cloud, resource.id, srcKey, destKey, destResourceId)
|
|
if (mode === 'move') await deleteStorageObject(cloud, resource.id, srcKey)
|
|
},
|
|
onSuccess: (_, vars) => {
|
|
setCopyObject(null)
|
|
if (vars.mode === 'move') onSelectObject(undefined)
|
|
void qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]})
|
|
if (vars.destResourceId) void qc.invalidateQueries({queryKey: ['storage-objects', cloud, vars.destResourceId]})
|
|
},
|
|
})
|
|
|
|
// ── Navigation helpers ──
|
|
function navigate(nextPrefix: string) {
|
|
setPrefix(nextPrefix)
|
|
setSearch('')
|
|
setSelectedKeys(new Set())
|
|
setDeleteConfirm(null)
|
|
setDeleteFolderConfirm(null)
|
|
setPreviewObject(null)
|
|
onSelectObject(undefined)
|
|
}
|
|
|
|
// ── Bulk delete ──
|
|
async function handleBulkDelete() {
|
|
if (!resource || selectedKeys.size === 0) return
|
|
setBulkDeleting(true)
|
|
try {
|
|
await Promise.all([...selectedKeys].map((key) => deleteStorageObject(cloud, resource.id, key)))
|
|
if (selectedObjectKey && selectedKeys.has(selectedObjectKey)) onSelectObject(undefined)
|
|
setSelectedKeys(new Set())
|
|
void qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource.id]})
|
|
} finally {
|
|
setBulkDeleting(false)
|
|
}
|
|
}
|
|
|
|
if (!resource) {
|
|
return (
|
|
<section className="object-browser empty compact">
|
|
<h3>Select a storage resource</h3>
|
|
<p>Choose a bucket or container to browse objects and blobs.</p>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
const objects = query.data?.objects ?? []
|
|
const objectLabel = cloud === 'azure' ? 'blobs' : 'objects'
|
|
|
|
// ── Filtered data ──
|
|
const q = search.toLowerCase()
|
|
const filteredObjects = q
|
|
? objects.filter((o) => o.name.toLowerCase().includes(q) || o.key.toLowerCase().includes(q))
|
|
: objects
|
|
const filteredFolders = filteredObjects.filter((o) => o.type === 'folder')
|
|
const filteredFiles = filteredObjects.filter((o) => o.type === 'object')
|
|
const totalFolders = objects.filter((o) => o.type === 'folder').length
|
|
const totalFiles = objects.filter((o) => o.type === 'object').length
|
|
|
|
// ── Bulk select state ──
|
|
const allSelected = filteredFiles.length > 0 && filteredFiles.every((f) => selectedKeys.has(f.key))
|
|
const someSelected = !allSelected && filteredFiles.some((f) => selectedKeys.has(f.key))
|
|
|
|
const error = query.error ?? uploadMut.error ?? createFolderMut.error ?? deleteMut.error ?? deleteFolderMut.error ?? emptyBucketMut.error ?? moveCopyMut.error
|
|
|
|
return (
|
|
<section className="object-browser">
|
|
{previewObject && resource && objectPreviewKind(previewObject.name) && (
|
|
<ImagePreviewModal
|
|
kind={objectPreviewKind(previewObject.name)!}
|
|
name={previewObject.name}
|
|
src={storageObjectDownloadUrl(cloud, resource.id, previewObject.key)}
|
|
onClose={() => setPreviewObject(null)}
|
|
/>
|
|
)}
|
|
|
|
{copyObject && resource && (
|
|
<MoveOrCopyModal
|
|
cloud={cloud}
|
|
resource={resource}
|
|
srcObject={copyObject}
|
|
canCopy={canCopy}
|
|
isPending={moveCopyMut.isPending}
|
|
error={moveCopyMut.error instanceof Error ? moveCopyMut.error.message : null}
|
|
onClose={() => { setCopyObject(null); moveCopyMut.reset() }}
|
|
onConfirm={(destKey, destResourceId, mode) => moveCopyMut.mutate({srcKey: copyObject.key, destKey, destResourceId, mode})}
|
|
/>
|
|
)}
|
|
|
|
{/* ── Header ── */}
|
|
<div className="object-browser-header">
|
|
<div>
|
|
<p className="eyebrow">Objects</p>
|
|
<h3>{cloud === 'aws' ? <span className="copy-value">{resource.name}<CopyValueButton value={resource.name} label="Bucket name"/></span> : resource.name}</h3>
|
|
<div className="object-browser-subtitle">
|
|
<ObjectBreadcrumb prefix={prefix} onNavigate={navigate}/>
|
|
<span>{totalFolders} folders, {totalFiles} {objectLabel}</span>
|
|
</div>
|
|
</div>
|
|
<div className="object-browser-actions">
|
|
{prefix && (
|
|
<button className="button" type="button" onClick={() => navigate(parentPrefix(prefix))}>
|
|
<ChevronLeft size={14}/>
|
|
Back
|
|
</button>
|
|
)}
|
|
{cloud === 'aws' && (
|
|
<button className="button danger" type="button" disabled={emptyBucketMut.isPending} onClick={() => setEmptyBucketConfirm(true)}>
|
|
<Trash2 size={14}/>
|
|
Empty bucket
|
|
</button>
|
|
)}
|
|
{createFolderCapability && (
|
|
<button className="button" type="button" disabled={!canCreateFolder} title={createFolderCapability.reason} onClick={() => setCreateFolderOpen((open) => !open)}>
|
|
<Folder size={14}/>
|
|
New folder
|
|
</button>
|
|
)}
|
|
{uploadCapability && (
|
|
<>
|
|
<input className="input object-prefix-input" value={uploadPrefix} onChange={(event) => setUploadPrefix(normalizePrefix(event.target.value))} placeholder={prefix ? `Upload to ${prefix}` : 'Upload prefix'}/>
|
|
<input ref={fileRef} type="file" hidden onChange={(event) => {
|
|
const file = event.target.files?.[0]
|
|
if (file) uploadMut.mutate(file)
|
|
event.currentTarget.value = ''
|
|
}}/>
|
|
<button className="button" type="button" disabled={!canUpload} title={uploadCapability.reason} onClick={() => fileRef.current?.click()}>
|
|
<Upload size={14}/>
|
|
{uploadMut.isPending ? 'Uploading' : 'Upload'}
|
|
</button>
|
|
</>
|
|
)}
|
|
<button className="button" type="button" disabled={query.isFetching} onClick={() => query.refetch()}>
|
|
<RefreshCw size={14}/>
|
|
{query.isFetching ? 'Loading' : 'Refresh'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Create folder ── */}
|
|
{createFolderOpen && (
|
|
<form className="object-create-folder" onSubmit={(event) => {
|
|
event.preventDefault()
|
|
if (folderName.trim()) createFolderMut.mutate(folderName)
|
|
}}>
|
|
<label>
|
|
<span>Folder name</span>
|
|
<input className="input" value={folderName} onChange={(event) => setFolderName(event.target.value)} placeholder={prefix ? `${prefix}new-folder/` : 'new-folder/'}/>
|
|
</label>
|
|
<button className="button primary" type="submit" disabled={createFolderMut.isPending || !folderName.trim()}>
|
|
<Folder size={14}/>
|
|
{createFolderMut.isPending ? 'Creating' : 'Create folder'}
|
|
</button>
|
|
</form>
|
|
)}
|
|
{emptyBucketConfirm && (
|
|
<form className="object-create-folder" onSubmit={(event) => {
|
|
event.preventDefault()
|
|
if (emptyBucketText === resource.name) emptyBucketMut.mutate()
|
|
}}>
|
|
<label>
|
|
<span>Type <code>{resource.name}</code> to permanently delete every object and folder in this bucket.</span>
|
|
<input className="input" value={emptyBucketText} onChange={(event) => setEmptyBucketText(event.target.value)} placeholder={resource.name}/>
|
|
</label>
|
|
<button className="button danger" type="submit" disabled={emptyBucketMut.isPending || emptyBucketText !== resource.name}>
|
|
<Trash2 size={14}/>
|
|
{emptyBucketMut.isPending ? 'Emptying bucket' : 'Empty bucket'}
|
|
</button>
|
|
<button className="button" type="button" disabled={emptyBucketMut.isPending} onClick={() => {
|
|
setEmptyBucketConfirm(false)
|
|
setEmptyBucketText('')
|
|
}}>Cancel</button>
|
|
</form>
|
|
)}
|
|
{folderUpload && <p className={folderUpload.failed > 0 ? 'error-text compact-text' : 'muted compact-text'}>
|
|
Folder upload: {folderUpload.completed} of {folderUpload.total} files uploaded{folderUpload.failed > 0 ? `, ${folderUpload.failed} failed` : ''}.
|
|
</p>}
|
|
{folderUploadNotice && <p className="error-text compact-text">{folderUploadNotice}</p>}
|
|
{canUpload && <div className="object-upload-dropzone" onDragOver={(event) => event.preventDefault()} onDrop={(event) => {
|
|
event.preventDefault()
|
|
void droppedFiles(event.dataTransfer).then((files) => {
|
|
if (files.length > 0) {
|
|
setFolderUploadNotice(null)
|
|
folderUploadMut.mutate(files)
|
|
} else {
|
|
setFolderUploadNotice('Drop files or a folder from your desktop to upload them.')
|
|
}
|
|
})
|
|
}}>
|
|
<Folder size={18}/><strong>Drop files or folders here</strong><span>Folder paths are preserved under the current upload prefix.</span>
|
|
</div>}
|
|
|
|
{/* ── Error ── */}
|
|
{error && (
|
|
<div className="inline-error">
|
|
{error instanceof Error ? error.message : 'Storage operation failed'}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Search bar ── */}
|
|
{!query.isLoading && objects.length > 0 && (
|
|
<div className="object-search-bar">
|
|
<Search size={13}/>
|
|
<input
|
|
className="input"
|
|
value={search}
|
|
onChange={(e) => { setSearch(e.target.value); setSelectedKeys(new Set()) }}
|
|
placeholder={`Search ${objectLabel} and folders…`}
|
|
/>
|
|
{search && (
|
|
<button className="icon-btn" type="button" onClick={() => setSearch('')} aria-label="Clear search" title="Clear search">
|
|
<X size={13} aria-hidden="true"/>
|
|
</button>
|
|
)}
|
|
{search && (
|
|
<span style={{fontSize: 12, color: 'var(--text-3)', whiteSpace: 'nowrap'}}>
|
|
{filteredObjects.length} / {objects.length}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Table ── */}
|
|
{query.isLoading ? (
|
|
<div className="empty compact">
|
|
<h3>Loading objects</h3>
|
|
<p>Reading objects from the selected storage resource.</p>
|
|
</div>
|
|
) : objects.length === 0 ? (
|
|
<div className="empty compact">
|
|
<h3>No {objectLabel}</h3>
|
|
<p>{prefix ? `The ${prefix} prefix is empty.` : `This ${resource.type} has no ${objectLabel} yet.`}</p>
|
|
</div>
|
|
) : filteredObjects.length === 0 ? (
|
|
<div className="empty compact">
|
|
<h3>No results</h3>
|
|
<p>Nothing matches “{search}”.</p>
|
|
</div>
|
|
) : (
|
|
<table className="table object-table">
|
|
<thead>
|
|
<tr>
|
|
<th style={{width: 32, textAlign: 'center'}}>
|
|
<input
|
|
type="checkbox"
|
|
ref={(el) => { if (el) el.indeterminate = someSelected }}
|
|
checked={allSelected}
|
|
title={allSelected ? 'Deselect all' : 'Select all files'}
|
|
onChange={() => {
|
|
if (allSelected) setSelectedKeys(new Set())
|
|
else setSelectedKeys(new Set(filteredFiles.map((f) => f.key)))
|
|
}}
|
|
/>
|
|
</th>
|
|
<th>Name</th>
|
|
<th>Type</th>
|
|
<th>Size</th>
|
|
<th>Last Modified</th>
|
|
<th aria-label="Actions"/>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredFolders.map((object) => (
|
|
<tr key={object.key}>
|
|
<td/>
|
|
<td onClick={() => navigate(object.key)} style={{cursor: 'pointer'}}>
|
|
<span className="object-name">
|
|
<Folder size={14}/>
|
|
{object.name}
|
|
<ChevronRight size={12}/>
|
|
</span>
|
|
</td>
|
|
<td>Folder</td>
|
|
<td>—</td>
|
|
<td>{object.lastModified ?? '—'}</td>
|
|
<td className="table-actions">
|
|
{canDelete && deleteFolderConfirm === object.key ? (
|
|
<span className="object-folder-confirm">
|
|
<button className="button danger compact" disabled={deleteFolderMut.isPending} onClick={() => deleteFolderMut.mutate(object)}>
|
|
Delete folder
|
|
</button>
|
|
<button className="button compact" disabled={deleteFolderMut.isPending} onClick={() => setDeleteFolderConfirm(null)}>
|
|
Cancel
|
|
</button>
|
|
</span>
|
|
) : deleteCapability && (
|
|
<button className="icon-btn danger" disabled={!canDelete} title={deleteCapability.reason ?? `Delete ${object.name} and all contents`} aria-label={`Delete ${object.name} folder`} onClick={() => setDeleteFolderConfirm(object.key)}>
|
|
<Trash2 size={13}/>
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{filteredFiles.map((object) => {
|
|
const isSelected = selectedKeys.has(object.key)
|
|
const iconKind = objectIconKind(object.name)
|
|
const previewKind = objectPreviewKind(object.name)
|
|
return (
|
|
<tr
|
|
key={object.key}
|
|
className={[
|
|
selectedObjectKey === object.key ? 'selected' : '',
|
|
isSelected ? 'bulk-selected' : '',
|
|
].filter(Boolean).join(' ')}
|
|
>
|
|
<td style={{textAlign: 'center'}}>
|
|
<input
|
|
type="checkbox"
|
|
checked={isSelected}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onChange={(e) => {
|
|
const next = new Set(selectedKeys)
|
|
if (e.target.checked) next.add(object.key)
|
|
else next.delete(object.key)
|
|
setSelectedKeys(next)
|
|
}}
|
|
/>
|
|
</td>
|
|
<td onClick={() => onSelectObject(object)} style={{cursor: 'pointer'}}>
|
|
<span className="object-name">
|
|
{iconKind ? (
|
|
<ObjectThumbnail kind={iconKind} src={storageObjectDownloadUrl(cloud, resource.id, object.key)} name={object.name} canLoad={canDownload}/>
|
|
) : (
|
|
<File size={14}/>
|
|
)}
|
|
{object.name}
|
|
</span>
|
|
</td>
|
|
<td>{objectTypeLabel(object.name)}</td>
|
|
<td>{object.size === null ? '—' : formatBytes(object.size)}</td>
|
|
<td>{object.lastModified ?? '—'}</td>
|
|
<td className="table-actions">
|
|
{previewKind && (
|
|
<button
|
|
className="icon-btn"
|
|
type="button"
|
|
disabled={!canDownload}
|
|
title={canDownload ? `Preview ${object.name}` : (downloadCapability?.reason ?? `Preview ${object.name}`)}
|
|
onClick={(e) => { e.stopPropagation(); if (canDownload) setPreviewObject(object) }}
|
|
>
|
|
<Eye size={13}/>
|
|
</button>
|
|
)}
|
|
{downloadCapability && (
|
|
<a className={`icon-btn ${canDownload ? '' : 'disabled'}`} href={canDownload ? storageObjectDownloadUrl(cloud, resource.id, object.key) : undefined} title={downloadCapability.reason ?? `Download ${object.name}`}>
|
|
<Download size={13}/>
|
|
</a>
|
|
)}
|
|
{copyCapability && (
|
|
<button className="icon-btn" disabled={!canCopy} title={copyCapability.reason ?? `Copy or move ${object.name}`} aria-label={`Copy or move ${object.name}`} onClick={() => setCopyObject(object)}>
|
|
<Copy size={13}/>
|
|
</button>
|
|
)}
|
|
{canDelete && deleteConfirm === object.key ? (
|
|
<button className="button danger compact" disabled={deleteMut.isPending} onClick={() => {
|
|
deleteMut.mutate(object)
|
|
setDeleteConfirm(null)
|
|
}}>
|
|
Confirm
|
|
</button>
|
|
) : (
|
|
deleteCapability && (
|
|
<button className="icon-btn danger" disabled={!canDelete} title={deleteCapability.reason ?? `Delete ${object.name}`} onClick={() => setDeleteConfirm(object.key)}>
|
|
<Trash2 size={13}/>
|
|
</button>
|
|
)
|
|
)}
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
|
|
{/* ── Bulk action bar ── */}
|
|
{selectedKeys.size > 0 && (
|
|
<div className="bulk-bar">
|
|
<span className="bulk-count">
|
|
{selectedKeys.size} {objectLabel.replace(/s$/, '')}{selectedKeys.size !== 1 ? 's' : ''} selected
|
|
</span>
|
|
<span className="bulk-spacer"/>
|
|
<button
|
|
className="button"
|
|
onClick={() => setSelectedKeys(new Set())}
|
|
disabled={bulkDeleting}
|
|
>
|
|
Deselect all
|
|
</button>
|
|
{deleteCapability && (
|
|
<button
|
|
className="button danger"
|
|
disabled={!canDelete || bulkDeleting}
|
|
title={deleteCapability.reason}
|
|
onClick={() => void handleBulkDelete()}
|
|
>
|
|
{bulkDeleting ? <Loader2 size={13}/> : <Trash2 size={13}/>}
|
|
Delete {selectedKeys.size}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// ─── Move / Copy modal ────────────────────────────────────────────────────────
|
|
|
|
const NEW_RESOURCE_SENTINEL = '__new__'
|
|
|
|
function MoveOrCopyModal({
|
|
cloud,
|
|
resource,
|
|
srcObject,
|
|
canCopy,
|
|
isPending,
|
|
error,
|
|
onClose,
|
|
onConfirm,
|
|
}: {
|
|
cloud: CloudProvider
|
|
resource: CloudResource
|
|
srcObject: StorageObject
|
|
canCopy: boolean
|
|
isPending: boolean
|
|
error: string | null
|
|
onClose: () => void
|
|
onConfirm: (destKey: string, destResourceId: string | undefined, mode: 'move' | 'copy') => void
|
|
}) {
|
|
const qc = useQueryClient()
|
|
const resourceLabel = cloud === 'azure' ? 'container' : 'bucket'
|
|
const [mode, setMode] = useState<'move' | 'copy'>('copy')
|
|
const [destKey, setDestKey] = useState(() => {
|
|
const parts = srcObject.key.split('/')
|
|
const filename = parts.pop() ?? srcObject.key
|
|
const dir = parts.join('/')
|
|
return dir ? `${dir}/copy-of-${filename}` : `copy-of-${filename}`
|
|
})
|
|
const [destResourceId, setDestResourceId] = useState(resource.id)
|
|
const [newResourceName, setNewResourceName] = useState('')
|
|
|
|
const resourcesQuery = useQuery({
|
|
queryKey: ['cloud-resources', cloud, 'storage'],
|
|
queryFn: ({signal}) => listCloudResources(cloud, 'storage', undefined, signal),
|
|
})
|
|
|
|
const createResourceMut = useMutation({
|
|
mutationFn: () => {
|
|
const nameField = cloud === 'azure' ? 'containerName' : 'bucketName'
|
|
return createCloudResource(cloud, 'storage', {[nameField]: newResourceName.trim()})
|
|
},
|
|
onSuccess: (created) => {
|
|
setDestResourceId(created.id)
|
|
setNewResourceName('')
|
|
void qc.invalidateQueries({queryKey: ['cloud-resources', cloud, 'storage']})
|
|
},
|
|
})
|
|
|
|
const isCreatingNew = destResourceId === NEW_RESOURCE_SENTINEL
|
|
const resources = resourcesQuery.data ?? []
|
|
const resolvedDestId = isCreatingNew ? undefined : (destResourceId !== resource.id ? destResourceId : undefined)
|
|
|
|
return (
|
|
<div className="copy-modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
|
|
<div className="copy-modal">
|
|
<div className="drawer-tabs" style={{marginBottom: 14}}>
|
|
<button className={`drawer-tab ${mode === 'move' ? 'active' : ''}`} onClick={() => setMode('move')}>Move</button>
|
|
<button className={`drawer-tab ${mode === 'copy' ? 'active' : ''}`} onClick={() => setMode('copy')}>Copy</button>
|
|
</div>
|
|
|
|
<div style={{fontSize: 12, color: '#8d9cad', marginBottom: 12}}>
|
|
Source: <span className="mono" style={{color: 'var(--text-2)'}}>{resource.name}/{srcObject.key}</span>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label>Destination {resourceLabel}</label>
|
|
{resourcesQuery.isLoading ? (
|
|
<div style={{display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: '#8d9cad'}}>
|
|
<Loader2 size={13}/> Loading {resourceLabel}s…
|
|
</div>
|
|
) : (
|
|
<select className="input" value={destResourceId} onChange={(e) => setDestResourceId(e.target.value)}>
|
|
{resources.map((r) => (
|
|
<option key={r.id} value={r.id}>
|
|
{r.name}{r.id === resource.id ? ' (current)' : ''}
|
|
</option>
|
|
))}
|
|
<option value={NEW_RESOURCE_SENTINEL}>+ New {resourceLabel}…</option>
|
|
</select>
|
|
)}
|
|
</div>
|
|
|
|
{isCreatingNew && (
|
|
<div style={{display: 'flex', gap: 6, alignItems: 'center', marginTop: -6, marginBottom: 4}}>
|
|
<input
|
|
className="input"
|
|
autoFocus
|
|
value={newResourceName}
|
|
onChange={(e) => setNewResourceName(e.target.value.toLowerCase().replace(cloud === 'azure' ? /[^a-z0-9-]/g : /[^a-z0-9.-]/g, ''))}
|
|
placeholder={`new-${resourceLabel}-name`}
|
|
onKeyDown={(e) => { if (e.key === 'Enter' && newResourceName.trim().length >= 3) createResourceMut.mutate() }}
|
|
/>
|
|
<button
|
|
className="button primary"
|
|
disabled={newResourceName.trim().length < 3 || createResourceMut.isPending}
|
|
onClick={() => createResourceMut.mutate()}
|
|
>
|
|
{createResourceMut.isPending ? <Loader2 size={13}/> : 'Create'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{createResourceMut.isError && (
|
|
<p style={{fontSize: 12, color: 'var(--status-error)', margin: '0 0 6px'}}>
|
|
{createResourceMut.error instanceof Error ? createResourceMut.error.message : 'Create failed'}
|
|
</p>
|
|
)}
|
|
|
|
<div className="form-row">
|
|
<label>Destination key</label>
|
|
<input className="input" value={destKey} onChange={(e) => setDestKey(e.target.value)}/>
|
|
</div>
|
|
|
|
{error && <p style={{fontSize: 12, color: 'var(--status-error)', margin: '0 0 4px'}}>{error}</p>}
|
|
|
|
<div className="copy-modal-footer">
|
|
<button className="button" onClick={onClose} disabled={isPending}>Cancel</button>
|
|
<button
|
|
className="button primary"
|
|
disabled={!destKey.trim() || isCreatingNew || !canCopy || isPending}
|
|
onClick={() => onConfirm(destKey, resolvedDestId, mode)}
|
|
>
|
|
{isPending ? <Loader2 size={13}/> : <Copy size={13}/>}
|
|
{mode === 'move' ? 'Move' : 'Copy'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Breadcrumb ───────────────────────────────────────────────────────────────
|
|
|
|
function ObjectBreadcrumb({prefix, onNavigate}: {prefix: string; onNavigate: (prefix: string) => void}) {
|
|
const segments = prefix ? prefix.replace(/\/$/, '').split('/') : []
|
|
return (
|
|
<div className="object-breadcrumb">
|
|
<button type="button" onClick={() => onNavigate('')}>Root</button>
|
|
{segments.map((segment, index) => {
|
|
const path = `${segments.slice(0, index + 1).join('/')}/`
|
|
return (
|
|
<span key={path}>
|
|
<ChevronRight size={11}/>
|
|
<button type="button" onClick={() => onNavigate(path)}>{segment}</button>
|
|
</span>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
function normalizePrefix(value: string): string {
|
|
const trimmed = value.trim().replace(/^\/+/, '')
|
|
return trimmed && !trimmed.endsWith('/') ? `${trimmed}/` : trimmed
|
|
}
|
|
|
|
function normalizeFolderName(value: string): string {
|
|
const normalized = value.trim().replace(/^\/+/, '')
|
|
return normalized.endsWith('/') ? normalized : `${normalized}/`
|
|
}
|
|
|
|
function parentPrefix(prefix: string): string {
|
|
const segments = prefix.replace(/\/$/, '').split('/').filter(Boolean)
|
|
segments.pop()
|
|
return segments.length ? `${segments.join('/')}/` : ''
|
|
}
|
|
|
|
function objectTypeLabel(name: string): string {
|
|
const extension = name.split('.').pop()?.trim().toUpperCase()
|
|
return extension && extension !== name.toUpperCase() ? `${extension} file` : 'File'
|
|
}
|
|
|
|
interface DroppedEntry {
|
|
isFile: boolean
|
|
isDirectory: boolean
|
|
name: string
|
|
file(callback: (file: File) => void, errorCallback?: (error: DOMException) => void): void
|
|
createReader(): {readEntries(callback: (entries: DroppedEntry[]) => void, errorCallback?: (error: DOMException) => void): void}
|
|
}
|
|
|
|
async function droppedFiles(dataTransfer: DataTransfer): Promise<File[]> {
|
|
const items = Array.from(dataTransfer.items)
|
|
const entries = items.map((item) => (item as DataTransferItem & {webkitGetAsEntry?: () => unknown}).webkitGetAsEntry?.() as DroppedEntry | null | undefined).filter((entry): entry is DroppedEntry => entry !== null && entry !== undefined)
|
|
if (entries.length === 0) return Array.from(dataTransfer.files)
|
|
const files: File[] = []
|
|
for (const entry of entries) files.push(...await filesInEntry(entry))
|
|
return files
|
|
}
|
|
|
|
async function filesInEntry(entry: DroppedEntry, prefix = ''): Promise<File[]> {
|
|
const files: File[] = []
|
|
const path = `${prefix}${entry.name}`
|
|
if (entry.isFile) {
|
|
const file = await new Promise<File>((resolve, reject) => entry.file(resolve, reject))
|
|
Object.defineProperty(file, 'webkitRelativePath', {value: path})
|
|
return [file]
|
|
}
|
|
if (!entry.isDirectory) return files
|
|
const reader = entry.createReader()
|
|
let entries: DroppedEntry[]
|
|
do {
|
|
entries = await new Promise<DroppedEntry[]>((resolve, reject) => reader.readEntries(resolve, reject))
|
|
for (const child of entries) files.push(...await filesInEntry(child, `${path}/`))
|
|
} while (entries.length > 0)
|
|
return files
|
|
}
|
|
|
|
async function deleteFolderContents(cloud: CloudProvider, bucketName: string, prefix: string): Promise<void> {
|
|
const listing = await listStorageObjects(cloud, bucketName, prefix)
|
|
const folders = listing.objects.filter((object) => object.type === 'folder')
|
|
const objects = listing.objects.filter((object) => object.type === 'object')
|
|
await Promise.all(folders.map((folder) => deleteFolderContents(cloud, bucketName, folder.key)))
|
|
await Promise.all(objects.map((object) => deleteStorageObject(cloud, bucketName, object.key)))
|
|
await deleteStorageObject(cloud, bucketName, prefix)
|
|
}
|
|
|
|
async function emptyBucket(cloud: CloudProvider, bucketName: string): Promise<void> {
|
|
const listing = await listStorageObjects(cloud, bucketName, '')
|
|
const folders = listing.objects.filter((object) => object.type === 'folder')
|
|
const objects = listing.objects.filter((object) => object.type === 'object')
|
|
await Promise.all(folders.map((folder) => deleteFolderContents(cloud, bucketName, folder.key)))
|
|
await Promise.all(objects.map((object) => deleteStorageObject(cloud, bucketName, object.key)))
|
|
}
|