Platform dash: add tabs, bucket manager, new dirs, fix button layout
- Object store is now a tab ("Object Browser") alongside "Buckets"
- Buckets tab: create and delete buckets
- New directory creation via NEW DIR button
- DOWNLOAD and DELETE buttons are now full words with borders and
spacing between them to prevent misclicks
- Bucket selector dropdown when multiple buckets exist
- All API routes accept optional bucket query param
This commit is contained in:
59
apps/platform-dash/src/app/api/s3/buckets/route.ts
Normal file
59
apps/platform-dash/src/app/api/s3/buckets/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
S3Client,
|
||||
ListBucketsCommand,
|
||||
CreateBucketCommand,
|
||||
DeleteBucketCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
|
||||
function getClient() {
|
||||
return new S3Client({
|
||||
endpoint: process.env.S3_ENDPOINT || "http://garage.platform.svc:3900",
|
||||
region: process.env.S3_REGION || "garage",
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID || "",
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || "",
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const client = getClient();
|
||||
try {
|
||||
const result = await client.send(new ListBucketsCommand({}));
|
||||
const buckets = (result.Buckets || []).map((b) => b.Name).filter(Boolean);
|
||||
return NextResponse.json({ buckets });
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "Failed to list buckets";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
const name = request.nextUrl.searchParams.get("name");
|
||||
if (!name) return NextResponse.json({ error: "name required" }, { status: 400 });
|
||||
|
||||
const client = getClient();
|
||||
try {
|
||||
await client.send(new CreateBucketCommand({ Bucket: name }));
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "Failed to create bucket";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const name = request.nextUrl.searchParams.get("name");
|
||||
if (!name) return NextResponse.json({ error: "name required" }, { status: 400 });
|
||||
|
||||
const client = getClient();
|
||||
try {
|
||||
await client.send(new DeleteBucketCommand({ Bucket: name }));
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "Failed to delete bucket";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
DeleteObjectCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
|
||||
const BUCKET = process.env.S3_BUCKET || "artifacts";
|
||||
const DEFAULT_BUCKET = process.env.S3_BUCKET || "artifacts";
|
||||
|
||||
function getClient() {
|
||||
return new S3Client({
|
||||
@@ -20,22 +20,20 @@ function getClient() {
|
||||
});
|
||||
}
|
||||
|
||||
// GET — list objects
|
||||
export async function GET(request: NextRequest) {
|
||||
const prefix = request.nextUrl.searchParams.get("prefix") || "";
|
||||
const bucket = request.nextUrl.searchParams.get("bucket") || DEFAULT_BUCKET;
|
||||
const client = getClient();
|
||||
|
||||
try {
|
||||
const result = await client.send(
|
||||
new ListObjectsV2Command({ Bucket: BUCKET, Prefix: prefix || undefined })
|
||||
new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix || undefined })
|
||||
);
|
||||
|
||||
const objects = (result.Contents || []).map((obj) => ({
|
||||
key: obj.Key,
|
||||
size: obj.Size,
|
||||
lastModified: obj.LastModified?.toISOString(),
|
||||
}));
|
||||
|
||||
return NextResponse.json({ objects });
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "S3 error";
|
||||
@@ -43,12 +41,10 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// PUT — upload object
|
||||
export async function PUT(request: NextRequest) {
|
||||
const key = request.nextUrl.searchParams.get("key");
|
||||
if (!key) {
|
||||
return NextResponse.json({ error: "key required" }, { status: 400 });
|
||||
}
|
||||
const bucket = request.nextUrl.searchParams.get("bucket") || DEFAULT_BUCKET;
|
||||
if (!key) return NextResponse.json({ error: "key required" }, { status: 400 });
|
||||
|
||||
const body = await request.arrayBuffer();
|
||||
const client = getClient();
|
||||
@@ -56,7 +52,7 @@ export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: Buffer.from(body),
|
||||
ContentType: request.headers.get("content-type") || "application/octet-stream",
|
||||
@@ -69,19 +65,15 @@ export async function PUT(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE — remove object
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const key = request.nextUrl.searchParams.get("key");
|
||||
if (!key) {
|
||||
return NextResponse.json({ error: "key required" }, { status: 400 });
|
||||
}
|
||||
const bucket = request.nextUrl.searchParams.get("bucket") || DEFAULT_BUCKET;
|
||||
if (!key) return NextResponse.json({ error: "key required" }, { status: 400 });
|
||||
|
||||
const client = getClient();
|
||||
|
||||
try {
|
||||
await client.send(
|
||||
new DeleteObjectCommand({ Bucket: BUCKET, Key: key })
|
||||
);
|
||||
await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "Delete failed";
|
||||
|
||||
@@ -3,20 +3,24 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import {
|
||||
tokens,
|
||||
Label,
|
||||
Mono,
|
||||
Btn,
|
||||
Panel,
|
||||
PanelHeader,
|
||||
Divider,
|
||||
} from "@/components/design-system";
|
||||
|
||||
// ─── TYPES ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface S3Object {
|
||||
key: string;
|
||||
size: number;
|
||||
lastModified: string;
|
||||
}
|
||||
|
||||
type Tab = "objects" | "buckets";
|
||||
|
||||
// ─── HELPERS ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
@@ -33,27 +37,98 @@ function formatDate(iso: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
function pathParts(key: string): { dir: string; name: string } {
|
||||
const i = key.lastIndexOf("/");
|
||||
return i === -1
|
||||
? { dir: "", name: key }
|
||||
: { dir: key.slice(0, i + 1), name: key.slice(i + 1) };
|
||||
}
|
||||
// ─── MAIN ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function PlatformDash() {
|
||||
const [tab, setTab] = useState<Tab>("objects");
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
maxWidth: 960,
|
||||
margin: "0 auto",
|
||||
padding: `${tokens.space[6]}px ${tokens.space[4]}px`,
|
||||
fontFamily: tokens.font.mono,
|
||||
}}>
|
||||
<div style={{ marginBottom: tokens.space[4] }}>
|
||||
<h1 style={{
|
||||
fontSize: tokens.size.xl,
|
||||
fontFamily: tokens.font.mono,
|
||||
fontWeight: 400,
|
||||
color: tokens.color.text0,
|
||||
margin: 0,
|
||||
letterSpacing: tokens.tracking.tight,
|
||||
}}>
|
||||
PLATFORM DASHBOARD
|
||||
</h1>
|
||||
<Mono size={tokens.size.sm} color={tokens.color.text3}>
|
||||
garage object store
|
||||
</Mono>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{
|
||||
display: "flex",
|
||||
gap: 0,
|
||||
marginBottom: tokens.space[4],
|
||||
borderBottom: `1px solid ${tokens.color.border0}`,
|
||||
}}>
|
||||
{(["objects", "buckets"] as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
borderBottom: tab === t ? `2px solid ${tokens.color.accent}` : "2px solid transparent",
|
||||
color: tab === t ? tokens.color.text0 : tokens.color.text3,
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: tokens.size.sm,
|
||||
letterSpacing: tokens.tracking.wide,
|
||||
padding: `${tokens.space[2]}px ${tokens.space[4]}px`,
|
||||
cursor: "pointer",
|
||||
textTransform: "uppercase",
|
||||
transition: `all ${tokens.transition.fast}`,
|
||||
}}
|
||||
>
|
||||
{t === "objects" ? "OBJECT BROWSER" : "BUCKETS"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "objects" ? <ObjectBrowser /> : <BucketManager />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── OBJECT BROWSER ──────────────────────────────────────────────────────────
|
||||
|
||||
function ObjectBrowser() {
|
||||
const [bucket, setBucket] = useState("artifacts");
|
||||
const [buckets, setBuckets] = useState<string[]>([]);
|
||||
const [objects, setObjects] = useState<S3Object[]>([]);
|
||||
const [prefix, setPrefix] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [showNewDir, setShowNewDir] = useState(false);
|
||||
const [newDirName, setNewDirName] = useState("");
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = useCallback(async (pfx: string) => {
|
||||
// Load bucket list
|
||||
useEffect(() => {
|
||||
fetch("/api/s3/buckets")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setBuckets(data.buckets || []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async (pfx: string, b?: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const targetBucket = b ?? bucket;
|
||||
try {
|
||||
const res = await fetch(`/api/s3?prefix=${encodeURIComponent(pfx)}`);
|
||||
const res = await fetch(`/api/s3?prefix=${encodeURIComponent(pfx)}&bucket=${encodeURIComponent(targetBucket)}`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
setObjects(data.objects || []);
|
||||
@@ -63,11 +138,11 @@ export default function PlatformDash() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [bucket]);
|
||||
|
||||
useEffect(() => { load(""); }, [load]);
|
||||
|
||||
// Derive "folders" and files from current prefix
|
||||
// Derive folders and files
|
||||
const folders = new Set<string>();
|
||||
const files: S3Object[] = [];
|
||||
for (const obj of objects) {
|
||||
@@ -89,7 +164,7 @@ export default function PlatformDash() {
|
||||
try {
|
||||
for (const file of Array.from(fileList)) {
|
||||
const key = prefix + file.name;
|
||||
const res = await fetch(`/api/s3?key=${encodeURIComponent(key)}`, {
|
||||
const res = await fetch(`/api/s3?key=${encodeURIComponent(key)}&bucket=${encodeURIComponent(bucket)}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": file.type || "application/octet-stream" },
|
||||
body: file,
|
||||
@@ -108,7 +183,7 @@ export default function PlatformDash() {
|
||||
if (!confirm(`Delete ${key}?`)) return;
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/s3?key=${encodeURIComponent(key)}`, {
|
||||
const res = await fetch(`/api/s3?key=${encodeURIComponent(key)}&bucket=${encodeURIComponent(bucket)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
@@ -118,6 +193,26 @@ export default function PlatformDash() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateDir() {
|
||||
const name = newDirName.trim().replace(/\/+$/, "");
|
||||
if (!name) return;
|
||||
setError(null);
|
||||
try {
|
||||
const key = prefix + name + "/.keep";
|
||||
const res = await fetch(`/api/s3?key=${encodeURIComponent(key)}&bucket=${encodeURIComponent(bucket)}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/x-empty" },
|
||||
body: "",
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
setNewDirName("");
|
||||
setShowNewDir(false);
|
||||
await load(prefix);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to create directory");
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
@@ -125,168 +220,289 @@ export default function PlatformDash() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
maxWidth: 900,
|
||||
margin: "0 auto",
|
||||
padding: `${tokens.space[6]}px ${tokens.space[4]}px`,
|
||||
fontFamily: tokens.font.mono,
|
||||
}}>
|
||||
<div style={{ marginBottom: tokens.space[6] }}>
|
||||
<h1 style={{
|
||||
fontSize: tokens.size.xl,
|
||||
fontFamily: tokens.font.mono,
|
||||
fontWeight: 400,
|
||||
color: tokens.color.text0,
|
||||
margin: 0,
|
||||
letterSpacing: tokens.tracking.tight,
|
||||
}}>
|
||||
PLATFORM DASHBOARD
|
||||
</h1>
|
||||
<Mono size={tokens.size.sm} color={tokens.color.text3}>
|
||||
garage object store · artifacts bucket
|
||||
</Mono>
|
||||
</div>
|
||||
|
||||
<Panel>
|
||||
<PanelHeader label="ARTIFACTS">
|
||||
<div style={{ display: "flex", gap: tokens.space[2] }}>
|
||||
<Btn onClick={() => load(prefix)} variant="default">REFRESH</Btn>
|
||||
<Btn
|
||||
onClick={() => fileRef.current?.click()}
|
||||
variant="primary"
|
||||
disabled={uploading}
|
||||
<Panel>
|
||||
<PanelHeader label="OBJECT BROWSER">
|
||||
<div style={{ display: "flex", gap: tokens.space[2], alignItems: "center" }}>
|
||||
{/* Bucket selector */}
|
||||
{buckets.length > 1 && (
|
||||
<select
|
||||
value={bucket}
|
||||
onChange={(e) => { setBucket(e.target.value); setPrefix(""); load("", e.target.value); }}
|
||||
style={{
|
||||
background: tokens.color.bg0,
|
||||
border: `1px solid ${tokens.color.border0}`,
|
||||
color: tokens.color.text1,
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: tokens.size.xs,
|
||||
padding: `${tokens.space[1]}px ${tokens.space[2]}px`,
|
||||
height: 32,
|
||||
}}
|
||||
>
|
||||
{uploading ? "UPLOADING..." : "UPLOAD"}
|
||||
</Btn>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => handleUpload(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
</PanelHeader>
|
||||
{buckets.map((b) => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
)}
|
||||
<Btn onClick={() => setShowNewDir(!showNewDir)} variant="default">NEW DIR</Btn>
|
||||
<Btn onClick={() => load(prefix)} variant="default">REFRESH</Btn>
|
||||
<Btn onClick={() => fileRef.current?.click()} variant="primary" disabled={uploading}>
|
||||
{uploading ? "UPLOADING..." : "UPLOAD"}
|
||||
</Btn>
|
||||
<input ref={fileRef} type="file" multiple style={{ display: "none" }} onChange={(e) => handleUpload(e.target.files)} />
|
||||
</div>
|
||||
</PanelHeader>
|
||||
|
||||
{/* Breadcrumbs */}
|
||||
{/* New directory input */}
|
||||
{showNewDir && (
|
||||
<div style={{
|
||||
padding: `${tokens.space[2]}px ${tokens.space[3]}px`,
|
||||
display: "flex",
|
||||
gap: tokens.space[2],
|
||||
alignItems: "center",
|
||||
gap: tokens.space[1],
|
||||
borderBottom: `1px solid ${tokens.color.border0}`,
|
||||
background: tokens.color.bg0,
|
||||
}}>
|
||||
<span
|
||||
onClick={() => load("")}
|
||||
<Mono size={tokens.size.xs} color={tokens.color.text3}>NEW FOLDER:</Mono>
|
||||
<input
|
||||
value={newDirName}
|
||||
onChange={(e) => setNewDirName(e.target.value)}
|
||||
placeholder="folder-name"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreateDir()}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: prefix ? tokens.color.accent : tokens.color.text0,
|
||||
fontSize: tokens.size.sm,
|
||||
flex: 1, height: 28, fontSize: tokens.size.sm,
|
||||
background: tokens.color.bg0, border: `1px solid ${tokens.color.border0}`,
|
||||
color: tokens.color.text0, fontFamily: tokens.font.mono,
|
||||
padding: `0 ${tokens.space[2]}px`, outline: "none",
|
||||
}}
|
||||
>
|
||||
/
|
||||
</span>
|
||||
{breadcrumbs.map((seg, i) => {
|
||||
const path = breadcrumbs.slice(0, i + 1).join("/") + "/";
|
||||
const isLast = i === breadcrumbs.length - 1;
|
||||
return (
|
||||
<span key={path} style={{ display: "flex", alignItems: "center", gap: tokens.space[1] }}>
|
||||
<Mono size={tokens.size.xs} color={tokens.color.text3}>/</Mono>
|
||||
<span
|
||||
onClick={() => !isLast && load(path)}
|
||||
style={{
|
||||
cursor: isLast ? "default" : "pointer",
|
||||
color: isLast ? tokens.color.text0 : tokens.color.accent,
|
||||
fontSize: tokens.size.sm,
|
||||
fontFamily: tokens.font.mono,
|
||||
}}
|
||||
>
|
||||
{seg}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
/>
|
||||
<Btn onClick={handleCreateDir} variant="primary">CREATE</Btn>
|
||||
<Btn onClick={() => { setShowNewDir(false); setNewDirName(""); }} variant="default">CANCEL</Btn>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: `${tokens.space[2]}px ${tokens.space[3]}px`,
|
||||
background: tokens.color.failDim,
|
||||
color: tokens.color.fail,
|
||||
{/* Breadcrumbs */}
|
||||
<div style={{
|
||||
padding: `${tokens.space[2]}px ${tokens.space[3]}px`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.space[1],
|
||||
borderBottom: `1px solid ${tokens.color.border0}`,
|
||||
background: tokens.color.bg0,
|
||||
}}>
|
||||
<Mono size={tokens.size.sm} color={tokens.color.text3}>{bucket}</Mono>
|
||||
<span
|
||||
onClick={() => load("")}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: prefix ? tokens.color.accent : tokens.color.text0,
|
||||
fontSize: tokens.size.sm,
|
||||
fontFamily: tokens.font.mono,
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drop zone + listing */}
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
style={{
|
||||
minHeight: 200,
|
||||
border: dragOver ? `2px dashed ${tokens.color.accent}` : "2px solid transparent",
|
||||
transition: `border ${tokens.transition.fast}`,
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{ padding: tokens.space[4], textAlign: "center" }}>
|
||||
<Mono size={tokens.size.sm} color={tokens.color.text3}>Loading...</Mono>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Parent dir */}
|
||||
{prefix && (
|
||||
<Row
|
||||
icon="↑"
|
||||
name=".."
|
||||
onClick={() => {
|
||||
const parts = prefix.slice(0, -1).split("/");
|
||||
parts.pop();
|
||||
load(parts.length ? parts.join("/") + "/" : "");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/
|
||||
</span>
|
||||
{breadcrumbs.map((seg, i) => {
|
||||
const path = breadcrumbs.slice(0, i + 1).join("/") + "/";
|
||||
const isLast = i === breadcrumbs.length - 1;
|
||||
return (
|
||||
<span key={path} style={{ display: "flex", alignItems: "center", gap: tokens.space[1] }}>
|
||||
<Mono size={tokens.size.xs} color={tokens.color.text3}>/</Mono>
|
||||
<span
|
||||
onClick={() => !isLast && load(path)}
|
||||
style={{
|
||||
cursor: isLast ? "default" : "pointer",
|
||||
color: isLast ? tokens.color.text0 : tokens.color.accent,
|
||||
fontSize: tokens.size.sm,
|
||||
fontFamily: tokens.font.mono,
|
||||
}}
|
||||
>
|
||||
{seg}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Folders */}
|
||||
{Array.from(folders).sort().map((f) => (
|
||||
<Row
|
||||
key={f}
|
||||
icon="▸"
|
||||
name={f.slice(prefix.length, -1) + "/"}
|
||||
onClick={() => load(f)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Files */}
|
||||
{files.sort((a, b) => a.key.localeCompare(b.key)).map((obj) => (
|
||||
<FileRow
|
||||
key={obj.key}
|
||||
obj={obj}
|
||||
prefix={prefix}
|
||||
onDelete={() => handleDelete(obj.key)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{folders.size === 0 && files.length === 0 && !prefix && (
|
||||
<div style={{ padding: tokens.space[4], textAlign: "center" }}>
|
||||
<Mono size={tokens.size.sm} color={tokens.color.text3}>
|
||||
Bucket is empty. Drag files here or click UPLOAD.
|
||||
</Mono>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: `${tokens.space[2]}px ${tokens.space[3]}px`,
|
||||
background: tokens.color.failDim,
|
||||
color: tokens.color.fail,
|
||||
fontSize: tokens.size.sm,
|
||||
fontFamily: tokens.font.mono,
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drop zone + listing */}
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
style={{
|
||||
minHeight: 200,
|
||||
border: dragOver ? `2px dashed ${tokens.color.accent}` : "2px solid transparent",
|
||||
transition: `border ${tokens.transition.fast}`,
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{ padding: tokens.space[4], textAlign: "center" }}>
|
||||
<Mono size={tokens.size.sm} color={tokens.color.text3}>Loading...</Mono>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{prefix && (
|
||||
<FolderRow
|
||||
name=".."
|
||||
icon="↑"
|
||||
onClick={() => {
|
||||
const parts = prefix.slice(0, -1).split("/");
|
||||
parts.pop();
|
||||
load(parts.length ? parts.join("/") + "/" : "");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{Array.from(folders).sort().map((f) => (
|
||||
<FolderRow key={f} icon="▸" name={f.slice(prefix.length, -1) + "/"} onClick={() => load(f)} />
|
||||
))}
|
||||
|
||||
{files.sort((a, b) => a.key.localeCompare(b.key)).map((obj) => (
|
||||
<FileRow key={obj.key} obj={obj} onDelete={() => handleDelete(obj.key)} />
|
||||
))}
|
||||
|
||||
{folders.size === 0 && files.length === 0 && !prefix && (
|
||||
<div style={{ padding: tokens.space[4], textAlign: "center" }}>
|
||||
<Mono size={tokens.size.sm} color={tokens.color.text3}>
|
||||
Bucket is empty. Drag files here or click UPLOAD.
|
||||
</Mono>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ icon, name, onClick }: { icon: string; name: string; onClick: () => void }) {
|
||||
// ─── BUCKET MANAGER ──────────────────────────────────────────────────────────
|
||||
|
||||
function BucketManager() {
|
||||
const [buckets, setBuckets] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/s3/buckets");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
setBuckets(data.buckets || []);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load buckets");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function handleCreate() {
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/s3/buckets?name=${encodeURIComponent(name)}`, { method: "PUT" });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
setNewName("");
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to create bucket");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(name: string) {
|
||||
if (!confirm(`Delete bucket "${name}"? This will fail if the bucket is not empty.`)) return;
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/s3/buckets?name=${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : "Failed to delete bucket");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel>
|
||||
<PanelHeader label="BUCKETS">
|
||||
<Btn onClick={load} variant="default">REFRESH</Btn>
|
||||
</PanelHeader>
|
||||
|
||||
{/* Create bucket */}
|
||||
<div style={{
|
||||
padding: `${tokens.space[2]}px ${tokens.space[3]}px`,
|
||||
display: "flex",
|
||||
gap: tokens.space[2],
|
||||
alignItems: "center",
|
||||
borderBottom: `1px solid ${tokens.color.border0}`,
|
||||
background: tokens.color.bg0,
|
||||
}}>
|
||||
<input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="new-bucket-name"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
style={{
|
||||
flex: 1, height: 28, fontSize: tokens.size.sm,
|
||||
background: tokens.color.bg0, border: `1px solid ${tokens.color.border0}`,
|
||||
color: tokens.color.text0, fontFamily: tokens.font.mono,
|
||||
padding: `0 ${tokens.space[2]}px`, outline: "none",
|
||||
}}
|
||||
/>
|
||||
<Btn onClick={handleCreate} variant="primary" disabled={creating || !newName.trim()}>
|
||||
{creating ? "CREATING..." : "CREATE BUCKET"}
|
||||
</Btn>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: `${tokens.space[2]}px ${tokens.space[3]}px`,
|
||||
background: tokens.color.failDim,
|
||||
color: tokens.color.fail,
|
||||
fontSize: tokens.size.sm,
|
||||
fontFamily: tokens.font.mono,
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div style={{ padding: tokens.space[4], textAlign: "center" }}>
|
||||
<Mono size={tokens.size.sm} color={tokens.color.text3}>Loading...</Mono>
|
||||
</div>
|
||||
) : buckets.length === 0 ? (
|
||||
<div style={{ padding: tokens.space[4], textAlign: "center" }}>
|
||||
<Mono size={tokens.size.sm} color={tokens.color.text3}>No buckets.</Mono>
|
||||
</div>
|
||||
) : (
|
||||
buckets.map((b) => (
|
||||
<BucketRow key={b} name={b} onDelete={() => handleDelete(b)} />
|
||||
))
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── ROW COMPONENTS ──────────────────────────────────────────────────────────
|
||||
|
||||
function FolderRow({ icon, name, onClick }: { icon: string; name: string; onClick: () => void }) {
|
||||
const [hover, setHover] = useState(false);
|
||||
return (
|
||||
<div
|
||||
@@ -310,9 +526,9 @@ function Row({ icon, name, onClick }: { icon: string; name: string; onClick: ()
|
||||
);
|
||||
}
|
||||
|
||||
function FileRow({ obj, prefix, onDelete }: { obj: S3Object; prefix: string; onDelete: () => void }) {
|
||||
function FileRow({ obj, onDelete }: { obj: S3Object; onDelete: () => void }) {
|
||||
const [hover, setHover] = useState(false);
|
||||
const { name } = pathParts(obj.key);
|
||||
const name = obj.key.split("/").pop() || obj.key;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -333,7 +549,7 @@ function FileRow({ obj, prefix, onDelete }: { obj: S3Object; prefix: string; onD
|
||||
<Mono size={tokens.size.xs} color={tokens.color.text3}>{formatBytes(obj.size)}</Mono>
|
||||
<Mono size={tokens.size.xs} color={tokens.color.text3}>{formatDate(obj.lastModified)}</Mono>
|
||||
{hover && (
|
||||
<div style={{ display: "flex", gap: tokens.space[1] }}>
|
||||
<>
|
||||
<a
|
||||
href={`/api/s3/download?key=${encodeURIComponent(obj.key)}`}
|
||||
style={{
|
||||
@@ -341,9 +557,11 @@ function FileRow({ obj, prefix, onDelete }: { obj: S3Object; prefix: string; onD
|
||||
fontSize: tokens.size.xs,
|
||||
fontFamily: tokens.font.mono,
|
||||
textDecoration: "none",
|
||||
padding: `2px ${tokens.space[2]}px`,
|
||||
border: `1px solid ${tokens.color.accent}`,
|
||||
}}
|
||||
>
|
||||
DL
|
||||
DOWNLOAD
|
||||
</a>
|
||||
<span
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
||||
@@ -352,11 +570,51 @@ function FileRow({ obj, prefix, onDelete }: { obj: S3Object; prefix: string; onD
|
||||
fontSize: tokens.size.xs,
|
||||
fontFamily: tokens.font.mono,
|
||||
cursor: "pointer",
|
||||
padding: `2px ${tokens.space[2]}px`,
|
||||
border: `1px solid ${tokens.color.fail}`,
|
||||
marginLeft: tokens.space[3],
|
||||
}}
|
||||
>
|
||||
DEL
|
||||
DELETE
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BucketRow({ name, onDelete }: { name: string; onDelete: () => void }) {
|
||||
const [hover, setHover] = useState(false);
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
padding: `${tokens.space[2]}px ${tokens.space[3]}px`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: tokens.space[2],
|
||||
borderBottom: `1px solid ${tokens.color.border0}`,
|
||||
background: hover ? tokens.color.bg2 : "transparent",
|
||||
transition: `background ${tokens.transition.fast}`,
|
||||
}}
|
||||
>
|
||||
<Mono size={tokens.size.sm} color={tokens.color.accent}>◆</Mono>
|
||||
<Mono size={tokens.size.sm} color={tokens.color.text0} style={{ flex: 1 }}>{name}</Mono>
|
||||
{hover && (
|
||||
<span
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
||||
style={{
|
||||
color: tokens.color.fail,
|
||||
fontSize: tokens.size.xs,
|
||||
fontFamily: tokens.font.mono,
|
||||
cursor: "pointer",
|
||||
padding: `2px ${tokens.space[2]}px`,
|
||||
border: `1px solid ${tokens.color.fail}`,
|
||||
}}
|
||||
>
|
||||
DELETE
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user