From 70fe831ff7209fdd7e952affae7325d72a409046 Mon Sep 17 00:00:00 2001 From: Henry Rausch Date: Sat, 6 Jun 2026 17:04:17 +0200 Subject: [PATCH] Add auto-download for optimized image results Automatically start downloading processed files once a job completes. Single-file image results are now served raw rather than inside a ZIP, and jobs now persist through an initial download to allow the UI's manual download button to remain a functional fallback. --- frontend/src/App.svelte | 83 +++++++++++++++++++------ frontend/src/components/Dropzone.svelte | 18 +++--- frontend/src/lib/presets.js | 7 +++ handlers/download.go | 46 +++++++++++++- handlers/download_test.go | 47 ++++++++++++++ handlers/store.go | 29 ++++++--- 6 files changed, 194 insertions(+), 36 deletions(-) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 1a22fc6..c554a4d 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -9,14 +9,19 @@ import ProgressCard from './components/ProgressCard.svelte'; import HowItWorks from './components/HowItWorks.svelte'; import LegalModal from './components/LegalModal.svelte'; + import CropModal from './components/CropModal.svelte'; import { createProgress } from './stores/progress.svelte.js'; - import { BUNDLE_PRESETS } from './lib/presets.js'; + import { BUNDLE_PRESETS, PACK_PRESETS } from './lib/presets.js'; const progress = createProgress(); let files = $state([]); // [{ file, url }] let selectedPresets = $state([]); // preset name strings let legalOpen = $state(null); // 'imprint' | 'privacy' | null + // The file entry whose crop is being adjusted, or null. Owned here (not in + // Dropzone) so CropModal renders at the page root, escaping the animate-fade-up + // transform that would otherwise trap its position:fixed overlay. + let cropping = $state(null); let status = $derived(progress.status); let canSubmit = $derived( @@ -24,6 +29,15 @@ ); let busy = $derived(status === 'uploading' || status === 'processing'); + // True when the job will yield exactly one output file, so the server returns a + // raw image instead of a ZIP. Mirrors the backend's soleImageOutput rule: one + // file, one plain image preset (not a multi-file pack, not an all-files bundle). + let singleImageResult = $derived.by(() => { + if (files.length !== 1 || selectedPresets.length !== 1) return false; + const only = selectedPresets[0]; + return !PACK_PRESETS.has(only) && !BUNDLE_PRESETS.has(only); + }); + async function submit() { if (!canSubmit) return; @@ -58,8 +72,33 @@ progress.reset(); files = []; selectedPresets = []; + cropping = null; + autoDownloaded = false; + } + + // Triggers a browser download of the given URL without navigating away. Using a + // transient click keeps us on the page (so the "Start over" UI and + // the fallback button remain) while the file saves. + function triggerDownload(url) { + const a = document.createElement('a'); + a.href = url; + a.download = ''; + document.body.appendChild(a); + a.click(); + a.remove(); } + // Auto-download once the job completes, so the user gets their file without a + // click. The manual button stays as a fallback; on the server the first + // download keeps the job alive and the second (a fallback click) frees it. + let autoDownloaded = $state(false); + $effect(() => { + if (status === 'done' && progress.downloadUrl && !autoDownloaded) { + autoDownloaded = true; + triggerDownload(progress.downloadUrl); + } + }); + // Ensure the stream is torn down if the component unmounts mid-flight. $effect(() => () => progress.close()); @@ -79,7 +118,8 @@

- Drop images, pick targets, download optimized variants as a ZIP. + Drop images, pick targets, download the optimized result (a ZIP, or a single + image when that’s all you asked for).

