mirror of
https://github.com/Nezumi-2711/s3-drive-storage-manage.git
synced 2026-09-22 13:48:31 +00:00
feat: add page for manage bucket
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
import { apiFetch, apiRequest, IS_MOCK_MODE } from "../../../lib/api-client"
|
||||
import {
|
||||
mockAbortUpload,
|
||||
mockCompleteUpload,
|
||||
mockCreateDownloadTicket,
|
||||
mockCreateFolder,
|
||||
mockDeleteFolder,
|
||||
mockDeleteObject,
|
||||
mockGetObjectMetadata,
|
||||
mockInitiateUpload,
|
||||
mockListObjects,
|
||||
mockPutObjectContent,
|
||||
mockUploadPart,
|
||||
} from "./objects.mock"
|
||||
import type {
|
||||
CompleteUploadRequest,
|
||||
CompleteUploadResponse,
|
||||
CreateFolderRequest,
|
||||
CreateFolderResponse,
|
||||
DownloadTicketResponse,
|
||||
InitiateUploadRequest,
|
||||
InitiateUploadResponse,
|
||||
ListObjectsResponse,
|
||||
ObjectMetadataResponse,
|
||||
UploadPartResponse,
|
||||
} from "./objects.types"
|
||||
|
||||
export async function listObjects(bucket: string, prefix = "", delimiter = "/"): Promise<ListObjectsResponse> {
|
||||
if (IS_MOCK_MODE) {
|
||||
return mockListObjects(bucket, prefix, delimiter)
|
||||
}
|
||||
const params = new URLSearchParams()
|
||||
params.set("bucket", bucket)
|
||||
if (prefix) params.set("prefix", prefix)
|
||||
if (delimiter) params.set("delimiter", delimiter)
|
||||
|
||||
return apiRequest<ListObjectsResponse>(`/api/objects?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getObjectMetadata(bucket: string, key: string): Promise<ObjectMetadataResponse> {
|
||||
if (IS_MOCK_MODE) {
|
||||
return mockGetObjectMetadata(bucket, key)
|
||||
}
|
||||
const params = new URLSearchParams({ bucket, key })
|
||||
return apiRequest<ObjectMetadataResponse>(`/api/objects/metadata?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function deleteObject(bucket: string, key: string): Promise<void> {
|
||||
if (IS_MOCK_MODE) {
|
||||
return mockDeleteObject(bucket, key)
|
||||
}
|
||||
const params = new URLSearchParams({ bucket, key })
|
||||
return apiRequest<void>(`/api/objects?${params.toString()}`, { method: "DELETE" })
|
||||
}
|
||||
|
||||
export async function createFolder(req: CreateFolderRequest): Promise<CreateFolderResponse> {
|
||||
if (IS_MOCK_MODE) {
|
||||
return mockCreateFolder(req)
|
||||
}
|
||||
return apiRequest<CreateFolderResponse>("/api/objects/folder", {
|
||||
method: "POST",
|
||||
body: req,
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteFolder(bucket: string, prefix: string, recursive = false): Promise<void> {
|
||||
if (IS_MOCK_MODE) {
|
||||
return mockDeleteFolder(bucket, prefix, recursive)
|
||||
}
|
||||
const params = new URLSearchParams({ bucket, prefix })
|
||||
if (recursive) params.set("recursive", "1")
|
||||
return apiRequest<void>(`/api/objects/folder?${params.toString()}`, { method: "DELETE" })
|
||||
}
|
||||
|
||||
export async function putObjectContent(
|
||||
bucket: string,
|
||||
key: string,
|
||||
data: Blob | ArrayBuffer | string,
|
||||
contentType?: string,
|
||||
): Promise<{ key: string; etag: string; size: number }> {
|
||||
if (IS_MOCK_MODE) {
|
||||
return mockPutObjectContent(bucket, key, data)
|
||||
}
|
||||
const params = new URLSearchParams({ bucket, key })
|
||||
const headers: Record<string, string> = {}
|
||||
if (contentType) {
|
||||
headers["Content-Type"] = contentType
|
||||
}
|
||||
return apiRequest<{ key: string; etag: string; size: number }>(`/api/objects/content?${params.toString()}`, {
|
||||
method: "PUT",
|
||||
body: data,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
export async function createDownloadTicket(bucket: string, key: string): Promise<DownloadTicketResponse> {
|
||||
if (IS_MOCK_MODE) {
|
||||
return mockCreateDownloadTicket(bucket, key)
|
||||
}
|
||||
return apiRequest<DownloadTicketResponse>("/api/objects/download-ticket", {
|
||||
method: "POST",
|
||||
body: { bucket, key },
|
||||
})
|
||||
}
|
||||
|
||||
export async function downloadObjectDirect(bucket: string, key: string): Promise<Blob> {
|
||||
const params = new URLSearchParams({ bucket, key })
|
||||
const res = await apiFetch(`/api/objects/content?${params.toString()}`)
|
||||
return res.blob()
|
||||
}
|
||||
|
||||
export async function initiateMultipartUpload(req: InitiateUploadRequest): Promise<InitiateUploadResponse> {
|
||||
if (IS_MOCK_MODE) {
|
||||
return mockInitiateUpload(req)
|
||||
}
|
||||
return apiRequest<InitiateUploadResponse>("/api/objects/uploads", {
|
||||
method: "POST",
|
||||
body: req,
|
||||
})
|
||||
}
|
||||
|
||||
export async function uploadMultipartPart(
|
||||
bucket: string,
|
||||
key: string,
|
||||
uploadId: string,
|
||||
partNumber: number,
|
||||
data: Blob,
|
||||
signal?: AbortSignal,
|
||||
onProgress?: (loaded: number, total: number) => void,
|
||||
): Promise<UploadPartResponse> {
|
||||
if (IS_MOCK_MODE) {
|
||||
return mockUploadPart(partNumber)
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
bucket,
|
||||
key,
|
||||
uploadId,
|
||||
partNumber: String(partNumber),
|
||||
})
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open("PUT", `${import.meta.env.VITE_API_URL || ""}/api/objects/uploads/part?${params.toString()}`)
|
||||
|
||||
const token = localStorage.getItem("s3_drive_auth_token")
|
||||
if (token) {
|
||||
xhr.setRequestHeader("Authorization", `Bearer ${token}`)
|
||||
}
|
||||
|
||||
if (onProgress && xhr.upload) {
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
onProgress(e.loaded, e.total)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", () => {
|
||||
xhr.abort()
|
||||
reject(new DOMException("Upload aborted", "AbortError"))
|
||||
})
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
const res = JSON.parse(xhr.responseText) as UploadPartResponse
|
||||
resolve(res)
|
||||
} catch {
|
||||
resolve({ partNumber, etag: xhr.getResponseHeader("ETag")?.replace(/"/g, "") || "" })
|
||||
}
|
||||
} else {
|
||||
let message = `Part upload failed with status ${xhr.status}`
|
||||
try {
|
||||
const json = JSON.parse(xhr.responseText) as { message?: string }
|
||||
if (json.message) message = json.message
|
||||
} catch {
|
||||
if (xhr.status === 413) {
|
||||
message = "Payload too large for upload (Cloudflare limit)"
|
||||
}
|
||||
}
|
||||
const error = new Error(message) as Error & { status?: number; retryAfter?: string }
|
||||
error.status = xhr.status
|
||||
error.retryAfter = xhr.getResponseHeader("Retry-After") || undefined
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
xhr.onerror = () => {
|
||||
reject(new Error("Network error during part upload"))
|
||||
}
|
||||
|
||||
xhr.send(data)
|
||||
})
|
||||
}
|
||||
|
||||
export async function completeMultipartUpload(req: CompleteUploadRequest): Promise<CompleteUploadResponse> {
|
||||
if (IS_MOCK_MODE) {
|
||||
return mockCompleteUpload(req)
|
||||
}
|
||||
return apiRequest<CompleteUploadResponse>("/api/objects/uploads/complete", {
|
||||
method: "POST",
|
||||
body: req,
|
||||
})
|
||||
}
|
||||
|
||||
export async function abortMultipartUpload(bucket: string, key: string, uploadId: string): Promise<void> {
|
||||
if (IS_MOCK_MODE) {
|
||||
return mockAbortUpload()
|
||||
}
|
||||
const params = new URLSearchParams({ bucket, key, uploadId })
|
||||
return apiRequest<void>(`/api/objects/uploads?${params.toString()}`, { method: "DELETE" })
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const objectKeys = {
|
||||
all: ["objects"] as const,
|
||||
lists: () => [...objectKeys.all, "list"] as const,
|
||||
list: (bucket: string, prefix = "") => [...objectKeys.lists(), bucket, prefix] as const,
|
||||
details: () => [...objectKeys.all, "detail"] as const,
|
||||
metadata: (bucket: string, key: string) => [...objectKeys.details(), bucket, key] as const,
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import type {
|
||||
CompleteUploadRequest,
|
||||
CompleteUploadResponse,
|
||||
CreateFolderRequest,
|
||||
CreateFolderResponse,
|
||||
DownloadTicketResponse,
|
||||
InitiateUploadRequest,
|
||||
InitiateUploadResponse,
|
||||
ListObjectsResponse,
|
||||
ObjectMetadataResponse,
|
||||
S3ObjectItem,
|
||||
UploadPartResponse,
|
||||
} from "./objects.types"
|
||||
|
||||
// In-memory mock storage seeded for the 4 mock buckets
|
||||
const mockObjects = new Map<string, S3ObjectItem>()
|
||||
|
||||
function seedMockData() {
|
||||
if (mockObjects.size > 0) return
|
||||
|
||||
const initialItems: Array<{ bucket: string; key: string; size: number; contentType: string }> = [
|
||||
{ bucket: "documents", key: "reports/q1-report.pdf", size: 2_450_000, contentType: "application/pdf" },
|
||||
{ bucket: "documents", key: "reports/q2-summary.docx", size: 1_200_000, contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" },
|
||||
{ bucket: "documents", key: "invoice-1024.pdf", size: 450_000, contentType: "application/pdf" },
|
||||
{ bucket: "documents", key: "notes.txt", size: 1_024, contentType: "text/plain" },
|
||||
{ bucket: "media-storage", key: "videos/promo-2026.mp4", size: 145_000_000, contentType: "video/mp4" },
|
||||
{ bucket: "media-storage", key: "photos/banner.jpg", size: 3_200_000, contentType: "image/jpeg" },
|
||||
{ bucket: "backups", key: "db-dump-2026-08-20.sql.gz", size: 85_000_000, contentType: "application/gzip" },
|
||||
{ bucket: "public-assets", key: "logo.svg", size: 12_400, contentType: "image/svg+xml" },
|
||||
{ bucket: "public-assets", key: "styles.css", size: 34_200, contentType: "text/css" },
|
||||
]
|
||||
|
||||
const now = new Date().toISOString()
|
||||
for (const item of initialItems) {
|
||||
const fullKey = `${item.bucket}/${item.key}`
|
||||
const name = item.key.split("/").pop() || item.key
|
||||
mockObjects.set(fullKey, {
|
||||
key: item.key,
|
||||
name,
|
||||
size: item.size,
|
||||
contentType: item.contentType,
|
||||
lastModified: now,
|
||||
etag: Math.random().toString(36).substring(2, 15),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
seedMockData()
|
||||
|
||||
export async function mockListObjects(bucket: string, prefix = "", delimiter = "/"): Promise<ListObjectsResponse> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
seedMockData()
|
||||
|
||||
const foldersSet = new Set<string>()
|
||||
const objects: S3ObjectItem[] = []
|
||||
|
||||
for (const [mapKey, item] of mockObjects.entries()) {
|
||||
if (!mapKey.startsWith(`${bucket}/`)) continue
|
||||
const key = item.key
|
||||
|
||||
if (!key.startsWith(prefix)) continue
|
||||
|
||||
const suffix = key.slice(prefix.length)
|
||||
if (delimiter && suffix.includes(delimiter)) {
|
||||
const folderSegment = suffix.split(delimiter)[0]
|
||||
foldersSet.add(`${prefix}${folderSegment}/`)
|
||||
} else {
|
||||
objects.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
const folders = Array.from(foldersSet)
|
||||
.sort()
|
||||
.map((folderPrefix) => {
|
||||
const trimmed = folderPrefix.endsWith("/") ? folderPrefix.slice(0, -1) : folderPrefix
|
||||
const name = trimmed.split("/").pop() || trimmed
|
||||
return { prefix: folderPrefix, name }
|
||||
})
|
||||
|
||||
return {
|
||||
bucket,
|
||||
prefix,
|
||||
delimiter,
|
||||
folders,
|
||||
objects,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockGetObjectMetadata(bucket: string, key: string): Promise<ObjectMetadataResponse> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
seedMockData()
|
||||
|
||||
const item = mockObjects.get(`${bucket}/${key}`)
|
||||
if (!item) {
|
||||
throw new Error(`Object '${key}' not found in bucket '${bucket}'`)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
export async function mockDeleteObject(bucket: string, key: string): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
mockObjects.delete(`${bucket}/${key}`)
|
||||
}
|
||||
|
||||
export async function mockCreateFolder(req: CreateFolderRequest): Promise<CreateFolderResponse> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
let prefix = req.prefix
|
||||
if (!prefix.endsWith("/")) prefix = `${prefix}/`
|
||||
return { prefix }
|
||||
}
|
||||
|
||||
export async function mockDeleteFolder(bucket: string, prefix: string, recursive = false): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
let p = prefix
|
||||
if (!p.endsWith("/")) p = `${p}/`
|
||||
|
||||
const matchingKeys: string[] = []
|
||||
for (const [mapKey] of mockObjects.entries()) {
|
||||
if (mapKey.startsWith(`${bucket}/${p}`)) {
|
||||
matchingKeys.push(mapKey)
|
||||
}
|
||||
}
|
||||
|
||||
if (matchingKeys.length > 0 && !recursive) {
|
||||
throw new Error("Folder is not empty")
|
||||
}
|
||||
|
||||
for (const key of matchingKeys) {
|
||||
mockObjects.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockPutObjectContent(bucket: string, key: string, data: Blob | ArrayBuffer | string): Promise<{ key: string; etag: string; size: number }> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
seedMockData()
|
||||
|
||||
let size = 0
|
||||
if (typeof data === "string") {
|
||||
size = new TextEncoder().encode(data).byteLength
|
||||
} else if (data instanceof Blob) {
|
||||
size = data.size
|
||||
} else if (data instanceof ArrayBuffer) {
|
||||
size = data.byteLength
|
||||
}
|
||||
|
||||
const name = key.split("/").pop() || key
|
||||
const etag = Math.random().toString(36).substring(2, 15)
|
||||
|
||||
mockObjects.set(`${bucket}/${key}`, {
|
||||
key,
|
||||
name,
|
||||
size,
|
||||
contentType: (data instanceof Blob ? data.type : "") || "application/octet-stream",
|
||||
lastModified: new Date().toISOString(),
|
||||
etag,
|
||||
})
|
||||
|
||||
return { key, etag, size }
|
||||
}
|
||||
|
||||
export async function mockCreateDownloadTicket(bucket: string, key: string): Promise<DownloadTicketResponse> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
const ticket = Math.random().toString(36).substring(2, 15)
|
||||
return {
|
||||
ticket,
|
||||
downloadUrl: `/api/objects/content?bucket=${encodeURIComponent(bucket)}&key=${encodeURIComponent(key)}&ticket=${ticket}`,
|
||||
expiresIn: 120,
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockInitiateUpload(req: InitiateUploadRequest): Promise<InitiateUploadResponse> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
return {
|
||||
uploadId: `mock-upload-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`,
|
||||
bucket: req.bucket,
|
||||
key: req.key,
|
||||
partSize: 8 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockUploadPart(partNumber: number): Promise<UploadPartResponse> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
return {
|
||||
partNumber,
|
||||
etag: `mock-part-etag-${partNumber}`,
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockCompleteUpload(req: CompleteUploadRequest): Promise<CompleteUploadResponse> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
seedMockData()
|
||||
|
||||
const name = req.key.split("/").pop() || req.key
|
||||
const etag = `mock-complete-etag-${Date.now()}`
|
||||
const size = req.totalSize || 10_000_000
|
||||
|
||||
mockObjects.set(`${req.bucket}/${req.key}`, {
|
||||
key: req.key,
|
||||
name,
|
||||
size,
|
||||
contentType: "application/octet-stream",
|
||||
lastModified: new Date().toISOString(),
|
||||
etag,
|
||||
})
|
||||
|
||||
return { key: req.key, etag }
|
||||
}
|
||||
|
||||
export async function mockAbortUpload(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
export interface S3ObjectItem {
|
||||
key: string
|
||||
name: string
|
||||
size: number
|
||||
contentType: string
|
||||
lastModified: string | null
|
||||
etag: string
|
||||
}
|
||||
|
||||
export interface S3FolderItem {
|
||||
prefix: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ListObjectsResponse {
|
||||
bucket: string
|
||||
prefix: string
|
||||
delimiter: string | null
|
||||
folders: S3FolderItem[]
|
||||
objects: S3ObjectItem[]
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export interface ObjectMetadataResponse {
|
||||
key: string
|
||||
name: string
|
||||
size: number
|
||||
contentType: string
|
||||
lastModified: string | null
|
||||
etag: string
|
||||
}
|
||||
|
||||
export interface CreateFolderRequest {
|
||||
bucket: string
|
||||
prefix: string
|
||||
}
|
||||
|
||||
export interface CreateFolderResponse {
|
||||
prefix: string
|
||||
}
|
||||
|
||||
export interface InitiateUploadRequest {
|
||||
bucket: string
|
||||
key: string
|
||||
contentType?: string
|
||||
}
|
||||
|
||||
export interface InitiateUploadResponse {
|
||||
uploadId: string
|
||||
bucket: string
|
||||
key: string
|
||||
partSize: number
|
||||
}
|
||||
|
||||
export interface CompleteUploadPart {
|
||||
partNumber: number
|
||||
etag: string
|
||||
}
|
||||
|
||||
export interface CompleteUploadRequest {
|
||||
bucket: string
|
||||
key: string
|
||||
uploadId: string
|
||||
parts: CompleteUploadPart[]
|
||||
totalSize?: number
|
||||
}
|
||||
|
||||
export interface CompleteUploadResponse {
|
||||
key: string
|
||||
etag: string
|
||||
}
|
||||
|
||||
export interface DownloadTicketResponse {
|
||||
ticket: string
|
||||
downloadUrl: string
|
||||
expiresIn: number
|
||||
}
|
||||
|
||||
export interface UploadPartResponse {
|
||||
partNumber: number
|
||||
etag: string
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState } from "react"
|
||||
import { FolderPlus, X } from "lucide-react"
|
||||
import { Button } from "../../../components/ui/button"
|
||||
import { Dialog } from "../../../components/ui/dialog"
|
||||
import { Input } from "../../../components/ui/input"
|
||||
import { Label } from "../../../components/ui/label"
|
||||
import { useCreateFolder } from "../hooks/useCreateFolder"
|
||||
|
||||
interface CreateFolderDialogProps {
|
||||
bucket: string
|
||||
currentPrefix: string
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function CreateFolderDialog({
|
||||
bucket,
|
||||
currentPrefix,
|
||||
open,
|
||||
onClose,
|
||||
}: CreateFolderDialogProps) {
|
||||
const [folderName, setFolderName] = useState("")
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const createFolderMutation = useCreateFolder(bucket, currentPrefix)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
const trimmed = folderName.trim()
|
||||
if (!trimmed) {
|
||||
setError("Folder name is required")
|
||||
return
|
||||
}
|
||||
|
||||
if (trimmed.includes("/")) {
|
||||
setError("Folder name cannot contain '/'")
|
||||
return
|
||||
}
|
||||
|
||||
const fullPrefix = `${currentPrefix}${trimmed}/`
|
||||
|
||||
try {
|
||||
await createFolderMutation.mutateAsync({
|
||||
bucket,
|
||||
prefix: fullPrefix,
|
||||
})
|
||||
setFolderName("")
|
||||
onClose()
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create folder")
|
||||
}
|
||||
}
|
||||
|
||||
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-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">
|
||||
<FolderPlus className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-foreground">Create Folder</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
In <span className="font-mono font-semibold text-foreground">{currentPrefix || "/"}</span>
|
||||
</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">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="folder-name">Folder Name</Label>
|
||||
<Input
|
||||
id="folder-name"
|
||||
placeholder="e.g. photos, reports, 2026"
|
||||
value={folderName}
|
||||
onChange={(e) => setFolderName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="p-6 pt-0 flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={createFolderMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createFolderMutation.isPending}>
|
||||
{createFolderMutation.isPending ? "Creating..." : "Create Folder"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useState } from "react"
|
||||
import { AlertTriangle, Trash2, X, AlertCircle } from "lucide-react"
|
||||
import { Button } from "../../../components/ui/button"
|
||||
import { Dialog } from "../../../components/ui/dialog"
|
||||
import { Input } from "../../../components/ui/input"
|
||||
import { Label } from "../../../components/ui/label"
|
||||
import { useDeleteFolder } from "../hooks/useDeleteFolder"
|
||||
|
||||
interface DeleteFolderDialogProps {
|
||||
bucket: string
|
||||
currentPrefix: string
|
||||
folderPrefix: string | null
|
||||
folderName: string | null
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function DeleteFolderDialog({
|
||||
bucket,
|
||||
currentPrefix,
|
||||
folderPrefix,
|
||||
folderName,
|
||||
open,
|
||||
onClose,
|
||||
}: DeleteFolderDialogProps) {
|
||||
const [confirmName, setConfirmName] = useState("")
|
||||
const [isNonEmptyError, setIsNonEmptyError] = useState(false)
|
||||
const [forceRecursive, setForceRecursive] = useState(false)
|
||||
const deleteMutation = useDeleteFolder(bucket, currentPrefix)
|
||||
|
||||
if (!folderPrefix || !folderName) return null
|
||||
|
||||
const handleClose = () => {
|
||||
setConfirmName("")
|
||||
setIsNonEmptyError(false)
|
||||
setForceRecursive(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteMutation.mutateAsync({
|
||||
prefix: folderPrefix,
|
||||
recursive: forceRecursive,
|
||||
})
|
||||
handleClose()
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (msg.includes("not empty") || msg.includes("409")) {
|
||||
setIsNonEmptyError(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isConfirmed = confirmName === folderName
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} className="overflow-hidden border-border/80 bg-card/95 backdrop-blur-md shadow-2xl">
|
||||
<div 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-rose-500/5 via-red-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">
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-foreground">Delete Folder</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Move folder to Drive trash</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
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">
|
||||
{isNonEmptyError ? (
|
||||
<div className="p-3 bg-amber-500/10 border border-amber-500/20 rounded-xl text-xs space-y-2.5">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400 font-semibold">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
<span>Folder is not empty</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
To recursively move this folder and all its contents to Google Drive Trash, please type the folder name{" "}
|
||||
<span className="font-mono font-bold text-foreground">{folderName}</span> below:
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="confirm-folder" className="sr-only">
|
||||
Confirm folder name
|
||||
</Label>
|
||||
<Input
|
||||
id="confirm-folder"
|
||||
value={confirmName}
|
||||
onChange={(e) => {
|
||||
setConfirmName(e.target.value)
|
||||
if (e.target.value === folderName) {
|
||||
setForceRecursive(true)
|
||||
}
|
||||
}}
|
||||
placeholder={folderName}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-foreground">
|
||||
Are you sure you want to delete folder{" "}
|
||||
<span className="font-mono text-xs font-semibold text-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{folderName}
|
||||
</span>
|
||||
?
|
||||
</p>
|
||||
)}
|
||||
|
||||
{deleteMutation.isError && !isNonEmptyError && (
|
||||
<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>{deleteMutation.error?.message || "Failed to delete folder"}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 pt-0 flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={
|
||||
deleteMutation.isPending ||
|
||||
(isNonEmptyError && !isConfirmed)
|
||||
}
|
||||
>
|
||||
{deleteMutation.isPending
|
||||
? "Deleting..."
|
||||
: isNonEmptyError
|
||||
? "Delete Folder & Contents"
|
||||
: "Delete Folder"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Trash2, X, AlertCircle } from "lucide-react"
|
||||
import { Button } from "../../../components/ui/button"
|
||||
import { Dialog } from "../../../components/ui/dialog"
|
||||
import { useDeleteObject } from "../hooks/useDeleteObject"
|
||||
|
||||
interface DeleteObjectDialogProps {
|
||||
bucket: string
|
||||
currentPrefix: string
|
||||
objectKey: string | null
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function DeleteObjectDialog({
|
||||
bucket,
|
||||
currentPrefix,
|
||||
objectKey,
|
||||
open,
|
||||
onClose,
|
||||
}: DeleteObjectDialogProps) {
|
||||
const deleteMutation = useDeleteObject(bucket, currentPrefix)
|
||||
|
||||
if (!objectKey) return null
|
||||
const filename = objectKey.split("/").filter(Boolean).pop() || objectKey
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteMutation.mutateAsync(objectKey)
|
||||
onClose()
|
||||
} catch {
|
||||
// Error handled by mutation state
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} className="overflow-hidden border-border/80 bg-card/95 backdrop-blur-md shadow-2xl">
|
||||
<div 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-rose-500/5 via-red-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">
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-foreground">Delete Object</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Permanently remove this file from Google Drive</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">
|
||||
<p className="text-sm text-foreground">
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-mono text-xs font-semibold text-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{filename}
|
||||
</span>
|
||||
?
|
||||
</p>
|
||||
|
||||
{deleteMutation.isError && (
|
||||
<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>{deleteMutation.error?.message || "Failed to delete object"}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 pt-0 flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? "Deleting..." : "Delete Object"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ChevronRight, Database, Folder } from "lucide-react"
|
||||
import { Link } from "react-router"
|
||||
import { parsePrefixBreadcrumbs } from "../lib/prefix"
|
||||
|
||||
interface ObjectBreadcrumbProps {
|
||||
bucket: string
|
||||
prefix: string
|
||||
onNavigatePrefix: (prefix: string) => void
|
||||
}
|
||||
|
||||
export function ObjectBreadcrumb({ bucket, prefix, onNavigatePrefix }: ObjectBreadcrumbProps) {
|
||||
const segments = parsePrefixBreadcrumbs(prefix)
|
||||
|
||||
return (
|
||||
<nav className="flex items-center space-x-1 text-sm text-muted-foreground overflow-x-auto py-2">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-1.5 hover:text-foreground transition-colors shrink-0"
|
||||
>
|
||||
<Database className="w-4 h-4 text-primary" />
|
||||
<span>Buckets</span>
|
||||
</Link>
|
||||
|
||||
<ChevronRight className="w-4 h-4 shrink-0 text-muted-foreground/50" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigatePrefix("")}
|
||||
className={`hover:text-foreground font-medium transition-colors shrink-0 ${
|
||||
prefix === "" ? "text-foreground font-semibold" : ""
|
||||
}`}
|
||||
>
|
||||
{bucket}
|
||||
</button>
|
||||
|
||||
{segments.map((seg, idx) => {
|
||||
const isLast = idx === segments.length - 1
|
||||
return (
|
||||
<div key={seg.prefix} className="flex items-center space-x-1 shrink-0">
|
||||
<ChevronRight className="w-4 h-4 shrink-0 text-muted-foreground/50" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigatePrefix(seg.prefix)}
|
||||
className={`flex items-center gap-1 hover:text-foreground transition-colors ${
|
||||
isLast ? "text-foreground font-semibold" : ""
|
||||
}`}
|
||||
>
|
||||
<Folder className="w-3.5 h-3.5 text-primary/70" />
|
||||
<span>{seg.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useState } from "react"
|
||||
import { FolderPlus, RefreshCw } from "lucide-react"
|
||||
import { Button } from "../../../components/ui/button"
|
||||
import { Skeleton } from "../../../components/ui/skeleton"
|
||||
import { useObjects } from "../hooks/useObjects"
|
||||
import { useUploadQueue } from "../hooks/useUploadQueue"
|
||||
import { downloadObject } from "../lib/download"
|
||||
import { CreateFolderDialog } from "./CreateFolderDialog"
|
||||
import { DeleteFolderDialog } from "./DeleteFolderDialog"
|
||||
import { DeleteObjectDialog } from "./DeleteObjectDialog"
|
||||
import { ObjectBreadcrumb } from "./ObjectBreadcrumb"
|
||||
import { ObjectTable } from "./ObjectTable"
|
||||
import { UploadDropzone } from "./UploadDropzone"
|
||||
import { UploadQueuePanel } from "./UploadQueuePanel"
|
||||
|
||||
interface ObjectBrowserProps {
|
||||
bucket: string
|
||||
prefix: string
|
||||
onNavigatePrefix: (prefix: string) => void
|
||||
}
|
||||
|
||||
export function ObjectBrowser({ bucket, prefix, onNavigatePrefix }: ObjectBrowserProps) {
|
||||
const [createFolderOpen, setCreateFolderOpen] = useState(false)
|
||||
const [deleteObjectTarget, setDeleteObjectTarget] = useState<string | null>(null)
|
||||
const [deleteFolderTarget, setDeleteFolderTarget] = useState<{ prefix: string; name: string } | null>(null)
|
||||
|
||||
const { data, isLoading, isError, error, refetch, isRefetching } = useObjects(bucket, prefix)
|
||||
const { uploads, addFiles, cancelUpload, retryUpload, clearFinished } = useUploadQueue(bucket, prefix)
|
||||
|
||||
const handleDownload = async (key: string) => {
|
||||
try {
|
||||
await downloadObject(bucket, key, true)
|
||||
} catch (err) {
|
||||
console.error("Failed to download object", err)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Top Header / Breadcrumb & Actions */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 pb-2 border-b border-border/50">
|
||||
<ObjectBreadcrumb
|
||||
bucket={bucket}
|
||||
prefix={prefix}
|
||||
onNavigatePrefix={onNavigatePrefix}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isLoading || isRefetching}
|
||||
className="text-xs h-8"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 mr-1.5 ${isRefetching ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => setCreateFolderOpen(true)}
|
||||
className="text-xs h-8"
|
||||
>
|
||||
<FolderPlus className="w-3.5 h-3.5 mr-1.5" />
|
||||
New Folder
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Dropzone */}
|
||||
<UploadDropzone onFilesSelected={addFiles} />
|
||||
|
||||
{/* Upload Queue Panel */}
|
||||
<UploadQueuePanel
|
||||
uploads={uploads}
|
||||
onCancel={cancelUpload}
|
||||
onRetry={retryUpload}
|
||||
onClearFinished={clearFinished}
|
||||
/>
|
||||
|
||||
{/* Object Listing Table */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="p-6 text-center border border-destructive/20 bg-destructive/5 rounded-lg">
|
||||
<p className="text-sm text-destructive font-medium">Failed to load objects</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{error?.message || "An unexpected error occurred"}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
className="mt-3 text-xs"
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ObjectTable
|
||||
bucket={bucket}
|
||||
folders={data?.folders || []}
|
||||
objects={data?.objects || []}
|
||||
truncated={data?.truncated || false}
|
||||
onNavigateFolder={(nextPrefix) => onNavigatePrefix(nextPrefix)}
|
||||
onDownloadObject={handleDownload}
|
||||
onDeleteObject={(key) => setDeleteObjectTarget(key)}
|
||||
onDeleteFolder={(folderPrefix, name) => setDeleteFolderTarget({ prefix: folderPrefix, name })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dialogs */}
|
||||
<CreateFolderDialog
|
||||
bucket={bucket}
|
||||
currentPrefix={prefix}
|
||||
open={createFolderOpen}
|
||||
onClose={() => setCreateFolderOpen(false)}
|
||||
/>
|
||||
|
||||
<DeleteObjectDialog
|
||||
bucket={bucket}
|
||||
currentPrefix={prefix}
|
||||
objectKey={deleteObjectTarget}
|
||||
open={Boolean(deleteObjectTarget)}
|
||||
onClose={() => setDeleteObjectTarget(null)}
|
||||
/>
|
||||
|
||||
<DeleteFolderDialog
|
||||
bucket={bucket}
|
||||
currentPrefix={prefix}
|
||||
folderPrefix={deleteFolderTarget?.prefix || null}
|
||||
folderName={deleteFolderTarget?.name || null}
|
||||
open={Boolean(deleteFolderTarget)}
|
||||
onClose={() => setDeleteFolderTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useState } from "react"
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
Copy,
|
||||
Download,
|
||||
File,
|
||||
Folder,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
import { Button } from "../../../components/ui/button"
|
||||
import { formatBytes, formatRelativeTime } from "../../../lib/format"
|
||||
import type { S3FolderItem, S3ObjectItem } from "../api/objects.types"
|
||||
|
||||
interface ObjectTableProps {
|
||||
bucket: string
|
||||
folders: S3FolderItem[]
|
||||
objects: S3ObjectItem[]
|
||||
truncated: boolean
|
||||
onNavigateFolder: (prefix: string) => void
|
||||
onDownloadObject: (key: string) => void
|
||||
onDeleteObject: (key: string) => void
|
||||
onDeleteFolder: (prefix: string, name: string) => void
|
||||
}
|
||||
|
||||
type SortField = "name" | "size" | "lastModified"
|
||||
type SortOrder = "asc" | "desc"
|
||||
|
||||
export function ObjectTable({
|
||||
bucket,
|
||||
folders,
|
||||
objects,
|
||||
truncated,
|
||||
onNavigateFolder,
|
||||
onDownloadObject,
|
||||
onDeleteObject,
|
||||
onDeleteFolder,
|
||||
}: ObjectTableProps) {
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null)
|
||||
const [filterQuery, setFilterQuery] = useState("")
|
||||
const [sortField, setSortField] = useState<SortField>("name")
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
|
||||
|
||||
const handleCopyUri = (key: string) => {
|
||||
const uri = `s3://${bucket}/${key}`
|
||||
navigator.clipboard.writeText(uri).then(() => {
|
||||
setCopiedKey(key)
|
||||
setTimeout(() => setCopiedKey(null), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc")
|
||||
} else {
|
||||
setSortField(field)
|
||||
setSortOrder("asc")
|
||||
}
|
||||
}
|
||||
|
||||
// Filter folders and objects
|
||||
const filteredFolders = folders.filter((f) =>
|
||||
f.name.toLowerCase().includes(filterQuery.toLowerCase())
|
||||
)
|
||||
|
||||
const filteredObjects = objects.filter((o) =>
|
||||
o.name.toLowerCase().includes(filterQuery.toLowerCase())
|
||||
)
|
||||
|
||||
// Sort objects
|
||||
const sortedObjects = [...filteredObjects].sort((a, b) => {
|
||||
let cmp = 0
|
||||
if (sortField === "name") {
|
||||
cmp = a.name.localeCompare(b.name)
|
||||
} else if (sortField === "size") {
|
||||
cmp = a.size - b.size
|
||||
} else if (sortField === "lastModified") {
|
||||
const timeA = a.lastModified ? new Date(a.lastModified).getTime() : 0
|
||||
const timeB = b.lastModified ? new Date(b.lastModified).getTime() : 0
|
||||
cmp = timeA - timeB
|
||||
}
|
||||
return sortOrder === "asc" ? cmp : -cmp
|
||||
})
|
||||
|
||||
const isEmpty = filteredFolders.length === 0 && sortedObjects.length === 0
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{truncated && (
|
||||
<div className="p-3 bg-amber-500/10 border border-amber-500/20 rounded-md flex items-start gap-2.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-semibold">Listing is truncated</p>
|
||||
<p className="text-muted-foreground mt-0.5">
|
||||
This folder contains too many items and results were truncated at the maximum scan cap.
|
||||
Navigate into subfolders to inspect items in smaller scopes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by name in this folder..."
|
||||
value={filterQuery}
|
||||
onChange={(e) => setFilterQuery(e.target.value)}
|
||||
className="text-xs h-8 px-3 rounded-md border border-input bg-background/50 placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring w-full max-w-xs"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground shrink-0">
|
||||
{folders.length} folder{folders.length === 1 ? "" : "s"},{" "}
|
||||
{objects.length} file{objects.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded-lg overflow-hidden bg-card">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-muted/40 border-b border-border text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-2.5 px-3 font-medium">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("name")}
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
>
|
||||
<span>Name</span>
|
||||
<ArrowUpDown className="w-3 h-3" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="py-2.5 px-3 font-medium w-28">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("size")}
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
>
|
||||
<span>Size</span>
|
||||
<ArrowUpDown className="w-3 h-3" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="py-2.5 px-3 font-medium w-36 hidden sm:table-cell">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSort("lastModified")}
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
>
|
||||
<span>Last Modified</span>
|
||||
<ArrowUpDown className="w-3 h-3" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="py-2.5 px-3 font-medium w-32 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/40">
|
||||
{isEmpty ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="py-8 text-center text-muted-foreground">
|
||||
{filterQuery ? "No items match your filter" : "This folder is empty"}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<>
|
||||
{/* Folders */}
|
||||
{filteredFolders.map((folder) => (
|
||||
<tr
|
||||
key={folder.prefix}
|
||||
className="hover:bg-muted/20 transition-colors group"
|
||||
>
|
||||
<td className="py-2.5 px-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigateFolder(folder.prefix)}
|
||||
className="flex items-center gap-2 text-foreground font-medium hover:text-primary transition-colors text-left"
|
||||
>
|
||||
<Folder className="w-4 h-4 text-primary fill-primary/20 shrink-0" />
|
||||
<span className="truncate">{folder.name}/</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-muted-foreground">—</td>
|
||||
<td className="py-2.5 px-3 text-muted-foreground hidden sm:table-cell">—</td>
|
||||
<td className="py-2.5 px-3 text-right">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDeleteFolder(folder.prefix, folder.name)}
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
title="Delete folder"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{/* Objects */}
|
||||
{sortedObjects.map((obj) => (
|
||||
<tr
|
||||
key={obj.key}
|
||||
className="hover:bg-muted/20 transition-colors group"
|
||||
>
|
||||
<td className="py-2.5 px-3">
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
<File className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span className="truncate" title={obj.key}>
|
||||
{obj.name}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-muted-foreground">
|
||||
{formatBytes(obj.size)}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-muted-foreground hidden sm:table-cell">
|
||||
{obj.lastModified ? formatRelativeTime(obj.lastModified) : "—"}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleCopyUri(obj.key)}
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
title="Copy s3:// URI"
|
||||
>
|
||||
{copiedKey === obj.key ? (
|
||||
<Check className="w-3.5 h-3.5 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDownloadObject(obj.key)}
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
title="Download object"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDeleteObject(obj.key)}
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
title="Delete object"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useRef } from "react"
|
||||
import { UploadCloud } from "lucide-react"
|
||||
|
||||
interface UploadDropzoneProps {
|
||||
onFilesSelected: (files: File[]) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function UploadDropzone({ onFilesSelected, disabled }: UploadDropzoneProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (disabled) return
|
||||
|
||||
const files = Array.from(e.dataTransfer.files)
|
||||
if (files.length > 0) {
|
||||
onFilesSelected(files)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (disabled || !e.target.files) return
|
||||
const files = Array.from(e.target.files)
|
||||
if (files.length > 0) {
|
||||
onFilesSelected(files)
|
||||
}
|
||||
// reset input so the same files can be picked again
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => !disabled && fileInputRef.current?.click()}
|
||||
className={`border-2 border-dashed border-muted-foreground/25 hover:border-primary/50 transition-colors rounded-lg p-6 flex flex-col items-center justify-center gap-2 cursor-pointer bg-card/30 hover:bg-card/60 ${
|
||||
disabled ? "opacity-50 cursor-not-allowed pointer-events-none" : ""
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleChange}
|
||||
multiple
|
||||
className="hidden"
|
||||
/>
|
||||
<div className="p-3 bg-primary/10 rounded-full text-primary">
|
||||
<UploadCloud className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Drag and drop files here, or <span className="text-primary hover:underline">browse</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Supports multipart upload with progress tracking for large files
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { AlertCircle, CheckCircle2, RefreshCw, X } from "lucide-react"
|
||||
import { Badge } from "../../../components/ui/badge"
|
||||
import { Button } from "../../../components/ui/button"
|
||||
import { Progress } from "../../../components/ui/progress"
|
||||
import { formatBytes } from "../../../lib/format"
|
||||
import type { UploadItem } from "../lib/upload-manager"
|
||||
|
||||
interface UploadQueuePanelProps {
|
||||
uploads: UploadItem[]
|
||||
onCancel: (id: string) => void
|
||||
onRetry: (id: string) => void
|
||||
onClearFinished: () => void
|
||||
}
|
||||
|
||||
export function UploadQueuePanel({
|
||||
uploads,
|
||||
onCancel,
|
||||
onRetry,
|
||||
onClearFinished,
|
||||
}: UploadQueuePanelProps) {
|
||||
if (uploads.length === 0) return null
|
||||
|
||||
const hasFinished = uploads.some(
|
||||
(u) => u.status === "done" || u.status === "error" || u.status === "canceled"
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="border border-border/70 rounded-lg bg-card p-4 space-y-3 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold flex items-center gap-2">
|
||||
<span>Upload Queue</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{uploads.length}
|
||||
</Badge>
|
||||
</h4>
|
||||
{hasFinished && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClearFinished}
|
||||
className="text-xs h-7 px-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Clear Completed
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5 max-h-60 overflow-y-auto pr-1">
|
||||
{uploads.map((item) => {
|
||||
const filename = item.file.name
|
||||
const isDone = item.status === "done"
|
||||
const isError = item.status === "error"
|
||||
const isCanceled = item.status === "canceled"
|
||||
const isUploading = item.status === "uploading" || item.status === "initiating" || item.status === "completing"
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="p-2.5 rounded border border-border/50 bg-background/50 text-xs space-y-1.5"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 truncate">
|
||||
{isDone && <CheckCircle2 className="w-3.5 h-3.5 text-emerald-500 shrink-0" />}
|
||||
{isError && <AlertCircle className="w-3.5 h-3.5 text-destructive shrink-0" />}
|
||||
{isUploading && <RefreshCw className="w-3.5 h-3.5 text-primary animate-spin shrink-0" />}
|
||||
<span className="font-medium truncate text-foreground">{filename}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-muted-foreground">
|
||||
{formatBytes(item.bytesUploaded)} / {formatBytes(item.size)}
|
||||
</span>
|
||||
{isUploading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCancel(item.id)}
|
||||
className="text-muted-foreground hover:text-foreground p-0.5 rounded"
|
||||
title="Cancel upload"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{(isError || isCanceled) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRetry(item.id)}
|
||||
className="text-primary hover:underline p-0.5 rounded text-[11px]"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isUploading && (
|
||||
<div className="space-y-1">
|
||||
<Progress value={item.progress} className="h-1.5" />
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground">
|
||||
<span>
|
||||
{item.status === "initiating" && "Initializing upload session..."}
|
||||
{item.status === "completing" && "Finalizing multipart upload..."}
|
||||
{item.status === "uploading" &&
|
||||
(item.totalParts
|
||||
? `Uploading part ${item.currentPart || 1} of ${item.totalParts}...`
|
||||
: "Uploading...")}
|
||||
</span>
|
||||
<span>{item.progress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<p className="text-[11px] text-destructive truncate">
|
||||
{item.errorMessage || "Upload failed"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFolder } from "../api/objects.api"
|
||||
import { objectKeys } from "../api/objects.keys"
|
||||
import type { CreateFolderRequest, CreateFolderResponse } from "../api/objects.types"
|
||||
|
||||
export function useCreateFolder(bucket: string, currentPrefix = "") {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<CreateFolderResponse, Error, CreateFolderRequest>({
|
||||
mutationFn: createFolder,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: objectKeys.list(bucket, currentPrefix) })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { deleteFolder } from "../api/objects.api"
|
||||
import { objectKeys } from "../api/objects.keys"
|
||||
|
||||
export function useDeleteFolder(bucket: string, currentPrefix = "") {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<void, Error, { prefix: string; recursive?: boolean }>({
|
||||
mutationFn: ({ prefix, recursive }) => deleteFolder(bucket, prefix, recursive),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: objectKeys.list(bucket, currentPrefix) })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { deleteObject } from "../api/objects.api"
|
||||
import { objectKeys } from "../api/objects.keys"
|
||||
|
||||
export function useDeleteObject(bucket: string, currentPrefix = "") {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: (key: string) => deleteObject(bucket, key),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: objectKeys.list(bucket, currentPrefix) })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { listObjects } from "../api/objects.api"
|
||||
import { objectKeys } from "../api/objects.keys"
|
||||
import type { ListObjectsResponse } from "../api/objects.types"
|
||||
|
||||
export function useObjects(bucket: string, prefix = "", delimiter = "/") {
|
||||
return useQuery<ListObjectsResponse, Error>({
|
||||
queryKey: objectKeys.list(bucket, prefix),
|
||||
queryFn: () => listObjects(bucket, prefix, delimiter),
|
||||
enabled: Boolean(bucket),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useSyncExternalStore } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { objectKeys } from "../api/objects.keys"
|
||||
import { uploadManager } from "../lib/upload-manager"
|
||||
|
||||
export function useUploadQueue(bucket?: string, prefix?: string) {
|
||||
const queryClient = useQueryClient()
|
||||
const uploads = useSyncExternalStore(uploadManager.subscribe, uploadManager.getSnapshot)
|
||||
|
||||
useEffect(() => {
|
||||
uploadManager.setOnCompleteCallback((item) => {
|
||||
queryClient.invalidateQueries({ queryKey: objectKeys.list(item.bucket, item.prefix) })
|
||||
})
|
||||
}, [queryClient])
|
||||
|
||||
const addFiles = (files: File[]) => {
|
||||
if (!bucket) return
|
||||
uploadManager.addFiles(files, bucket, prefix || "")
|
||||
}
|
||||
|
||||
const cancelUpload = (id: string) => {
|
||||
uploadManager.cancelUpload(id)
|
||||
}
|
||||
|
||||
const retryUpload = (id: string) => {
|
||||
uploadManager.retryUpload(id)
|
||||
}
|
||||
|
||||
const clearFinished = () => {
|
||||
uploadManager.clearFinished()
|
||||
}
|
||||
|
||||
return {
|
||||
uploads,
|
||||
addFiles,
|
||||
cancelUpload,
|
||||
retryUpload,
|
||||
clearFinished,
|
||||
hasActive: uploadManager.hasActiveUploads(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createDownloadTicket, downloadObjectDirect } from "../api/objects.api"
|
||||
|
||||
/**
|
||||
* Triggers a browser file download using either a ticket or blob URL fallback.
|
||||
*/
|
||||
export async function downloadObject(bucket: string, key: string, useTicket = true): Promise<void> {
|
||||
const filename = key.split("/").filter(Boolean).pop() || "download"
|
||||
|
||||
if (useTicket) {
|
||||
try {
|
||||
const ticketRes = await createDownloadTicket(bucket, key)
|
||||
const baseUrl = import.meta.env.VITE_API_URL || ""
|
||||
const url = `${baseUrl}${ticketRes.downloadUrl}`
|
||||
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.download = filename
|
||||
link.target = "_blank"
|
||||
link.rel = "noopener noreferrer"
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
return
|
||||
} catch (err) {
|
||||
console.warn("Download ticket failed, falling back to direct blob download:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to direct download via Blob
|
||||
const blob = await downloadObjectDirect(bucket, key)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
|
||||
// Safari requires setTimeout before revoking object URL
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}, 100)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export interface BreadcrumbSegment {
|
||||
name: string
|
||||
prefix: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a prefix string so that:
|
||||
* - Empty string or "/" becomes ""
|
||||
* - Any non-empty folder prefix ends with a single "/"
|
||||
* - Leading slashes are trimmed
|
||||
*/
|
||||
export function normalizePrefix(prefix = ""): string {
|
||||
const trimmed = prefix.replace(/^\/+/, "")
|
||||
if (!trimmed || trimmed === "/") return ""
|
||||
return trimmed.endsWith("/") ? trimmed : `${trimmed}/`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a prefix like "documents/2024/august/" into breadcrumb items:
|
||||
* [
|
||||
* { name: "documents", prefix: "documents/" },
|
||||
* { name: "2024", prefix: "documents/2024/" },
|
||||
* { name: "august", prefix: "documents/2024/august/" }
|
||||
* ]
|
||||
*/
|
||||
export function parsePrefixBreadcrumbs(prefix = ""): BreadcrumbSegment[] {
|
||||
const normalized = normalizePrefix(prefix)
|
||||
if (!normalized) return []
|
||||
|
||||
const parts = normalized.split("/").filter(Boolean)
|
||||
const segments: BreadcrumbSegment[] = []
|
||||
let accumulated = ""
|
||||
|
||||
for (const part of parts) {
|
||||
accumulated += `${part}/`
|
||||
segments.push({
|
||||
name: part,
|
||||
prefix: accumulated,
|
||||
})
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns parent prefix of a given prefix.
|
||||
* "photos/vacation/2024/" -> "photos/vacation/"
|
||||
* "photos/" -> ""
|
||||
*/
|
||||
export function getParentPrefix(prefix = ""): string {
|
||||
const normalized = normalizePrefix(prefix)
|
||||
if (!normalized) return ""
|
||||
const parts = normalized.split("/").filter(Boolean)
|
||||
if (parts.length <= 1) return ""
|
||||
return `${parts.slice(0, -1).join("/")}/`
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import {
|
||||
abortMultipartUpload,
|
||||
completeMultipartUpload,
|
||||
initiateMultipartUpload,
|
||||
putObjectContent,
|
||||
uploadMultipartPart,
|
||||
} from "../api/objects.api"
|
||||
|
||||
export type UploadStatus =
|
||||
| "queued"
|
||||
| "initiating"
|
||||
| "uploading"
|
||||
| "completing"
|
||||
| "done"
|
||||
| "error"
|
||||
| "canceled"
|
||||
|
||||
export interface UploadItem {
|
||||
id: string
|
||||
file: File
|
||||
bucket: string
|
||||
key: string
|
||||
prefix: string
|
||||
size: number
|
||||
progress: number // 0 to 100
|
||||
bytesUploaded: number
|
||||
status: UploadStatus
|
||||
errorMessage?: string
|
||||
uploadId?: string
|
||||
currentPart?: number
|
||||
totalParts?: number
|
||||
abortController?: AbortController
|
||||
}
|
||||
|
||||
type Listener = () => void
|
||||
|
||||
const MAX_CONCURRENT_FILES = 2
|
||||
const DIRECT_UPLOAD_LIMIT = 8 * 1024 * 1024 // 8 MiB
|
||||
const PART_SIZE = 8 * 1024 * 1024 // 8 MiB part chunks
|
||||
|
||||
class UploadManager {
|
||||
private queue: UploadItem[] = []
|
||||
private activeUploads = 0
|
||||
private listeners = new Set<Listener>()
|
||||
private onCompleteCallback?: (item: UploadItem) => void
|
||||
|
||||
constructor() {
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("beforeunload", (e) => {
|
||||
if (this.hasActiveUploads()) {
|
||||
e.preventDefault()
|
||||
e.returnValue = ""
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener)
|
||||
return () => {
|
||||
this.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
getSnapshot = (): UploadItem[] => {
|
||||
return this.queue
|
||||
}
|
||||
|
||||
private notify() {
|
||||
this.queue = [...this.queue]
|
||||
for (const listener of this.listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
setOnCompleteCallback(cb: (item: UploadItem) => void) {
|
||||
this.onCompleteCallback = cb
|
||||
}
|
||||
|
||||
hasActiveUploads(): boolean {
|
||||
return this.queue.some(
|
||||
(item) =>
|
||||
item.status === "queued" ||
|
||||
item.status === "initiating" ||
|
||||
item.status === "uploading" ||
|
||||
item.status === "completing"
|
||||
)
|
||||
}
|
||||
|
||||
addFiles(files: File[], bucket: string, prefix: string) {
|
||||
const newItems: UploadItem[] = files.map((file) => {
|
||||
const key = `${prefix}${file.name}`
|
||||
return {
|
||||
id: `upload-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
file,
|
||||
bucket,
|
||||
key,
|
||||
prefix,
|
||||
size: file.size,
|
||||
progress: 0,
|
||||
bytesUploaded: 0,
|
||||
status: "queued",
|
||||
}
|
||||
})
|
||||
|
||||
this.queue.push(...newItems)
|
||||
this.notify()
|
||||
this.processQueue()
|
||||
}
|
||||
|
||||
cancelUpload(id: string) {
|
||||
const item = this.queue.find((i) => i.id === id)
|
||||
if (!item) return
|
||||
|
||||
if (item.abortController) {
|
||||
item.abortController.abort()
|
||||
}
|
||||
|
||||
if (item.uploadId && item.status !== "done") {
|
||||
abortMultipartUpload(item.bucket, item.key, item.uploadId).catch(() => {})
|
||||
}
|
||||
|
||||
item.status = "canceled"
|
||||
this.notify()
|
||||
this.processQueue()
|
||||
}
|
||||
|
||||
retryUpload(id: string) {
|
||||
const item = this.queue.find((i) => i.id === id)
|
||||
if (!item || (item.status !== "error" && item.status !== "canceled")) return
|
||||
|
||||
item.status = "queued"
|
||||
item.progress = 0
|
||||
item.bytesUploaded = 0
|
||||
item.errorMessage = undefined
|
||||
item.uploadId = undefined
|
||||
item.currentPart = undefined
|
||||
item.totalParts = undefined
|
||||
item.abortController = undefined
|
||||
|
||||
this.notify()
|
||||
this.processQueue()
|
||||
}
|
||||
|
||||
clearFinished() {
|
||||
this.queue = this.queue.filter(
|
||||
(i) => i.status !== "done" && i.status !== "canceled" && i.status !== "error"
|
||||
)
|
||||
this.notify()
|
||||
}
|
||||
|
||||
private processQueue() {
|
||||
if (this.activeUploads >= MAX_CONCURRENT_FILES) return
|
||||
|
||||
const nextItem = this.queue.find((item) => item.status === "queued")
|
||||
if (!nextItem) return
|
||||
|
||||
this.activeUploads++
|
||||
this.startUpload(nextItem).finally(() => {
|
||||
this.activeUploads--
|
||||
this.processQueue()
|
||||
})
|
||||
}
|
||||
|
||||
private async startUpload(item: UploadItem) {
|
||||
item.abortController = new AbortController()
|
||||
|
||||
try {
|
||||
if (item.size <= DIRECT_UPLOAD_LIMIT) {
|
||||
await this.handleDirectUpload(item)
|
||||
} else {
|
||||
await this.handleMultipartUpload(item)
|
||||
}
|
||||
|
||||
item.status = "done"
|
||||
item.progress = 100
|
||||
item.bytesUploaded = item.size
|
||||
this.notify()
|
||||
if (this.onCompleteCallback) {
|
||||
this.onCompleteCallback(item)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (item.status === "canceled") return
|
||||
|
||||
item.status = "error"
|
||||
item.errorMessage = err instanceof Error ? err.message : String(err)
|
||||
this.notify()
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDirectUpload(item: UploadItem) {
|
||||
item.status = "uploading"
|
||||
item.progress = 50
|
||||
this.notify()
|
||||
|
||||
await putObjectContent(
|
||||
item.bucket,
|
||||
item.key,
|
||||
item.file,
|
||||
item.file.type || "application/octet-stream"
|
||||
)
|
||||
}
|
||||
|
||||
private async handleMultipartUpload(item: UploadItem) {
|
||||
item.status = "initiating"
|
||||
this.notify()
|
||||
|
||||
const initRes = await initiateMultipartUpload({
|
||||
bucket: item.bucket,
|
||||
key: item.key,
|
||||
contentType: item.file.type || "application/octet-stream",
|
||||
})
|
||||
|
||||
item.uploadId = initRes.uploadId
|
||||
const partSize = initRes.partSize || PART_SIZE
|
||||
const totalParts = Math.ceil(item.size / partSize) || 1
|
||||
item.totalParts = totalParts
|
||||
item.status = "uploading"
|
||||
this.notify()
|
||||
|
||||
const completedParts: Array<{ partNumber: number; etag: string }> = []
|
||||
let totalBytesUploaded = 0
|
||||
|
||||
for (let partNumber = 1; partNumber <= totalParts; partNumber++) {
|
||||
if ((item.status as UploadStatus) === "canceled") break
|
||||
|
||||
item.currentPart = partNumber
|
||||
const start = (partNumber - 1) * partSize
|
||||
const end = Math.min(start + partSize, item.size)
|
||||
const partBlob = item.file.slice(start, end)
|
||||
const partLength = end - start
|
||||
|
||||
// Upload part with retry for 503 / network errors
|
||||
let partResult: { partNumber: number; etag: string } | null = null
|
||||
let attempts = 0
|
||||
const maxAttempts = 3
|
||||
|
||||
while (!partResult && attempts < maxAttempts) {
|
||||
attempts++
|
||||
try {
|
||||
partResult = await uploadMultipartPart(
|
||||
item.bucket,
|
||||
item.key,
|
||||
item.uploadId,
|
||||
partNumber,
|
||||
partBlob,
|
||||
item.abortController?.signal,
|
||||
(loaded) => {
|
||||
const currentTotal = totalBytesUploaded + loaded
|
||||
item.bytesUploaded = currentTotal
|
||||
item.progress = Math.min(99, Math.round((currentTotal / item.size) * 100))
|
||||
this.notify()
|
||||
}
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { status?: number }).status
|
||||
const retryAfter = (err as { retryAfter?: string }).retryAfter
|
||||
if (status === 503 && attempts < maxAttempts) {
|
||||
const delayMs = retryAfter ? parseInt(retryAfter, 10) * 1000 : 1500 * attempts
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
} else if (attempts >= maxAttempts) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!partResult) {
|
||||
throw new Error(`Failed to upload part ${partNumber}`)
|
||||
}
|
||||
|
||||
completedParts.push({ partNumber, etag: partResult.etag })
|
||||
totalBytesUploaded += partLength
|
||||
item.bytesUploaded = totalBytesUploaded
|
||||
item.progress = Math.min(99, Math.round((totalBytesUploaded / item.size) * 100))
|
||||
this.notify()
|
||||
}
|
||||
|
||||
if ((item.status as UploadStatus) === "canceled") return
|
||||
|
||||
item.status = "completing"
|
||||
this.notify()
|
||||
|
||||
await completeMultipartUpload({
|
||||
bucket: item.bucket,
|
||||
key: item.key,
|
||||
uploadId: item.uploadId,
|
||||
parts: completedParts,
|
||||
totalSize: item.size,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const uploadManager = new UploadManager()
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useMemo } from "react"
|
||||
import {
|
||||
FolderTree,
|
||||
FolderOpen,
|
||||
RotateCw,
|
||||
Sparkles,
|
||||
AlertCircle,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
CheckCircle2,
|
||||
RefreshCw,
|
||||
} from "lucide-react"
|
||||
import { Link } from "react-router"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { Card, CardContent, CardDescription, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -442,7 +444,12 @@ export function BucketStatsTable({ stats, isLoading, error }: BucketStatsTablePr
|
||||
<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>
|
||||
<Link
|
||||
to={`/buckets/${encodeURIComponent(b.name)}`}
|
||||
className="font-mono font-semibold text-foreground text-xs hover:text-primary hover:underline"
|
||||
>
|
||||
{b.name}
|
||||
</Link>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
s3://{b.name}
|
||||
</span>
|
||||
@@ -513,6 +520,17 @@ export function BucketStatsTable({ stats, isLoading, error }: BucketStatsTablePr
|
||||
|
||||
{/* Actions */}
|
||||
<td className="py-3 px-4 text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Link to={`/buckets/${encodeURIComponent(b.name)}`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
|
||||
title="Browse objects"
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -522,6 +540,7 @@ export function BucketStatsTable({ stats, isLoading, error }: BucketStatsTablePr
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
@@ -546,11 +565,27 @@ export function BucketStatsTable({ stats, isLoading, error }: BucketStatsTablePr
|
||||
<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>
|
||||
<Link
|
||||
to={`/buckets/${encodeURIComponent(b.name)}`}
|
||||
className="font-mono font-bold text-sm text-foreground truncate hover:text-primary hover:underline block"
|
||||
>
|
||||
{b.name}
|
||||
</Link>
|
||||
<div className="text-[10px] font-mono text-muted-foreground">s3://{b.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Link to={`/buckets/${encodeURIComponent(b.name)}`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg"
|
||||
title="Browse objects"
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -561,6 +596,7 @@ export function BucketStatsTable({ stats, isLoading, error }: BucketStatsTablePr
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</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">
|
||||
|
||||
+15
-3
@@ -19,12 +19,12 @@ export interface ApiRequestOptions extends Omit<RequestInit, "body"> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe fetch wrapper with error handling and automatic bearer authentication.
|
||||
* Core fetch wrapper returning the raw Response with auth headers and error handling.
|
||||
*/
|
||||
export async function apiRequest<T>(
|
||||
export async function apiFetch(
|
||||
path: string,
|
||||
options: ApiRequestOptions = {}
|
||||
): Promise<T> {
|
||||
): Promise<Response> {
|
||||
const { body, auth = true, headers: customHeaders, ...restOptions } = options
|
||||
|
||||
const headers = new Headers(customHeaders)
|
||||
@@ -76,6 +76,18 @@ export async function apiRequest<T>(
|
||||
throw new ApiError(response.status, errorMessage)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe fetch wrapper with error handling, automatic bearer authentication and JSON parsing.
|
||||
*/
|
||||
export async function apiRequest<T>(
|
||||
path: string,
|
||||
options: ApiRequestOptions = {}
|
||||
): Promise<T> {
|
||||
const response = await apiFetch(path, options)
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { ArrowLeft, Database, HardDrive, Shield } from "lucide-react"
|
||||
import { Link, useParams, useSearchParams } from "react-router"
|
||||
import { Badge } from "../components/ui/badge"
|
||||
import { Button } from "../components/ui/button"
|
||||
import { ThemeToggle } from "../components/ThemeToggle"
|
||||
import { useBucketStats } from "../features/status/hooks/useBucketStats"
|
||||
import { ObjectBrowser } from "../features/objects/components/ObjectBrowser"
|
||||
import { normalizePrefix } from "../features/objects/lib/prefix"
|
||||
import { useLogout } from "../features/auth/hooks/useLogout"
|
||||
|
||||
export function BucketBrowser() {
|
||||
const { bucket = "" } = useParams<{ bucket: string }>()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const { data: statsData } = useBucketStats()
|
||||
const { mutate: logout, isPending: isLoggingOut } = useLogout()
|
||||
|
||||
const rawPrefix = searchParams.get("prefix") || ""
|
||||
const prefix = normalizePrefix(rawPrefix)
|
||||
|
||||
const handleNavigatePrefix = (newPrefix: string) => {
|
||||
const normalized = normalizePrefix(newPrefix)
|
||||
if (!normalized) {
|
||||
searchParams.delete("prefix")
|
||||
} else {
|
||||
searchParams.set("prefix", normalized)
|
||||
}
|
||||
setSearchParams(searchParams)
|
||||
}
|
||||
|
||||
const currentBucket = statsData?.buckets?.find((b) => b.name === bucket)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground flex flex-col font-sans">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-30 border-b border-border/80 bg-background/80 backdrop-blur-md">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-2 group text-foreground font-semibold text-lg hover:text-primary transition-colors"
|
||||
>
|
||||
<div className="p-1.5 bg-primary/10 rounded-lg group-hover:bg-primary/20 transition-colors">
|
||||
<HardDrive className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<span className="hidden sm:inline">S3 Drive Storage</span>
|
||||
</Link>
|
||||
|
||||
<span className="text-muted-foreground/50">/</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="font-semibold text-sm sm:text-base">{bucket}</span>
|
||||
{currentBucket?.publicRead && (
|
||||
<Badge variant="outline" className="text-[10px] py-0 border-emerald-500/30 text-emerald-600 dark:text-emerald-400 bg-emerald-500/10">
|
||||
Public Read
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => logout()}
|
||||
disabled={isLoggingOut}
|
||||
className="text-xs"
|
||||
>
|
||||
<Shield className="w-3.5 h-3.5 mr-1 text-muted-foreground" />
|
||||
{isLoggingOut ? "Signing out..." : "Sign Out"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to="/">
|
||||
<Button type="button" variant="ghost" size="sm" className="text-xs h-8">
|
||||
<ArrowLeft className="w-3.5 h-3.5 mr-1" />
|
||||
Back to Overview
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ObjectBrowser
|
||||
bucket={bucket}
|
||||
prefix={prefix}
|
||||
onNavigatePrefix={handleNavigatePrefix}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default BucketBrowser
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router"
|
||||
import SignIn from "@/pages/SignIn"
|
||||
import Dashboard from "@/pages/Dashboard"
|
||||
import BucketBrowser from "@/pages/BucketBrowser"
|
||||
import { ProtectedRoute } from "@/components/ProtectedRoute"
|
||||
import { PublicOnlyRoute } from "@/components/PublicOnlyRoute"
|
||||
|
||||
@@ -24,6 +25,14 @@ export function AppRouter() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/buckets/:bucket"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<BucketBrowser />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
Reference in New Issue
Block a user