diff --git a/src/features/objects/api/objects.api.ts b/src/features/objects/api/objects.api.ts new file mode 100644 index 0000000..f07bc1e --- /dev/null +++ b/src/features/objects/api/objects.api.ts @@ -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 { + 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(`/api/objects?${params.toString()}`) +} + +export async function getObjectMetadata(bucket: string, key: string): Promise { + if (IS_MOCK_MODE) { + return mockGetObjectMetadata(bucket, key) + } + const params = new URLSearchParams({ bucket, key }) + return apiRequest(`/api/objects/metadata?${params.toString()}`) +} + +export async function deleteObject(bucket: string, key: string): Promise { + if (IS_MOCK_MODE) { + return mockDeleteObject(bucket, key) + } + const params = new URLSearchParams({ bucket, key }) + return apiRequest(`/api/objects?${params.toString()}`, { method: "DELETE" }) +} + +export async function createFolder(req: CreateFolderRequest): Promise { + if (IS_MOCK_MODE) { + return mockCreateFolder(req) + } + return apiRequest("/api/objects/folder", { + method: "POST", + body: req, + }) +} + +export async function deleteFolder(bucket: string, prefix: string, recursive = false): Promise { + if (IS_MOCK_MODE) { + return mockDeleteFolder(bucket, prefix, recursive) + } + const params = new URLSearchParams({ bucket, prefix }) + if (recursive) params.set("recursive", "1") + return apiRequest(`/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 = {} + 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 { + if (IS_MOCK_MODE) { + return mockCreateDownloadTicket(bucket, key) + } + return apiRequest("/api/objects/download-ticket", { + method: "POST", + body: { bucket, key }, + }) +} + +export async function downloadObjectDirect(bucket: string, key: string): Promise { + 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 { + if (IS_MOCK_MODE) { + return mockInitiateUpload(req) + } + return apiRequest("/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 { + 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 { + if (IS_MOCK_MODE) { + return mockCompleteUpload(req) + } + return apiRequest("/api/objects/uploads/complete", { + method: "POST", + body: req, + }) +} + +export async function abortMultipartUpload(bucket: string, key: string, uploadId: string): Promise { + if (IS_MOCK_MODE) { + return mockAbortUpload() + } + const params = new URLSearchParams({ bucket, key, uploadId }) + return apiRequest(`/api/objects/uploads?${params.toString()}`, { method: "DELETE" }) +} diff --git a/src/features/objects/api/objects.keys.ts b/src/features/objects/api/objects.keys.ts new file mode 100644 index 0000000..aa4ab8b --- /dev/null +++ b/src/features/objects/api/objects.keys.ts @@ -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, +} diff --git a/src/features/objects/api/objects.mock.ts b/src/features/objects/api/objects.mock.ts new file mode 100644 index 0000000..77e665c --- /dev/null +++ b/src/features/objects/api/objects.mock.ts @@ -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() + +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 { + await new Promise((resolve) => setTimeout(resolve, 150)) + seedMockData() + + const foldersSet = new Set() + 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 { + 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 { + await new Promise((resolve) => setTimeout(resolve, 150)) + mockObjects.delete(`${bucket}/${key}`) +} + +export async function mockCreateFolder(req: CreateFolderRequest): Promise { + 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 { + 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 { + 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 { + 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 { + await new Promise((resolve) => setTimeout(resolve, 200)) + return { + partNumber, + etag: `mock-part-etag-${partNumber}`, + } +} + +export async function mockCompleteUpload(req: CompleteUploadRequest): Promise { + 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 { + await new Promise((resolve) => setTimeout(resolve, 100)) +} diff --git a/src/features/objects/api/objects.types.ts b/src/features/objects/api/objects.types.ts new file mode 100644 index 0000000..51ffef4 --- /dev/null +++ b/src/features/objects/api/objects.types.ts @@ -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 +} diff --git a/src/features/objects/components/CreateFolderDialog.tsx b/src/features/objects/components/CreateFolderDialog.tsx new file mode 100644 index 0000000..79e9471 --- /dev/null +++ b/src/features/objects/components/CreateFolderDialog.tsx @@ -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(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 ( + +
+ {/* Header */} +
+
+
+ +
+
+

Create Folder

+

+ In {currentPrefix || "/"} +

+
+
+ +
+ +
+
+ + setFolderName(e.target.value)} + autoFocus + /> +
+ + {error &&

{error}

} +
+ +
+ + +
+
+
+ ) +} diff --git a/src/features/objects/components/DeleteFolderDialog.tsx b/src/features/objects/components/DeleteFolderDialog.tsx new file mode 100644 index 0000000..6fd90a4 --- /dev/null +++ b/src/features/objects/components/DeleteFolderDialog.tsx @@ -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 ( + +
+ {/* Header */} +
+
+
+ +
+
+

Delete Folder

+

Move folder to Drive trash

+
+
+ +
+ +
+ {isNonEmptyError ? ( +
+
+ + Folder is not empty +
+

+ To recursively move this folder and all its contents to Google Drive Trash, please type the folder name{" "} + {folderName} below: +

+
+ + { + setConfirmName(e.target.value) + if (e.target.value === folderName) { + setForceRecursive(true) + } + }} + placeholder={folderName} + autoFocus + /> +
+
+ ) : ( +

+ Are you sure you want to delete folder{" "} + + {folderName} + + ? +

+ )} + + {deleteMutation.isError && !isNonEmptyError && ( +
+ + {deleteMutation.error?.message || "Failed to delete folder"} +
+ )} +
+ +
+ + +
+
+
+ ) +} diff --git a/src/features/objects/components/DeleteObjectDialog.tsx b/src/features/objects/components/DeleteObjectDialog.tsx new file mode 100644 index 0000000..7dc11ab --- /dev/null +++ b/src/features/objects/components/DeleteObjectDialog.tsx @@ -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 ( + +
+ {/* Header */} +
+
+
+ +
+
+

Delete Object

+

Permanently remove this file from Google Drive

+
+
+ +
+ +
+

+ Are you sure you want to delete{" "} + + {filename} + + ? +

+ + {deleteMutation.isError && ( +
+ + {deleteMutation.error?.message || "Failed to delete object"} +
+ )} +
+ +
+ + +
+
+
+ ) +} diff --git a/src/features/objects/components/ObjectBreadcrumb.tsx b/src/features/objects/components/ObjectBreadcrumb.tsx new file mode 100644 index 0000000..b3fadc4 --- /dev/null +++ b/src/features/objects/components/ObjectBreadcrumb.tsx @@ -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 ( + + ) +} diff --git a/src/features/objects/components/ObjectBrowser.tsx b/src/features/objects/components/ObjectBrowser.tsx new file mode 100644 index 0000000..7f09aca --- /dev/null +++ b/src/features/objects/components/ObjectBrowser.tsx @@ -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(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 ( +
+ {/* Top Header / Breadcrumb & Actions */} +
+ + +
+ + + +
+
+ + {/* Upload Dropzone */} + + + {/* Upload Queue Panel */} + + + {/* Object Listing Table */} + {isLoading ? ( +
+ + + + +
+ ) : isError ? ( +
+

Failed to load objects

+

+ {error?.message || "An unexpected error occurred"} +

+ +
+ ) : ( + onNavigatePrefix(nextPrefix)} + onDownloadObject={handleDownload} + onDeleteObject={(key) => setDeleteObjectTarget(key)} + onDeleteFolder={(folderPrefix, name) => setDeleteFolderTarget({ prefix: folderPrefix, name })} + /> + )} + + {/* Dialogs */} + setCreateFolderOpen(false)} + /> + + setDeleteObjectTarget(null)} + /> + + setDeleteFolderTarget(null)} + /> +
+ ) +} diff --git a/src/features/objects/components/ObjectTable.tsx b/src/features/objects/components/ObjectTable.tsx new file mode 100644 index 0000000..538d3f2 --- /dev/null +++ b/src/features/objects/components/ObjectTable.tsx @@ -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(null) + const [filterQuery, setFilterQuery] = useState("") + const [sortField, setSortField] = useState("name") + const [sortOrder, setSortOrder] = useState("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 ( +
+ {truncated && ( +
+ +
+

Listing is truncated

+

+ This folder contains too many items and results were truncated at the maximum scan cap. + Navigate into subfolders to inspect items in smaller scopes. +

+
+
+ )} + +
+ 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" + /> +
+ {folders.length} folder{folders.length === 1 ? "" : "s"},{" "} + {objects.length} file{objects.length === 1 ? "" : "s"} +
+
+ +
+
+ + + + + + + + + + + {isEmpty ? ( + + + + ) : ( + <> + {/* Folders */} + {filteredFolders.map((folder) => ( + + + + + + + ))} + + {/* Objects */} + {sortedObjects.map((obj) => ( + + + + + + + ))} + + )} + +
+ + + + + + Actions
+ {filterQuery ? "No items match your filter" : "This folder is empty"} +
+ + + +
+
+ + + {obj.name} + +
+
+ {formatBytes(obj.size)} + + {obj.lastModified ? formatRelativeTime(obj.lastModified) : "—"} + +
+ + + +
+
+
+
+
+ ) +} diff --git a/src/features/objects/components/UploadDropzone.tsx b/src/features/objects/components/UploadDropzone.tsx new file mode 100644 index 0000000..f04f33c --- /dev/null +++ b/src/features/objects/components/UploadDropzone.tsx @@ -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(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) => { + 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 ( +
!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" : "" + }`} + > + +
+ +
+
+

+ Drag and drop files here, or browse +

+

+ Supports multipart upload with progress tracking for large files +

+
+
+ ) +} diff --git a/src/features/objects/components/UploadQueuePanel.tsx b/src/features/objects/components/UploadQueuePanel.tsx new file mode 100644 index 0000000..5916b13 --- /dev/null +++ b/src/features/objects/components/UploadQueuePanel.tsx @@ -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 ( +
+
+

+ Upload Queue + + {uploads.length} + +

+ {hasFinished && ( + + )} +
+ +
+ {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 ( +
+
+
+ {isDone && } + {isError && } + {isUploading && } + {filename} +
+
+ + {formatBytes(item.bytesUploaded)} / {formatBytes(item.size)} + + {isUploading && ( + + )} + {(isError || isCanceled) && ( + + )} +
+
+ + {isUploading && ( +
+ +
+ + {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...")} + + {item.progress}% +
+
+ )} + + {isError && ( +

+ {item.errorMessage || "Upload failed"} +

+ )} +
+ ) + })} +
+
+ ) +} diff --git a/src/features/objects/hooks/useCreateFolder.ts b/src/features/objects/hooks/useCreateFolder.ts new file mode 100644 index 0000000..80e95ab --- /dev/null +++ b/src/features/objects/hooks/useCreateFolder.ts @@ -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({ + mutationFn: createFolder, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: objectKeys.list(bucket, currentPrefix) }) + }, + }) +} diff --git a/src/features/objects/hooks/useDeleteFolder.ts b/src/features/objects/hooks/useDeleteFolder.ts new file mode 100644 index 0000000..4b93a67 --- /dev/null +++ b/src/features/objects/hooks/useDeleteFolder.ts @@ -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({ + mutationFn: ({ prefix, recursive }) => deleteFolder(bucket, prefix, recursive), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: objectKeys.list(bucket, currentPrefix) }) + }, + }) +} diff --git a/src/features/objects/hooks/useDeleteObject.ts b/src/features/objects/hooks/useDeleteObject.ts new file mode 100644 index 0000000..452f1fa --- /dev/null +++ b/src/features/objects/hooks/useDeleteObject.ts @@ -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({ + mutationFn: (key: string) => deleteObject(bucket, key), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: objectKeys.list(bucket, currentPrefix) }) + }, + }) +} diff --git a/src/features/objects/hooks/useObjects.ts b/src/features/objects/hooks/useObjects.ts new file mode 100644 index 0000000..539d8a8 --- /dev/null +++ b/src/features/objects/hooks/useObjects.ts @@ -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({ + queryKey: objectKeys.list(bucket, prefix), + queryFn: () => listObjects(bucket, prefix, delimiter), + enabled: Boolean(bucket), + staleTime: 30_000, + }) +} diff --git a/src/features/objects/hooks/useUploadQueue.ts b/src/features/objects/hooks/useUploadQueue.ts new file mode 100644 index 0000000..ece4be5 --- /dev/null +++ b/src/features/objects/hooks/useUploadQueue.ts @@ -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(), + } +} diff --git a/src/features/objects/lib/download.ts b/src/features/objects/lib/download.ts new file mode 100644 index 0000000..bcca47e --- /dev/null +++ b/src/features/objects/lib/download.ts @@ -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 { + 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) +} diff --git a/src/features/objects/lib/prefix.ts b/src/features/objects/lib/prefix.ts new file mode 100644 index 0000000..b76b6c2 --- /dev/null +++ b/src/features/objects/lib/prefix.ts @@ -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("/")}/` +} diff --git a/src/features/objects/lib/upload-manager.ts b/src/features/objects/lib/upload-manager.ts new file mode 100644 index 0000000..eff954b --- /dev/null +++ b/src/features/objects/lib/upload-manager.ts @@ -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() + 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() diff --git a/src/features/status/components/BucketStatsTable.tsx b/src/features/status/components/BucketStatsTable.tsx index d42bd4e..93a3c64 100644 --- a/src/features/status/components/BucketStatsTable.tsx +++ b/src/features/status/components/BucketStatsTable.tsx @@ -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
- {b.name} + + {b.name} + s3://{b.name} @@ -513,15 +520,27 @@ export function BucketStatsTable({ stats, isLoading, error }: BucketStatsTablePr {/* Actions */} - +
+ + + + +
) @@ -546,20 +565,37 @@ export function BucketStatsTable({ stats, isLoading, error }: BucketStatsTablePr
-
{b.name}
+ + {b.name} +
s3://{b.name}
- +
+ + + + +
{/* Stats Grid inside card */} diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index fced629..680999c 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -19,12 +19,12 @@ export interface ApiRequestOptions extends Omit { } /** - * 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( +export async function apiFetch( path: string, options: ApiRequestOptions = {} -): Promise { +): Promise { const { body, auth = true, headers: customHeaders, ...restOptions } = options const headers = new Headers(customHeaders) @@ -76,6 +76,18 @@ export async function apiRequest( 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( + path: string, + options: ApiRequestOptions = {} +): Promise { + const response = await apiFetch(path, options) + if (response.status === 204) { return undefined as T } diff --git a/src/pages/BucketBrowser.tsx b/src/pages/BucketBrowser.tsx new file mode 100644 index 0000000..28516bc --- /dev/null +++ b/src/pages/BucketBrowser.tsx @@ -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 ( +
+ {/* Header */} +
+
+
+ +
+ +
+ S3 Drive Storage + + + / + +
+ + {bucket} + {currentBucket?.publicRead && ( + + Public Read + + )} +
+
+ +
+ + +
+
+
+ + {/* Main Content */} +
+
+ + + +
+ + +
+
+ ) +} +export default BucketBrowser diff --git a/src/router.tsx b/src/router.tsx index 6181d95..0383162 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -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() { } /> + + + + } + /> } />