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 {
2219.back-link {2219.back-link {
2220 font-size: 0.9em;2220 font-size: 0.9em;
2221}2221}
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}
2222.release {2237.release {
2223 margin-bottom: 1.25rem;2238 margin-bottom: 1.25rem;
2224}2239}
@@ -2329,21 +2344,19 @@ pre.code {
2329 opacity: 0;2344 opacity: 0;
2330 pointer-events: none;2345 pointer-events: none;
2331}2346}
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}
2344.asset-upload .btn {2347.asset-upload .btn {
2345 margin-top: 0.75rem;2348 margin-top: 0.75rem;
2346}2349}
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}
2347.release-form-actions {2360.release-form-actions {
2348 display: flex;2361 display: flex;
2349 align-items: center;2362 align-items: center;
assets/fabrica.js +6 −16
@@ -170,29 +170,19 @@
170 }170 }
171 });171 });
172172
173 // ---- Release asset dropzone (drag-drop / click, editable name + size) ----173 // ---- Release asset dropzone: pick or drop a file to upload it immediately ----
174 document.querySelectorAll(".asset-upload").forEach(function (form) {174 document.querySelectorAll(".asset-upload").forEach(function (form) {
175 var input = form.querySelector("input[type=file]");175 var input = form.querySelector("input[type=file]");
176 var zone = form.querySelector(".dropzone");176 var zone = form.querySelector(".dropzone");
177 var pending = form.querySelector(".asset-pending");177 if (!input || !zone) return;
178 if (!input || !zone || !pending) return;178 var submit = form.querySelector("button[type=submit]");
179 function show() {179 if (submit) submit.hidden = true; // JS uploads on selection; button is no-JS only
180 var f = input.files[0];180 input.addEventListener("change", function () { if (input.files.length) form.submit(); });
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 });
191 ["dragover", "dragenter", "dragleave", "drop"].forEach(function (ev) {181 ["dragover", "dragenter", "dragleave", "drop"].forEach(function (ev) {
192 zone.addEventListener(ev, function (e) {182 zone.addEventListener(ev, function (e) {
193 e.preventDefault();183 e.preventDefault();
194 zone.classList.toggle("drag", ev === "dragover" || ev === "dragenter");184 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(); }
196 });186 });
197 });187 });
198 });188 });
crates/web/src/releases.rs +20 −9
@@ -147,7 +147,11 @@ pub(crate) async fn view(
147 .await;147 .await;
148 let body = html! {148 let body = html! {
149 (repo_header(state, &ctx, Tab::Releases, &ctx.repo.default_branch, &branches))149 (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 }
151 (card)155 (card)
152 };156 };
153 let title = format!(157 let title = format!(
@@ -278,11 +282,23 @@ pub async fn edit_form(
278 .user_by_id(&repo.owner_id)282 .user_by_id(&repo.owner_id)
279 .await?283 .await?
280 .map_or(repo.owner_id.clone(), |u| u.username);284 .map_or(repo.owner_id.clone(), |u| u.username);
285 let releases = format!("/{owner}/{}/-/releases", repo.path);
281 let back = format!("/{owner}/{}/-/releases/{}", repo.path, release.tag);286 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 };
282 let assets = state.store.list_release_assets(&release.id).await?;292 let assets = state.store.list_release_assets(&release.id).await?;
283 let (jar, chrome) = build_chrome(&state, jar, Some(user), uri.path().to_string());293 let (jar, chrome) = build_chrome(&state, jar, Some(user), uri.path().to_string());
284 let body = html! {294 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 }
286 (release_form(&format!("/release/{}/edit", release.id), &chrome.csrf, Some(&release), &[], &[]))302 (release_form(&format!("/release/{}/edit", release.id), &chrome.csrf, Some(&release), &[], &[]))
287 (assets_editor(&release.id, &chrome.csrf, &assets))303 (assets_editor(&release.id, &chrome.csrf, &assets))
288 };304 };
@@ -366,18 +382,13 @@ fn assets_editor(release_id: &str, csrf: &str, assets: &[store::ReleaseAsset]) -
366 }382 }
367 form method="post" action=(format!("/release/{release_id}/asset")) enctype="multipart/form-data" class="asset-upload" {383 form method="post" action=(format!("/release/{release_id}/asset")) enctype="multipart/form-data" class="asset-upload" {
368 input type="hidden" name="_csrf" value=(csrf);384 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 }
376 label class="dropzone" {385 label class="dropzone" {
377 input type="file" name="asset" class="dropzone-input" required;386 input type="file" name="asset" class="dropzone-input" required;
378 (icon(Icon::Download))387 (icon(Icon::Download))
379 span { "Attach a file by dropping it here or selecting it." }388 span { "Attach a file by dropping it here or selecting it." }
380 }389 }
390 // Hidden by JS, which uploads on selection; the button is the
391 // no-JS fallback.
381 button class="btn inline-btn" type="submit" { "Upload asset" }392 button class="btn inline-btn" type="submit" { "Upload asset" }
382 }393 }
383 }394 }
data/fabrica/releases/01kyf3cgb99w0wjas5a5jbx2pt +0 −0

Binary file not shown.