fabrica

hanna/fabrica

feat(web): auto-upload assets, breadcrumbs, and asset-row polish

fc49ceb · hanna committed on 2026-07-26

- The dropzone uploads on selection/drop (no separate button); the plain button
  remains as the no-JS fallback, hidden when JS is active.
- Replace the release view/edit back links with GitHub-style breadcrumbs
  (Releases / name, and … / Edit).
- Reset the stacked-form top margin on the per-asset delete button so it centres
  in its row, and make it compact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
4 files changed · +51 −37UnifiedSplit
assets/base.css +25 −12
@@ -2219,6 +2219,21 @@ pre.code {
22192219 .back-link {
22202220 font-size: 0.9em;
22212221 }
2222+/* Breadcrumb (Releases / name / Edit). */
2223+.crumbs {
2224+ display: flex;
2225+ align-items: center;
2226+ gap: 0.4rem;
2227+ flex-wrap: wrap;
2228+ margin-bottom: 1rem;
2229+ font-size: 0.95em;
2230+}
2231+.crumbs a {
2232+ color: var(--fb-accent);
2233+}
2234+.crumb-current {
2235+ color: var(--fb-fg-muted);
2236+}
22222237 .release {
22232238 margin-bottom: 1.25rem;
22242239 }
@@ -2329,21 +2344,19 @@ pre.code {
23292344 opacity: 0;
23302345 pointer-events: none;
23312346 }
2332-/* The pending (not-yet-uploaded) file: editable name, size, remove. */
2333-.asset-pending {
2334- display: flex;
2335- align-items: center;
2336- gap: 0.6rem;
2337- margin-bottom: 0.75rem;
2338-}
2339-.asset-pending-name {
2340- flex: 1;
2341- min-width: 0;
2342- font-family: var(--fb-font-mono);
2343-}
23442347 .asset-upload .btn {
23452348 margin-top: 0.75rem;
23462349 }
2350+/* The per-asset delete button sits in a card form; kill the stacked-form top
2351+ margin so it centres in the row, and keep it compact. Specificity beats
2352+ `.card form button[type="submit"]:not(.inline-btn)`. */
2353+.card .asset-list .asset-row form button[type="submit"] {
2354+ margin-top: 0;
2355+}
2356+.asset-row .icon-btn {
2357+ width: 1.75rem;
2358+ height: 1.75rem;
2359+}
23472360 .release-form-actions {
23482361 display: flex;
23492362 align-items: center;
assets/fabrica.js +6 −16
@@ -170,29 +170,19 @@
170170 }
171171 });
172172
173- // ---- Release asset dropzone (drag-drop / click, editable name + size) ----
173+ // ---- Release asset dropzone: pick or drop a file to upload it immediately ----
174174 document.querySelectorAll(".asset-upload").forEach(function (form) {
175175 var input = form.querySelector("input[type=file]");
176176 var zone = form.querySelector(".dropzone");
177- var pending = form.querySelector(".asset-pending");
178- if (!input || !zone || !pending) return;
179- function show() {
180- var f = input.files[0];
181- pending.hidden = !f;
182- if (!f) return;
183- pending.querySelector("input[type=text]").value = f.name;
184- pending.querySelector(".asset-size").textContent =
185- "(" + (f.size < 1048576 ? (f.size / 1024).toFixed(1) + " KB" : (f.size / 1048576).toFixed(2) + " MB") + ")";
186- }
187- input.addEventListener("change", show);
188- pending.querySelector(".asset-remove").addEventListener("click", function () {
189- input.value = ""; pending.hidden = true;
190- });
177+ if (!input || !zone) return;
178+ var submit = form.querySelector("button[type=submit]");
179+ if (submit) submit.hidden = true; // JS uploads on selection; button is no-JS only
180+ input.addEventListener("change", function () { if (input.files.length) form.submit(); });
191181 ["dragover", "dragenter", "dragleave", "drop"].forEach(function (ev) {
192182 zone.addEventListener(ev, function (e) {
193183 e.preventDefault();
194184 zone.classList.toggle("drag", ev === "dragover" || ev === "dragenter");
195- if (ev === "drop" && e.dataTransfer.files.length) { input.files = e.dataTransfer.files; show(); }
185+ if (ev === "drop" && e.dataTransfer.files.length) { input.files = e.dataTransfer.files; form.submit(); }
196186 });
197187 });
198188 });
crates/web/src/releases.rs +20 −9
@@ -147,7 +147,11 @@ pub(crate) async fn view(
147147 .await;
148148 let body = html! {
149149 (repo_header(state, &ctx, Tab::Releases, &ctx.repo.default_branch, &branches))
150- p { a class="back-link" href=(format!("{base}/-/releases")) { "← Releases" } }
150+ nav class="crumbs" aria-label="Breadcrumb" {
151+ a href=(format!("{base}/-/releases")) { "Releases" }
152+ span class="slug-sep" { "/" }
153+ span class="crumb-current" { (if release.name.is_empty() { &release.tag } else { &release.name }) }
154+ }
151155 (card)
152156 };
153157 let title = format!(
@@ -278,11 +282,23 @@ pub async fn edit_form(
278282 .user_by_id(&repo.owner_id)
279283 .await?
280284 .map_or(repo.owner_id.clone(), |u| u.username);
285+ let releases = format!("/{owner}/{}/-/releases", repo.path);
281286 let back = format!("/{owner}/{}/-/releases/{}", repo.path, release.tag);
287+ let crumb = if release.name.is_empty() {
288+ &release.tag
289+ } else {
290+ &release.name
291+ };
282292 let assets = state.store.list_release_assets(&release.id).await?;
283293 let (jar, chrome) = build_chrome(&state, jar, Some(user), uri.path().to_string());
284294 let body = html! {
285- p { a class="back-link" href=(back) { "← Back to release" } }
295+ nav class="crumbs" aria-label="Breadcrumb" {
296+ a href=(releases) { "Releases" }
297+ span class="slug-sep" { "/" }
298+ a href=(back) { (crumb) }
299+ span class="slug-sep" { "/" }
300+ span class="crumb-current" { "Edit" }
301+ }
286302 (release_form(&format!("/release/{}/edit", release.id), &chrome.csrf, Some(&release), &[], &[]))
287303 (assets_editor(&release.id, &chrome.csrf, &assets))
288304 };
@@ -366,18 +382,13 @@ fn assets_editor(release_id: &str, csrf: &str, assets: &[store::ReleaseAsset]) -
366382 }
367383 form method="post" action=(format!("/release/{release_id}/asset")) enctype="multipart/form-data" class="asset-upload" {
368384 input type="hidden" name="_csrf" value=(csrf);
369- // Populated by JS when a file is picked (editable name, size,
370- // remove); stays hidden without JS.
371- div class="asset-pending" hidden {
372- input type="text" name="name" class="asset-pending-name" aria-label="Asset name";
373- span class="asset-size muted" {}
374- button type="button" class="asset-remove icon-btn" aria-label="Remove file" title="Remove file" { (icon(Icon::X)) }
375- }
376385 label class="dropzone" {
377386 input type="file" name="asset" class="dropzone-input" required;
378387 (icon(Icon::Download))
379388 span { "Attach a file by dropping it here or selecting it." }
380389 }
390+ // Hidden by JS, which uploads on selection; the button is the
391+ // no-JS fallback.
381392 button class="btn inline-btn" type="submit" { "Upload asset" }
382393 }
383394 }
data/fabrica/releases/01kyf3cgb99w0wjas5a5jbx2pt +0 −0

Binary file not shown.