feat: add page for manage bucket

This commit is contained in:
2026-08-21 12:25:11 +07:00
parent e3e641444c
commit a60de5cb5c
24 changed files with 2203 additions and 23 deletions
+215
View File
@@ -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" })
}
+7
View File
@@ -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,
}
+212
View File
@@ -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))
}
+82
View File
@@ -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) })
},
})
}
+13
View File
@@ -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(),
}
}
+43
View File
@@ -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)
}
+56
View File
@@ -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("/")}/`
}
+293
View File
@@ -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,15 +520,27 @@ export function BucketStatsTable({ stats, isLoading, error }: BucketStatsTablePr
{/* Actions */}
<td className="py-3 px-4 text-center">
<Button
variant="ghost"
size="sm"
onClick={() => setDeletingBucket(b.name)}
className="h-7 w-7 p-0 text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors"
title="Delete bucket"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<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"
onClick={() => setDeletingBucket(b.name)}
className="h-7 w-7 p-0 text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors"
title="Delete bucket"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr>
)
@@ -546,20 +565,37 @@ 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>
<Button
variant="ghost"
size="sm"
onClick={() => setDeletingBucket(b.name)}
className="h-7 w-7 p-0 text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg shrink-0"
title="Delete bucket"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<div 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"
onClick={() => setDeletingBucket(b.name)}
className="h-7 w-7 p-0 text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg shrink-0"
title="Delete bucket"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* Stats Grid inside card */}
+15 -3
View File
@@ -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
}
+98
View File
@@ -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
+9
View File
@@ -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>