Something went wrong. Try again.
atproto Thingiverse but good
Something went wrong. Try again.
Rust
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562use 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=<id>`).#[allow(clippy::useless_format)]#[component]pub(crate) fn Publish(draft: String) -> Element { let session = use_context::<Signal<SessionIdentity>>(); 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<DraftThingInput>, draft_id: Signal<Option<String>>, step: Signal<WizardStep>, phase: Signal<PublishPhase>, save: Signal<SaveState>, upload: Signal<UploadStatus>, image_upload: Signal<UploadStatus>, drafts: Signal<Vec<DraftSummary>>, /// Authenticated actor DID, used to build CDN preview URLs for uploaded images. did: Option<String>, /// 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<HashMap<SmolStr, SmolStr>>,}
#[component]fn PublishWizard(resume: String) -> Element { let client = use_context::<PolymodelClient>(); let session = use_context::<Signal<SessionIdentity>>(); 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::<String>::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::<DraftSummary>::new); let image_data_uris = use_signal(HashMap::<SmolStr, SmolStr>::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=<id>` 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=<id>` 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<DraftSummary> = 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<String> = 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<String> = 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<String> { 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<String> { 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<u8>, mut upload: Signal<UploadStatus>, mut image_data_uris: Signal<HashMap<SmolStr, SmolStr>>,) -> Option<Image> { 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<u8>, _upload: Signal<UploadStatus>, _image_data_uris: Signal<HashMap<SmolStr, SmolStr>>,) -> Option<Image> { 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<u8>, width: u32, height: u32, ) -> Result<Vec<u8>, 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<u8>, mime: &str) -> Result<String, String> { 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<Vec<u8>, 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::<js_sys::Uint8Array>() 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<dyn FnMut(web_sys::MessageEvent)>);
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<dyn FnMut(web_sys::ErrorEvent)>);
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 { <jacquard_common::SmolStr as AsRef<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" ); }}