Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 65 additions & 18 deletions frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,35 @@
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(
status === 'idle' && files.length > 0 && selectedPresets.length > 0,
);
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;

Expand Down Expand Up @@ -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 <a download> 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());
</script>
Expand All @@ -79,7 +118,8 @@
</h1>
</div>
<p class="text-sm text-ctp-subtext1 sm:text-base">
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).
</p>
</header>

Expand All @@ -94,7 +134,7 @@
<HowItWorks />
</div>
<div class="animate-fade-up" style="animation-delay: 120ms">
<Dropzone bind:files {selectedPresets} />
<Dropzone bind:files {selectedPresets} bind:cropping />
</div>
<div class="animate-fade-up" style="animation-delay: 180ms">
<PresetSelector bind:selected={selectedPresets} />
Expand Down Expand Up @@ -143,22 +183,28 @@
{/if}

{#if status === 'done'}
<a
class="group inline-flex animate-pop items-center gap-2 self-start rounded-lg bg-gradient-to-r from-ctp-green to-ctp-teal bg-[length:200%_auto] px-5 py-3 font-semibold text-ctp-base shadow-lg shadow-ctp-green/25 transition-all duration-300 hover:-translate-y-0.5 hover:bg-right hover:shadow-xl hover:shadow-ctp-green/40"
href={progress.downloadUrl}
>
<svg
class="h-5 w-5 transition-transform duration-300 group-hover:translate-y-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
<div class="flex flex-col gap-1.5">
<p class="text-sm text-ctp-subtext1">
Your download should start automatically. If it didn’t, use the button below.
</p>
<a
class="group inline-flex animate-pop items-center gap-2 self-start rounded-lg bg-gradient-to-r from-ctp-green to-ctp-teal bg-[length:200%_auto] px-5 py-3 font-semibold text-ctp-base shadow-lg shadow-ctp-green/25 transition-all duration-300 hover:-translate-y-0.5 hover:bg-right hover:shadow-xl hover:shadow-ctp-green/40"
href={progress.downloadUrl}
download
>
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
Download ZIP
</a>
<svg
class="h-5 w-5 transition-transform duration-300 group-hover:translate-y-0.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
{singleImageResult ? 'Download image' : 'Download ZIP'}
</a>
</div>
{:else if status === 'error'}
<p class="text-ctp-red">
{progress.errorMessage ?? 'Something went wrong.'}
Expand Down Expand Up @@ -203,3 +249,4 @@
</main>

<LegalModal bind:open={legalOpen} />
<CropModal bind:open={cropping} {selectedPresets} />
18 changes: 11 additions & 7 deletions frontend/src/components/Dropzone.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -261,5 +267,3 @@
</p>
{/if}
</div>

<CropModal bind:open={cropping} {selectedPresets} />
7 changes: 7 additions & 0 deletions frontend/src/lib/presets.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 43 additions & 3 deletions handlers/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
47 changes: 47 additions & 0 deletions handlers/download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
29 changes: 21 additions & 8 deletions handlers/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading