Skip to main content

max / makenotwork

Describe both wizard image uploads, and keep one S3Uploader Shape 3 steps 5 and 6. The two wizard steps emitted <mnw-image-uploader> with a hand-written accept list, dropzone, progress panel and error panel each; both now render one described field from upload_field::image and are bound by the same static/upload.js binder the audio and version surfaces use. Two host attributes carry what the description cannot: data-upload-fills names the input that takes the URL the confirm answered with, and data-upload-shows marks each place the picture goes, with data-upload-empty and data-upload-filled inside it. Same class as the data-upload-goes and data-upload-refreshes already there. The server now renders both the placeholder and the picture, so the host sets a src and swaps which is hidden instead of building an img. frontend/src/islands/uploader/ goes with its last consumer, which leaves one S3Uploader rather than two. The plan expected the collapse to land on s3.ts and delete static/upload.js, but upload.js is loaded on every page and twelve files under static/ still read its global, so the surviving copy is the one that runs. 866 lines of upload code across the three files is 598.
Author: Max Johnson <me@maxj.phd> · 2026-09-01 15:52 UTC
Signed with PGP, not checked
Commit: 0318be469545515baf0d32da61d00d95c9987506
Parent: 2787e4d
10 files changed, +273 insertions, -392 deletions
@@ -142,10 +142,17 @@
142 142 * (`/api/upload`, `/api/versions/{id}/upload`, `/api/internal/upload`), so the
143 143 * confirm route is the presign route with its last segment swapped.
144 144 *
145 - * Two host attributes, both about what happens after the bytes land and neither
146 - * of them describable: `data-upload-goes` is an address to follow, and
147 - * `data-upload-refreshes` is a control to press. They sit on an ancestor of the
148 - * field, since they belong to the surface rather than to the file.
145 + * Four host attributes, all about what happens after the bytes land and none of
146 + * them describable. Three sit on an ancestor of the field, since they belong to
147 + * the surface rather than to the file: `data-upload-goes` is an address to
148 + * follow, `data-upload-refreshes` is a control to press, and
149 + * `data-upload-fills` is a selector for the input that takes the URL the confirm
150 + * answered with. The fourth, `data-upload-shows`, marks each place inside that
151 + * surface where the picture itself goes; within one of those, `data-upload-empty`
152 + * is the placeholder to hide and `data-upload-filled` the element to reveal, and
153 + * the `img` found there has its `src` set. That pair replaced
154 + * `frontend/src/islands/uploader/image-uploader.ts`, which built the same markup
155 + * from JS against hardcoded element ids.
149 156 */
150 157 (function() {
151 158 function payload(sender) {
@@ -189,6 +196,27 @@
189 196 return panel;
190 197 }
191 198
199 + /* Where a returned URL goes. The confirm answers with the address of the
200 + stored image; the surface says which input holds it for the form to post
201 + and which regions show it. An image whose element is already in the
202 + markup is why nothing is constructed here: the server rendered the
203 + placeholder and the picture side by side, so landing one is setting a
204 + `src` and swapping which of the two is hidden. */
205 + function land(surface, url) {
206 + if (!surface) return;
207 + var fills = surface.dataset.uploadFills;
208 + if (fills) {
209 + var input = document.querySelector(fills);
210 + if (input) input.value = url;
211 + }
212 + surface.querySelectorAll('[data-upload-shows]').forEach(function(shown) {
213 + var img = shown.querySelector('img');
214 + if (img) img.src = url;
215 + shown.querySelectorAll('[data-upload-empty]').forEach(function(el) { el.hidden = true; });
216 + shown.querySelectorAll('[data-upload-filled]').forEach(function(el) { el.hidden = false; });
217 + });
218 + }
219 +
192 220 function bind(sender) {
193 221 if (sender.dataset.uploadBound) return;
194 222 var input = sender.querySelector('input[type="file"]');
@@ -196,7 +224,7 @@
196 224 sender.dataset.uploadBound = '1';
197 225
198 226 var area = sender.closest('.file-upload-area') || sender;
199 - var surface = sender.closest('[data-upload-goes], [data-upload-refreshes]');
227 + var surface = sender.closest('[data-upload-goes], [data-upload-refreshes], [data-upload-fills]');
200 228 var panel = null;
201 229 var uploader = null;
202 230
@@ -259,6 +287,9 @@
259 287 );
260 288 }
261 289 if (!surface) return;
290 + /* `image_url` is what both image confirms answer with, and the
291 + only field either of them has that anything reads. */
292 + if (result && result.image_url) land(surface, result.image_url);
262 293 if (surface.dataset.uploadGoes) {
263 294 window.location.href = surface.dataset.uploadGoes;
264 295 } else if (surface.dataset.uploadRefreshes) {
@@ -355,6 +355,26 @@
355 355 max-width: 300px;
356 356 }
357 357
358 + /* The described upload field draws a sunken panel; inside a wizard image
359 + picker it keeps the dashed edge the hand-written dropzone had. */
360 + .project-image-upload .file-upload-area {
361 + border: 2px dashed var(--border);
362 + border-radius: var(--radius-control);
363 + background: var(--surface-page);
364 + margin-bottom: 0;
365 + }
366 +
367 + .project-image-upload .file-upload-area:hover,
368 + .project-image-upload .file-upload-area.dragover {
369 + border-color: var(--action);
370 + background: var(--surface-page);
371 + }
372 +
373 + .project-image-placeholder {
374 + padding: var(--gap-page);
375 + text-align: center;
376 + }
377 +
358 378 .project-image-upload .secondary {
359 379 align-self: flex-start;
360 380 }
@@ -138,6 +138,45 @@
138 138 )
139 139 }
140 140
141 + /// The one image a project or an item shows for itself.
142 + ///
143 + /// One file, three media types, and an entity named the way its presign route
144 + /// reads it: `project_id` or `item_id`, riding in the payload as the action's
145 + /// parameter. The two wizard steps shared one implementation before this
146 + /// (`frontend/src/islands/uploader/image-uploader.ts`, deleted with the
147 + /// conversion), so they convert as one function rather than two.
148 + ///
149 + /// What comes back is a URL, and where it lands is the host's: a hidden input
150 + /// that the wizard form posts, and one or two places that show the picture.
151 + /// Both are the same class of fact as `data-upload-goes` and
152 + /// `data-upload-refreshes`, so both are attributes on the surface rather than
153 + /// members of the description. See `static/upload.js`.
154 + #[must_use]
155 + pub fn image(presign: &str, id_field: &str, id_value: &str) -> String {
156 + let field = Field::upload(
157 + "cover-image",
158 + "Cover image",
159 + [
160 + Accepted::media_type("image/jpeg"),
161 + Accepted::media_type("image/png"),
162 + Accepted::media_type("image/webp"),
163 + ],
164 + )
165 + .writes(
166 + Action::post(presign)
167 + .with(id_field, id_value)
168 + .awaiting()
169 + .by_host(),
170 + );
171 +
172 + area(
173 + "Drop an image here or choose one to upload",
174 + "JPG, PNG or WebP, square and at least 400x400px, up to 10 MB",
175 + "image/jpeg",
176 + field,
177 + )
178 + }
179 +
141 180 /// The files a new version is being built from, picked before it exists.
142 181 ///
143 182 /// Several at once, and deliberately with no destination: nothing can be sent
@@ -252,6 +291,145 @@
252 291 assert!(html.contains("Drop audio file here"), "{html}");
253 292 }
254 293
294 + /// Three media types, one destination, and the entity riding in the
295 + /// payload. The accept list was written out by hand in both wizard steps
296 + /// before this, in two files that had no way to disagree loudly.
297 + #[test]
298 + fn an_image_says_its_three_media_types_and_where_it_goes() {
299 + let html = super::image(
300 + "/api/items/image/presign",
301 + "item_id",
302 + "11111111-1111-1111-1111-111111111111",
303 + );
304 +
305 + assert!(
306 + html.contains(r#"accept="image/jpeg,image/png,image/webp""#),
307 + "{html}"
308 + );
309 + assert!(!html.contains(" multiple"), "{html}");
310 + assert!(
311 + html.contains(r#"data-sends="/api/items/image/presign""#),
312 + "{html}"
313 + );
314 + assert!(html.contains("item_id"), "{html}");
315 + assert!(html.contains("data-awaiting="), "{html}");
316 + assert!(!html.contains("hx-post"), "{html}");
317 + }
318 +
319 + /// Both wizard steps take the same three types through the same function,
320 + /// and differ only in the address and the name of the entity. This is the
321 + /// assertion that would have caught the two hand-written accept lists
322 + /// drifting apart.
323 + #[test]
324 + fn both_wizard_images_take_the_same_files() {
325 + let item = super::image("/api/items/image/presign", "item_id", "i");
326 + let project = super::image("/api/projects/image/presign", "project_id", "p");
327 + let accept = r#"accept="image/jpeg,image/png,image/webp""#;
328 +
329 + assert!(item.contains(accept), "{item}");
330 + assert!(project.contains(accept), "{project}");
331 + assert!(
332 + project.contains(r#"data-sends="/api/projects/image/presign""#),
333 + "{project}"
334 + );
335 + assert!(project.contains("project_id"), "{project}");
336 + }
337 +
338 + fn nav() -> Vec<crate::templates::StepNavItem> {
339 + vec![crate::templates::StepNavItem {
340 + name: "basics",
341 + label: "Basics",
342 + state: "active",
343 + }]
344 + }
345 +
346 + /// The item wizard step, rendered whole. Beyond the described field this
347 + /// asserts the two host attributes the binder needs and that the island it
348 + /// replaced is gone: `<mnw-image-uploader>` reached the same markup by
349 + /// hardcoded element ids from TypeScript.
350 + #[test]
351 + fn the_item_wizard_step_is_wired_and_says_where_the_url_lands() {
352 + use askama::Template as _;
353 +
354 + let html = crate::templates::WizardItemBasicsTemplate {
355 + nav: nav(),
356 + project_slug: "a-project".into(),
357 + item_id: "11111111-1111-1111-1111-111111111111".into(),
358 + title: "A track".into(),
359 + description: String::new(),
360 + cover_image_url: None,
361 + }
362 + .render()
363 + .expect("render the item basics step");
364 +
365 + assert!(
366 + html.contains(r#"data-sends="/api/items/image/presign""#),
367 + "{html}"
368 + );
369 + assert!(
370 + html.contains(r##"data-upload-fills="#cover-image-url""##),
371 + "{html}"
372 + );
373 + assert!(html.contains("data-upload-shows"), "{html}");
374 + assert!(html.contains("data-upload-empty"), "{html}");
375 + assert!(html.contains("data-upload-filled"), "{html}");
376 + assert!(!html.contains("mnw-image-uploader"), "{html}");
377 + }
378 +
379 + /// The project wizard step has two places the picture goes, the dropzone's
380 + /// own preview and the card preview, which is the whole reason
381 + /// `data-upload-shows` is a mark on many elements rather than one selector.
382 + #[test]
383 + fn the_project_wizard_step_shows_the_image_in_two_places() {
384 + use askama::Template as _;
385 +
386 + let html = crate::templates::WizardProjectAppearanceTemplate {
387 + nav: nav(),
388 + slug: "a-project".into(),
389 + project_id: "22222222-2222-2222-2222-222222222222".into(),
390 + cover_image_url: None,
391 + project_title: "A project".into(),
392 + }
393 + .render()
394 + .expect("render the project appearance step");
395 +
396 + assert!(
397 + html.contains(r#"data-sends="/api/projects/image/presign""#),
398 + "{html}"
399 + );
400 + assert_eq!(html.matches("data-upload-shows").count(), 2, "{html}");
401 + assert!(
402 + html.contains(r##"data-upload-fills="#cover-image-url""##),
403 + "{html}"
404 + );
405 + assert!(!html.contains("mnw-image-uploader"), "{html}");
406 + }
407 +
408 + /// An image already chosen is rendered as the picture, with the placeholder
409 + /// hidden, and the host never has to build either. The `src` is written
410 + /// only when there is one: an `img` with an empty `src` refetches the page.
411 + #[test]
412 + fn an_image_already_there_is_rendered_not_constructed() {
413 + use askama::Template as _;
414 +
415 + let html = crate::templates::WizardProjectAppearanceTemplate {
416 + nav: nav(),
417 + slug: "a-project".into(),
418 + project_id: "22222222-2222-2222-2222-222222222222".into(),
419 + cover_image_url: Some("https://cdn.example/cover.jpg".into()),
420 + project_title: "A project".into(),
421 + }
422 + .render()
423 + .expect("render the project appearance step");
424 +
425 + assert!(
426 + html.contains(r#"src="https://cdn.example/cover.jpg""#),
427 + "{html}"
428 + );
429 + assert!(!html.contains(r#"src="""#), "{html}");
430 + assert!(html.contains("data-upload-empty hidden"), "{html}");
431 + }
432 +
255 433 /// An item whose only interesting field is the audio it holds. The rest is
256 434 /// what `Item` needs to exist, and none of it reaches this markup.
257 435 fn audio_item(audio_s3_key: Option<String>) -> crate::types::Item {
@@ -25,14 +25,14 @@
25 25 import { timing } from './timing.ts';
26 26
27 27 // Common, lightweight islands are registered globally here (side-effect import
28 - // defines the custom element). Heavy or page-specific islands (media player,
29 - // uploader) load per-page instead. Custom elements auto-upgrade on connect, so
30 - // carousels in HTMX-swapped content wire themselves with no extra glue.
28 + // defines the custom element). Heavy or page-specific islands (media player)
29 + // load per-page instead. Custom elements auto-upgrade on connect, so carousels
30 + // in HTMX-swapped content wire themselves with no extra glue.
31 31 import '../islands/carousel.ts';
32 - // Image uploader appears in HTMX-swapped wizard steps, so it's registered
33 - // globally (auto-upgrades on swap). The heavier upload flows (item audio/version
34 - // queue, gallery, bundle) still use the legacy static/upload.js until migrated.
35 - import '../islands/uploader/image-uploader.ts';
32 + // No uploader island. Every upload the server renders now emits its own field
33 + // from `crate::quasi::upload_field` and is bound by static/upload.js, so the
34 + // second S3Uploader that lived under islands/uploader/ went with the last of
35 + // its consumers.
36 36 // SyncKit dashboard: the keys-endpoint secret action. Registers a dispatcher
37 37 // verb (no window global) and is inert on pages without the button.
38 38 import '../islands/synckit-keys-secret.ts';
@@ -5,9 +5,15 @@
5 5 <p class="step-description">Title, description, and cover image for your item.</p>
6 6 <p class="form-hint">An <strong>item</strong> is an individual piece of content: a song, episode, chapter, download, or release. Fans purchase or access items directly.</p>
7 7
8 + {#- `data-upload-fills` is where the URL the confirm answers with lands, and
9 + `data-upload-shows` marks each place the picture itself goes. Both are
10 + host facts about what happens after the bytes land, the same class as
11 + `data-upload-goes` on the audio surface, and `static/upload.js` reads
12 + them. The upload itself is described: see `crate::quasi::upload_field`. -#}
8 13 <form hx-post="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/basics"
9 14 hx-target="#wizard-step" hx-swap="innerHTML"
10 - hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/content">
15 + hx-push-url="/dashboard/project/{{ project_slug }}/new-item/{{ item_id }}/step/content"
16 + data-upload-fills="#cover-image-url">
11 17 <div class="form-group">
12 18 <label for="wiz-item-title">Title</label>
13 19 <input type="text" id="wiz-item-title" name="title" required
@@ -24,36 +30,15 @@
24 30 <div class="form-group">
25 31 <label>Cover Image <span class="wizard-optional-tag">(optional)</span></label>
26 32
27 - <div class="project-image-upload" id="image-upload-area">
28 - <div class="project-image-dropzone" id="image-dropzone">
29 - {% if let Some(url) = cover_image_url %}
30 - <div class="project-image-current" id="image-current">
31 - <img src="{{ url }}" alt="Item image" class="wizard-cover-img">
32 - </div>
33 - {% else %}
34 - <div class="project-image-placeholder" id="image-placeholder">
35 - <p>Square, at least 400x400px</p>
36 - <p class="hint">JPG, PNG, or WebP. Max 10 MB.</p>
37 - </div>
38 - {% endif %}
39 - <input type="file" id="image-file-input" accept="image/jpeg,image/png,image/webp" class="sr-only">
40 - </div>
41 -
42 - <button type="button" class="btn-secondary" id="choose-image-btn"
43 - data-action="click" data-target="image-file-input">Choose File</button>
44 -
45 - <div class="upload-status" id="image-upload-status">
46 - <div class="upload-progress-inline hidden" id="image-upload-progress">
47 - <span id="image-upload-filename"></span>
48 - <span id="image-upload-percent">0%</span>
49 - <div class="progress-bar-container">
50 - <div class="progress-bar" id="image-progress-bar"></div>
51 - </div>
52 - </div>
53 - <div class="upload-error-inline hidden" id="image-upload-error">
54 - <span class="error-message" id="image-error-message"></span>
33 + <div class="project-image-upload">
34 + <div class="project-image-preview" data-upload-shows>
35 + <div class="project-image-placeholder" data-upload-empty{% if cover_image_url.is_some() %} hidden{% endif %}>No image yet</div>
36 + <div class="project-image-current" data-upload-filled{% if cover_image_url.is_none() %} hidden{% endif %}>
37 + <img alt="Item image" class="wizard-cover-img"{% if let Some(url) = cover_image_url %} src="{{ url }}"{% endif %}>
55 38 </div>
56 39 </div>
40 +
41 + {{ crate::quasi::upload_field::image("/api/items/image/presign", "item_id", item_id.as_str())|safe }}
57 42 </div>
58 43
59 44 <input type="hidden" name="cover_image_url" id="cover-image-url"
@@ -69,10 +54,3 @@
69 54 </div>
70 55 </form>
71 56 </div>
72 -
73 - <mnw-image-uploader hidden
74 - data-presign-url="/api/items/image/presign"
75 - data-confirm-url="/api/items/image/confirm"
76 - data-id-field="item_id"
77 - data-id-value="{{ item_id }}"
78 - data-alt="Item image"></mnw-image-uploader>
@@ -4,43 +4,29 @@
4 4 <h2 class="subtitle-h2">Appearance</h2>
5 5 <p class="step-description">Upload a project image. This appears on your project page and in search results.</p>
6 6
7 + {#- `data-upload-fills` is where the URL the confirm answers with lands, and
8 + `data-upload-shows` marks each place the picture itself goes, which here
9 + is two: the dropzone's own preview and the card preview below it. Both
10 + are host facts about what happens after the bytes land, the same class as
11 + `data-upload-goes` on the audio surface, and `static/upload.js` reads
12 + them. The upload itself is described: see `crate::quasi::upload_field`. -#}
7 13 <form hx-post="/dashboard/new-project/{{ slug }}/step/appearance"
8 14 hx-target="#wizard-step" hx-swap="innerHTML"
9 - hx-push-url="/dashboard/new-project/{{ slug }}/step/monetization">
15 + hx-push-url="/dashboard/new-project/{{ slug }}/step/monetization"
16 + data-upload-fills="#cover-image-url">
10 17
11 18 <div class="form-group">
12 19 <label>Project Image</label>
13 20
14 - <div class="project-image-upload" id="image-upload-area">
15 - <div class="project-image-dropzone" id="image-dropzone">
16 - {% if let Some(url) = cover_image_url %}
17 - <div class="project-image-current" id="image-current">
18 - <img src="{{ url }}" alt="Project image" class="wizard-cover-img">
19 - </div>
20 - {% else %}
21 - <div class="project-image-placeholder" id="image-placeholder">
22 - <p>Square, at least 400x400px</p>
23 - <p class="hint">JPG, PNG, or WebP. Max 10 MB.</p>
24 - </div>
25 - {% endif %}
26 - <input type="file" id="image-file-input" accept="image/jpeg,image/png,image/webp" class="sr-only">
27 - </div>
28 -
29 - <button type="button" class="btn-secondary" id="choose-image-btn"
30 - data-action="click" data-target="image-file-input">Choose File</button>
31 -
32 - <div class="upload-status" id="image-upload-status">
33 - <div class="upload-progress-inline hidden" id="image-upload-progress">
34 - <span id="image-upload-filename"></span>
35 - <span id="image-upload-percent">0%</span>
36 - <div class="progress-bar-container">
37 - <div class="progress-bar" id="image-progress-bar"></div>
38 - </div>
39 - </div>
40 - <div class="upload-error-inline hidden" id="image-upload-error">
41 - <span class="error-message" id="image-error-message"></span>
21 + <div class="project-image-upload">
22 + <div class="project-image-preview" data-upload-shows>
23 + <div class="project-image-placeholder" data-upload-empty{% if cover_image_url.is_some() %} hidden{% endif %}>No image yet</div>
24 + <div class="project-image-current" data-upload-filled{% if cover_image_url.is_none() %} hidden{% endif %}>
25 + <img alt="Project image" class="wizard-cover-img"{% if let Some(url) = cover_image_url %} src="{{ url }}"{% endif %}>
42 26 </div>
43 27 </div>
28 +
29 + {{ crate::quasi::upload_field::image("/api/projects/image/presign", "project_id", project_id.as_str())|safe }}
44 30 </div>
45 31
46 32 <input type="hidden" name="cover_image_url" id="cover-image-url"
@@ -50,12 +36,9 @@
50 36 <div class="form-group">
51 37 <label>Preview</label>
52 38 <div class="project-card-preview">
53 - <div class="preview-cover" id="preview-cover">
54 - {% if let Some(url) = cover_image_url %}
55 - <img src="{{ url }}" alt="Preview" class="wizard-cover-img">
56 - {% else %}
57 - <div class="preview-cover-empty">No image</div>
58 - {% endif %}
39 + <div class="preview-cover" id="preview-cover" data-upload-shows>
40 + <div class="preview-cover-empty" data-upload-empty{% if cover_image_url.is_some() %} hidden{% endif %}>No image</div>
41 + <img alt="Preview" class="wizard-cover-img" data-upload-filled{% if cover_image_url.is_none() %} hidden{% endif %}{% if let Some(url) = cover_image_url %} src="{{ url }}"{% endif %}>
59 42 </div>
60 43 <div class="preview-info">
61 44 <strong>{{ project_title }}</strong>
@@ -76,11 +59,3 @@
76 59 </div>
77 60 </form>
78 61 </div>
79 -
80 - <mnw-image-uploader hidden
81 - data-presign-url="/api/projects/image/presign"
82 - data-confirm-url="/api/projects/image/confirm"
83 - data-id-field="project_id"
84 - data-id-value="{{ project_id }}"
85 - data-alt="Project image"
86 - data-preview-target="preview-cover"></mnw-image-uploader>