//! The Askama entry point for a described upload. //! //! Shape 3 of the conversion plan (wiki `mnw-shape-conversion-plans`), and the //! one that had to wait for two vocabulary rulings rather than one. `f7261a5a` //! settled what an upload says about itself: what it takes //! ([`Field::upload`] plus its accept list), how many at a time //! ([`Field::many`]), where the bytes go (the field's action) and that it //! waits ([`Action::awaiting`]). `a81384d4` settled who makes the calls, which //! none of those four axes covered: an MNW upload is presign, then a PUT //! straight to storage from the browser, then confirm, and a renderer posting //! the field at the first of those would be wrong about the response shape and //! about where the bytes end up. [`Action::by_host`] is the answer. The //! renderer emits the address as `data-sends` and performs nothing; //! `static/upload.js` reads it and runs the chain. //! //! # What is described here and what stays the host's //! //! Described: the accept list, the multiplicity, the destination, the payload //! that rides with it, and that the call waits. Every one of those was //! hand-written in a template before this, three times over with three //! spellings of the same accept list. //! //! Host: the drop gesture and the progress bar. Neither is an omission. //! `makeover-layout`'s `FieldKind::File` doc says a drop area, a picker button //! and a typed path are one field and that the gesture "is not described for //! the reason no gesture is", so the dragging stays in `upload.js`. Progress is //! `Awaiting`'s ruling: the mark says the call waits and carries a measured //! size when there is one, and the renderer observes the rest. The server //! renders this markup before a file exists, so there is no size to write down //! and the mark is the unmeasured one. //! //! # Why a function per surface rather than one builder //! //! Three call sites, each with a fixed accept list that is a fact about MNW //! rather than about the template it sits in. A generic entry point would put //! `.zip,.dmg,.exe,.appimage,.deb,.tar.gz,.clap,.vst3` back in Askama, in two //! places, which is the duplication this shape exists to remove. So the lists //! live here once and each surface is named for what it is. use makeover_layout::{Family, FieldKind}; use quasi_router::{Accepted, Action, Field, Node, Repeat}; /// Every suffix a version file may carry. /// /// One list, read by both version surfaces. A suffix names no family by ruling /// (`f7261a5a`: a suffix-to-family table rots), so these are `Suffix` and the /// reader gets no media disclosure from them, which is right for a build /// artifact. fn version_suffixes() -> Vec { [ ".zip", ".dmg", ".exe", ".appimage", ".deb", ".tar.gz", ".clap", ".vst3", ] .into_iter() .map(Accepted::suffix) .collect() } /// The drop area an upload field sits in, and the field itself. /// /// The wrapper is this app's and the group inside it is the renderer's, the /// same division `widgets::carousel` and `quasi::rich_field` make. What the /// wrapper carries is the gesture, which is why it is here: `upload.js` binds /// drag, drop and click-to-open on `.file-upload-area`, and finds the /// destination on the field wrapper the renderer emitted inside it. /// /// `fallback` is what to call the bytes when the browser offers no media type /// for them, which happens for the suffixes S3 has never heard of. It is a host /// fact and not a described one: the presigned URL binds the content type, so /// the PUT has to send back the exact string the presign was asked for, and /// `S3Client::validate_content_type` refuses `application/octet-stream` for an /// item's audio. It rides as an attribute here rather than as a literal in /// Askama so that both halves of the pair stay in one file. fn area(prompt: &str, hint: &str, fallback: &str, field: Field) -> String { use quasi_axum::Serves as _; // No shell: a fragment landing inside a document Askama already built. let inner = quasi_webview::Webview::new().fragment(&Node::field(field)); format!( "
\
{}
\
{}
{inner}
", crate::helpers::escape_html(fallback), crate::helpers::escape_html(prompt), crate::helpers::escape_html(hint), ) } /// An item's audio file, replaced whole each time. /// /// One file, any audio media type, landing through `/api/upload/presign`. The /// two values the chain needs beyond the file ride as the action's parameters /// and reach the host as `data-vals`, which is the whole of what the old /// `data-item-id` attribute and the hard-coded `file_type: 'audio'` literal /// were doing. #[must_use] pub fn audio(item_id: &str) -> String { let field = Field::upload("audio", "Audio file", [Accepted::family(Family::Audio)]).writes( Action::post("/api/upload/presign") .with("item_id", item_id) .with("file_type", "audio") .awaiting() .by_host(), ); area( "Drop audio file here or choose one to upload", "Supports MP3, WAV, FLAC, M4A up to 500 MB", "audio/mpeg", field, ) } /// One file for a version that already exists. /// /// The same accept list as [`version_queue`] and a destination of its own, /// since the version this belongs to is already in the database and its id is /// in the address rather than in the payload. #[must_use] pub fn existing_version(version_id: &str) -> String { let field = Field::upload("version-file", "Version file", version_suffixes()).writes( Action::post(format!("/api/versions/{version_id}/upload/presign")) .awaiting() .by_host(), ); area( "Drop file to upload for this version", "ZIP, DMG, EXE, AppImage, DEB, tar.gz, CLAP, VST3", "application/octet-stream", field, ) } /// The one image a project or an item shows for itself. /// /// One file, three media types, and an entity named the way its presign route /// reads it: `project_id` or `item_id`, riding in the payload as the action's /// parameter. The two wizard steps shared one implementation before this /// (`frontend/src/islands/uploader/image-uploader.ts`, deleted with the /// conversion), so they convert as one function rather than two. /// /// What comes back is a URL, and where it lands is the host's: a hidden input /// that the wizard form posts, and one or two places that show the picture. /// Both are the same class of fact as `data-upload-goes` and /// `data-upload-refreshes`, so both are attributes on the surface rather than /// members of the description. See `static/upload.js`. #[must_use] pub fn image(presign: &str, id_field: &str, id_value: &str) -> String { let field = Field::upload( "cover-image", "Cover image", [ Accepted::media_type("image/jpeg"), Accepted::media_type("image/png"), Accepted::media_type("image/webp"), ], ) .writes( Action::post(presign) .with(id_field, id_value) .awaiting() .by_host(), ); area( "Drop an image here or choose one to upload", "JPG, PNG or WebP, square and at least 400x400px, up to 10 MB", "image/jpeg", field, ) } /// The files a new version is being built from, picked before it exists. /// /// Several at once, and deliberately with no destination: nothing can be sent /// until the reader has named the version and labelled each file, so the /// address belongs to [`version_upload_all`] and not to this field. What is /// described is what it takes, how many, and the standing help beside it, /// which is the half that was written out by hand in the template. /// /// The hint arrived here rather than staying markup because the template was /// writing a second `Files` label above the one this field already emits. A /// field owns its own label and its own standing help; anything writing either /// beside it is describing the same question twice. #[must_use] pub fn version_queue() -> String { use quasi_axum::Serves as _; let field = Field::upload("version-files", "Files", version_suffixes()) .many() .hint( "Add one file per platform. Each gets its own label \ (e.g. \"macOS (arm)\", \"Linux (x86_64)\").", ); quasi_webview::Webview::new().fragment(&Node::field(field)) } /// What a new version carries besides its files. /// /// Two ordinary questions, and they were only ever markup because the surface /// around them was. Their names are the ids `item-upload.js` reads them back /// by: a field's name is its id in this renderer, which is the same fact /// [`version_queue`]'s picker already relies on. /// /// Emitted as two fragments side by side rather than one region, because the /// grid they sit in is the template's and a region would put a box around /// them. #[must_use] pub fn version_details() -> String { use quasi_axum::Serves as _; let mut number = Field::new(FieldKind::Text, "new-version-number", "Version Number"); number.placeholder = Some("e.g., 1.0".to_owned()); let mut changelog = Field::new(FieldKind::Text, "version-changelog", "Notes (optional)"); changelog.placeholder = Some("What changed in this version...".to_owned()); let mut html = quasi_webview::Webview::new().fragment(&Node::field(number)); html.push_str(&quasi_webview::Webview::new().fragment(&Node::field(changelog))); html } /// The files the reader has picked, one slot each, with the label that names /// the platform it is for. /// /// The shape `7f04f751` counted and could not say, until quasi 0.96 said it. /// Three members carry it and each answers a different half of what the /// hand-written table was doing: /// /// - [`Repeat::added_by`] names [`version_queue`]'s picker as where the slots /// come from, so no renderer offers an add control. A blank row here is /// nothing a reader can fill: there is no way to type a file. /// - [`Instance::called`] names each slot by its file, set by the host as it /// takes them, because only the picker knows what a `File` is called. The /// ordinal would have put "File 2" where "track-arm.dmg" goes. /// - [`Progress`] is the per-slot status the run sets, which is what the /// second copy of this list inside the progress panel used to draw. /// /// The question itself is the label, and it is the only thing here that is /// answered: the file name is what the slot *is*, and the bytes go to S3 /// through a presigned PUT rather than through this form. #[must_use] pub fn version_file_queue() -> String { use quasi_axum::Serves as _; let mut label = Field::new(FieldKind::Text, "version-file", "File"); label.placeholder = Some("e.g., macOS (arm)".to_owned()); let field = label.repeating(Repeat::new().added_by("version-files").removing("Remove")); quasi_webview::Webview::new().fragment(&Node::field(field)) } /// The control that starts the run, and the address it starts at. /// /// [`Action::by_host`] for the reason every other upload here is: the host /// makes three calls per file behind this one address, and a renderer posting /// the form at the first of them would be wrong about the response shape and /// about where the bytes go. What is described is where the run begins and /// that it waits; the sequence stays `item-upload.js`. /// /// This is what took the last string literal for a route out of that file. It /// built `/api/items//versions` by hand from a `data-item-id` attribute on /// an ancestor, which is an address written down twice in two languages. #[must_use] pub fn version_upload_all(item_id: &str) -> String { use quasi_axum::Serves as _; let action = Action::post(format!("/api/items/{item_id}/versions")) .awaiting() .by_host(); quasi_webview::Webview::new().fragment(&Node::act("Upload All", action)) } #[cfg(test)] mod tests { /// The three members `7f04f751` waited on, on the surface that wanted all /// of them. Read together they are the conversion: before this the rows /// were built by `addFileRow` with inline styles, the label input was /// found by a hand-written class, and nothing said a slot could fail. #[test] fn the_picked_file_queue_says_where_its_slots_come_from_and_names_them_by_file() { let html = super::version_file_queue(); // The slots come from the picker above, so no renderer offers an add // control: there is no blank a reader could fill. assert!( html.contains(r#"data-repeat-add-from="version-files""#), "{html}" ); // The add control itself, which is a different attribute from the one // above and would otherwise match it as a prefix. assert!(!html.contains("field-repeat-add"), "{html}"); // The question that is actually answered is the label. assert!(html.contains(r#"data-repeat="version-file""#), "{html}"); assert!(html.contains("data-repeat-slots"), "{html}"); // And the blank the host clones per picked file. assert!(html.contains(" Vec { vec![crate::templates::StepNavItem { name: "basics", label: "Basics", state: "active", }] } /// The item wizard step, rendered whole. Beyond the described field this /// asserts the two host attributes the binder needs and that the island it /// replaced is gone: `` reached the same markup by /// hardcoded element ids from TypeScript. #[test] fn the_item_wizard_step_is_wired_and_says_where_the_url_lands() { use askama::Template as _; let html = crate::templates::WizardItemBasicsTemplate { nav: nav(), project_slug: "a-project".into(), item_id: "11111111-1111-1111-1111-111111111111".into(), title: "A track".into(), description: String::new(), cover_image_url: None, } .render() .expect("render the item basics step"); assert!( html.contains(r#"data-sends="/api/items/image/presign""#), "{html}" ); assert!( html.contains(r##"data-upload-fills="#cover-image-url""##), "{html}" ); assert!(html.contains("data-upload-shows"), "{html}"); assert!(html.contains("data-upload-empty"), "{html}"); assert!(html.contains("data-upload-filled"), "{html}"); assert!(!html.contains("mnw-image-uploader"), "{html}"); } /// The project wizard step has two places the picture goes, the dropzone's /// own preview and the card preview, which is the whole reason /// `data-upload-shows` is a mark on many elements rather than one selector. #[test] fn the_project_wizard_step_shows_the_image_in_two_places() { use askama::Template as _; let html = crate::templates::WizardProjectAppearanceTemplate { nav: nav(), slug: "a-project".into(), project_id: "22222222-2222-2222-2222-222222222222".into(), cover_image_url: None, project_title: "A project".into(), } .render() .expect("render the project appearance step"); assert!( html.contains(r#"data-sends="/api/projects/image/presign""#), "{html}" ); assert_eq!(html.matches("data-upload-shows").count(), 2, "{html}"); assert!( html.contains(r##"data-upload-fills="#cover-image-url""##), "{html}" ); assert!(!html.contains("mnw-image-uploader"), "{html}"); } /// An image already chosen is rendered as the picture, with the placeholder /// hidden, and the host never has to build either. The `src` is written /// only when there is one: an `img` with an empty `src` refetches the page. #[test] fn an_image_already_there_is_rendered_not_constructed() { use askama::Template as _; let html = crate::templates::WizardProjectAppearanceTemplate { nav: nav(), slug: "a-project".into(), project_id: "22222222-2222-2222-2222-222222222222".into(), cover_image_url: Some("https://cdn.example/cover.jpg".into()), project_title: "A project".into(), } .render() .expect("render the project appearance step"); assert!( html.contains(r#"src="https://cdn.example/cover.jpg""#), "{html}" ); assert!(!html.contains(r#"src="""#), "{html}"); assert!(html.contains("data-upload-empty hidden"), "{html}"); } /// An item whose only interesting field is the audio it holds. The rest is /// what `Item` needs to exist, and none of it reaches this markup. fn audio_item(audio_s3_key: Option) -> crate::types::Item { crate::types::Item { id: "11111111-1111-1111-1111-111111111111".into(), title: "A recording".into(), price: "0".into(), price_cents: 0, item_type: "audio".into(), description: String::new(), thumbnail: String::new(), release_date: String::new(), sales_count: 0, tags: Vec::new(), content: crate::types::ItemContent::Audio { duration: None, duration_seconds: None, cover_url: None, episode_number: None, audio_s3_key, }, cover_image_url: None, is_free: true, can_access: true, enable_license_keys: false, default_max_activations: None, pwyw_enabled: false, pwyw_min_cents: None, publish_at: None, is_public: true, listed: true, bundle_item_count: 0, license_preset: None, custom_license_text: None, ai_tier: crate::db::AiTier::Handmade, ai_disclosure: None, } } /// The details tab, rendered whole. The two call sites are Askama's, so /// one of each is rendered here to say the wiring holds: this one also /// carries the address the binder follows once the bytes have landed, /// which is a host fact and has nowhere else to live. #[test] fn the_audio_call_site_is_wired_and_carries_where_it_goes_after() { use askama::Template as _; let html = crate::templates::ItemDetailsTabTemplate { item: audio_item(None), bundle_items: Vec::new(), bundleable_items: Vec::new(), sections: Vec::new(), tag_suggestions: crate::templates::TagSuggestionsTemplate { suggestions: Vec::new(), }, } .render() .expect("render the details tab"); assert!( html.contains(r#"data-sends="/api/upload/presign""#), "{html}" ); assert!( html.contains( r#"data-upload-goes="/dashboard/item/11111111-1111-1111-1111-111111111111?tab=files""# ), "{html}" ); } /// The files tab, rendered whole. Two assertions the JS depends on: the /// queue's input keeps the id `item-upload.js` reads it back by, and a /// version with no file gets a field of its own addressed to that version. #[test] fn the_files_call_sites_keep_what_the_host_reads_them_by() { use askama::Template as _; let version = crate::types::Version { id: "22222222-2222-2222-2222-222222222222".into(), number: "1.0".into(), uploaded_date: "2026-08-20".into(), file_count: 0, size: "0 B".into(), downloads: 0, status: "draft".into(), is_current: true, has_file: false, file_name: None, label: None, }; let html = crate::templates::ItemVersionUploadTemplate { item: audio_item(None), versions: vec![version], } .render() .expect("render the uploader"); assert!(html.contains(r#"id="version-files""#), "{html}"); assert!( html.contains(r#"id="existing-version-upload-22222222-2222-2222-2222-222222222222""#), "{html}" ); assert!( html.contains( r#"data-sends="/api/versions/22222222-2222-2222-2222-222222222222/upload/presign""# ), "{html}" ); } /// The two names `item-upload.js` reads the new version's own facts back /// by. A field's name is its id in this renderer, so renaming either of /// these renames the id, and the script would silently read an empty /// version number and post one with no name. #[test] fn the_new_version_questions_keep_the_names_the_host_reads() { let html = super::version_details(); assert!(html.contains(r#"id="new-version-number""#), "{html}"); assert!(html.contains(r#"name="new-version-number""#), "{html}"); assert!(html.contains(r#"id="version-changelog""#), "{html}"); assert!(html.contains(r#"placeholder="e.g., 1.0""#), "{html}"); } /// Where the run starts and who makes it. `data-sends` is the whole of /// what the script needs: it used to build this address out of a /// `data-item-id` attribute, which is one route written down twice. #[test] fn upload_all_carries_its_address_and_no_transport() { let html = super::version_upload_all("11111111-1111-1111-1111-111111111111"); assert!(html.contains("Files<").count(), 1, "{html}"); assert!(html.contains("Add one file per platform"), "{html}"); } /// The uploader surface, rendered whole: exactly one control the script /// can find by the address it carries, so `button[data-act][data-sends]` /// is unambiguous, and the landing address is on the wrapper. #[test] fn the_uploader_surface_names_one_control_and_where_it_lands() { use askama::Template as _; let html = crate::templates::ItemVersionUploadTemplate { item: audio_item(None), versions: Vec::new(), } .render() .expect("render the uploader"); // Three on the surface, plus the Remove inside the queue's blank // `