@@ -94,7 +134,7 @@
- +
@@ -143,22 +183,28 @@ {/if} {#if status === 'done'} - -

+ Your download should start automatically. If it didn’t, use the button below. +

+
- - - Download ZIP - + + {singleImageResult ? 'Download image' : 'Download ZIP'} + +
{:else if status === 'error'}

{progress.errorMessage ?? 'Something went wrong.'} @@ -203,3 +249,4 @@ + diff --git a/frontend/src/components/Dropzone.svelte b/frontend/src/components/Dropzone.svelte index f943891..d725c98 100644 --- a/frontend/src/components/Dropzone.svelte +++ b/frontend/src/components/Dropzone.svelte @@ -3,13 +3,19 @@ // selected files are bound back to the parent via `files`. Touch devices // (which don't support drag-and-drop) use the click fallback. - import CropModal from './CropModal.svelte'; import { cropAspects } from '../lib/presets.js'; - let { files = $bindable([]), selectedPresets = [], disabled = false } = $props(); - - // The file entry whose crop is being adjusted (drives CropModal), or null. - let cropping = $state(null); + // `cropping` is bound up to the parent (App), which renders CropModal at the + // page root. The modal MUST live outside Dropzone's `animate-fade-up` wrapper: + // that wrapper animates `transform`, which establishes a containing block and + // would make the modal's `position: fixed` resolve against the wrapper instead + // of the viewport — rendering the overlay inline and hiding its buttons. + let { + files = $bindable([]), + selectedPresets = [], + cropping = $bindable(null), + disabled = false, + } = $props(); // True when at least one selected preset crops to a fixed shape, so the // "Adjust crop" affordance is meaningful. When false the button is hidden to @@ -261,5 +267,3 @@

{/if} - - diff --git a/frontend/src/lib/presets.js b/frontend/src/lib/presets.js index 3df82ce..e0281b4 100644 --- a/frontend/src/lib/presets.js +++ b/frontend/src/lib/presets.js @@ -136,6 +136,13 @@ export const BUNDLE_PRESETS = new Set([ 'linkedin_doc_square', ]); +// PACK_PRESETS are per-file presets that expand into MULTIPLE files (a folder in +// the ZIP) rather than a single image: the favicon pack and the responsive +// srcset pack. They never qualify for the single-file raw-image download, so the +// frontend counts them as multi-output. Mirror of the pack Kinds in +// processor/preset.go (KindFaviconPack / KindSrcsetPack). +export const PACK_PRESETS = new Set(['favicon', 'srcset_web']); + // cropAspects maps each fixed-aspect (cropping) preset name to its target aspect // ratio { w, h }. These are the presets the backend runs through the crop-to-fill // path (Preset.Resizes() — both Width and Height set), so they honor a per-file diff --git a/handlers/download.go b/handlers/download.go index 265e8c3..583fbee 100644 --- a/handlers/download.go +++ b/handlers/download.go @@ -23,6 +23,17 @@ func Download(store *Store) fiber.Handler { outputs := job.Outputs() + // Single-file fast path: when the job produced exactly one output file, + // return it raw (with its real content-type and filename) instead of a + // one-entry ZIP — the common "one image, one preset" case. A pack (favicon + // has many members) or bundle, or any job with 2+ outputs, still ZIPs. + if of, ok := soleImageOutput(outputs); ok { + c.Attachment(of.preset + extFor(of.format)) + c.Type(extFor(of.format)) // sets Content-Type from the extension + freeIfRedownloaded(store, job, jobID) + return c.Send(of.data) + } + // Content-Disposition: attachment; filename="optimized.zip". c.Attachment("optimized.zip") @@ -65,13 +76,25 @@ func Download(store *Store) fiber.Handler { _ = zw.Close() _ = w.Flush() - // Free the job once its bytes have been streamed. A second download - // of the same id then 404s, as the acceptance criteria expect. - store.Delete(jobID) + // The first download (typically the auto-download) keeps the job so the + // manual button still works; the second frees it. See freeIfRedownloaded. + freeIfRedownloaded(store, job, jobID) }) } } +// freeIfRedownloaded records a download and deletes the job once it has been +// downloaded twice. The frontend auto-downloads on completion and also shows a +// manual button as a fallback; keeping the job alive through the first download +// means a fallback click still succeeds, while the second click (or a retry) +// cleans up the in-memory state. The TTL reaper frees jobs that are never +// downloaded a second time. +func freeIfRedownloaded(store *Store, job *Job, jobID string) { + if job.MarkDownloaded() >= 2 { + store.Delete(jobID) + } +} + // writePack writes every member of a pack preset into the ZIP under a folder // named after the preset (e.g. "favicon/favicon.ico"). When the job has multiple // source files the folder is further namespaced by source base, mirroring the @@ -133,6 +156,23 @@ func hasMultipleSources(outputs []outFile) bool { return false } +// soleImageOutput returns the single output and true when the job produced +// exactly one plain image file — one output, with image data and no pack/bundle +// members. That is the case we can serve raw instead of zipping. Packs (favicon, +// srcset) and bundles (PDF) carry their files in `pack`, so they never qualify +// even when they are the only output, since they are inherently multi-file or +// folder-shaped. +func soleImageOutput(outputs []outFile) (outFile, bool) { + if len(outputs) != 1 { + return outFile{}, false + } + of := outputs[0] + if of.bundle || len(of.pack) > 0 || of.data == nil { + return outFile{}, false + } + return of, true +} + // extFor maps an output format to its file extension. func extFor(f processor.Format) string { switch f { diff --git a/handlers/download_test.go b/handlers/download_test.go index 2321cbb..f6d6e3f 100644 --- a/handlers/download_test.go +++ b/handlers/download_test.go @@ -39,6 +39,53 @@ func TestHasMultipleSources(t *testing.T) { } } +// TestSoleImageOutput locks the single-file fast path: only a lone plain image +// output is served raw; multi-output jobs, packs, and bundles still ZIP. +func TestSoleImageOutput(t *testing.T) { + tests := []struct { + name string + outputs []outFile + want bool + }{ + {"empty", nil, false}, + { + name: "one plain image", + outputs: []outFile{{preset: "instagram_square", format: processor.FormatJPEG, data: []byte("img")}}, + want: true, + }, + { + name: "two images stay zipped", + outputs: []outFile{ + {preset: "instagram_square", data: []byte("a")}, + {preset: "convert_webp", data: []byte("b")}, + }, + want: false, + }, + { + // A favicon pack is a single output but folder-shaped (many members). + name: "lone pack stays zipped", + outputs: []outFile{{preset: "favicon", pack: []processor.OutputFile{{Name: "favicon.ico", Data: []byte("ico")}}}}, + want: false, + }, + { + name: "lone bundle stays zipped", + outputs: []outFile{{preset: "linkedin_doc_square", bundle: true, pack: []processor.OutputFile{{Name: "x.pdf", Data: []byte("%PDF")}}}}, + want: false, + }, + { + name: "nil data is not served raw", + outputs: []outFile{{preset: "instagram_square", data: nil}}, + want: false, + }, + } + for _, tt := range tests { + _, got := soleImageOutput(tt.outputs) + if got != tt.want { + t.Errorf("%s: soleImageOutput ok = %v, want %v", tt.name, got, tt.want) + } + } +} + // TestWriteBundleTopLevel verifies a bundle output lands at the ZIP root under // its member filename, never namespaced by source or preset folder. func TestWriteBundleTopLevel(t *testing.T) { diff --git a/handlers/store.go b/handlers/store.go index 3592e37..96f2e2d 100644 --- a/handlers/store.go +++ b/handlers/store.go @@ -64,14 +64,15 @@ type Job struct { // It is set once at Create and never mutated, so it needs no lock. createdAt time.Time - mu sync.Mutex - total int - done int - status string // processing | complete | error - finished bool - events []progressEvent // append-only history for replay - outputs []outFile // successful outputs for the ZIP - subs []chan progressEvent // active SSE subscribers + mu sync.Mutex + total int + done int + status string // processing | complete | error + finished bool + downloads int // count of completed downloads (see MarkDownloaded) + events []progressEvent // append-only history for replay + outputs []outFile // successful outputs for the ZIP + subs []chan progressEvent // active SSE subscribers } // Store is the in-memory job registry. No Redis, no disk: jobs live until their @@ -300,6 +301,18 @@ func (j *Job) closeSubsLocked() { j.subs = nil } +// MarkDownloaded records one completed download and returns the new running +// count. The download handler keeps a job alive through the first download (so an +// auto-download leaves the manual button working as a fallback) and frees it on +// the second — the user has clearly got the file by then. The TTL reaper is the +// backstop if a second download never comes. +func (j *Job) MarkDownloaded() int { + j.mu.Lock() + defer j.mu.Unlock() + j.downloads++ + return j.downloads +} + // Outputs returns the successful outputs collected so far. func (j *Job) Outputs() []outFile { j.mu.Lock()