initial commit

This commit is contained in:
Paul Jenkins
2026-03-26 11:06:15 +00:00
commit 20c1298a7f
14 changed files with 2859 additions and 0 deletions

19
frontend/index.html Normal file
View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>S3 Mover</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;700;800&family=Space+Grotesk:wght@500;700&display=swap"
rel="stylesheet"
/>
<script type="module" src="/src/main.jsx"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

1815
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
frontend/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "s3mover-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^5.0.0",
"vite": "^7.1.3"
}
}

213
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,213 @@
import { useEffect, useState } from "react";
const apiBase = import.meta.env.VITE_API_BASE_URL || "http://localhost:8080";
function formatBytes(size) {
if (size === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const index = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1);
const value = size / 1024 ** index;
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
}
function formatDate(value) {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
async function readError(response, fallback) {
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
const payload = await response.json();
if (payload?.error) {
return payload.error;
}
}
const text = await response.text();
return text.trim() || fallback;
}
export default function App() {
const [files, setFiles] = useState([]);
const [selectedFile, setSelectedFile] = useState(null);
const [keyName, setKeyName] = useState("");
const [status, setStatus] = useState("Loading files...");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function loadFiles() {
try {
setError("");
setStatus("Refreshing bucket contents...");
const response = await fetch(`${apiBase}/api/files`);
if (!response.ok) {
throw new Error(await readError(response, `Failed to list files (${response.status})`));
}
const payload = await response.json();
setFiles(payload.files || []);
setStatus(`Loaded ${payload.files?.length || 0} file${payload.files?.length === 1 ? "" : "s"}.`);
} catch (err) {
setError(err.message);
setStatus("Unable to load files.");
}
}
useEffect(() => {
loadFiles();
}, []);
async function handleUpload(event) {
event.preventDefault();
if (!selectedFile) {
setError("Choose a file before uploading.");
return;
}
const body = new FormData();
body.append("file", selectedFile);
if (keyName.trim()) {
body.append("key", keyName.trim());
}
try {
setBusy(true);
setError("");
setStatus(`Uploading ${selectedFile.name}...`);
const response = await fetch(`${apiBase}/api/files`, {
method: "POST",
body,
});
if (!response.ok) {
throw new Error(await readError(response, `Upload failed (${response.status})`));
}
setSelectedFile(null);
setKeyName("");
event.target.reset();
await loadFiles();
setStatus(`Uploaded ${selectedFile.name}.`);
} catch (err) {
setError(err.message);
setStatus("Upload failed.");
} finally {
setBusy(false);
}
}
async function handleDelete(fileKey) {
if (!window.confirm(`Delete ${fileKey}?`)) {
return;
}
try {
setBusy(true);
setError("");
setStatus(`Deleting ${fileKey}...`);
const response = await fetch(`${apiBase}/api/files/${encodeURIComponent(fileKey)}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error(await readError(response, `Delete failed (${response.status})`));
}
await loadFiles();
setStatus(`Deleted ${fileKey}.`);
} catch (err) {
setError(err.message);
setStatus("Delete failed.");
} finally {
setBusy(false);
}
}
function handleDownload(fileKey) {
window.location.href = `${apiBase}/api/files/${encodeURIComponent(fileKey)}`;
}
return (
<main className="shell">
<section className="hero">
<div>
<p className="eyebrow">React + Go + S3</p>
<h1>S3 Mover</h1>
<p className="lede">
Browse bucket contents and move files in and out of S3 without leaving the browser.
</p>
</div>
<div className="status-card">
<span>Status</span>
<strong>{status}</strong>
{error ? <p>{error}</p> : null}
</div>
</section>
<section className="panel upload-panel">
<div>
<h2>Upload a file</h2>
<p>Leave the object key blank to reuse the local filename.</p>
</div>
<form onSubmit={handleUpload} className="upload-form">
<label>
<span>File</span>
<input
type="file"
onChange={(event) => setSelectedFile(event.target.files?.[0] || null)}
disabled={busy}
/>
</label>
<label>
<span>Object key</span>
<input
type="text"
placeholder="reports/quarterly.csv"
value={keyName}
onChange={(event) => setKeyName(event.target.value)}
disabled={busy}
/>
</label>
<button type="submit" disabled={busy || !selectedFile}>
{busy ? "Working..." : "Upload"}
</button>
</form>
</section>
<section className="panel">
<div className="table-header">
<div>
<h2>Bucket contents</h2>
<p>{files.length} items visible</p>
</div>
<button type="button" className="ghost-button" onClick={loadFiles} disabled={busy}>
Refresh
</button>
</div>
<div className="file-list">
{files.length === 0 ? (
<div className="empty-state">No files found in the configured bucket or prefix.</div>
) : (
files.map((file) => (
<article className="file-row" key={file.key}>
<div className="file-meta">
<strong>{file.key}</strong>
<span>{formatBytes(file.size)}</span>
<span>{formatDate(file.lastModified)}</span>
</div>
<div className="actions">
<button type="button" onClick={() => handleDownload(file.key)} disabled={busy}>
Download
</button>
<button type="button" className="danger" onClick={() => handleDelete(file.key)} disabled={busy}>
Delete
</button>
</div>
</article>
))
)}
</div>
</section>
</main>
);
}

11
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

245
frontend/src/styles.css Normal file
View File

@@ -0,0 +1,245 @@
:root {
font-family: "Manrope", sans-serif;
color: #eff7f5;
background:
radial-gradient(circle at top left, rgba(85, 204, 167, 0.28), transparent 28%),
radial-gradient(circle at top right, rgba(254, 190, 92, 0.16), transparent 24%),
linear-gradient(160deg, #081514 0%, #0d2420 52%, #142f29 100%);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--panel: rgba(8, 18, 16, 0.72);
--panel-border: rgba(210, 255, 241, 0.12);
--accent: #80f1ca;
--accent-strong: #3bc899;
--danger: #ff7e6b;
--muted: #9db8b1;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
}
#root {
min-height: 100vh;
}
.shell {
width: min(1120px, calc(100vw - 32px));
margin: 0 auto;
padding: 48px 0 64px;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 24px;
align-items: stretch;
margin-bottom: 24px;
}
.eyebrow,
h1,
h2,
.status-card strong {
font-family: "Space Grotesk", sans-serif;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--accent);
font-size: 0.75rem;
margin: 0 0 12px;
}
h1 {
font-size: clamp(2.5rem, 6vw, 5rem);
line-height: 0.95;
margin: 0;
}
.lede,
.panel p,
.file-meta span,
.status-card p {
color: var(--muted);
}
.hero .lede {
max-width: 40rem;
font-size: 1.05rem;
}
.status-card,
.panel {
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 24px;
backdrop-filter: blur(18px);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.18);
}
.status-card {
padding: 24px;
display: flex;
flex-direction: column;
gap: 10px;
}
.status-card span {
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 0.75rem;
color: var(--accent);
}
.status-card strong {
font-size: 1.25rem;
}
.panel {
padding: 24px;
margin-bottom: 24px;
}
.upload-panel {
display: grid;
grid-template-columns: minmax(0, 260px) 1fr;
gap: 24px;
}
.upload-form {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
align-items: end;
}
.upload-form label {
display: grid;
gap: 8px;
}
.upload-form span {
color: var(--muted);
font-size: 0.9rem;
}
input {
width: 100%;
padding: 14px 16px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.06);
color: inherit;
}
button {
border: 0;
border-radius: 14px;
padding: 14px 18px;
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: #04241b;
font-weight: 800;
}
button:disabled {
opacity: 0.6;
cursor: wait;
}
.ghost-button {
background: rgba(255, 255, 255, 0.08);
color: inherit;
}
.danger {
background: rgba(255, 126, 107, 0.14);
color: #ffb6aa;
}
.table-header,
.file-row,
.actions {
display: flex;
align-items: center;
}
.table-header {
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.file-list {
display: grid;
gap: 12px;
}
.file-row {
justify-content: space-between;
gap: 24px;
padding: 18px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.file-meta {
display: flex;
flex-wrap: wrap;
gap: 12px 16px;
align-items: center;
}
.file-meta strong {
width: 100%;
font-size: 1rem;
}
.actions {
gap: 10px;
}
.empty-state {
padding: 28px;
text-align: center;
color: var(--muted);
border: 1px dashed rgba(255, 255, 255, 0.12);
border-radius: 18px;
}
@media (max-width: 900px) {
.hero,
.upload-panel,
.upload-form {
grid-template-columns: 1fr;
}
.file-row,
.actions {
align-items: stretch;
flex-direction: column;
}
.actions button {
width: 100%;
}
}

11
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: "0.0.0.0",
},
});