Platform dash: add tabs, bucket manager, new dirs, fix button layout
Some checks failed
CI / lint-and-test (push) Successful in 42s
Deploy Production / deploy (push) Successful in 1m20s
CI / build (push) Has been cancelled

- 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:
Julia McGhee
2026-03-22 10:46:09 +00:00
parent ff3c50305b
commit d08a630172
3 changed files with 486 additions and 177 deletions

View 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 });
}
}

View File

@@ -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";

View File

@@ -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 &middot; 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>
);