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"
);
}
}