use dioxus::html::FileData; use std::collections::HashMap; use dioxus::prelude::*; use crate::Route; use crate::client::PolymodelClient; use crate::session::SessionIdentity; use draft::{DraftModelInput, DraftPartInput, DraftSummary, DraftThingInput}; use jacquard_common::deps::smol_str::SmolStr; use polymodel_api::space_polymodel::library::Image; use state::{PublishPhase, WizardStep}; pub(crate) mod draft; pub(crate) mod draft_client; pub(crate) mod state; /// Common open licenses offered in the wizard. The first is the effective /// default when none is chosen. const LICENSE_OPTIONS: &[&str] = &[ "CC-BY-4.0", "CC-BY-SA-4.0", "CC0-1.0", "CC-BY-NC-4.0", "CC-BY-NC-SA-4.0", "MIT", "All rights reserved", ]; const DEFAULT_LICENSE: &str = "CC-BY-4.0"; /// Bluesky CDN thumbnail URL for an uploaded image blob (mirrors the appview's /// `blob_cdn_url`) so the wizard can preview an image right after upload. fn image_cdn_url(did: &str, image: &Image) -> String { let cid = image.image.blob().r#ref.as_str(); format!("https://cdn.bsky.app/img/feed_thumbnail/plain/{did}/{cid}@jpeg") } /// Build a `data:` URI from raw bytes for immediate display before CDN /// replication completes. Uses base64 encoding. fn data_uri_from_bytes(bytes: &[u8], mime: &str) -> SmolStr { use base64::Engine; let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); format!("data:{mime};base64,{encoded}").into() } /// Transient state of a single in-flight raw upload (file or image). #[derive(Clone, PartialEq)] enum UploadStatus { Idle, Uploading(String), Error(String), } /// Whether the server-side draft is in sync with the in-memory composition. #[derive(Clone, Copy, PartialEq)] enum SaveState { /// Initial state: nothing saved yet, so the status reads as empty rather than /// falsely claiming everything is saved. New, Saved, Saving, Dirty, Error, } /// Publish route. Anonymous visitors see the sign-in callout (returning here); /// signed-in users get the full wizard. `draft` is an optional draft id to /// resume (`/publish?draft=`). #[allow(clippy::useless_format)] #[component] pub(crate) fn Publish(draft: String) -> Element { let session = use_context::>(); let identity = session.read().clone(); // Session still resolving on first load: show a neutral loading state rather // than flashing the signed-out callout at users who are actually signed in. if matches!(identity, SessionIdentity::Unknown) { return rsx! { main { class: "publish-page", section { class: "publish-intro blueprint-panel", aria_label: "Publish", h1 { "Publish" } div { class: "skeleton-line skeleton-wide" } div { class: "skeleton-line" } } } }; } if matches!(identity, SessionIdentity::Anonymous) { return rsx! { main { class: "publish-page", section { class: "publish-signed-out blueprint-panel", aria_label: "Sign in", h1 { "Share your models" } form { class: "session-control", action: "/oauth/start", method: "get", input { r#type: "hidden", name: "return_to", value: "/publish" }, input { r#type: "text", name: "identifier", placeholder: "alice.bsky.social", aria_label: "ATProto handle", } button { class: "button button-primary", r#type: "submit", "Sign in" } } p { class: "product-lede", "Sign in to share your creations using your Bluesky or other Atmosphere account." } } } }; } rsx! { PublishWizard { resume: draft } } } #[derive(Clone)] struct Wizard { client: PolymodelClient, composition: Signal, draft_id: Signal>, step: Signal, phase: Signal, save: Signal, upload: Signal, image_upload: Signal, drafts: Signal>, /// Authenticated actor DID, used to build CDN preview URLs for uploaded images. did: Option, /// Transient data-URI overrides for freshly-uploaded images, keyed by /// blobref CID. CDN URLs for just-uploaded blobs aren't available until /// replication completes; this shows the image immediately. image_data_uris: Signal>, } #[component] fn PublishWizard(resume: String) -> Element { let client = use_context::(); let session = use_context::>(); let did = match &*session.read() { SessionIdentity::Authenticated(identity) => Some(identity.did.as_str().to_string()), _ => None, }; // Seed a new draft with the effective default license so the control reflects // real state from the start (a control only fires change when its value changes). let composition = use_signal(|| DraftThingInput { license: Some(DEFAULT_LICENSE.to_string()), ..DraftThingInput::default() }); let draft_id = use_signal(|| Option::::None); let step = use_signal(|| WizardStep::Basics); let phase = use_signal(|| PublishPhase::Editing); let save = use_signal(|| SaveState::New); let upload = use_signal(|| UploadStatus::Idle); let image_upload = use_signal(|| UploadStatus::Idle); let drafts = use_signal(Vec::::new); let image_data_uris = use_signal(HashMap::::new); let wizard = Wizard { client, composition, draft_id, step, phase, save, upload, image_upload, drafts, did, image_data_uris, }; // Resume a draft once on mount when `?draft=` is present; always load // the actor's existing drafts so they can be picked up as entry points. { let wizard = wizard.clone(); let resume = resume.clone(); use_hook(move || { if !resume.trim().is_empty() { let mut wizard = wizard.clone(); let resume = resume.clone(); spawn(async move { match draft_client::get_draft(&wizard.client, resume.trim()).await { Ok(loaded) => { wizard.composition.set(loaded); wizard.draft_id.set(Some(resume.trim().to_string())); } Err(error) => { tracing::warn!(%error, "failed to resume draft"); } } }); } let wizard = wizard.clone(); spawn(async move { wizard.reload_drafts().await }); }); } // Mirror the active draft id into the URL so a full reload (e.g. the dev // server's hot-reload websocket dropping and reconnecting) resumes the same // draft via `?draft=` instead of dropping you on a blank `/publish`. // replace() avoids stacking a history entry on every autosave. { let draft_id = wizard.draft_id; use_effect(move || { if let Some(id) = draft_id.read().clone() { navigator().replace(Route::Publish { draft: id }); } }); } let current = *wizard.step.read(); let body = match current { WizardStep::Basics => wizard.render_basics(), WizardStep::Files => wizard.render_files(), WizardStep::Images => wizard.render_images(), WizardStep::Details => wizard.render_details(), WizardStep::Review => wizard.render_review(), }; rsx! { main { class: "publish-page", { wizard.render_header() } if let PublishPhase::Published { repo, rkey } = wizard.phase.read().clone() { { wizard.render_published(repo, rkey) } } else { { wizard.render_resume() } { wizard.render_stepper() } section { class: "wizard-body blueprint-panel", aria_label: "{current.label()}", {body} } { wizard.render_nav() } } } } } impl Wizard { /// Create (first save) or update the server-side draft. async fn save_draft(self) { let mut save = self.save; save.set(SaveState::Saving); let payload = self.composition.read().clone(); let existing = self.draft_id.read().clone(); let result = match existing { Some(id) => draft_client::put_draft(&self.client, &id, &payload) .await .map(|_| id), None => draft_client::create_draft(&self.client, &payload).await, }; match result { Ok(id) => { let mut draft_id = self.draft_id; draft_id.set(Some(id)); save.set(SaveState::Saved); } Err(error) => { tracing::warn!(%error, "failed to save draft"); save.set(SaveState::Error); } } } fn mark_dirty(&self) { let mut save = self.save; save.set(SaveState::Dirty); } fn goto(&self, target: WizardStep) { let mut step = self.step; step.set(target); let wizard = self.clone(); spawn(async move { wizard.save_draft().await }); } /// Refresh the actor's draft summaries for the resume list. async fn reload_drafts(self) { let mut drafts = self.drafts; match draft_client::list_drafts(&self.client).await { Ok(list) => drafts.set(list), Err(error) => tracing::warn!(%error, "failed to list drafts"), } } /// Load an existing draft into the wizard in place (without a route change). async fn resume_draft(self, id: String) { match draft_client::get_draft(&self.client, &id).await { Ok(loaded) => { let mut composition = self.composition; let mut draft_id = self.draft_id; let mut step = self.step; let mut save = self.save; composition.set(loaded); draft_id.set(Some(id)); step.set(WizardStep::Basics); save.set(SaveState::Saved); } Err(error) => tracing::warn!(%error, "failed to resume draft"), } } /// Delete a draft from the resume list; clears the editor if it was active. async fn discard_draft(self, id: String) { match draft_client::delete_draft(&self.client, &id).await { Ok(_) => { if self.draft_id.read().as_deref() == Some(id.as_str()) { let mut composition = self.composition; let mut draft_id = self.draft_id; composition.set(DraftThingInput::default()); draft_id.set(None); } self.clone().reload_drafts().await; } Err(error) => tracing::warn!(%error, "failed to delete draft"), } } /// Resume list: existing server-side drafts shown as entry points. Hidden /// when the actor has no other drafts than the one being edited. fn render_resume(&self) -> Element { let drafts = self.drafts.read().clone(); let active = self.draft_id.read().clone(); let others: Vec = drafts .into_iter() .filter(|d| active.as_deref() != Some(d.draft_id.as_str())) .collect(); if others.is_empty() { return rsx! {}; } rsx! { section { class: "wizard-resume blueprint-panel", aria_label: "Resume a draft", h2 { class: "wizard-subhead", "Resume a draft" } ul { class: "wizard-resume-list", for summary in others { { let id = summary.draft_id.clone(); let name = summary .name .clone() .filter(|n| !n.trim().is_empty()) .unwrap_or_else(|| "Untitled draft".to_string()); let model_count = summary.model_count; let resume_w = self.clone(); let resume_id = id.clone(); let discard_w = self.clone(); let discard_id = id.clone(); rsx! { li { key: "{id}", class: "wizard-resume-item", div { class: "wizard-resume-meta", span { class: "wizard-resume-name", "{name}" } span { class: "card-meta", "{model_count} model(s)" } } div { class: "button-row", button { class: "button button-secondary", onclick: move |_| { let wizard = resume_w.clone(); let id = resume_id.clone(); spawn(async move { wizard.resume_draft(id).await }); }, "Resume" } button { class: "button button-ghost", onclick: move |_| { let wizard = discard_w.clone(); let id = discard_id.clone(); spawn(async move { wizard.discard_draft(id).await }); }, "Delete" } } } } } } } } } } fn render_header(&self) -> Element { let phase = self.phase.read().clone(); let save = *self.save.read(); let (pill_class, pill_label) = match &phase { PublishPhase::Published { .. } => ("status-pill", "Published"), PublishPhase::Publishing => ("status-pill", "Publishing…"), PublishPhase::Failed(_) => ("status-pill status-error", "Publish failed"), PublishPhase::Editing => ("status-pill status-muted", "Draft"), }; let save_label = match save { SaveState::New => "", SaveState::Saved => "Saved", SaveState::Saving => "Saving…", SaveState::Dirty => "Unsaved changes", SaveState::Error => "Save failed", }; rsx! { header { class: "publish-head", div { class: "publish-head-row", h1 { "Publish a thing" } span { class: "{pill_class}", "{pill_label}" } } p { class: "publish-save-state", aria_live: "polite", "{save_label}" } if let PublishPhase::Failed(message) = &phase { p { class: "wizard-error", role: "alert", "{message}" } } } } } fn render_stepper(&self) -> Element { let composition = self.composition.read(); let current = *self.step.read(); let basics_done = composition.basics_complete(); let files_done = composition.files_complete(); drop(composition); rsx! { nav { class: "wizard-steps", aria_label: "Publish steps", for s in WizardStep::ALL { { let done = match s { WizardStep::Basics => basics_done, WizardStep::Files => files_done, _ => false, }; let is_active = s == current; let wizard = self.clone(); let active_class = if is_active { "is-active" } else { "" }; let done_class = if done { "is-complete" } else { "" }; rsx! { button { key: "step-{s.number()}", class: "tab wizard-step {active_class} {done_class}", aria_current: if is_active { "step" } else { "false" }, onclick: move |_| wizard.goto(s), span { class: "wizard-step-num", "{s.number()}" } span { class: "wizard-step-label", "{s.label()}" } if done { span { class: "wizard-step-check", aria_hidden: "true", "✓" } } } } } } } } } fn render_nav(&self) -> Element { let current = *self.step.read(); let publishing = matches!(*self.phase.read(), PublishPhase::Publishing); let prev = current.prev(); let next = current.next(); let wizard_back = self.clone(); let wizard_next = self.clone(); let wizard_save = self.clone(); rsx! { footer { class: "wizard-nav", if let Some(prev) = prev { button { class: "button button-ghost", disabled: publishing, onclick: move |_| wizard_back.goto(prev), "Back" } } div { class: "wizard-nav-spacer" } button { class: "button button-secondary", disabled: publishing, onclick: move |_| { let wizard = wizard_save.clone(); spawn(async move { wizard.save_draft().await }); }, "Save draft" } if let Some(next) = next { button { class: "button button-primary", disabled: publishing, onclick: move |_| wizard_next.goto(next), "Next: {next.label()}" } } } } } fn render_basics(&self) -> Element { let composition = self.composition.read(); let name = composition.name.clone().unwrap_or_default(); let summary = composition.summary.clone().unwrap_or_default(); drop(composition); let name_w = self.clone(); let summary_w = self.clone(); rsx! { h2 { "Basics" } label { class: "field", span { class: "field-label", "Name" } input { class: "field-input", r#type: "text", value: "{name}", placeholder: "e.g. Modular desk organizer", oninput: move |evt| { let mut c = name_w.composition; c.write().name = Some(evt.value()); name_w.mark_dirty(); }, } } label { class: "field", span { class: "field-label", "Summary" } textarea { class: "field-input field-textarea", value: "{summary}", placeholder: "What is it?", oninput: move |evt| { let mut c = summary_w.composition; let v = evt.value(); c.write().summary = if v.trim().is_empty() { None } else { Some(v) }; summary_w.mark_dirty(); }, } } } } fn render_files(&self) -> Element { let composition = self.composition.read(); let models = composition.models.clone(); let upload = self.upload.read().clone(); drop(composition); let add_model_w = self.clone(); rsx! { h2 { "Files & parts" } p { class: "wizard-step-hint", "Add one or more models. Each model holds ordered parts; upload a geometry file for every part." } match &upload { UploadStatus::Uploading(label) => rsx! { div { class: "wizard-upload-status", role: "status", span { class: "wizard-progress-bar", "aria-hidden": "true" } span { "Uploading {label}…" } } }, UploadStatus::Error(message) => rsx! { p { class: "wizard-error", role: "alert", "{message}" } }, UploadStatus::Idle => rsx! {}, } if models.is_empty() { p { class: "wizard-empty", "No models yet. Add one to start uploading parts." } } for (mi, model) in models.iter().enumerate() { { let model_name = model.name.clone().unwrap_or_default(); let parts = model.parts.clone(); let name_w = self.clone(); let remove_w = self.clone(); let upload_w = self.clone(); rsx! { section { key: "model-{mi}", class: "wizard-model", div { class: "wizard-model-head", label { class: "field wizard-model-name", span { class: "field-label", "Model {mi + 1} name" } input { class: "field-input", r#type: "text", value: "{model_name}", placeholder: "e.g. Base plate", oninput: move |evt| { let mut c = name_w.composition; c.write().models[mi].name = Some(evt.value()); name_w.mark_dirty(); }, } } button { class: "button button-ghost wizard-remove", onclick: move |_| { let mut c = remove_w.composition; c.write().models.remove(mi); remove_w.mark_dirty(); }, "Remove model" } } if parts.is_empty() { p { class: "wizard-empty", "No parts yet. Upload a file below." } } ul { class: "wizard-part-list", for (pi, part) in parts.iter().enumerate() { { let part_name = part.name.clone().unwrap_or_default(); let size = part.file.as_ref().map(|f| human_size(f.size)).unwrap_or_default(); let format = part.format.clone().unwrap_or_default(); let pname_w = self.clone(); let premove_w = self.clone(); rsx! { li { key: "part-{mi}-{pi}", class: "wizard-part", label { class: "field wizard-part-name", span { class: "field-label sr-only", "Part name" } input { class: "field-input", r#type: "text", value: "{part_name}", oninput: move |evt| { let mut c = pname_w.composition; c.write().models[mi].parts[pi].name = Some(evt.value()); pname_w.mark_dirty(); }, } } span { class: "wizard-part-meta card-meta", if !format.is_empty() { "{format} · " } "{size}" } button { class: "button button-ghost wizard-remove", onclick: move |_| { let mut c = premove_w.composition; c.write().models[mi].parts.remove(pi); premove_w.mark_dirty(); }, "Remove" } } } } } } label { class: "button button-secondary wizard-file-button", "Add a file" input { class: "wizard-file-input", r#type: "file", accept: ".stl,.obj,.gltf,.glb,.3mf,.step,.stp,model/*,application/octet-stream", onchange: move |evt| { if let Some(file) = evt.files().into_iter().next() { let wizard = upload_w.clone(); spawn(async move { wizard.handle_file_upload(mi, file).await }); } }, } } } } } } button { class: "button button-secondary wizard-add-model", onclick: move |_| { let mut c = add_model_w.composition; c.write().models.push(DraftModelInput::default()); add_model_w.mark_dirty(); }, "Add a model" } } } async fn handle_file_upload(self, model_index: usize, file: FileData) { let filename = file.name(); let mut upload = self.upload; upload.set(UploadStatus::Uploading(filename.clone())); let bytes = match file.read_bytes().await { Ok(bytes) => bytes.to_vec(), Err(_) => { upload.set(UploadStatus::Error( "Could not read the selected file.".into(), )); return; } }; let mime = file .content_type() .filter(|s| !s.is_empty()) .unwrap_or_else(|| mime_from_filename(&filename).to_string()); // Clone before staging: `stage_file` takes `bytes` by value, but the // worker preview request still needs a temporary Blob URL for the same bytes. match draft_client::stage_file(&self.client, &filename, &mime, bytes.clone()).await { Ok(staged) => { let format = format_from_filename(&filename); let mut part = DraftPartInput { name: Some(filename.clone()), upload_id: Some(staged.upload_id.to_string()), file: Some(staged.file), format: format.clone(), ..Default::default() }; // Best-effort preview generation (wasm-only; no-op off-wasm). // v1 supports STL only in the publish wizard — skip preview for other formats. let preview = if format.as_deref() == Some("stl") { let client = self.client.clone(); let upload_sig = self.upload; let data_uris_sig = self.image_data_uris; generate_preview(client, filename.clone(), bytes, upload_sig, data_uris_sig) .await } else { None }; { let mut composition = self.composition; let mut guard = composition.write(); if let Some(image) = preview { let parts_before_push = guard.models.get(model_index).map_or(0, |m| m.parts.len()); maybe_seed_cover(&mut guard, model_index, parts_before_push, &image); attach_preview(&mut part, image); } if let Some(model) = guard.models.get_mut(model_index) { model.parts.push(part); } } upload.set(UploadStatus::Idle); let wizard = self.clone(); wizard.save_draft().await; } Err(error) => { upload.set(UploadStatus::Error(format!("Upload failed: {error}"))); } } } fn render_images(&self) -> Element { let composition = self.composition.read(); let cover = composition.cover.clone().unwrap_or_default(); let previews = composition.previews.clone().unwrap_or_default(); let status = self.image_upload.read().clone(); drop(composition); let cover_w = self.clone(); let preview_w = self.clone(); rsx! { h2 { "Images" } match &status { UploadStatus::Uploading(label) => rsx! { div { class: "wizard-upload-status", role: "status", span { class: "wizard-progress-bar", "aria-hidden": "true" } span { "Uploading {label}…" } } }, UploadStatus::Error(message) => rsx! { p { class: "wizard-error", role: "alert", "{message}" } }, UploadStatus::Idle => rsx! {}, } div { class: "wizard-image-group", h3 { class: "wizard-subhead", "Cover" } { self.render_image_list(&cover, true) } label { class: "button button-secondary wizard-file-button", "Add cover image" input { class: "wizard-file-input", r#type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", onchange: move |evt| { if let Some(file) = evt.files().into_iter().next() { let wizard = cover_w.clone(); spawn(async move { wizard.handle_image_upload(true, file).await }); } }, } } } div { class: "wizard-image-group", h3 { class: "wizard-subhead", "Previews" } { self.render_image_list(&previews, false) } label { class: "button button-secondary wizard-file-button", "Add preview image" input { class: "wizard-file-input", r#type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", onchange: move |evt| { if let Some(file) = evt.files().into_iter().next() { let wizard = preview_w.clone(); spawn(async move { wizard.handle_image_upload(false, file).await }); } }, } } } } } fn render_image_list(&self, images: &[Image], is_cover: bool) -> Element { if images.is_empty() { return rsx! { p { class: "wizard-empty", "No images yet." } }; } rsx! { ul { class: "wizard-image-list", for (i, image) in images.iter().enumerate() { { let alt = image.alt.to_string(); let ratio = format!("{}×{}", image.aspect_ratio.width, image.aspect_ratio.height); let cid = image.image.blob().r#ref.as_str().to_string(); let preview_url = self.image_data_uris.read().get(cid.as_str()) .map(|uri| uri.to_string()) .or_else(|| self.did.as_ref().map(|did| image_cdn_url(did, image))); let alt_w = self.clone(); let remove_w = self.clone(); rsx! { li { key: "image-{is_cover}-{i}", class: "wizard-image", div { class: "wizard-image-thumb blueprint-media", aria_hidden: "true", if let Some(url) = preview_url { img { class: "wizard-image-img", src: "{url}", alt: "" } } else { span { class: "blueprint-dimension", "{ratio}" } } } label { class: "field wizard-image-alt", span { class: "field-label", "Alt text" } input { class: "field-input", r#type: "text", value: "{alt}", placeholder: "Describe the image", oninput: move |evt| { let mut c = alt_w.composition; let mut guard = c.write(); let list = if is_cover { guard.cover.as_mut() } else { guard.previews.as_mut() }; if let Some(list) = list && let Some(img) = list.get_mut(i) { img.alt = evt.value().into(); } drop(guard); alt_w.mark_dirty(); }, } } button { class: "button button-ghost wizard-remove", onclick: move |_| { let mut c = remove_w.composition; let mut guard = c.write(); let list = if is_cover { guard.cover.as_mut() } else { guard.previews.as_mut() }; if let Some(list) = list && i < list.len() { list.remove(i); } drop(guard); remove_w.mark_dirty(); }, "Remove" } } } } } } } } async fn handle_image_upload(self, is_cover: bool, file: FileData) { let filename = file.name(); let mut status = self.image_upload; status.set(UploadStatus::Uploading(filename.clone())); let bytes = match file.read_bytes().await { Ok(bytes) => bytes.to_vec(), Err(_) => { status.set(UploadStatus::Error( "Could not read the selected image.".into(), )); return; } }; let mime = file .content_type() .filter(|s| !s.is_empty()) .unwrap_or_else(|| mime_from_filename(&filename).to_string()); match draft_client::upload_image(&self.client, &filename, &mime, bytes.clone()).await { Ok(image) => { { let cid = image.image.blob().r#ref.as_str().to_string(); let data_uri = data_uri_from_bytes(&bytes, &mime); let mut data_uris = self.image_data_uris; data_uris.write().insert(cid.into(), data_uri); let mut composition = self.composition; let mut guard = composition.write(); let list = if is_cover { guard.cover.get_or_insert_with(Vec::new) } else { guard.previews.get_or_insert_with(Vec::new) }; list.push(image); } status.set(UploadStatus::Idle); let wizard = self.clone(); wizard.save_draft().await; } Err(error) => { status.set(UploadStatus::Error(format!("Image upload failed: {error}"))); } } } fn render_details(&self) -> Element { let composition = self.composition.read(); let instructions = composition .instructions .clone() .unwrap_or_default() .join("\n"); let license = composition .license .clone() .unwrap_or_else(|| DEFAULT_LICENSE.to_string()); let tags = composition.tags.clone().unwrap_or_default(); drop(composition); let tags = tags.join(", "); let license_w = self.clone(); let tags_w = self.clone(); let instructions_w = self.clone(); rsx! { h2 { "Details" } label { class: "field", span { class: "field-label", "License" } input { class: "field-input", r#type: "text", list: "license-options", value: "{license}", placeholder: "{DEFAULT_LICENSE}", oninput: move |evt| { let mut c = license_w.composition; let v = evt.value(); c.write().license = if v.trim().is_empty() { None } else { Some(v) }; license_w.mark_dirty(); }, } datalist { id: "license-options", for option in LICENSE_OPTIONS { option { key: "{option}", value: "{option}" } } } } label { class: "field", span { class: "field-label", "Tags" } input { class: "field-input", r#type: "text", value: "{tags}", placeholder: "comma, separated", oninput: move |evt| { let mut c = tags_w.composition; let parsed: Vec = evt .value() .split(',') .map(|t| t.trim().to_string()) .filter(|t| !t.is_empty()) .collect(); c.write().tags = if parsed.is_empty() { None } else { Some(parsed) }; tags_w.mark_dirty(); }, } } label { class: "field", span { class: "field-label", "Instructions" } textarea { class: "field-input field-textarea field-textarea-tall", value: "{instructions}", placeholder: "One step per line", oninput: move |evt| { let mut c = instructions_w.composition; let value = evt.value(); // Store the text exactly as typed (split only on newlines) so // spaces and blank lines survive editing; empty lines are // dropped at assembly. let lines: Vec = value.split('\n').map(str::to_string).collect(); c.write().instructions = if value.trim().is_empty() { None } else { Some(lines) }; instructions_w.mark_dirty(); }, } } } } fn render_review(&self) -> Element { let composition = self.composition.read(); let assembled = composition.assemble(); let name = composition.display_name().unwrap_or("Untitled").to_string(); let license = composition .license .clone() .unwrap_or_else(|| DEFAULT_LICENSE.to_string()); let summary = composition.summary.clone().unwrap_or_default(); let tags = composition.tags.clone().unwrap_or_default(); let models = composition.models.clone(); let model_count = composition.model_count(); let cover_count = composition.cover.as_ref().map(|c| c.len()).unwrap_or(0); let preview_count = composition.previews.as_ref().map(|p| p.len()).unwrap_or(0); let instructions = composition.instructions.clone().unwrap_or_default(); drop(composition); let publishing = matches!(*self.phase.read(), PublishPhase::Publishing); let issues = assembled.as_ref().err().cloned(); let name_error = issues .as_ref() .map(|e| e.has_field("name")) .unwrap_or(false); let license_error = issues .as_ref() .map(|e| e.has_field("license")) .unwrap_or(false); let name_row_class = if name_error { "wizard-review-row is-error" } else { "wizard-review-row" }; let license_row_class = if license_error { "wizard-review-row is-error" } else { "wizard-review-row" }; let can_publish = assembled.is_ok() && !publishing; let publish_w = self.clone(); rsx! { h2 { "Review & publish" } p { class: "wizard-step-hint", "This is what becomes public when you publish." } div { class: "wizard-review", div { class: "{name_row_class}", span { class: "field-label", "Name" } span { "{name}" } } div { class: "{license_row_class}", span { class: "field-label", "License" } span { class: "status-pill", "{license}" } } if !summary.is_empty() { div { class: "wizard-review-row", span { class: "field-label", "Summary" } span { "{summary}" } } } if !tags.is_empty() { div { class: "wizard-review-row", span { class: "field-label", "Tags" } div { class: "thing-card-chips", for tag in tags { span { key: "{tag}", class: "status-pill", "{tag}" } } } } } div { class: "wizard-review-row", span { class: "field-label", "Media" } span { class: "card-meta", "{cover_count} cover · {preview_count} preview" } } div { class: "wizard-review-row", span { class: "field-label", "Models ({model_count})" } div { class: "wizard-review-models", for (mi, model) in models.iter().enumerate() { { let mname = model.name.clone().unwrap_or_else(|| format!("Model {}", mi + 1)); let part_count = model.parts.len(); rsx! { div { key: "review-model-{mi}", class: "wizard-review-model", strong { "{mname}" } span { class: "card-meta", "{part_count} part(s)" } } } } } } } if !instructions.is_empty() { div { class: "wizard-review-row", span { class: "field-label", "Instructions" } span { class: "card-meta", "{instructions.len()} step(s)" } } } } if let Some(issues) = issues { div { class: "wizard-issues error-state state-card", role: "alert", h3 { class: "wizard-subhead", "Fix these before publishing" } ul { class: "instruction-list", for issue in issues.issues { li { key: "{issue.field}", "{issue.message}" } } } } } div { class: "wizard-publish-row", button { class: "button button-primary wizard-publish", disabled: !can_publish, onclick: move |_| { let wizard = publish_w.clone(); spawn(async move { wizard.publish().await }); }, if publishing { "Publishing…" } else { "Publish" } } } } } async fn publish(self) { // Persist the latest edits first. If that save fails, do NOT publish — // otherwise we would publish stale server-side draft state. self.clone().save_draft().await; if matches!(*self.save.read(), SaveState::Error) { let mut phase = self.phase; phase.set(PublishPhase::Failed( "Could not save your latest changes, so nothing was published. Try again.".into(), )); return; } let Some(id) = self.draft_id.read().clone() else { let mut phase = self.phase; phase.set(PublishPhase::Failed( "Draft was not saved; try again.".into(), )); return; }; let mut phase = self.phase; phase.set(PublishPhase::Publishing); match draft_client::publish_draft(&self.client, &id).await { Ok(output) => { let uri = &output.thing.uri; let repo = uri.authority().to_string(); let rkey = uri.rkey().map(|r| r.to_string()).unwrap_or_default(); phase.set(PublishPhase::Published { repo, rkey }); } Err(error) => { phase.set(PublishPhase::Failed(format!("Publish failed: {error}"))); } } } fn render_published(&self, repo: String, rkey: String) -> Element { rsx! { section { class: "wizard-published state-card", aria_label: "Published", span { class: "status-pill", "Published" } h2 { "Your thing is live" } Link { class: "button button-primary", to: Route::ThingDetail { repo, rkey }, "View it" } } } } } /// Best-effort MIME type from a filename extension (the server re-sniffs image /// dimensions independently). fn mime_from_filename(name: &str) -> &'static str { match extension(name).as_deref() { Some("stl") => "model/stl", Some("obj") => "model/obj", Some("gltf") => "model/gltf+json", Some("glb") => "model/gltf-binary", Some("3mf") => "model/3mf", Some("step") | Some("stp") => "model/step", Some("png") => "image/png", Some("jpg") | Some("jpeg") => "image/jpeg", Some("webp") => "image/webp", Some("gif") => "image/gif", _ => "application/octet-stream", } } /// Short geometry format label from a filename, if recognized. fn format_from_filename(name: &str) -> Option { match extension(name).as_deref() { Some("stl") => Some("stl".into()), Some("obj") => Some("obj".into()), Some("gltf") | Some("glb") => Some("gltf".into()), Some("3mf") => Some("3mf".into()), Some("step") | Some("stp") => Some("step".into()), _ => None, } } fn extension(name: &str) -> Option { name.rsplit_once('.') .map(|(_, ext)| ext.to_ascii_lowercase()) } /// Human-readable byte size. fn human_size(bytes: i64) -> String { let bytes = bytes.max(0) as f64; const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"]; let mut value = bytes; let mut unit = 0; while value >= 1024.0 && unit < UNITS.len() - 1 { value /= 1024.0; unit += 1; } if unit == 0 { format!("{} {}", value as i64, UNITS[unit]) } else { format!("{value:.1} {}", UNITS[unit]) } } /// Attach a preview image to a draft part. v1 sets (overwrites) rather than /// appends: each file stage creates a fresh `DraftPartInput`, so this is the /// single auto-preview for that part. fn attach_preview(part: &mut DraftPartInput, image: Image) { part.previews = Some(vec![image]); } /// Seed the thing-level cover from the first file of the first model when no /// cover exists. Only fires for `model_index == 0` when the model has zero /// parts *before* pushing the new one (the first file overall) and cover is /// empty. Non-empty cover is never overwritten. fn maybe_seed_cover( composition: &mut DraftThingInput, model_index: usize, parts_before_push: usize, image: &Image, ) { if model_index == 0 && parts_before_push == 0 && composition.cover.as_ref().is_none_or(Vec::is_empty) { composition.cover = Some(vec![image.clone()]); } } /// Render a PNG preview from staged file bytes and upload it. Wasm-only: on /// non-wasm targets this is a no-op stub returning `None`. #[cfg(all(target_family = "wasm", target_os = "unknown"))] async fn generate_preview( client: PolymodelClient, filename: String, bytes: Vec, mut upload: Signal, mut image_data_uris: Signal>, ) -> Option { let png_bytes = match renderer_preview::render_preview_png(&filename, bytes, 1024, 1024).await { Ok(bytes) => bytes, Err(err) => { tracing::warn!(%err, "preview render failed for {filename}"); return None; } }; let alt = format!("Rendered preview of {filename}"); upload.set(UploadStatus::Uploading(format!("preview of {filename}"))); match draft_client::upload_image(&client, &alt, "image/png", png_bytes.clone()).await { Ok(image) => { let cid = image.image.blob().r#ref.as_str().to_string(); let data_uri = data_uri_from_bytes(&png_bytes, "image/png"); image_data_uris.write().insert(cid.into(), data_uri); Some(image) } Err(err) => { tracing::warn!(%err, "preview upload failed for {filename}"); upload.set(UploadStatus::Idle); None } } } #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] async fn generate_preview( _client: PolymodelClient, _filename: String, _bytes: Vec, _upload: Signal, _image_data_uris: Signal>, ) -> Option { None } #[cfg(all(target_family = "wasm", target_os = "unknown"))] mod renderer_preview { use polymodel_renderer_protocol::{ MeshFormat, RendererCommand, RendererEvent, deserialize_event, serialize_command, }; use wasm_bindgen::JsCast; use wasm_bindgen::closure::Closure; use wasm_bindgen::prelude::*; pub(super) async fn render_preview_png( filename: &str, bytes: Vec, width: u32, height: u32, ) -> Result, String> { let format = MeshFormat::from_extension(filename) .ok_or_else(|| "unsupported preview format".to_string())?; let blob_url = blob_url_for_bytes(bytes, mime_from_format(format))?; let result = request_preview_png(format, blob_url.clone(), width, height).await; let _ = web_sys::Url::revoke_object_url(&blob_url); result } fn mime_from_format(format: MeshFormat) -> &'static str { match format { MeshFormat::Stl => "model/stl", MeshFormat::Obj => "model/obj", MeshFormat::Gltf => "model/gltf+json", MeshFormat::Threemf => "model/3mf", } } fn blob_url_for_bytes(bytes: Vec, mime: &str) -> Result { let array = js_sys::Uint8Array::from(bytes.as_slice()); let parts = js_sys::Array::new(); parts.push(&array); let options = web_sys::BlobPropertyBag::new(); options.set_type(mime); let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options) .map_err(|e| format!("preview blob: {e:?}"))?; web_sys::Url::create_object_url_with_blob(&blob) .map_err(|e| format!("preview blob URL: {e:?}")) } async fn request_preview_png( format: MeshFormat, primary_url: String, width: u32, height: u32, ) -> Result, String> { let opts = web_sys::WorkerOptions::new(); opts.set_type(web_sys::WorkerType::Module); let worker = web_sys::Worker::new_with_options("/renderer_worker_loader.js", &opts) .map_err(|e| format!("preview worker: {e:?}"))?; let request_id = js_sys::Math::random().to_bits() as u32; let command = RendererCommand::RenderPreviewImage { request_id, format, primary_url, width, height, }; let command_bytes = serialize_command(&command).map_err(|e| format!("preview command serialize: {e}"))?; let worker_for_message = worker.clone(); let worker_for_error = worker.clone(); let promise = js_sys::Promise::new(&mut move |resolve, reject| { let command_bytes = command_bytes.clone(); let worker_for_message = worker_for_message.clone(); let resolve_message = resolve.clone(); let reject_message = reject.clone(); let onmessage = Closure::wrap(Box::new(move |event: web_sys::MessageEvent| { let data = event.data(); if data.as_string().as_deref() == Some("__worker_ready") { let array = js_sys::Uint8Array::from(command_bytes.as_slice()); if let Err(e) = worker_for_message.post_message(&array) { let _ = reject_message.call1( &JsValue::NULL, &JsValue::from_str(&format!("preview command post: {e:?}")), ); } return; } let Ok(array) = data.dyn_into::() else { return; }; let bytes = array.to_vec(); match deserialize_event(&bytes) { Ok(RendererEvent::PreviewImageRendered { request_id: event_request_id, png, }) if event_request_id == request_id => { let array = js_sys::Uint8Array::from(png.as_slice()); let _ = resolve_message.call1(&JsValue::NULL, &array); } Ok(RendererEvent::PreviewImageError { request_id: event_request_id, message, }) if event_request_id == request_id => { let _ = reject_message.call1(&JsValue::NULL, &JsValue::from_str(&message)); } Ok(_) => {} Err(e) => { let _ = reject_message.call1( &JsValue::NULL, &JsValue::from_str(&format!("preview event deserialize: {e}")), ); } } }) as Box); let reject_error = reject.clone(); let onerror = Closure::wrap(Box::new(move |event: web_sys::ErrorEvent| { let _ = reject_error.call1(&JsValue::NULL, &JsValue::from_str(&event.message())); }) as Box); worker.set_onmessage(Some(onmessage.as_ref().unchecked_ref())); worker.set_onerror(Some(onerror.as_ref().unchecked_ref())); onmessage.forget(); onerror.forget(); }); let output = wasm_bindgen_futures::JsFuture::from(promise) .await .map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))?; worker_for_error.terminate(); Ok(js_sys::Uint8Array::new(&output).to_vec()) } } #[cfg(test)] mod tests { use super::*; use std::str::FromStr; fn alt_str(image: &Image) -> &str { >::as_ref(&image.alt) } #[test] fn mime_and_format_from_extension() { assert_eq!(mime_from_filename("Tray.STL"), "model/stl"); assert_eq!(mime_from_filename("photo.png"), "image/png"); assert_eq!(mime_from_filename("weird"), "application/octet-stream"); assert_eq!(format_from_filename("a.glb").as_deref(), Some("gltf")); assert_eq!(format_from_filename("a.txt"), None); } #[test] fn human_size_scales_units() { assert_eq!(human_size(512), "512 B"); assert_eq!(human_size(2048), "2.0 KB"); assert_eq!(human_size(5 * 1024 * 1024), "5.0 MB"); } fn test_image(alt: &str) -> Image { use jacquard_common::types::blob::{Blob, BlobRef, MimeType}; use jacquard_common::types::cid::CidLink; use polymodel_api::space_polymodel::library::AspectRatio; Image { alt: alt.into(), aspect_ratio: AspectRatio { height: 1024, width: 1024, extra_data: None, }, image: BlobRef::Blob(Blob { r#ref: CidLink::from_str( "bafyreigqdh7sjw7w6d7bk6f4x4n4n4n4n4n4n4n4n4n4n4n4n4n4n4n4n4n4", ) .unwrap(), mime_type: MimeType::new("image/png".into()), size: 42, }), extra_data: None, } } #[test] fn attach_preview_sets_previews_when_empty() { let mut part = DraftPartInput { name: Some("Bracket".into()), upload_id: Some("upload-1".into()), ..Default::default() }; assert!(part.previews.is_none()); attach_preview(&mut part, test_image("Rendered preview of Bracket.stl")); assert_eq!(part.previews.as_ref().unwrap().len(), 1); assert_eq!( alt_str(&part.previews.as_ref().unwrap()[0]), "Rendered preview of Bracket.stl" ); } #[test] fn attach_preview_overwrites_existing_previews() { let mut part = DraftPartInput { name: Some("Bracket".into()), previews: Some(vec![test_image("old user-uploaded preview")]), ..Default::default() }; assert_eq!(part.previews.as_ref().unwrap().len(), 1); attach_preview(&mut part, test_image("Rendered preview of Bracket.stl")); assert_eq!(part.previews.as_ref().unwrap().len(), 1); assert_eq!( alt_str(&part.previews.as_ref().unwrap()[0]), "Rendered preview of Bracket.stl" ); } #[test] fn maybe_seed_cover_seeds_first_file_first_model() { let mut composition = DraftThingInput::default(); let image = test_image("Rendered preview of Tray.stl"); maybe_seed_cover(&mut composition, 0, 0, &image); assert_eq!(composition.cover.as_ref().unwrap().len(), 1); assert_eq!( alt_str(&composition.cover.as_ref().unwrap()[0]), "Rendered preview of Tray.stl" ); } #[test] fn maybe_seed_cover_does_not_seed_for_nonzero_model() { let mut composition = DraftThingInput::default(); let image = test_image("Rendered preview of Lid.stl"); maybe_seed_cover(&mut composition, 1, 0, &image); assert!(composition.cover.is_none()); } #[test] fn maybe_seed_cover_does_not_seed_when_parts_already_exist() { let mut composition = DraftThingInput::default(); let image = test_image("Rendered preview of second.stl"); maybe_seed_cover(&mut composition, 0, 1, &image); assert!(composition.cover.is_none()); } #[test] fn maybe_seed_cover_does_not_overwrite_nonempty_cover() { let mut composition = DraftThingInput { cover: Some(vec![test_image("user cover")]), ..Default::default() }; let image = test_image("Rendered preview of Tray.stl"); maybe_seed_cover(&mut composition, 0, 0, &image); assert_eq!(composition.cover.as_ref().unwrap().len(), 1); assert_eq!( alt_str(&composition.cover.as_ref().unwrap()[0]), "user cover" ); } #[test] fn maybe_seed_cover_re_stage_after_delete_does_not_seed_over_user_cover() { // Edge: model-0 parts deleted back to zero, but a user manually set a // cover. The cover must NOT be overwritten by a new auto-preview. let mut composition = DraftThingInput { cover: Some(vec![test_image("user cover")]), ..Default::default() }; let image = test_image("Rendered preview of re-staged.stl"); maybe_seed_cover(&mut composition, 0, 0, &image); assert_eq!( alt_str(&composition.cover.as_ref().unwrap()[0]), "user cover" ); } #[test] fn maybe_seed_cover_re_stage_after_delete_seeds_when_both_empty() { // Edge: model-0 parts deleted back to zero AND cover is empty. A new // first file should re-seed the cover. let mut composition = DraftThingInput::default(); let image = test_image("Rendered preview of fresh.stl"); maybe_seed_cover(&mut composition, 0, 0, &image); assert_eq!(composition.cover.as_ref().unwrap().len(), 1); assert_eq!( alt_str(&composition.cover.as_ref().unwrap()[0]), "Rendered preview of fresh.stl" ); } }