max / makenotwork
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> |