feat: serve static upload app from axum
Introduce the first PLAN.md milestone: replace the hello-world binary with an Axum server that binds to localhost by default, exposes a health endpoint, and serves the static browser UI from the repository's static directory. The router is available through the library crate so integration tests can exercise server behavior without opening a network listener. Add a justfile for routine validation and document the initial project shape, configuration knobs, and reusable test checklist. The rustfmt config now uses only stable options so the new formatting recipe runs without nightly warnings. The upload API and resumable chunk behavior are intentionally left for later milestones; the UI currently handles file selection only. Test Plan: - just check Refs: PLAN.md milestone 1
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
const fileInput = document.querySelector("#file-input");
|
||||
const fileSummary = document.querySelector("#file-summary");
|
||||
const fileName = document.querySelector("#file-name");
|
||||
const fileSize = document.querySelector("#file-size");
|
||||
const startButton = document.querySelector("#start-button");
|
||||
const eventLog = document.querySelector("#event-log");
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const formatter = new Intl.NumberFormat(undefined, {
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
let value = bytes;
|
||||
let unit = units[0];
|
||||
|
||||
for (const candidate of units) {
|
||||
unit = candidate;
|
||||
if (value < 1024 || candidate === units.at(-1)) {
|
||||
break;
|
||||
}
|
||||
value /= 1024;
|
||||
}
|
||||
|
||||
return `${formatter.format(value)} ${unit}`;
|
||||
}
|
||||
|
||||
function log(message) {
|
||||
eventLog.replaceChildren();
|
||||
const item = document.createElement("li");
|
||||
item.textContent = message;
|
||||
eventLog.append(item);
|
||||
}
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
const [file] = fileInput.files;
|
||||
|
||||
if (!file) {
|
||||
fileSummary.hidden = true;
|
||||
startButton.disabled = true;
|
||||
log("Choose a file to begin.");
|
||||
return;
|
||||
}
|
||||
|
||||
fileName.textContent = file.name;
|
||||
fileSize.textContent = formatBytes(file.size);
|
||||
fileSummary.hidden = false;
|
||||
startButton.disabled = false;
|
||||
log("Ready to create an upload record.");
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>upl</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
<script src="/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<main class="app-shell">
|
||||
<section class="upload-panel" aria-labelledby="app-title">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h1 id="app-title">upl</h1>
|
||||
<p class="subtle">Resumable uploads to this machine.</p>
|
||||
</div>
|
||||
<span class="status-pill" id="connection-status">Server online</span>
|
||||
</div>
|
||||
|
||||
<label class="file-picker">
|
||||
<span>Select file</span>
|
||||
<input id="file-input" type="file">
|
||||
</label>
|
||||
|
||||
<div class="file-summary" id="file-summary" hidden>
|
||||
<strong id="file-name"></strong>
|
||||
<span id="file-size"></span>
|
||||
</div>
|
||||
|
||||
<div class="progress-wrap" aria-label="Upload progress">
|
||||
<div class="progress-bar" id="progress-bar"></div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="start-button" type="button" disabled>Start</button>
|
||||
<button id="pause-button" type="button" disabled>Pause</button>
|
||||
<button id="resume-button" type="button" disabled>Resume</button>
|
||||
</div>
|
||||
|
||||
<ol class="event-log" id="event-log" aria-live="polite">
|
||||
<li>Choose a file to begin.</li>
|
||||
</ol>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,177 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #f6f7f9;
|
||||
--panel: #ffffff;
|
||||
--text: #1d2430;
|
||||
--muted: #667085;
|
||||
--line: #d0d5dd;
|
||||
--accent: #147a73;
|
||||
--accent-strong: #0f5f59;
|
||||
--track: #e4e7ec;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: min(720px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.upload-panel {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 10px 30px rgb(16 24 40 / 8%);
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
h1,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.subtle {
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
flex: 0 0 auto;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 34%, transparent);
|
||||
border-radius: 999px;
|
||||
color: var(--accent-strong);
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.file-picker {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 18px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-picker span {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.file-summary {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.file-summary span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.progress-wrap {
|
||||
height: 14px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--track);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
transition: width 180ms ease;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
min-width: 96px;
|
||||
min-height: 40px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: #ffffff;
|
||||
background: var(--accent);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
color: var(--muted);
|
||||
background: var(--track);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.event-log {
|
||||
min-height: 80px;
|
||||
margin: 0;
|
||||
padding: 14px 14px 14px 32px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #14161a;
|
||||
--panel: #1f242b;
|
||||
--text: #f2f4f7;
|
||||
--muted: #b8c0cc;
|
||||
--line: #3d4654;
|
||||
--accent: #35b8aa;
|
||||
--accent-strong: #9ce4dc;
|
||||
--track: #343b46;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.panel-heading {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.upload-panel {
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user