···11import { expect, test } from '@playwright/test';
2233-// Coverage boundary: the publish wizard renders only for an authenticated
44-// ATProto session, and a live PDS publish needs real OAuth, which this CI e2e
55-// harness cannot establish. So e2e covers the signed-out callout (no wizard);
66-// the signed-in wizard assembly, draft round-trip, validation, and promote-on-
77-// publish are covered by the Rust unit/integration suites (src/publish/draft.rs,
88-// src/appview/drafts.rs) plus the manual Playwright pass against `just serve`.
33+// Coverage boundary: the publish flow renders only for an authenticated ATProto
44+// session, and a live PDS publish needs real OAuth, which this CI e2e harness
55+// cannot establish. So e2e covers the signed-out callout (no flow); the
66+// signed-in upload-first assembly, draft round-trip, validation, and
77+// promote-on-publish are covered by the Rust unit/integration suites
88+// (src/publish/draft.rs, src/appview/drafts.rs) plus the manual Playwright pass
99+// against `just serve`.
9101010-test('signed-out /publish shows the sign-in callout, not the wizard', async ({ page }) => {
1111+test('signed-out /publish shows the sign-in callout, not the flow', async ({ page }) => {
1112 await page.goto('/publish');
12131314 await expect(page.getByRole('heading', { name: 'Share your models' })).toBeVisible();
1415 const callout = page.locator('section[aria-label="Sign in"]');
1516 await expect(callout.getByRole('button', { name: 'Sign in' })).toBeVisible();
16171717- // The signed-out surface must not expose the wizard stepper or publish action.
1818- await expect(page.getByRole('navigation', { name: 'Publish steps' })).toHaveCount(0);
1818+ // The signed-out surface must not expose the hero upload zone or publish action.
1919+ await expect(page.getByLabel('Upload model files')).toHaveCount(0);
1920 await expect(page.getByRole('button', { name: 'Publish', exact: true })).toHaveCount(0);
2021});
2122
+1878-538
src/publish.rs
···11-use dioxus::html::FileData;
22-use std::collections::HashMap;
11+//! Single-page, upload-first publish flow.
22+//!
33+//! Dropping geometry files is the hero action: it auto-creates the
44+//! Thing→Model→Part structure (one default model, each file a part) and
55+//! auto-fills the thing name from the first filename. All optional metadata —
66+//! thing-level fields, per-model/per-part fields, and thing-level
77+//! `intended_to_work_with` — lives behind progressive disclosure that
88+//! auto-expands for multi-model use, ending in a compact "what's public"
99+//! confirm + Publish. The server-side draft/publish backend is unchanged.
1010+//!
1111+//! Validation stays in [`DraftThingInput::assemble`](draft::DraftThingInput);
1212+//! every exposed field is already carried on the draft types and mapped there.
3131414+use dioxus::html::{FileData, HasFileData};
415use dioxus::prelude::*;
1616+use std::collections::{HashMap, HashSet};
517618use crate::Route;
719use crate::client::PolymodelClient;
820use crate::session::SessionIdentity;
99-use draft::{DraftModelInput, DraftPartInput, DraftSummary, DraftThingInput};
2121+use draft::{
2222+ Bbox, DraftModelInput, DraftPartInput, DraftSummary, DraftThingInput,
2323+ ensure_default_model_for_upload, parse_thing_token,
2424+};
1025use jacquard_common::deps::smol_str::SmolStr;
2626+use polymodel_api::com_atproto::repo::strong_ref::StrongRef;
1127use polymodel_api::space_polymodel::library::Image;
1212-use state::{PublishPhase, WizardStep};
2828+use state::PublishPhase;
13291430pub(crate) mod draft;
1531pub(crate) mod draft_client;
1632pub(crate) mod state;
17331818-/// Common open licenses offered in the wizard. The first is the effective
3434+/// Common open licenses offered in the flow. The first is the effective
1935/// default when none is chosen.
2036const LICENSE_OPTIONS: &[&str] = &[
2137 "CC-BY-4.0",
···2743 "All rights reserved",
2844];
2945const DEFAULT_LICENSE: &str = "CC-BY-4.0";
4646+const CUSTOM_LICENSE_VALUE: &str = "__custom__";
30473148/// Bluesky CDN thumbnail URL for an uploaded image blob (mirrors the appview's
3232-/// `blob_cdn_url`) so the wizard can preview an image right after upload.
4949+/// `blob_cdn_url`) so the flow can preview an image right after upload.
3350fn image_cdn_url(did: &str, image: &Image) -> String {
3451 let cid = image.image.blob().r#ref.as_str();
3552 format!("https://cdn.bsky.app/img/feed_thumbnail/plain/{did}/{cid}@jpeg")
···6481}
65826683/// Publish route. Anonymous visitors see the sign-in callout (returning here);
6767-/// signed-in users get the full wizard. `draft` is an optional draft id to
8484+/// signed-in users get the full flow. `draft` is an optional draft id to
6885/// resume (`/publish?draft=<id>`).
6986#[allow(clippy::useless_format)]
7087#[component]
···112129 }
113130114131 rsx! {
115115- PublishWizard { resume: draft }
132132+ PublishFlow { resume: draft }
116133 }
117134}
118135119136#[derive(Clone)]
120120-struct Wizard {
137137+struct Flow {
121138 client: PolymodelClient,
122139 composition: Signal<DraftThingInput>,
123140 draft_id: Signal<Option<String>>,
124124- step: Signal<WizardStep>,
125141 phase: Signal<PublishPhase>,
126142 save: Signal<SaveState>,
127143 upload: Signal<UploadStatus>,
···133149 /// blobref CID. CDN URLs for just-uploaded blobs aren't available until
134150 /// replication completes; this shows the image immediately.
135151 image_data_uris: Signal<HashMap<SmolStr, SmolStr>>,
152152+ /// Advanced (multi-model) disclosure open-state, shared with the auto-expand
153153+ /// at the add-model site and `resume_draft`. Controlled-<details> `ontoggle`
154154+ /// keeps it honest.
155155+ advanced_open: Signal<bool>,
156156+ /// One-way flag: once Advanced has auto-expanded (or a multi-model draft was
157157+ /// resumed expanded), it is never auto-opened again — so a user who collapses
158158+ /// Advanced and later removes/re-adds a model stays collapsed.
159159+ advanced_auto_expanded: Signal<bool>,
160160+ /// Live text of the "Works with" paste field, so blur can resolve it.
161161+ works_with_text: Signal<String>,
162162+ /// Whether a file drag is over the hero dropzone (drives the drag-over class).
163163+ drag_over: Signal<bool>,
164164+ /// Resolved display names for "Works with" chips, keyed by thing URI string.
165165+ /// The persisted `intended_to_work_with` holds only `StrongRef`s (no name), so
166166+ /// the human-readable label lives here alongside the refs.
167167+ compat_names: Signal<std::collections::HashMap<String, String>>,
168168+ /// License fields where the user explicitly opened the custom-license input.
169169+ custom_license_targets: Signal<HashSet<String>>,
170170+ /// Status of the "Works with" resolve flow (kept separate from file-upload
171171+ /// status so the two do not overwrite each other's feedback).
172172+ works_with_status: Signal<UploadStatus>,
173173+ /// Guards against concurrent saves: while a save is in flight, further
174174+ /// `mark_dirty_and_save` calls just mark Dirty; the completing save re-runs
175175+ /// if still Dirty. Prevents duplicate-draft creation on rapid edits.
176176+ save_in_flight: Signal<bool>,
177177+ /// The mounted name input element, focused once after the first upload.
178178+ name_element: Signal<Option<std::rc::Rc<MountedData>>>,
136179}
137180138181#[component]
139139-fn PublishWizard(resume: String) -> Element {
182182+fn PublishFlow(resume: String) -> Element {
140183 let client = use_context::<PolymodelClient>();
141184 let session = use_context::<Signal<SessionIdentity>>();
142185 let did = match &*session.read() {
···150193 ..DraftThingInput::default()
151194 });
152195 let draft_id = use_signal(|| Option::<String>::None);
153153- let step = use_signal(|| WizardStep::Basics);
154196 let phase = use_signal(|| PublishPhase::Editing);
155197 let save = use_signal(|| SaveState::New);
156198 let upload = use_signal(|| UploadStatus::Idle);
···158200 let drafts = use_signal(Vec::<DraftSummary>::new);
159201 let image_data_uris = use_signal(HashMap::<SmolStr, SmolStr>::new);
160202161161- let wizard = Wizard {
203203+ // Disclosure open-state for the Advanced (multi-model) section, opened at the
204204+ // add-model site. Controlled-<details> `ontoggle` keeps it honest about
205205+ // manual collapses.
206206+ let advanced_open = use_signal(|| false);
207207+ // One-way flag: once Advanced has auto-expanded, it is never auto-opened again.
208208+ let advanced_auto_expanded = use_signal(|| false);
209209+ // Live text of the "Works with" paste field, so blur can resolve it.
210210+ let works_with_text = use_signal(String::new);
211211+ // Whether a file drag is over the hero dropzone (drives the drag-over class).
212212+ let drag_over = use_signal(|| false);
213213+ // Resolved display names for "Works with" chips (uri → name).
214214+ let compat_names = use_signal(std::collections::HashMap::new);
215215+ let custom_license_targets = use_signal(HashSet::<String>::new);
216216+ // "Works with" resolve status, separate from file-upload status.
217217+ let works_with_status = use_signal(|| UploadStatus::Idle);
218218+ // Guards against concurrent saves (see Flow::save_draft).
219219+ let save_in_flight = use_signal(|| false);
220220+ // The mounted name input element, focused once after the first upload.
221221+ let name_element: Signal<Option<std::rc::Rc<MountedData>>> = use_signal(|| None);
222222+223223+ let flow = Flow {
162224 client,
163225 composition,
164226 draft_id,
165165- step,
166227 phase,
167228 save,
168229 upload,
···170231 drafts,
171232 did,
172233 image_data_uris,
234234+ advanced_open,
235235+ advanced_auto_expanded,
236236+ works_with_text,
237237+ drag_over,
238238+ compat_names,
239239+ custom_license_targets,
240240+ works_with_status,
241241+ save_in_flight,
242242+ name_element,
173243 };
174244175245 // Resume a draft once on mount when `?draft=<id>` is present; always load
176246 // the actor's existing drafts so they can be picked up as entry points.
177247 {
178178- let wizard = wizard.clone();
248248+ let flow = flow.clone();
179249 let resume = resume.clone();
180250 use_hook(move || {
181251 if !resume.trim().is_empty() {
182182- let mut wizard = wizard.clone();
252252+ let flow = flow.clone();
183253 let resume = resume.clone();
184254 spawn(async move {
185185- match draft_client::get_draft(&wizard.client, resume.trim()).await {
255255+ match draft_client::get_draft(&flow.client, resume.trim()).await {
186256 Ok(loaded) => {
187187- wizard.composition.set(loaded);
188188- wizard.draft_id.set(Some(resume.trim().to_string()));
257257+ // A resumed multi-model draft should land with its
258258+ // Advanced section expanded.
259259+ let multi = loaded.models.len() > 1;
260260+ {
261261+ let mut composition = flow.composition;
262262+ composition.set(loaded);
263263+ }
264264+ let mut advanced_open = advanced_open;
265265+ let mut auto_expanded = advanced_auto_expanded;
266266+ advanced_open.set(multi);
267267+ auto_expanded.set(multi);
268268+ let mut draft_id = flow.draft_id;
269269+ draft_id.set(Some(resume.trim().to_string()));
189270 }
190271 Err(error) => {
191272 tracing::warn!(%error, "failed to resume draft");
···193274 }
194275 });
195276 }
196196- let wizard = wizard.clone();
197197- spawn(async move { wizard.reload_drafts().await });
277277+ let flow = flow.clone();
278278+ spawn(async move { flow.reload_drafts().await });
198279 });
199280 }
200281···203284 // draft via `?draft=<id>` instead of dropping you on a blank `/publish`.
204285 // replace() avoids stacking a history entry on every autosave.
205286 {
206206- let draft_id = wizard.draft_id;
287287+ let draft_id = flow.draft_id;
207288 use_effect(move || {
208289 if let Some(id) = draft_id.read().clone() {
209290 navigator().replace(Route::Publish { draft: id });
···211292 });
212293 }
213294214214- let current = *wizard.step.read();
215215- let body = match current {
216216- WizardStep::Basics => wizard.render_basics(),
217217- WizardStep::Files => wizard.render_files(),
218218- WizardStep::Images => wizard.render_images(),
219219- WizardStep::Details => wizard.render_details(),
220220- WizardStep::Review => wizard.render_review(),
221221- };
295295+ // (Auto-expand and focus-on-first-upload are handled at the mutation site,
296296+ // not via effects — observing signals to fire one-shot side effects re-runs
297297+ // on every change and needs fragile prev-state bookkeeping.)
222298223299 rsx! {
224300 main { class: "publish-page",
225225- { wizard.render_header() }
226226- if let PublishPhase::Published { repo, rkey } = wizard.phase.read().clone() {
227227- { wizard.render_published(repo, rkey) }
301301+ { flow.render_header() }
302302+ if let PublishPhase::Published { repo, rkey } = flow.phase.read().clone() {
303303+ { flow.render_published(repo, rkey) }
228304 } else {
229229- { wizard.render_resume() }
230230- { wizard.render_stepper() }
231231- section { class: "wizard-body blueprint-panel", aria_label: "{current.label()}",
232232- {body}
233233- }
234234- { wizard.render_nav() }
305305+ { flow.render_resume() }
306306+ { flow.render_editor() }
307307+ { flow.render_confirm() }
235308 }
236309 }
237310 }
238311}
239312240240-impl Wizard {
241241- /// Create (first save) or update the server-side draft.
313313+/// The level a shared field renderer targets: the thing, a model, or a part.
314314+#[derive(Clone, Copy)]
315315+enum FieldTarget {
316316+ Thing,
317317+ Model(usize),
318318+ Part(usize, usize),
319319+}
320320+321321+/// Which bbox axis/unit a single dimension input edits.
322322+#[derive(Clone, Copy)]
323323+enum BboxField {
324324+ X,
325325+ Y,
326326+ Z,
327327+ Unit,
328328+}
329329+330330+impl Flow {
331331+ /// Create (first save) or update the server-side draft. Serializes saves: if
332332+ /// a save is already in flight, callers should mark Dirty and let the
333333+ /// in-flight save re-run — `mark_dirty_and_save` enforces that, and this
334334+ /// method re-spawns when it finishes with Dirty still set, so rapid edits
335335+ /// coalesce instead of racing into duplicate drafts.
242336 async fn save_draft(self) {
337337+ {
338338+ let mut guard = self.save_in_flight;
339339+ if *guard.read() {
340340+ // Another save is mid-flight; mark dirty and let it pick this up.
341341+ let mut save = self.save;
342342+ save.set(SaveState::Dirty);
343343+ return;
344344+ }
345345+ guard.set(true);
346346+ }
243347 let mut save = self.save;
244348 save.set(SaveState::Saving);
245349 let payload = self.composition.read().clone();
246350 let existing = self.draft_id.read().clone();
351351+ if existing.is_none() && is_initial_composition(&payload) {
352352+ save.set(SaveState::New);
353353+ let mut guard = self.save_in_flight;
354354+ guard.set(false);
355355+ return;
356356+ }
247357 let result = match existing {
248358 Some(id) => draft_client::put_draft(&self.client, &id, &payload)
249359 .await
···254364 Ok(id) => {
255365 let mut draft_id = self.draft_id;
256366 draft_id.set(Some(id));
257257- save.set(SaveState::Saved);
367367+ // Only mark Saved if no edit landed while the request was in
368368+ // flight — otherwise leave Dirty so the re-run below persists it.
369369+ if !matches!(*save.read(), SaveState::Dirty) {
370370+ save.set(SaveState::Saved);
371371+ }
258372 }
259373 Err(error) => {
260374 tracing::warn!(%error, "failed to save draft");
261375 save.set(SaveState::Error);
262376 }
263377 }
378378+ // Release the guard; if edits landed during the save, re-run once.
379379+ let mut guard = self.save_in_flight;
380380+ guard.set(false);
381381+ if matches!(*self.save.read(), SaveState::Dirty) {
382382+ let flow = self.clone();
383383+ spawn(async move { flow.save_draft().await });
384384+ }
385385+ }
386386+387387+ /// Mark the composition dirty and persist it. Used after every structural or
388388+ /// committed-text mutation now that the step-change save trigger is gone.
389389+ /// Coalesces with any in-flight save (see `save_draft`).
390390+ fn mark_dirty_and_save(&self) {
391391+ let mut save = self.save;
392392+ save.set(SaveState::Dirty);
393393+ if *self.save_in_flight.read() {
394394+ return;
395395+ }
396396+ let flow = self.clone();
397397+ spawn(async move { flow.save_draft().await });
264398 }
265399266400 fn mark_dirty(&self) {
···268402 save.set(SaveState::Dirty);
269403 }
270404271271- fn goto(&self, target: WizardStep) {
272272- let mut step = self.step;
273273- step.set(target);
274274- let wizard = self.clone();
275275- spawn(async move { wizard.save_draft().await });
405405+ fn set_license_value(&self, target: FieldTarget, value: String) {
406406+ let mut composition = self.composition;
407407+ let mut guard = composition.write();
408408+ let value = if value.trim().is_empty() {
409409+ None
410410+ } else {
411411+ Some(value)
412412+ };
413413+ match target {
414414+ FieldTarget::Thing => guard.license = value,
415415+ FieldTarget::Model(mi) => {
416416+ if let Some(model) = guard.models.get_mut(mi) {
417417+ model.license = value;
418418+ }
419419+ }
420420+ FieldTarget::Part(_, _) => {}
421421+ }
276422 }
277423278424 /// Refresh the actor's draft summaries for the resume list.
···284430 }
285431 }
286432287287- /// Load an existing draft into the wizard in place (without a route change).
433433+ /// Load an existing draft into the editor in place (without a route change).
288434 async fn resume_draft(self, id: String) {
289435 match draft_client::get_draft(&self.client, &id).await {
290436 Ok(loaded) => {
291291- let mut composition = self.composition;
292292- let mut draft_id = self.draft_id;
293293- let mut step = self.step;
437437+ let multi = loaded.models.len() > 1;
438438+ {
439439+ let mut composition = self.composition;
440440+ composition.set(loaded);
441441+ }
442442+ {
443443+ let mut draft_id = self.draft_id;
444444+ draft_id.set(Some(id));
445445+ }
446446+ let mut advanced_open = self.advanced_open;
447447+ let mut auto_expanded = self.advanced_auto_expanded;
448448+ advanced_open.set(multi);
449449+ auto_expanded.set(multi);
294450 let mut save = self.save;
295295- composition.set(loaded);
296296- draft_id.set(Some(id));
297297- step.set(WizardStep::Basics);
298451 save.set(SaveState::Saved);
299452 }
300453 Err(error) => tracing::warn!(%error, "failed to resume draft"),
···317470 }
318471 }
319472320320- /// Resume list: existing server-side drafts shown as entry points. Hidden
321321- /// when the actor has no other drafts than the one being edited.
322473 fn render_resume(&self) -> Element {
323474 let drafts = self.drafts.read().clone();
324475 let active = self.draft_id.read().clone();
···342493 .filter(|n| !n.trim().is_empty())
343494 .unwrap_or_else(|| "Untitled draft".to_string());
344495 let model_count = summary.model_count;
345345- let resume_w = self.clone();
496496+ let resume_f = self.clone();
346497 let resume_id = id.clone();
347347- let discard_w = self.clone();
498498+ let discard_f = self.clone();
348499 let discard_id = id.clone();
349500 rsx! {
350501 li { key: "{id}", class: "wizard-resume-item",
···356507 button {
357508 class: "button button-secondary",
358509 onclick: move |_| {
359359- let wizard = resume_w.clone();
510510+ let flow = resume_f.clone();
360511 let id = resume_id.clone();
361361- spawn(async move { wizard.resume_draft(id).await });
512512+ spawn(async move { flow.resume_draft(id).await });
362513 },
363514 "Resume"
364515 }
365516 button {
366517 class: "button button-ghost",
367518 onclick: move |_| {
368368- let wizard = discard_w.clone();
519519+ let flow = discard_f.clone();
369520 let id = discard_id.clone();
370370- spawn(async move { wizard.discard_draft(id).await });
521521+ spawn(async move { flow.discard_draft(id).await });
371522 },
372523 "Delete"
373524 }
···397548 SaveState::Dirty => "Unsaved changes",
398549 SaveState::Error => "Save failed",
399550 };
551551+ let save_f = self.clone();
400552 rsx! {
401553 header { class: "publish-head",
402554 div { class: "publish-head-row",
403555 h1 { "Publish a thing" }
404556 span { class: "{pill_class}", "{pill_label}" }
557557+ button {
558558+ class: "button button-secondary publish-save",
559559+ disabled: matches!(save, SaveState::Saving),
560560+ onclick: move |_| {
561561+ let flow = save_f.clone();
562562+ spawn(async move { flow.save_draft().await });
563563+ },
564564+ "Save draft"
565565+ }
405566 }
406567 p { class: "publish-save-state", aria_live: "polite", "{save_label}" }
407568 if let PublishPhase::Failed(message) = &phase {
···411572 }
412573 }
413574414414- fn render_stepper(&self) -> Element {
415415- let composition = self.composition.read();
416416- let current = *self.step.read();
417417- let basics_done = composition.basics_complete();
418418- let files_done = composition.files_complete();
419419- drop(composition);
575575+ /// Upload/resolve status (progress or error), shown wherever file or
576576+ /// "Works with" staging can fail — both the hero (empty state) and the files
577577+ /// list (post-upload), so failures are never hidden by the hero swap.
578578+ fn render_upload_status(&self) -> Element {
579579+ let status = self.upload.read().clone();
580580+ match status {
581581+ UploadStatus::Uploading(label) => rsx! {
582582+ div { class: "wizard-upload-status", role: "status",
583583+ span { class: "wizard-progress-bar", "aria-hidden": "true" }
584584+ span { "Uploading {label}…" }
585585+ }
586586+ },
587587+ UploadStatus::Error(message) => rsx! {
588588+ p { class: "wizard-error", role: "alert", "{message}" }
589589+ },
590590+ UploadStatus::Idle => rsx! {},
591591+ }
592592+ }
593593+594594+ /// "Works with" resolve status, shown inside that disclosure only — kept
595595+ /// separate from the file-upload status so the two flows don't clobber each
596596+ /// other's feedback.
597597+ fn render_works_with_status(&self) -> Element {
598598+ let status = self.works_with_status.read().clone();
599599+ match status {
600600+ UploadStatus::Error(message) => rsx! {
601601+ p { class: "wizard-error", role: "alert", "{message}" }
602602+ },
603603+ _ => rsx! {},
604604+ }
605605+ }
606606+607607+ /// Image-upload status (cover/previews), distinct from geometry-file status.
608608+ fn render_image_upload_status(&self) -> Element {
609609+ let status = self.image_upload.read().clone();
610610+ match status {
611611+ UploadStatus::Uploading(label) => rsx! {
612612+ div { class: "wizard-upload-status", role: "status",
613613+ span { class: "wizard-progress-bar", "aria-hidden": "true" }
614614+ span { "Uploading {label}…" }
615615+ }
616616+ },
617617+ UploadStatus::Error(message) => rsx! {
618618+ p { class: "wizard-error", role: "alert", "{message}" }
619619+ },
620620+ UploadStatus::Idle => rsx! {},
621621+ }
622622+ }
623623+624624+ /// The hero upload zone — the empty-state dropzone, shown only before any
625625+ /// file is staged. A large, keyboard-reachable label wraps a `multiple`
626626+ /// file picker, and accepts dragged geometry files.
627627+ fn render_hero(&self) -> Element {
628628+ let drag_over = *self.drag_over.read();
629629+ let hero_w = self.clone();
630630+ let over_w = self.clone();
631631+ let leave_w = self.clone();
632632+ let drop_w = self.clone();
633633+ let drag_class = if drag_over { "is-drag-over" } else { "" };
420634 rsx! {
421421- nav { class: "wizard-steps", aria_label: "Publish steps",
422422- for s in WizardStep::ALL {
423423- {
424424- let done = match s {
425425- WizardStep::Basics => basics_done,
426426- WizardStep::Files => files_done,
427427- _ => false,
428428- };
429429- let is_active = s == current;
430430- let wizard = self.clone();
431431- let active_class = if is_active { "is-active" } else { "" };
432432- let done_class = if done { "is-complete" } else { "" };
433433- rsx! {
434434- button {
435435- key: "step-{s.number()}",
436436- class: "tab wizard-step {active_class} {done_class}",
437437- aria_current: if is_active { "step" } else { "false" },
438438- onclick: move |_| wizard.goto(s),
439439- span { class: "wizard-step-num", "{s.number()}" }
440440- span { class: "wizard-step-label", "{s.label()}" }
441441- if done {
442442- span { class: "wizard-step-check", aria_hidden: "true", "✓" }
443443- }
635635+ section { class: "publish-hero blueprint-panel", aria_label: "Upload model files",
636636+ label {
637637+ class: "publish-dropzone {drag_class}",
638638+ ondragover: move |evt| {
639639+ evt.prevent_default();
640640+ let mut drag_over = over_w.drag_over;
641641+ drag_over.set(true);
642642+ },
643643+ ondragleave: move |_| {
644644+ let mut drag_over = leave_w.drag_over;
645645+ drag_over.set(false);
646646+ },
647647+ ondrop: move |evt| {
648648+ evt.prevent_default();
649649+ let mut drag_over = drop_w.drag_over;
650650+ drag_over.set(false);
651651+ let files = evt.files();
652652+ if !files.is_empty() {
653653+ let flow = drop_w.clone();
654654+ spawn(async move { flow.handle_files_upload(files).await });
655655+ }
656656+ },
657657+ input {
658658+ r#type: "file",
659659+ multiple: true,
660660+ accept: ".stl,.obj,.gltf,.glb,.3mf,.step,.stp,model/*,application/octet-stream",
661661+ class: "wizard-file-input",
662662+ aria_label: "Upload model files",
663663+ onchange: move |evt| {
664664+ let files = evt.files();
665665+ if !files.is_empty() {
666666+ let flow = hero_w.clone();
667667+ spawn(async move { flow.handle_files_upload(files).await });
444668 }
669669+ },
670670+ }
671671+ div { class: "publish-dropzone-body",
672672+ p { class: "publish-dropzone-title", "Drop geometry files to begin" }
673673+ p { class: "publish-dropzone-hint card-meta",
674674+ "STL · OBJ · glTF · GLB · 3MF · STEP — or click to choose"
445675 }
446676 }
447677 }
678678+ { self.render_upload_status() }
448679 }
449680 }
450681 }
451682452452- fn render_nav(&self) -> Element {
453453- let current = *self.step.read();
454454- let publishing = matches!(*self.phase.read(), PublishPhase::Publishing);
455455- let prev = current.prev();
456456- let next = current.next();
457457- let wizard_back = self.clone();
458458- let wizard_next = self.clone();
459459- let wizard_save = self.clone();
683683+ /// Inline editor: the thing name + uploaded-files list (default model's
684684+ /// parts), then progressive-disclosure "more" sections and the Advanced
685685+ /// multi-model disclosure with per-model/per-part metadata.
686686+ fn render_editor(&self) -> Element {
687687+ let composition = self.composition.read();
688688+ let has_parts = composition.models.iter().any(|m| !m.parts.is_empty());
689689+ drop(composition);
690690+460691 rsx! {
461461- footer { class: "wizard-nav",
462462- if let Some(prev) = prev {
463463- button {
464464- class: "button button-ghost",
465465- disabled: publishing,
466466- onclick: move |_| wizard_back.goto(prev),
467467- "Back"
468468- }
469469- }
470470- div { class: "wizard-nav-spacer" }
471471- button {
472472- class: "button button-secondary",
473473- disabled: publishing,
474474- onclick: move |_| {
475475- let wizard = wizard_save.clone();
476476- spawn(async move { wizard.save_draft().await });
477477- },
478478- "Save draft"
479479- }
480480- if let Some(next) = next {
481481- button {
482482- class: "button button-primary",
483483- disabled: publishing,
484484- onclick: move |_| wizard_next.goto(next),
485485- "Next: {next.label()}"
486486- }
692692+ section { class: "wizard-body blueprint-panel", aria_label: "Publish",
693693+ { self.render_inline_name() }
694694+ { self.render_thing_sections() }
695695+ if has_parts {
696696+ { self.render_files_list() }
697697+ } else {
698698+ { self.render_hero() }
487699 }
700700+ { self.render_advanced() }
488701 }
489702 }
490703 }
491704492492- fn render_basics(&self) -> Element {
705705+ /// The editable thing name, shown once the first file is staged. Focuses
706706+ /// itself on first mount so the user lands on the most likely next edit.
707707+ fn render_inline_name(&self) -> Element {
493708 let composition = self.composition.read();
494709 let name = composition.name.clone().unwrap_or_default();
495495- let summary = composition.summary.clone().unwrap_or_default();
496710 drop(composition);
497497-498711 let name_w = self.clone();
499499- let summary_w = self.clone();
500500-712712+ let name_element = self.name_element;
501713 rsx! {
502502- h2 { "Basics" }
503503- label { class: "field",
714714+ label { class: "field publish-name-field",
504715 span { class: "field-label", "Name" }
505716 input {
506717 class: "field-input",
507718 r#type: "text",
508719 value: "{name}",
509720 placeholder: "e.g. Modular desk organizer",
721721+ onmounted: move |element| {
722722+ let mut name_element = name_element;
723723+ name_element.set(Some(element.data()));
724724+ },
510725 oninput: move |evt| {
511726 let mut c = name_w.composition;
512727 c.write().name = Some(evt.value());
513513- name_w.mark_dirty();
514728 },
729729+ onblur: move |_| name_w.clone().mark_dirty_and_save(),
515730 }
516731 }
517517- label { class: "field",
518518- span { class: "field-label", "Summary" }
519519- textarea {
520520- class: "field-input field-textarea",
521521- value: "{summary}",
522522- placeholder: "What is it?",
523523- oninput: move |evt| {
524524- let mut c = summary_w.composition;
525525- let v = evt.value();
526526- c.write().summary = if v.trim().is_empty() { None } else { Some(v) };
527527- summary_w.mark_dirty();
528528- },
732732+ }
733733+ }
734734+735735+ /// The default model's parts as an editable files list (rename + remove +
736736+ /// size/format meta), plus "Add more files" routed through the same batch
737737+ /// handler as the hero.
738738+ fn render_files_list(&self) -> Element {
739739+ let composition = self.composition.read();
740740+ let parts = composition
741741+ .models
742742+ .first()
743743+ .map(|m| m.parts.clone())
744744+ .unwrap_or_default();
745745+ drop(composition);
746746+ let add_w = self.clone();
747747+ let over_w = self.clone();
748748+ let leave_w = self.clone();
749749+ let drop_w = self.clone();
750750+ let drag_over = *self.drag_over.read();
751751+ let drag_class = if drag_over { "is-drag-over" } else { "" };
752752+ rsx! {
753753+ div {
754754+ class: "publish-files {drag_class}",
755755+ ondragover: move |evt| {
756756+ evt.prevent_default();
757757+ let mut drag_over = over_w.drag_over;
758758+ drag_over.set(true);
759759+ },
760760+ ondragleave: move |_| {
761761+ let mut drag_over = leave_w.drag_over;
762762+ drag_over.set(false);
763763+ },
764764+ ondrop: move |evt| {
765765+ evt.prevent_default();
766766+ let mut drag_over = drop_w.drag_over;
767767+ drag_over.set(false);
768768+ let files = evt.files();
769769+ if !files.is_empty() {
770770+ let flow = drop_w.clone();
771771+ spawn(async move { flow.handle_files_upload(files).await });
772772+ }
773773+ },
774774+ h2 { "Files" }
775775+ { self.render_upload_status() }
776776+ ul { class: "wizard-part-list",
777777+ for (pi, part) in parts.iter().enumerate() {
778778+ {
779779+ let part_name = part.name.clone().unwrap_or_default();
780780+ let size = part.file.as_ref().map(|f| human_size(f.size)).unwrap_or_default();
781781+ let format = part.format.clone().unwrap_or_default();
782782+ let pname_w = self.clone();
783783+ let premove_w = self.clone();
784784+ rsx! {
785785+ li { key: "part-0-{pi}", class: "wizard-part",
786786+ label { class: "field wizard-part-name",
787787+ span { class: "field-label sr-only", "Part name" }
788788+ input {
789789+ class: "field-input",
790790+ r#type: "text",
791791+ value: "{part_name}",
792792+ oninput: move |evt| {
793793+ let mut c = pname_w.composition;
794794+ c.write().models[0].parts[pi].name = Some(evt.value());
795795+ },
796796+ onblur: move |_| pname_w.clone().mark_dirty_and_save(),
797797+ }
798798+ }
799799+ span { class: "wizard-part-meta card-meta",
800800+ if !format.is_empty() { "{format} · " }
801801+ "{size}"
802802+ }
803803+ button {
804804+ class: "button button-ghost wizard-remove",
805805+ onclick: move |_| {
806806+ let mut c = premove_w.composition;
807807+ c.write().models[0].parts.remove(pi);
808808+ premove_w.clone().mark_dirty_and_save();
809809+ },
810810+ "Remove"
811811+ }
812812+ }
813813+ }
814814+ }
815815+ }
816816+ }
817817+ label { class: "button button-secondary wizard-file-button",
818818+ "Add more files"
819819+ input {
820820+ class: "wizard-file-input",
821821+ r#type: "file",
822822+ multiple: true,
823823+ accept: ".stl,.obj,.gltf,.glb,.3mf,.step,.stp,model/*,application/octet-stream",
824824+ onchange: move |evt| {
825825+ let files = evt.files();
826826+ if !files.is_empty() {
827827+ let flow = add_w.clone();
828828+ spawn(async move { flow.handle_files_upload(files).await });
829829+ }
830830+ },
831831+ }
529832 }
530833 }
531834 }
532835 }
533836534534- fn render_files(&self) -> Element {
837837+ /// Thing-level "more" sections behind progressive disclosure: Summary,
838838+ /// Tags, Images, Instructions, License, and Works with.
839839+ fn render_thing_sections(&self) -> Element {
840840+ rsx! {
841841+ div { class: "publish-sections",
842842+ { self.render_summary_section(FieldTarget::Thing, false) }
843843+ { self.render_tags_section(FieldTarget::Thing, false) }
844844+ { self.render_license_section(FieldTarget::Thing, false) }
845845+ { self.render_cover_inline() }
846846+ { self.render_instructions_section(FieldTarget::Thing) }
847847+ { self.render_images_section(FieldTarget::Thing) }
848848+ { self.render_works_with_section() }
849849+ }
850850+ }
851851+ }
852852+853853+ /// Advanced multi-model disclosure: add/remove models, per-model metadata,
854854+ /// and per-part metadata via the shared field renderers.
855855+ fn render_advanced(&self) -> Element {
535856 let composition = self.composition.read();
536857 let models = composition.models.clone();
537537- let upload = self.upload.read().clone();
538858 drop(composition);
859859+ let add_w = self.clone();
860860+ rsx! {
861861+ Disclosure {
862862+ title: "Advanced",
863863+ hint: "Multiple models and per-part metadata",
864864+ open: Some(self.advanced_open),
865865+ body: rsx! {
866866+ for (mi, model) in models.iter().enumerate() {
867867+ { self.render_model_block(mi, model) }
868868+ }
869869+ button {
870870+ class: "button button-secondary wizard-add-model",
871871+ onclick: move |_| {
872872+ let was_count = {
873873+ let g = add_w.composition.read();
874874+ g.models.len()
875875+ };
876876+ let next_name = format!("Model {}", was_count + 1);
877877+ let mut c = add_w.composition;
878878+ c.write().models.push(DraftModelInput {
879879+ name: Some(next_name),
880880+ ..Default::default()
881881+ });
882882+ // First time reaching a second model: auto-open
883883+ // Advanced once. The one-way flag means a later
884884+ // remove→re-add after a manual collapse stays put.
885885+ if was_count == 1 && !*add_w.advanced_auto_expanded.read() {
886886+ let mut advanced_open = add_w.advanced_open;
887887+ advanced_open.set(true);
888888+ let mut auto_expanded = add_w.advanced_auto_expanded;
889889+ auto_expanded.set(true);
890890+ }
891891+ add_w.clone().mark_dirty_and_save();
892892+ },
893893+ "Add a model"
894894+ }
895895+ }
896896+ }
897897+ }
898898+ }
539899540540- let add_model_w = self.clone();
900900+ fn render_model_block(&self, mi: usize, model: &DraftModelInput) -> Element {
901901+ let model_name = model.name.clone().unwrap_or_default();
902902+ let parts = model.parts.clone();
903903+ let name_w = self.clone();
904904+ let remove_w = self.clone();
905905+ let addfile_w = self.clone();
541906 rsx! {
542542- h2 { "Files & parts" }
543543- p { class: "wizard-step-hint", "Add one or more models. Each model holds ordered parts; upload a geometry file for every part." }
544544- match &upload {
545545- UploadStatus::Uploading(label) => rsx! {
546546- div { class: "wizard-upload-status", role: "status",
547547- span { class: "wizard-progress-bar", "aria-hidden": "true" }
548548- span { "Uploading {label}…" }
907907+ section { key: "model-{mi}", class: "wizard-model",
908908+ div { class: "wizard-model-head",
909909+ label { class: "field wizard-model-name",
910910+ span { class: "field-label", "Model {mi + 1} name" }
911911+ input {
912912+ class: "field-input",
913913+ r#type: "text",
914914+ value: "{model_name}",
915915+ placeholder: "e.g. Base plate",
916916+ oninput: move |evt| {
917917+ let mut c = name_w.composition;
918918+ c.write().models[mi].name = Some(evt.value());
919919+ },
920920+ onblur: move |_| name_w.clone().mark_dirty_and_save(),
921921+ }
922922+ }
923923+ button {
924924+ class: "button button-ghost wizard-remove",
925925+ onclick: move |_| {
926926+ let mut c = remove_w.composition;
927927+ c.write().models.remove(mi);
928928+ remove_w.clone().mark_dirty_and_save();
929929+ },
930930+ "Remove model"
549931 }
550550- },
551551- UploadStatus::Error(message) => rsx! {
552552- p { class: "wizard-error", role: "alert", "{message}" }
553553- },
554554- UploadStatus::Idle => rsx! {},
932932+ }
933933+ if parts.is_empty() {
934934+ p { class: "wizard-empty", "No parts yet. Upload a file below." }
935935+ }
936936+ ul { class: "wizard-part-list",
937937+ for (pi, part) in parts.iter().enumerate() {
938938+ { self.render_part_row(mi, pi, part) }
939939+ }
940940+ }
941941+ label { class: "button button-secondary wizard-file-button",
942942+ "Add a file to this model"
943943+ input {
944944+ class: "wizard-file-input",
945945+ r#type: "file",
946946+ accept: ".stl,.obj,.gltf,.glb,.3mf,.step,.stp,model/*,application/octet-stream",
947947+ onchange: move |evt| {
948948+ if let Some(file) = evt.files().into_iter().next() {
949949+ let flow = addfile_w.clone();
950950+ spawn(async move { flow.stage_file_into(mi, file).await });
951951+ }
952952+ },
953953+ }
954954+ }
955955+ { self.render_summary_section(FieldTarget::Model(mi), true) }
956956+ { self.render_tags_section(FieldTarget::Model(mi), true) }
957957+ { self.render_instructions_section(FieldTarget::Model(mi)) }
958958+ { self.render_license_section(FieldTarget::Model(mi), true) }
959959+ { self.render_images_section(FieldTarget::Model(mi)) }
555960 }
556556- if models.is_empty() {
557557- p { class: "wizard-empty", "No models yet. Add one to start uploading parts." }
961961+ }
962962+ }
963963+964964+ fn render_part_row(&self, mi: usize, pi: usize, part: &DraftPartInput) -> Element {
965965+ let part_name = part.name.clone().unwrap_or_default();
966966+ let size = part
967967+ .file
968968+ .as_ref()
969969+ .map(|f| human_size(f.size))
970970+ .unwrap_or_default();
971971+ let format = part.format.clone().unwrap_or_default();
972972+ let notes = part.notes.clone().unwrap_or_default();
973973+ let units = part.units.clone().unwrap_or_default();
974974+ let print_settings = part.print_settings.clone().unwrap_or_default().join("\n");
975975+ let bbox = part.dimensions.clone();
976976+ let pname_w = self.clone();
977977+ let premove_w = self.clone();
978978+ let notes_w = self.clone();
979979+ let units_w = self.clone();
980980+ let prints_w = self.clone();
981981+ rsx! {
982982+ li { key: "part-{mi}-{pi}", class: "wizard-part wizard-part-advanced",
983983+ div { class: "wizard-part-main",
984984+ label { class: "field wizard-part-name",
985985+ span { class: "field-label sr-only", "Part name" }
986986+ input {
987987+ class: "field-input",
988988+ r#type: "text",
989989+ value: "{part_name}",
990990+ oninput: move |evt| {
991991+ let mut c = pname_w.composition;
992992+ c.write().models[mi].parts[pi].name = Some(evt.value());
993993+ },
994994+ onblur: move |_| pname_w.clone().mark_dirty_and_save(),
995995+ }
996996+ }
997997+ span { class: "wizard-part-meta card-meta",
998998+ if !format.is_empty() { "{format} · " }
999999+ "{size}"
10001000+ }
10011001+ button {
10021002+ class: "button button-ghost wizard-remove",
10031003+ onclick: move |_| {
10041004+ let mut c = premove_w.composition;
10051005+ c.write().models[mi].parts.remove(pi);
10061006+ premove_w.clone().mark_dirty_and_save();
10071007+ },
10081008+ "Remove"
10091009+ }
10101010+ }
10111011+ div { class: "wizard-part-meta-grid",
10121012+ label { class: "field",
10131013+ span { class: "field-label", "Notes" }
10141014+ input {
10151015+ class: "field-input",
10161016+ r#type: "text",
10171017+ value: "{notes}",
10181018+ placeholder: "e.g. Print supports off",
10191019+ oninput: move |evt| {
10201020+ let mut c = notes_w.composition;
10211021+ let v = evt.value();
10221022+ c.write().models[mi].parts[pi].notes = if v.trim().is_empty() { None } else { Some(v) };
10231023+ },
10241024+ onblur: move |_| notes_w.clone().mark_dirty_and_save(),
10251025+ }
10261026+ }
10271027+ label { class: "field",
10281028+ span { class: "field-label", "Units" }
10291029+ input {
10301030+ class: "field-input",
10311031+ r#type: "text",
10321032+ value: "{units}",
10331033+ placeholder: "mm",
10341034+ oninput: move |evt| {
10351035+ let mut c = units_w.composition;
10361036+ let v = evt.value();
10371037+ c.write().models[mi].parts[pi].units = if v.trim().is_empty() { None } else { Some(v) };
10381038+ },
10391039+ onblur: move |_| units_w.clone().mark_dirty_and_save(),
10401040+ }
10411041+ }
10421042+ label { class: "field publish-field-wide",
10431043+ span { class: "field-label", "Print settings" }
10441044+ textarea {
10451045+ class: "field-input field-textarea",
10461046+ value: "{print_settings}",
10471047+ placeholder: "One setting per line",
10481048+ oninput: move |evt| {
10491049+ let mut c = prints_w.composition;
10501050+ let lines: Vec<String> = evt.value().split('\n').map(str::to_string).collect();
10511051+ c.write().models[mi].parts[pi].print_settings = if lines.iter().all(|l| l.trim().is_empty()) { None } else { Some(lines) };
10521052+ },
10531053+ onblur: move |_| prints_w.clone().mark_dirty_and_save(),
10541054+ }
10551055+ }
10561056+ fieldset { class: "field publish-bbox",
10571057+ legend { class: "field-label", "Dimensions" }
10581058+ { self.render_bbox(mi, pi, &bbox) }
10591059+ }
10601060+ }
10611061+ { self.render_images_section(FieldTarget::Part(mi, pi)) }
5581062 }
559559- for (mi, model) in models.iter().enumerate() {
560560- {
561561- let model_name = model.name.clone().unwrap_or_default();
562562- let parts = model.parts.clone();
563563- let name_w = self.clone();
564564- let remove_w = self.clone();
565565- let upload_w = self.clone();
566566- rsx! {
567567- section { key: "model-{mi}", class: "wizard-model",
568568- div { class: "wizard-model-head",
569569- label { class: "field wizard-model-name",
570570- span { class: "field-label", "Model {mi + 1} name" }
571571- input {
572572- class: "field-input",
573573- r#type: "text",
574574- value: "{model_name}",
575575- placeholder: "e.g. Base plate",
576576- oninput: move |evt| {
577577- let mut c = name_w.composition;
578578- c.write().models[mi].name = Some(evt.value());
579579- name_w.mark_dirty();
580580- },
581581- }
582582- }
583583- button {
584584- class: "button button-ghost wizard-remove",
585585- onclick: move |_| {
586586- let mut c = remove_w.composition;
587587- c.write().models.remove(mi);
588588- remove_w.mark_dirty();
589589- },
590590- "Remove model"
10631063+ }
10641064+ }
10651065+10661066+ /// Dimensions (bbox) inputs for a part. Each axis/unit is written live into
10671067+ /// the part's `dimensions` (creating an empty bbox on first edit); all-or-none
10681068+ /// is enforced at `assemble()` via the lexicon validator, so partial values
10691069+ /// simply block publish until complete — no hand-rolled float checks here.
10701070+ /// Existing `dimensions.unit` is preserved and defaults to `"mm"`.
10711071+ fn render_bbox(&self, mi: usize, pi: usize, bbox: &Option<Bbox>) -> Element {
10721072+ let (bx, by, bz, bunit) = match bbox {
10731073+ Some(b) => (
10741074+ b.x.as_str().to_string(),
10751075+ b.y.as_str().to_string(),
10761076+ b.z.as_str().to_string(),
10771077+ b.unit
10781078+ .as_ref()
10791079+ .map(|u| u.as_str().to_string())
10801080+ .unwrap_or_else(|| "mm".to_string()),
10811081+ ),
10821082+ None => (
10831083+ String::new(),
10841084+ String::new(),
10851085+ String::new(),
10861086+ "mm".to_string(),
10871087+ ),
10881088+ };
10891089+10901090+ let x_in = self.clone();
10911091+ let x_blur = self.clone();
10921092+ let y_in = self.clone();
10931093+ let y_blur = self.clone();
10941094+ let z_in = self.clone();
10951095+ let z_blur = self.clone();
10961096+ let u_in = self.clone();
10971097+ let u_blur = self.clone();
10981098+ rsx! {
10991099+ div { class: "publish-bbox-row",
11001100+ label { class: "field",
11011101+ span { class: "field-label sr-only", "Width (X)" }
11021102+ input {
11031103+ class: "field-input",
11041104+ r#type: "text",
11051105+ inputmode: "decimal",
11061106+ value: "{bx}",
11071107+ placeholder: "X",
11081108+ aria_label: "Width",
11091109+ oninput: move |evt| Flow::set_bbox_axis(&x_in, mi, pi, BboxField::X, evt.value()),
11101110+ onblur: move |_| x_blur.clone().mark_dirty_and_save(),
11111111+ }
11121112+ }
11131113+ label { class: "field",
11141114+ span { class: "field-label sr-only", "Depth (Y)" }
11151115+ input {
11161116+ class: "field-input",
11171117+ r#type: "text",
11181118+ inputmode: "decimal",
11191119+ value: "{by}",
11201120+ placeholder: "Y",
11211121+ aria_label: "Depth",
11221122+ oninput: move |evt| Flow::set_bbox_axis(&y_in, mi, pi, BboxField::Y, evt.value()),
11231123+ onblur: move |_| y_blur.clone().mark_dirty_and_save(),
11241124+ }
11251125+ }
11261126+ label { class: "field",
11271127+ span { class: "field-label sr-only", "Height (Z)" }
11281128+ input {
11291129+ class: "field-input",
11301130+ r#type: "text",
11311131+ inputmode: "decimal",
11321132+ value: "{bz}",
11331133+ placeholder: "Z",
11341134+ aria_label: "Height",
11351135+ oninput: move |evt| Flow::set_bbox_axis(&z_in, mi, pi, BboxField::Z, evt.value()),
11361136+ onblur: move |_| z_blur.clone().mark_dirty_and_save(),
11371137+ }
11381138+ }
11391139+ label { class: "field",
11401140+ span { class: "field-label sr-only", "Dimension unit" }
11411141+ input {
11421142+ class: "field-input",
11431143+ r#type: "text",
11441144+ value: "{bunit}",
11451145+ placeholder: "mm",
11461146+ aria_label: "Dimension unit",
11471147+ oninput: move |evt| Flow::set_bbox_axis(&u_in, mi, pi, BboxField::Unit, evt.value()),
11481148+ onblur: move |_| u_blur.clone().mark_dirty_and_save(),
11491149+ }
11501150+ }
11511151+ }
11521152+ }
11531153+ }
11541154+11551155+ /// Mutate a single bbox field on a part, initializing an empty bbox when
11561156+ /// none exists yet. A bbox that ends up fully empty is dropped so the draft
11571157+ /// stays clean and does not block publish with a partial/empty dimensions.
11581158+ fn set_bbox_axis(&self, mi: usize, pi: usize, field: BboxField, value: String) {
11591159+ let mut c = self.composition;
11601160+ let mut g = c.write();
11611161+ let Some(part) = g.models.get_mut(mi).and_then(|m| m.parts.get_mut(pi)) else {
11621162+ return;
11631163+ };
11641164+ let bbox = part.dimensions.get_or_insert_with(|| Bbox {
11651165+ unit: None,
11661166+ x: "".into(),
11671167+ y: "".into(),
11681168+ z: "".into(),
11691169+ extra_data: None,
11701170+ });
11711171+ match field {
11721172+ BboxField::X => bbox.x = value.into(),
11731173+ BboxField::Y => bbox.y = value.into(),
11741174+ BboxField::Z => bbox.z = value.into(),
11751175+ BboxField::Unit => {
11761176+ bbox.unit = if value.trim().is_empty() {
11771177+ None
11781178+ } else {
11791179+ Some(value.into())
11801180+ };
11811181+ }
11821182+ }
11831183+ // Drop a dimensions with no axes set so it does not serialize or block
11841184+ // publish with a partial (unit-only) bbox — unit alone is not a dimension.
11851185+ if bbox.x.as_str().trim().is_empty()
11861186+ && bbox.y.as_str().trim().is_empty()
11871187+ && bbox.z.as_str().trim().is_empty()
11881188+ {
11891189+ part.dimensions = None;
11901190+ }
11911191+ }
11921192+11931193+ // ---- Shared field renderers (one implementation per field type) ----
11941194+11951195+ fn render_summary_section(&self, target: FieldTarget, wrapped: bool) -> Element {
11961196+ let composition = self.composition.read();
11971197+ let value = match target {
11981198+ FieldTarget::Thing => composition.summary.clone().unwrap_or_default(),
11991199+ FieldTarget::Model(mi) => composition
12001200+ .models
12011201+ .get(mi)
12021202+ .and_then(|m| m.summary.clone())
12031203+ .unwrap_or_default(),
12041204+ FieldTarget::Part(_, _) => return rsx! {},
12051205+ };
12061206+ drop(composition);
12071207+ let w = self.clone();
12081208+ let field = rsx! {
12091209+ label { class: "field",
12101210+ span { class: "field-label", "Summary" }
12111211+ textarea {
12121212+ class: "field-input field-textarea",
12131213+ value: "{value}",
12141214+ placeholder: "What is it?",
12151215+ oninput: move |evt| {
12161216+ let v = evt.value();
12171217+ let mut c = w.composition;
12181218+ let mut g = c.write();
12191219+ match target {
12201220+ FieldTarget::Thing => g.summary = if v.trim().is_empty() { None } else { Some(v) },
12211221+ FieldTarget::Model(mi) => {
12221222+ if let Some(m) = g.models.get_mut(mi) {
12231223+ m.summary = if v.trim().is_empty() { None } else { Some(v) };
5911224 }
5921225 }
593593- if parts.is_empty() {
594594- p { class: "wizard-empty", "No parts yet. Upload a file below." }
595595- }
596596- ul { class: "wizard-part-list",
597597- for (pi, part) in parts.iter().enumerate() {
598598- {
599599- let part_name = part.name.clone().unwrap_or_default();
600600- let size = part.file.as_ref().map(|f| human_size(f.size)).unwrap_or_default();
601601- let format = part.format.clone().unwrap_or_default();
602602- let pname_w = self.clone();
603603- let premove_w = self.clone();
604604- rsx! {
605605- li { key: "part-{mi}-{pi}", class: "wizard-part",
606606- label { class: "field wizard-part-name",
607607- span { class: "field-label sr-only", "Part name" }
608608- input {
609609- class: "field-input",
610610- r#type: "text",
611611- value: "{part_name}",
612612- oninput: move |evt| {
613613- let mut c = pname_w.composition;
614614- c.write().models[mi].parts[pi].name = Some(evt.value());
615615- pname_w.mark_dirty();
616616- },
617617- }
618618- }
619619- span { class: "wizard-part-meta card-meta",
620620- if !format.is_empty() { "{format} · " }
621621- "{size}"
622622- }
623623- button {
624624- class: "button button-ghost wizard-remove",
625625- onclick: move |_| {
626626- let mut c = premove_w.composition;
627627- c.write().models[mi].parts.remove(pi);
628628- premove_w.mark_dirty();
629629- },
630630- "Remove"
631631- }
632632- }
633633- }
634634- }
12261226+ FieldTarget::Part(_, _) => {}
12271227+ }
12281228+ },
12291229+ onblur: move |_| w.clone().mark_dirty_and_save(),
12301230+ }
12311231+ }
12321232+ };
12331233+ if wrapped {
12341234+ rsx! {
12351235+ Disclosure {
12361236+ title: "Summary",
12371237+ hint: Some("A short description".to_string()),
12381238+ open: None,
12391239+ body: field,
12401240+ }
12411241+ }
12421242+ } else {
12431243+ field
12441244+ }
12451245+ }
12461246+12471247+ fn render_tags_section(&self, target: FieldTarget, wrapped: bool) -> Element {
12481248+ let composition = self.composition.read();
12491249+ let tags = match target {
12501250+ FieldTarget::Thing => composition.tags.clone().unwrap_or_default(),
12511251+ FieldTarget::Model(mi) => composition
12521252+ .models
12531253+ .get(mi)
12541254+ .and_then(|m| m.tags.clone())
12551255+ .unwrap_or_default(),
12561256+ FieldTarget::Part(_, _) => return rsx! {},
12571257+ };
12581258+ drop(composition);
12591259+ let tags = tags.join(", ");
12601260+ let w = self.clone();
12611261+ let field = rsx! {
12621262+ label { class: "field",
12631263+ span { class: "field-label", "Tags" }
12641264+ input {
12651265+ class: "field-input",
12661266+ r#type: "text",
12671267+ value: "{tags}",
12681268+ placeholder: "comma, separated",
12691269+ oninput: move |evt| {
12701270+ let parsed: Vec<String> = evt.value().split(',').map(|t| t.trim().to_string()).filter(|t| !t.is_empty()).collect();
12711271+ let mut c = w.composition;
12721272+ let mut g = c.write();
12731273+ match target {
12741274+ FieldTarget::Thing => g.tags = if parsed.is_empty() { None } else { Some(parsed) },
12751275+ FieldTarget::Model(mi) => {
12761276+ if let Some(m) = g.models.get_mut(mi) {
12771277+ m.tags = if parsed.is_empty() { None } else { Some(parsed) };
6351278 }
6361279 }
637637- label { class: "button button-secondary wizard-file-button",
638638- "Add a file"
639639- input {
640640- class: "wizard-file-input",
641641- r#type: "file",
642642- accept: ".stl,.obj,.gltf,.glb,.3mf,.step,.stp,model/*,application/octet-stream",
643643- onchange: move |evt| {
644644- if let Some(file) = evt.files().into_iter().next() {
645645- let wizard = upload_w.clone();
646646- spawn(async move { wizard.handle_file_upload(mi, file).await });
12801280+ FieldTarget::Part(_, _) => {}
12811281+ }
12821282+ },
12831283+ onblur: move |_| w.clone().mark_dirty_and_save(),
12841284+ }
12851285+ }
12861286+ };
12871287+ if wrapped {
12881288+ rsx! {
12891289+ Disclosure {
12901290+ title: "Tags",
12911291+ hint: Some("Comma-separated".to_string()),
12921292+ open: None,
12931293+ body: field,
12941294+ }
12951295+ }
12961296+ } else {
12971297+ field
12981298+ }
12991299+ }
13001300+13011301+ fn render_instructions_section(&self, target: FieldTarget) -> Element {
13021302+ let composition = self.composition.read();
13031303+ let value = match target {
13041304+ FieldTarget::Thing => composition
13051305+ .instructions
13061306+ .clone()
13071307+ .unwrap_or_default()
13081308+ .join("\n"),
13091309+ FieldTarget::Model(mi) => composition
13101310+ .models
13111311+ .get(mi)
13121312+ .and_then(|m| m.instructions.clone())
13131313+ .unwrap_or_default()
13141314+ .join("\n"),
13151315+ FieldTarget::Part(_, _) => return rsx! {},
13161316+ };
13171317+ drop(composition);
13181318+ let w = self.clone();
13191319+ rsx! {
13201320+ Disclosure {
13211321+ title: "Instructions",
13221322+ hint: "One step per line",
13231323+ open: None,
13241324+ body: rsx! {
13251325+ label { class: "field",
13261326+ span { class: "field-label sr-only", "Instructions" }
13271327+ textarea {
13281328+ class: "field-input field-textarea field-textarea-tall",
13291329+ value: "{value}",
13301330+ placeholder: "One step per line",
13311331+ oninput: move |evt| {
13321332+ let value = evt.value();
13331333+ let lines: Vec<String> = value.split('\n').map(str::to_string).collect();
13341334+ let mut c = w.composition;
13351335+ let mut g = c.write();
13361336+ let opt = if value.trim().is_empty() { None } else { Some(lines) };
13371337+ match target {
13381338+ FieldTarget::Thing => g.instructions = opt,
13391339+ FieldTarget::Model(mi) => {
13401340+ if let Some(m) = g.models.get_mut(mi) {
13411341+ m.instructions = opt;
6471342 }
648648- },
13431343+ }
13441344+ FieldTarget::Part(_, _) => {}
6491345 }
650650- }
13461346+ },
13471347+ onblur: move |_| w.clone().mark_dirty_and_save(),
6511348 }
6521349 }
6531350 }
6541351 }
655655- button {
656656- class: "button button-secondary wizard-add-model",
657657- onclick: move |_| {
658658- let mut c = add_model_w.composition;
659659- c.write().models.push(DraftModelInput::default());
660660- add_model_w.mark_dirty();
661661- },
662662- "Add a model"
663663- }
6641352 }
6651353 }
6661354667667- async fn handle_file_upload(self, model_index: usize, file: FileData) {
668668- let filename = file.name();
669669- let mut upload = self.upload;
670670- upload.set(UploadStatus::Uploading(filename.clone()));
671671- let bytes = match file.read_bytes().await {
672672- Ok(bytes) => bytes.to_vec(),
673673- Err(_) => {
674674- upload.set(UploadStatus::Error(
675675- "Could not read the selected file.".into(),
676676- ));
677677- return;
13551355+ fn render_license_section(&self, target: FieldTarget, wrapped: bool) -> Element {
13561356+ let composition = self.composition.read();
13571357+ let (value, is_default_thing) = match target {
13581358+ FieldTarget::Thing => {
13591359+ let v = composition
13601360+ .license
13611361+ .clone()
13621362+ .unwrap_or_else(|| DEFAULT_LICENSE.to_string());
13631363+ (v, true)
6781364 }
13651365+ FieldTarget::Model(mi) => {
13661366+ let v = composition
13671367+ .models
13681368+ .get(mi)
13691369+ .and_then(|m| m.license.clone())
13701370+ .unwrap_or_default();
13711371+ (v, false)
13721372+ }
13731373+ FieldTarget::Part(_, _) => return rsx! {},
6791374 };
680680- let mime = file
681681- .content_type()
682682- .filter(|s| !s.is_empty())
683683- .unwrap_or_else(|| mime_from_filename(&filename).to_string());
684684- // Clone before staging: `stage_file` takes `bytes` by value, but the
685685- // worker preview request still needs a temporary Blob URL for the same bytes.
686686- match draft_client::stage_file(&self.client, &filename, &mime, bytes.clone()).await {
687687- Ok(staged) => {
688688- let format = format_from_filename(&filename);
689689- let mut part = DraftPartInput {
690690- name: Some(filename.clone()),
691691- upload_id: Some(staged.upload_id.to_string()),
692692- file: Some(staged.file),
693693- format: format.clone(),
694694- ..Default::default()
695695- };
696696- // Best-effort preview generation (wasm-only; no-op off-wasm).
697697- // v1 supports STL only in the publish wizard — skip preview for other formats.
698698- let preview = if format.as_deref() == Some("stl") {
699699- let client = self.client.clone();
700700- let upload_sig = self.upload;
701701- let data_uris_sig = self.image_data_uris;
702702- generate_preview(client, filename.clone(), bytes, upload_sig, data_uris_sig)
703703- .await
704704- } else {
705705- None
706706- };
707707- {
708708- let mut composition = self.composition;
709709- let mut guard = composition.write();
710710- if let Some(image) = preview {
711711- let parts_before_push =
712712- guard.models.get(model_index).map_or(0, |m| m.parts.len());
713713- maybe_seed_cover(&mut guard, model_index, parts_before_push, &image);
714714- attach_preview(&mut part, image);
13751375+ drop(composition);
13761376+ let license_target = license_target_key(target);
13771377+ let explicit_custom = self.custom_license_targets.read().contains(&license_target);
13781378+ let known_license = LICENSE_OPTIONS.contains(&value.as_str());
13791379+ let is_custom = explicit_custom || (!value.trim().is_empty() && !known_license);
13801380+ let selected = if is_custom {
13811381+ CUSTOM_LICENSE_VALUE
13821382+ } else {
13831383+ value.as_str()
13841384+ };
13851385+ let custom_value = if is_custom && known_license {
13861386+ ""
13871387+ } else {
13881388+ value.as_str()
13891389+ };
13901390+ let select_w = self.clone();
13911391+ let custom_w = self.clone();
13921392+ let custom_blur_w = self.clone();
13931393+ let field = rsx! {
13941394+ div { class: "field publish-license-field",
13951395+ label { class: "field-label", r#for: "license-{license_target}", "License" }
13961396+ div { class: "publish-license-controls",
13971397+ select {
13981398+ id: "license-{license_target}",
13991399+ class: "field-input publish-license-select",
14001400+ value: "{selected}",
14011401+ onchange: move |evt| {
14021402+ let selected = evt.value();
14031403+ let mut custom_targets = select_w.custom_license_targets;
14041404+ if selected == CUSTOM_LICENSE_VALUE {
14051405+ custom_targets.write().insert(license_target.clone());
14061406+ return;
14071407+ }
14081408+ custom_targets.write().remove(&license_target);
14091409+ select_w.set_license_value(target, selected);
14101410+ select_w.clone().mark_dirty_and_save();
14111411+ },
14121412+ if !is_default_thing {
14131413+ option { value: "", "Use thing license" }
14141414+ }
14151415+ for option in LICENSE_OPTIONS {
14161416+ option { key: "{option}", value: "{option}", "{option}" }
14171417+ }
14181418+ option { value: CUSTOM_LICENSE_VALUE, "Custom…" }
7151419 }
716716- if let Some(model) = guard.models.get_mut(model_index) {
717717- model.parts.push(part);
14201420+ if is_custom {
14211421+ input {
14221422+ class: "field-input publish-license-custom",
14231423+ r#type: "text",
14241424+ value: "{custom_value}",
14251425+ placeholder: "Custom license string",
14261426+ aria_label: "Custom license",
14271427+ oninput: move |evt| {
14281428+ custom_w.set_license_value(target, evt.value());
14291429+ custom_w.mark_dirty();
14301430+ },
14311431+ onblur: move |_| custom_blur_w.clone().mark_dirty_and_save(),
14321432+ }
7181433 }
7191434 }
720720- upload.set(UploadStatus::Idle);
721721- let wizard = self.clone();
722722- wizard.save_draft().await;
7231435 }
724724- Err(error) => {
725725- upload.set(UploadStatus::Error(format!("Upload failed: {error}")));
14361436+ };
14371437+ if wrapped {
14381438+ let quiet = is_default_thing && value == DEFAULT_LICENSE;
14391439+ rsx! {
14401440+ Disclosure {
14411441+ title: "License",
14421442+ hint: if quiet { Some(DEFAULT_LICENSE.to_string()) } else { None },
14431443+ open: None,
14441444+ body: field,
14451445+ }
7261446 }
14471447+ } else {
14481448+ field
7271449 }
7281450 }
7291451730730- fn render_images(&self) -> Element {
14521452+ /// Inline cover-image editor for the thing level (always visible, not
14531453+ /// collapsed) — the cover is primary product media.
14541454+ fn render_cover_inline(&self) -> Element {
7311455 let composition = self.composition.read();
7321456 let cover = composition.cover.clone().unwrap_or_default();
733733- let previews = composition.previews.clone().unwrap_or_default();
734734- let status = self.image_upload.read().clone();
7351457 drop(composition);
736736-14581458+ let target = FieldTarget::Thing;
7371459 let cover_w = self.clone();
738738- let preview_w = self.clone();
7391460 rsx! {
740740- h2 { "Images" }
741741- match &status {
742742- UploadStatus::Uploading(label) => rsx! {
743743- div { class: "wizard-upload-status", role: "status",
744744- span { class: "wizard-progress-bar", "aria-hidden": "true" }
745745- span { "Uploading {label}…" }
746746- }
747747- },
748748- UploadStatus::Error(message) => rsx! { p { class: "wizard-error", role: "alert", "{message}" } },
749749- UploadStatus::Idle => rsx! {},
750750- }
751751- div { class: "wizard-image-group",
752752- h3 { class: "wizard-subhead", "Cover" }
753753- { self.render_image_list(&cover, true) }
14611461+ div { class: "wizard-image-group publish-cover-inline",
14621462+ span { class: "field-label", "Cover image" }
14631463+ { self.render_image_upload_status() }
14641464+ { self.render_image_list(&cover, target, true) }
7541465 label { class: "button button-secondary wizard-file-button",
7551466 "Add cover image"
7561467 input {
···7591470 accept: "image/png,image/jpeg,image/webp,image/gif",
7601471 onchange: move |evt| {
7611472 if let Some(file) = evt.files().into_iter().next() {
762762- let wizard = cover_w.clone();
763763- spawn(async move { wizard.handle_image_upload(true, file).await });
14731473+ let flow = cover_w.clone();
14741474+ spawn(async move { flow.handle_image_upload(target, true, file).await });
7641475 }
7651476 },
7661477 }
7671478 }
7681479 }
769769- div { class: "wizard-image-group",
770770- h3 { class: "wizard-subhead", "Previews" }
771771- { self.render_image_list(&previews, false) }
772772- label { class: "button button-secondary wizard-file-button",
773773- "Add preview image"
774774- input {
775775- class: "wizard-file-input",
776776- r#type: "file",
777777- accept: "image/png,image/jpeg,image/webp,image/gif",
778778- onchange: move |evt| {
779779- if let Some(file) = evt.files().into_iter().next() {
780780- let wizard = preview_w.clone();
781781- spawn(async move { wizard.handle_image_upload(false, file).await });
14801480+ }
14811481+ }
14821482+14831483+ fn render_images_section(&self, target: FieldTarget) -> Element {
14841484+ let composition = self.composition.read();
14851485+ // Cover is rendered inline at the thing level (render_cover_inline), so
14861486+ // this section only renders previews there; per-model it renders both.
14871487+ let cover = match target {
14881488+ FieldTarget::Model(mi) => composition
14891489+ .models
14901490+ .get(mi)
14911491+ .and_then(|m| m.cover.clone())
14921492+ .unwrap_or_default(),
14931493+ _ => Vec::new(),
14941494+ };
14951495+ let previews = match target {
14961496+ FieldTarget::Thing => composition.previews.clone().unwrap_or_default(),
14971497+ FieldTarget::Model(mi) => composition
14981498+ .models
14991499+ .get(mi)
15001500+ .and_then(|m| m.previews.clone())
15011501+ .unwrap_or_default(),
15021502+ FieldTarget::Part(mi, pi) => composition
15031503+ .models
15041504+ .get(mi)
15051505+ .and_then(|m| m.parts.get(pi))
15061506+ .and_then(|p| p.previews.clone())
15071507+ .unwrap_or_default(),
15081508+ };
15091509+ let has_cover_group = matches!(target, FieldTarget::Model(_));
15101510+ drop(composition);
15111511+ let cover_w = self.clone();
15121512+ let preview_w = self.clone();
15131513+ let (title, hint) = if has_cover_group {
15141514+ ("Images".to_string(), Some("Cover and previews".to_string()))
15151515+ } else {
15161516+ (
15171517+ "Preview images".to_string(),
15181518+ Some("Additional views".to_string()),
15191519+ )
15201520+ };
15211521+ rsx! {
15221522+ Disclosure {
15231523+ title,
15241524+ hint,
15251525+ open: None,
15261526+ body: rsx! {
15271527+ if has_cover_group {
15281528+ { self.render_image_upload_status() }
15291529+ }
15301530+ if has_cover_group {
15311531+ div { class: "wizard-image-group",
15321532+ h3 { class: "wizard-subhead", "Cover" }
15331533+ { self.render_image_list(&cover, target, true) }
15341534+ label { class: "button button-secondary wizard-file-button",
15351535+ "Add cover image"
15361536+ input {
15371537+ class: "wizard-file-input",
15381538+ r#type: "file",
15391539+ accept: "image/png,image/jpeg,image/webp,image/gif",
15401540+ onchange: move |evt| {
15411541+ if let Some(file) = evt.files().into_iter().next() {
15421542+ let flow = cover_w.clone();
15431543+ spawn(async move { flow.handle_image_upload(target, true, file).await });
15441544+ }
15451545+ },
15461546+ }
7821547 }
783783- },
15481548+ }
15491549+ }
15501550+ div { class: "wizard-image-group",
15511551+ h3 { class: "wizard-subhead", "Previews" }
15521552+ { self.render_image_list(&previews, target, false) }
15531553+ label { class: "button button-secondary wizard-file-button",
15541554+ "Add preview image"
15551555+ input {
15561556+ class: "wizard-file-input",
15571557+ r#type: "file",
15581558+ accept: "image/png,image/jpeg,image/webp,image/gif",
15591559+ onchange: move |evt| {
15601560+ if let Some(file) = evt.files().into_iter().next() {
15611561+ let flow = preview_w.clone();
15621562+ spawn(async move { flow.handle_image_upload(target, false, file).await });
15631563+ }
15641564+ },
15651565+ }
15661566+ }
7841567 }
7851568 }
7861569 }
7871570 }
7881571 }
7891572790790- fn render_image_list(&self, images: &[Image], is_cover: bool) -> Element {
15731573+ fn render_image_list(&self, images: &[Image], target: FieldTarget, is_cover: bool) -> Element {
7911574 if images.is_empty() {
7921575 return rsx! { p { class: "wizard-empty", "No images yet." } };
7931576 }
···8021585 .map(|uri| uri.to_string())
8031586 .or_else(|| self.did.as_ref().map(|did| image_cdn_url(did, image)));
8041587 let alt_w = self.clone();
15881588+ let alt_blur_w = self.clone();
8051589 let remove_w = self.clone();
8061590 rsx! {
8071591 li { key: "image-{is_cover}-{i}", class: "wizard-image",
···8221606 oninput: move |evt| {
8231607 let mut c = alt_w.composition;
8241608 let mut guard = c.write();
825825- let list = if is_cover { guard.cover.as_mut() } else { guard.previews.as_mut() };
16091609+ let list = image_list_mut(&mut guard, target, is_cover);
8261610 if let Some(list) = list
8271611 && let Some(img) = list.get_mut(i)
8281612 {
···8311615 drop(guard);
8321616 alt_w.mark_dirty();
8331617 },
16181618+ onblur: move |_| alt_blur_w.clone().mark_dirty_and_save(),
8341619 }
8351620 }
8361621 button {
···8381623 onclick: move |_| {
8391624 let mut c = remove_w.composition;
8401625 let mut guard = c.write();
841841- let list = if is_cover { guard.cover.as_mut() } else { guard.previews.as_mut() };
842842- if let Some(list) = list
843843- && i < list.len()
844844- {
16261626+ let list = image_list_mut(&mut guard, target, is_cover);
16271627+ if let Some(list) = list && i < list.len() {
8451628 list.remove(i);
8461629 }
8471630 drop(guard);
848848- remove_w.mark_dirty();
16311631+ remove_w.clone().mark_dirty_and_save();
8491632 },
8501633 "Remove"
8511634 }
···8571640 }
8581641 }
8591642860860- async fn handle_image_upload(self, is_cover: bool, file: FileData) {
861861- let filename = file.name();
862862- let mut status = self.image_upload;
863863- status.set(UploadStatus::Uploading(filename.clone()));
864864- let bytes = match file.read_bytes().await {
865865- Ok(bytes) => bytes.to_vec(),
866866- Err(_) => {
867867- status.set(UploadStatus::Error(
868868- "Could not read the selected image.".into(),
869869- ));
870870- return;
871871- }
872872- };
873873- let mime = file
874874- .content_type()
875875- .filter(|s| !s.is_empty())
876876- .unwrap_or_else(|| mime_from_filename(&filename).to_string());
877877- match draft_client::upload_image(&self.client, &filename, &mime, bytes.clone()).await {
878878- Ok(image) => {
879879- {
880880- let cid = image.image.blob().r#ref.as_str().to_string();
881881- let data_uri = data_uri_from_bytes(&bytes, &mime);
882882- let mut data_uris = self.image_data_uris;
883883- data_uris.write().insert(cid.into(), data_uri);
884884- let mut composition = self.composition;
885885- let mut guard = composition.write();
886886- let list = if is_cover {
887887- guard.cover.get_or_insert_with(Vec::new)
888888- } else {
889889- guard.previews.get_or_insert_with(Vec::new)
890890- };
891891- list.push(image);
16431643+ /// "Works with" — paste one or more thing URLs or `at://` URIs; each resolves
16441644+ /// to a `StrongRef` and renders as a removable chip showing the thing's name.
16451645+ fn render_works_with_section(&self) -> Element {
16461646+ let composition = self.composition.read();
16471647+ let refs = composition
16481648+ .intended_to_work_with
16491649+ .clone()
16501650+ .unwrap_or_default();
16511651+ drop(composition);
16521652+ rsx! {
16531653+ Disclosure {
16541654+ title: "Works with",
16551655+ hint: "Compatibility links to other things",
16561656+ open: None,
16571657+ body: rsx! {
16581658+ { self.render_works_with_status() }
16591659+ if !refs.is_empty() {
16601660+ ul { class: "publish-compat-list",
16611661+ for (i, r) in refs.iter().enumerate() {
16621662+ {
16631663+ let label = chip_label(self, r);
16641664+ let remove_w = self.clone();
16651665+ rsx! {
16661666+ li { key: "{i}", class: "publish-compat-chip",
16671667+ span { "{label}" }
16681668+ button {
16691669+ class: "button button-ghost publish-compat-remove",
16701670+ aria_label: "Remove {label}",
16711671+ onclick: move |_| {
16721672+ let mut c = remove_w.composition;
16731673+ let mut g = c.write();
16741674+ if let Some(list) = g.intended_to_work_with.as_mut() {
16751675+ list.remove(i);
16761676+ if list.is_empty() {
16771677+ g.intended_to_work_with = None;
16781678+ }
16791679+ }
16801680+ drop(g);
16811681+ remove_w.clone().mark_dirty_and_save();
16821682+ },
16831683+ "×"
16841684+ }
16851685+ }
16861686+ }
16871687+ }
16881688+ }
16891689+ }
16901690+ }
16911691+ { self.render_works_with_input() }
8921692 }
893893- status.set(UploadStatus::Idle);
894894- let wizard = self.clone();
895895- wizard.save_draft().await;
896896- }
897897- Err(error) => {
898898- status.set(UploadStatus::Error(format!("Image upload failed: {error}")));
8991693 }
9001694 }
9011695 }
9021696903903- fn render_details(&self) -> Element {
904904- let composition = self.composition.read();
905905- let instructions = composition
906906- .instructions
907907- .clone()
908908- .unwrap_or_default()
909909- .join("\n");
910910- let license = composition
911911- .license
912912- .clone()
913913- .unwrap_or_else(|| DEFAULT_LICENSE.to_string());
914914- let tags = composition.tags.clone().unwrap_or_default();
915915- drop(composition);
916916-917917- let tags = tags.join(", ");
918918- let license_w = self.clone();
919919- let tags_w = self.clone();
920920- let instructions_w = self.clone();
16971697+ /// The "Works with" paste field, bound to `works_with_text`.
16981698+ fn render_works_with_input(&self) -> Element {
16991699+ let current = self.works_with_text.read().clone();
17001700+ let text_sig = self.works_with_text;
17011701+ let flow = self.clone();
9211702 rsx! {
922922- h2 { "Details" }
9231703 label { class: "field",
924924- span { class: "field-label", "License" }
925925- input {
926926- class: "field-input",
927927- r#type: "text",
928928- list: "license-options",
929929- value: "{license}",
930930- placeholder: "{DEFAULT_LICENSE}",
17041704+ span { class: "field-label sr-only", "Add compatible things" }
17051705+ textarea {
17061706+ class: "field-input field-textarea",
17071707+ placeholder: "Paste a thing URL or at:// URI (one per line)",
17081708+ value: "{current}",
9311709 oninput: move |evt| {
932932- let mut c = license_w.composition;
933933- let v = evt.value();
934934- c.write().license = if v.trim().is_empty() { None } else { Some(v) };
935935- license_w.mark_dirty();
17101710+ let mut text_sig = text_sig;
17111711+ text_sig.set(evt.value());
9361712 },
937937- }
938938- datalist { id: "license-options",
939939- for option in LICENSE_OPTIONS {
940940- option { key: "{option}", value: "{option}" }
941941- }
942942- }
943943- }
944944- label { class: "field",
945945- span { class: "field-label", "Tags" }
946946- input {
947947- class: "field-input",
948948- r#type: "text",
949949- value: "{tags}",
950950- placeholder: "comma, separated",
951951- oninput: move |evt| {
952952- let mut c = tags_w.composition;
953953- let parsed: Vec<String> = evt
954954- .value()
955955- .split(',')
956956- .map(|t| t.trim().to_string())
957957- .filter(|t| !t.is_empty())
958958- .collect();
959959- c.write().tags = if parsed.is_empty() { None } else { Some(parsed) };
960960- tags_w.mark_dirty();
17131713+ onblur: move |_| {
17141714+ let value = text_sig.read().clone();
17151715+ if value.trim().is_empty() {
17161716+ return;
17171717+ }
17181718+ let flow = flow.clone();
17191719+ spawn(async move { flow.resolve_works_with(value).await });
9611720 },
9621721 }
9631722 }
964964- label { class: "field",
965965- span { class: "field-label", "Instructions" }
966966- textarea {
967967- class: "field-input field-textarea field-textarea-tall",
968968- value: "{instructions}",
969969- placeholder: "One step per line",
970970- oninput: move |evt| {
971971- let mut c = instructions_w.composition;
972972- let value = evt.value();
973973- // Store the text exactly as typed (split only on newlines) so
974974- // spaces and blank lines survive editing; empty lines are
975975- // dropped at assembly.
976976- let lines: Vec<String> = value.split('\n').map(str::to_string).collect();
977977- c.write().instructions = if value.trim().is_empty() { None } else { Some(lines) };
978978- instructions_w.mark_dirty();
979979- },
17231723+ }
17241724+ }
17251725+17261726+ async fn resolve_works_with(self, text: String) {
17271727+ {
17281728+ let mut status = self.works_with_status;
17291729+ status.set(UploadStatus::Idle);
17301730+ }
17311731+ let tokens: Vec<String> = text.split_whitespace().map(str::to_string).collect();
17321732+ let mut added = false;
17331733+ let mut unresolved: Vec<String> = Vec::new();
17341734+ for token in tokens {
17351735+ match parse_thing_token(&token) {
17361736+ Ok(uri) => match draft_client::resolve_strong_ref(&uri).await {
17371737+ Ok(resolved) => {
17381738+ let uri_str = resolved.strong_ref.uri.as_str().to_string();
17391739+ let name = resolved.name.clone();
17401740+ let already_present = {
17411741+ let g = self.composition.read();
17421742+ g.intended_to_work_with
17431743+ .as_ref()
17441744+ .map(|list| list.iter().any(|r| r.uri.as_str() == uri_str))
17451745+ .unwrap_or(false)
17461746+ };
17471747+ if !already_present {
17481748+ let mut c = self.composition;
17491749+ let mut g = c.write();
17501750+ let list = g.intended_to_work_with.get_or_insert_with(Vec::new);
17511751+ list.push(resolved.strong_ref);
17521752+ if let Some(name) = name {
17531753+ let mut names = self.compat_names;
17541754+ names.write().insert(uri_str, name);
17551755+ }
17561756+ added = true;
17571757+ }
17581758+ }
17591759+ Err(error) => {
17601760+ unresolved.push(token.clone());
17611761+ let mut status = self.works_with_status;
17621762+ status.set(UploadStatus::Error(format!(
17631763+ "Could not resolve “{token}”: {error}"
17641764+ )));
17651765+ }
17661766+ },
17671767+ Err(error) => {
17681768+ unresolved.push(token.clone());
17691769+ let mut status = self.works_with_status;
17701770+ status.set(UploadStatus::Error(format!("“{token}”: {error}")));
9801771 }
9811772 }
17731773+ }
17741774+ // Keep only the tokens that did not resolve, so the user can fix them;
17751775+ // clear the field once everything resolved.
17761776+ let mut text_sig = self.works_with_text;
17771777+ text_sig.set(unresolved.join("\n"));
17781778+ if added {
17791779+ self.mark_dirty_and_save();
9821780 }
9831781 }
9841782985985- fn render_review(&self) -> Element {
17831783+ /// Compact "what's public" confirm + Publish at the page bottom.
17841784+ fn render_confirm(&self) -> Element {
9861785 let composition = self.composition.read();
9871786 let assembled = composition.assemble();
9881787 let name = composition.display_name().unwrap_or("Untitled").to_string();
···9971796 let cover_count = composition.cover.as_ref().map(|c| c.len()).unwrap_or(0);
9981797 let preview_count = composition.previews.as_ref().map(|p| p.len()).unwrap_or(0);
9991798 let instructions = composition.instructions.clone().unwrap_or_default();
17991799+ let works_count = composition
18001800+ .intended_to_work_with
18011801+ .as_ref()
18021802+ .map(|w| w.len())
18031803+ .unwrap_or(0);
10001804 drop(composition);
1001180510021806 let publishing = matches!(*self.phase.read(), PublishPhase::Publishing);
···10051809 .as_ref()
10061810 .map(|e| e.has_field("name"))
10071811 .unwrap_or(false);
10081008- let license_error = issues
10091009- .as_ref()
10101010- .map(|e| e.has_field("license"))
10111011- .unwrap_or(false);
10121812 let name_row_class = if name_error {
10131013- "wizard-review-row is-error"
10141014- } else {
10151015- "wizard-review-row"
10161016- };
10171017- let license_row_class = if license_error {
10181813 "wizard-review-row is-error"
10191814 } else {
10201815 "wizard-review-row"
···10231818 let publish_w = self.clone();
1024181910251820 rsx! {
10261026- h2 { "Review & publish" }
10271027- p { class: "wizard-step-hint", "This is what becomes public when you publish." }
10281028-10291029- div { class: "wizard-review",
10301030- div { class: "{name_row_class}",
10311031- span { class: "field-label", "Name" }
10321032- span { "{name}" }
10331033- }
10341034- div { class: "{license_row_class}",
10351035- span { class: "field-label", "License" }
10361036- span { class: "status-pill", "{license}" }
18211821+ section { class: "wizard-body blueprint-panel publish-confirm", aria_label: "Review and publish",
18221822+ if let Some(issues) = issues {
18231823+ div { class: "wizard-issues error-state state-card", role: "alert",
18241824+ h3 { class: "wizard-subhead", "Fix these before publishing" }
18251825+ ul { class: "instruction-list",
18261826+ for issue in issues.issues {
18271827+ li { key: "{issue.field}", "{issue.message}" }
18281828+ }
18291829+ }
18301830+ }
10371831 }
10381038- if !summary.is_empty() {
18321832+ div { class: "wizard-review",
18331833+ div { class: "{name_row_class}",
18341834+ span { class: "field-label", "Name" }
18351835+ span { "{name}" }
18361836+ }
10391837 div { class: "wizard-review-row",
10401040- span { class: "field-label", "Summary" }
10411041- span { "{summary}" }
18381838+ span { class: "field-label", "License" }
18391839+ span { class: "status-pill", "{license}" }
18401840+ }
18411841+ if !summary.is_empty() {
18421842+ div { class: "wizard-review-row",
18431843+ span { class: "field-label", "Summary" }
18441844+ span { "{summary}" }
18451845+ }
18461846+ }
18471847+ if !tags.is_empty() {
18481848+ div { class: "wizard-review-row",
18491849+ span { class: "field-label", "Tags" }
18501850+ div { class: "thing-card-chips",
18511851+ for tag in tags { span { key: "{tag}", class: "status-pill", "{tag}" } }
18521852+ }
18531853+ }
10421854 }
10431043- }
10441044- if !tags.is_empty() {
10451855 div { class: "wizard-review-row",
10461046- span { class: "field-label", "Tags" }
10471047- div { class: "thing-card-chips",
10481048- for tag in tags { span { key: "{tag}", class: "status-pill", "{tag}" } }
10491049- }
18561856+ span { class: "field-label", "Media" }
18571857+ span { class: "card-meta", "{cover_count} cover · {preview_count} preview" }
10501858 }
10511051- }
10521052- div { class: "wizard-review-row",
10531053- span { class: "field-label", "Media" }
10541054- span { class: "card-meta", "{cover_count} cover · {preview_count} preview" }
10551055- }
10561056- div { class: "wizard-review-row",
10571057- span { class: "field-label", "Models ({model_count})" }
10581058- div { class: "wizard-review-models",
10591059- for (mi, model) in models.iter().enumerate() {
10601060- {
10611061- let mname = model.name.clone().unwrap_or_else(|| format!("Model {}", mi + 1));
10621062- let part_count = model.parts.len();
10631063- rsx! {
10641064- div { key: "review-model-{mi}", class: "wizard-review-model",
10651065- strong { "{mname}" }
10661066- span { class: "card-meta", "{part_count} part(s)" }
18591859+ div { class: "wizard-review-row",
18601860+ span { class: "field-label", "Models ({model_count})" }
18611861+ div { class: "wizard-review-models",
18621862+ for (mi, model) in models.iter().enumerate() {
18631863+ {
18641864+ let mname = model.name.clone().unwrap_or_else(|| format!("Model {}", mi + 1));
18651865+ let part_count = model.parts.len();
18661866+ rsx! {
18671867+ div { key: "review-model-{mi}", class: "wizard-review-model",
18681868+ strong { "{mname}" }
18691869+ span { class: "card-meta", "{part_count} part(s)" }
18701870+ }
10671871 }
10681872 }
10691873 }
10701874 }
10711875 }
10721072- }
10731073- if !instructions.is_empty() {
10741074- div { class: "wizard-review-row",
10751075- span { class: "field-label", "Instructions" }
10761076- span { class: "card-meta", "{instructions.len()} step(s)" }
18761876+ if !instructions.is_empty() {
18771877+ div { class: "wizard-review-row",
18781878+ span { class: "field-label", "Instructions" }
18791879+ span { class: "card-meta", "{instructions.len()} step(s)" }
18801880+ }
10771881 }
10781078- }
10791079- }
10801080-10811081- if let Some(issues) = issues {
10821082- div { class: "wizard-issues error-state state-card", role: "alert",
10831083- h3 { class: "wizard-subhead", "Fix these before publishing" }
10841084- ul { class: "instruction-list",
10851085- for issue in issues.issues {
10861086- li { key: "{issue.field}", "{issue.message}" }
18821882+ if works_count > 0 {
18831883+ div { class: "wizard-review-row",
18841884+ span { class: "field-label", "Works with" }
18851885+ span { class: "card-meta", "{works_count} link(s)" }
10871886 }
10881887 }
10891888 }
10901090- }
18891889+1091189010921092- div { class: "wizard-publish-row",
10931093- button {
10941094- class: "button button-primary wizard-publish",
10951095- disabled: !can_publish,
10961096- onclick: move |_| {
10971097- let wizard = publish_w.clone();
10981098- spawn(async move { wizard.publish().await });
10991099- },
11001100- if publishing { "Publishing…" } else { "Publish" }
18911891+18921892+ div { class: "wizard-publish-row",
18931893+ button {
18941894+ class: "button button-primary wizard-publish",
18951895+ disabled: !can_publish,
18961896+ onclick: move |_| {
18971897+ let wizard = publish_w.clone();
18981898+ spawn(async move { wizard.publish().await });
18991899+ },
19001900+ if publishing { "Publishing…" } else { "Publish" }
19011901+ }
11011902 }
11021903 }
11031904 }
···1105190611061907 async fn publish(self) {
11071908 // Persist the latest edits first. If that save fails, do NOT publish —
11081108- // otherwise we would publish stale server-side draft state.
11091109- self.clone().save_draft().await;
11101110- if matches!(*self.save.read(), SaveState::Error) {
11111111- let mut phase = self.phase;
11121112- phase.set(PublishPhase::Failed(
11131113- "Could not save your latest changes, so nothing was published. Try again.".into(),
11141114- ));
11151115- return;
19091909+ // otherwise we would publish stale server-side draft state. Re-save
19101910+ // until the state settles (an in-flight blur-save can leave us Dirty),
19111911+ // so the server-side draft reflects every edit before we publish it.
19121912+ loop {
19131913+ self.clone().save_draft().await;
19141914+ match *self.save.read() {
19151915+ SaveState::Error => {
19161916+ let mut phase = self.phase;
19171917+ phase.set(PublishPhase::Failed(
19181918+ "Could not save your latest changes, so nothing was published. Try again."
19191919+ .into(),
19201920+ ));
19211921+ return;
19221922+ }
19231923+ SaveState::Saved => break,
19241924+ // Dirty/Saving/New: a save is in flight or coalesced our edit.
19251925+ // Yield to the executor so that save's future can complete and
19261926+ // clear the guard, then loop to persist the latest state.
19271927+ SaveState::Dirty | SaveState::Saving | SaveState::New => {
19281928+ dioxus_sdk::time::sleep(std::time::Duration::from_millis(50)).await;
19291929+ continue;
19301930+ }
19311931+ }
11161932 }
11171933 let Some(id) = self.draft_id.read().clone() else {
11181934 let mut phase = self.phase;
···11511967 }
11521968}
1153196919701970+impl Flow {
19711971+ /// Stage every dropped/picked file through one batch: ensure the default
19721972+ /// model + auto name, stage each file (stop at first failure), then save once.
19731973+ async fn handle_files_upload(self, files: Vec<FileData>) {
19741974+ let mut upload = self.upload;
19751975+ let first_name = files.first().map(|f| f.name()).unwrap_or_default();
19761976+ {
19771977+ let mut composition = self.composition;
19781978+ let mut g = composition.write();
19791979+ ensure_default_model_for_upload(&mut g, &first_name);
19801980+ }
19811981+ let count = files.len();
19821982+ upload.set(UploadStatus::Uploading(if count == 1 {
19831983+ first_name.clone()
19841984+ } else {
19851985+ format!("{count} files")
19861986+ }));
19871987+ for file in files {
19881988+ let filename = file.name();
19891989+ match file.read_bytes().await {
19901990+ Ok(bytes) => {
19911991+ let bytes = bytes.to_vec();
19921992+ let mime = file
19931993+ .content_type()
19941994+ .filter(|s| !s.is_empty())
19951995+ .unwrap_or_else(|| mime_from_filename(&filename).to_string());
19961996+ match draft_client::stage_file(&self.client, &filename, &mime, bytes.clone())
19971997+ .await
19981998+ {
19991999+ Ok(staged) => {
20002000+ let format = format_from_filename(&filename);
20012001+ let mut part = DraftPartInput {
20022002+ name: Some(filename.clone()),
20032003+ upload_id: Some(staged.upload_id.to_string()),
20042004+ file: Some(staged.file),
20052005+ format: format.clone(),
20062006+ ..Default::default()
20072007+ };
20082008+ let preview = if supports_generated_preview(&filename) {
20092009+ generate_preview(
20102010+ self.client.clone(),
20112011+ filename.clone(),
20122012+ bytes,
20132013+ self.upload,
20142014+ self.image_data_uris,
20152015+ )
20162016+ .await
20172017+ } else {
20182018+ None
20192019+ };
20202020+ let was_first = {
20212021+ let g = self.composition.read();
20222022+ g.models.first().map(|m| m.parts.is_empty()).unwrap_or(true)
20232023+ };
20242024+ let mut composition = self.composition;
20252025+ let mut g = composition.write();
20262026+ if let Some(image) = preview {
20272027+ let parts_before_push =
20282028+ g.models.first().map_or(0, |m| m.parts.len());
20292029+ attach_generated_preview(
20302030+ &mut g,
20312031+ 0,
20322032+ parts_before_push,
20332033+ &mut part,
20342034+ image,
20352035+ );
20362036+ }
20372037+ if let Some(model) = g.models.get_mut(0) {
20382038+ model.parts.push(part);
20392039+ }
20402040+ drop(g);
20412041+ // Focus the name field once, on the first staged
20422042+ // file, so the user lands on the next likely edit.
20432043+ if was_first && let Some(element) = self.name_element.read().clone() {
20442044+ let name_element = self.name_element;
20452045+ spawn(async move {
20462046+ let _ = element.set_focus(true).await;
20472047+ let _ = name_element;
20482048+ });
20492049+ }
20502050+ }
20512051+ Err(error) => {
20522052+ upload.set(UploadStatus::Error(format!(
20532053+ "Upload failed for {filename}: {error}"
20542054+ )));
20552055+ let flow = self.clone();
20562056+ flow.save_draft().await;
20572057+ return;
20582058+ }
20592059+ }
20602060+ }
20612061+ Err(_) => {
20622062+ upload.set(UploadStatus::Error(format!("Could not read {filename}.")));
20632063+ let flow = self.clone();
20642064+ flow.save_draft().await;
20652065+ return;
20662066+ }
20672067+ }
20682068+ }
20692069+ upload.set(UploadStatus::Idle);
20702070+ let flow = self.clone();
20712071+ flow.save_draft().await;
20722072+ }
20732073+20742074+ /// Stage a single file into a specific model (the per-model "Add a file"
20752075+ /// button). Shares the per-file routine; saves once.
20762076+ async fn stage_file_into(self, model_index: usize, file: FileData) {
20772077+ let filename = file.name();
20782078+ let mut upload = self.upload;
20792079+ upload.set(UploadStatus::Uploading(filename.clone()));
20802080+ let bytes = match file.read_bytes().await {
20812081+ Ok(bytes) => bytes.to_vec(),
20822082+ Err(_) => {
20832083+ upload.set(UploadStatus::Error(
20842084+ "Could not read the selected file.".into(),
20852085+ ));
20862086+ return;
20872087+ }
20882088+ };
20892089+ let mime = file
20902090+ .content_type()
20912091+ .filter(|s| !s.is_empty())
20922092+ .unwrap_or_else(|| mime_from_filename(&filename).to_string());
20932093+ match draft_client::stage_file(&self.client, &filename, &mime, bytes.clone()).await {
20942094+ Ok(staged) => {
20952095+ let format = format_from_filename(&filename);
20962096+ let mut part = DraftPartInput {
20972097+ name: Some(filename.clone()),
20982098+ upload_id: Some(staged.upload_id.to_string()),
20992099+ file: Some(staged.file),
21002100+ format: format.clone(),
21012101+ ..Default::default()
21022102+ };
21032103+ let preview = if supports_generated_preview(&filename) {
21042104+ generate_preview(
21052105+ self.client.clone(),
21062106+ filename.clone(),
21072107+ bytes,
21082108+ self.upload,
21092109+ self.image_data_uris,
21102110+ )
21112111+ .await
21122112+ } else {
21132113+ None
21142114+ };
21152115+ {
21162116+ let mut composition = self.composition;
21172117+ let mut guard = composition.write();
21182118+ if let Some(image) = preview {
21192119+ let parts_before_push =
21202120+ guard.models.get(model_index).map_or(0, |m| m.parts.len());
21212121+ attach_generated_preview(
21222122+ &mut guard,
21232123+ model_index,
21242124+ parts_before_push,
21252125+ &mut part,
21262126+ image,
21272127+ );
21282128+ }
21292129+ if let Some(model) = guard.models.get_mut(model_index) {
21302130+ model.parts.push(part);
21312131+ }
21322132+ }
21332133+ upload.set(UploadStatus::Idle);
21342134+ let flow = self.clone();
21352135+ flow.save_draft().await;
21362136+ }
21372137+ Err(error) => {
21382138+ upload.set(UploadStatus::Error(format!("Upload failed: {error}")));
21392139+ }
21402140+ }
21412141+ }
21422142+21432143+ /// Upload an image and attach it to the target's cover/previews, then save
21442144+ /// once after success. The image-upload status is its own signal so it does
21452145+ /// not race the file-upload status.
21462146+ async fn handle_image_upload(self, target: FieldTarget, is_cover: bool, file: FileData) {
21472147+ let filename = file.name();
21482148+ let mut status = self.image_upload;
21492149+ status.set(UploadStatus::Uploading(filename.clone()));
21502150+ let bytes = match file.read_bytes().await {
21512151+ Ok(bytes) => bytes.to_vec(),
21522152+ Err(_) => {
21532153+ status.set(UploadStatus::Error(
21542154+ "Could not read the selected image.".into(),
21552155+ ));
21562156+ return;
21572157+ }
21582158+ };
21592159+ let mime = file
21602160+ .content_type()
21612161+ .filter(|s| !s.is_empty())
21622162+ .unwrap_or_else(|| mime_from_filename(&filename).to_string());
21632163+ match draft_client::upload_image(&self.client, &filename, &mime, bytes.clone()).await {
21642164+ Ok(image) => {
21652165+ {
21662166+ let cid = image.image.blob().r#ref.as_str().to_string();
21672167+ let data_uri = data_uri_from_bytes(&bytes, &mime);
21682168+ let mut data_uris = self.image_data_uris;
21692169+ data_uris.write().insert(cid.into(), data_uri);
21702170+ let mut composition = self.composition;
21712171+ let mut guard = composition.write();
21722172+ let list = image_list_mut(&mut guard, target, is_cover);
21732173+ if let Some(list) = list {
21742174+ list.push(image);
21752175+ }
21762176+ }
21772177+ status.set(UploadStatus::Idle);
21782178+ self.mark_dirty_and_save();
21792179+ }
21802180+ Err(error) => {
21812181+ status.set(UploadStatus::Error(format!("Image upload failed: {error}")));
21822182+ }
21832183+ }
21842184+ }
21852185+}
21862186+21872187+/// A controlled `<details>` disclosure. When `open` is `Some(signal)` the caller
21882188+/// drives open-state (the Advanced section, opened by the auto-expand effect);
21892189+/// when `None` the disclosure manages its own open-state. In both cases
21902190+/// `ontoggle` reads the real DOM open-state back into the owning signal so the
21912191+/// program and the user share one synced source of truth without fighting on
21922192+/// re-render.
21932193+#[component]
21942194+fn Disclosure(
21952195+ title: String,
21962196+ hint: Option<String>,
21972197+ open: Option<Signal<bool>>,
21982198+ body: Element,
21992199+) -> Element {
22002200+ let local = use_signal(|| false);
22012201+ let on_toggle = move |evt: ToggleEvent| {
22022202+ if let Some(now_open) = read_details_open(&evt) {
22032203+ match open {
22042204+ Some(signal) => {
22052205+ let mut signal = signal;
22062206+ signal.set(now_open);
22072207+ }
22082208+ None => {
22092209+ let mut local = local;
22102210+ local.set(now_open);
22112211+ }
22122212+ }
22132213+ }
22142214+ };
22152215+ let is_open = match open {
22162216+ Some(signal) => *signal.read(),
22172217+ None => *local.read(),
22182218+ };
22192219+ rsx! {
22202220+ details { class: "publish-disclosure", open: is_open, ontoggle: on_toggle,
22212221+ summary { class: "publish-disclosure-summary",
22222222+ h3 { class: "publish-disclosure-title", "{title}" }
22232223+ if let Some(hint) = &hint {
22242224+ span { class: "publish-disclosure-hint card-meta", "{hint}" }
22252225+ }
22262226+ }
22272227+ div { class: "publish-disclosure-body", {body} }
22282228+ }
22292229+ }
22302230+}
22312231+22322232+/// Read the real open-state of a toggled `<details>` from its DOM target. Wasm
22332233+/// only; returns `None` off-wasm (where the toggle event has no web backing).
22342234+#[cfg(target_arch = "wasm32")]
22352235+fn read_details_open(evt: &ToggleEvent) -> Option<bool> {
22362236+ use dioxus::web::WebEventExt;
22372237+ use wasm_bindgen::JsCast;
22382238+ evt.try_as_web_event()
22392239+ .and_then(|e| e.target())
22402240+ .and_then(|t| t.dyn_into::<web_sys::HtmlDetailsElement>().ok())
22412241+ .map(|el| el.open())
22422242+}
22432243+22442244+#[cfg(not(target_arch = "wasm32"))]
22452245+fn read_details_open(_evt: &ToggleEvent) -> Option<bool> {
22462246+ None
22472247+}
22482248+22492249+fn image_list_mut(
22502250+ thing: &mut DraftThingInput,
22512251+ target: FieldTarget,
22522252+ is_cover: bool,
22532253+) -> Option<&mut Vec<Image>> {
22542254+ match target {
22552255+ FieldTarget::Thing => {
22562256+ if is_cover {
22572257+ Some(thing.cover.get_or_insert_with(Vec::new))
22582258+ } else {
22592259+ Some(thing.previews.get_or_insert_with(Vec::new))
22602260+ }
22612261+ }
22622262+ FieldTarget::Model(mi) => {
22632263+ let model = thing.models.get_mut(mi)?;
22642264+ if is_cover {
22652265+ Some(model.cover.get_or_insert_with(Vec::new))
22662266+ } else {
22672267+ Some(model.previews.get_or_insert_with(Vec::new))
22682268+ }
22692269+ }
22702270+ FieldTarget::Part(mi, pi) => {
22712271+ let part = thing.models.get_mut(mi)?.parts.get_mut(pi)?;
22722272+ Some(part.previews.get_or_insert_with(Vec::new))
22732273+ }
22742274+ }
22752275+}
22762276+22772277+fn license_target_key(target: FieldTarget) -> String {
22782278+ match target {
22792279+ FieldTarget::Thing => "thing".to_string(),
22802280+ FieldTarget::Model(mi) => format!("model-{mi}"),
22812281+ FieldTarget::Part(mi, pi) => format!("part-{mi}-{pi}"),
22822282+ }
22832283+}
22842284+22852285+fn is_initial_composition(composition: &DraftThingInput) -> bool {
22862286+ composition.name.is_none()
22872287+ && matches!(composition.license.as_deref(), None | Some(DEFAULT_LICENSE))
22882288+ && composition.summary.is_none()
22892289+ && composition.tags.is_none()
22902290+ && composition.instructions.is_none()
22912291+ && composition.cover.is_none()
22922292+ && composition.previews.is_none()
22932293+ && composition.derived_from.is_none()
22942294+ && composition.intended_to_work_with.is_none()
22952295+ && composition.models.is_empty()
22962296+}
22972297+22982298+/// A short human label for a resolved "works with" chip: the resolved thing's
22992299+/// name when known, else the rkey (never a raw URI or CID).
23002300+fn chip_label(flow: &Flow, r: &StrongRef) -> String {
23012301+ if let Some(name) = flow.compat_names.read().get(r.uri.as_str()) {
23022302+ return name.clone();
23032303+ }
23042304+ if let Some(rkey) = r.uri.rkey() {
23052305+ rkey.as_str().to_string()
23062306+ } else {
23072307+ "thing".to_string()
23082308+ }
23092309+}
23102310+11542311/// Best-effort MIME type from a filename extension (the server re-sniffs image
11552312/// dimensions independently).
11562313fn mime_from_filename(name: &str) -> &'static str {
···11812338 }
11822339}
1183234023412341+fn supports_generated_preview(name: &str) -> bool {
23422342+ matches!(
23432343+ extension(name).as_deref(),
23442344+ Some("stl" | "obj" | "gltf" | "glb" | "step" | "stp")
23452345+ )
23462346+}
23472347+11842348fn extension(name: &str) -> Option<String> {
11852349 name.rsplit_once('.')
11862350 .map(|(_, ext)| ext.to_ascii_lowercase())
···12102374 part.previews = Some(vec![image]);
12112375}
1212237623772377+fn attach_generated_preview(
23782378+ composition: &mut DraftThingInput,
23792379+ model_index: usize,
23802380+ parts_before_push: usize,
23812381+ part: &mut DraftPartInput,
23822382+ image: Image,
23832383+) {
23842384+ maybe_seed_cover(composition, model_index, parts_before_push, &image);
23852385+ composition
23862386+ .previews
23872387+ .get_or_insert_with(Vec::new)
23882388+ .push(image.clone());
23892389+ if let Some(model) = composition.models.get_mut(model_index) {
23902390+ model
23912391+ .previews
23922392+ .get_or_insert_with(Vec::new)
23932393+ .push(image.clone());
23942394+ }
23952395+ attach_preview(part, image);
23962396+}
23972397+12132398/// Seed the thing-level cover from the first file of the first model when no
12142399/// cover exists. Only fires for `model_index == 0` when the model has zero
12152400/// parts *before* pushing the new one (the first file overall) and cover is
···12892474 width: u32,
12902475 height: u32,
12912476 ) -> Result<Vec<u8>, String> {
24772477+ if is_step_filename(filename) {
24782478+ let step_url = blob_url_for_bytes(bytes, "model/step")?;
24792479+ let glb_url = match convert_step_to_glb_url(step_url.clone()).await {
24802480+ Ok(url) => url,
24812481+ Err(error) => {
24822482+ let _ = web_sys::Url::revoke_object_url(&step_url);
24832483+ return Err(error);
24842484+ }
24852485+ };
24862486+ let result =
24872487+ request_preview_png(MeshFormat::Gltf, glb_url.clone(), width, height).await;
24882488+ let _ = web_sys::Url::revoke_object_url(&glb_url);
24892489+ let _ = web_sys::Url::revoke_object_url(&step_url);
24902490+ return result;
24912491+ }
24922492+12922493 let format = MeshFormat::from_extension(filename)
12932494 .ok_or_else(|| "unsupported preview format".to_string())?;
12942495 let blob_url = blob_url_for_bytes(bytes, mime_from_format(format))?;
···13182519 .map_err(|e| format!("preview blob URL: {e:?}"))
13192520 }
1320252125222522+ fn is_step_filename(filename: &str) -> bool {
25232523+ filename
25242524+ .rsplit_once('.')
25252525+ .map(|(_, ext)| matches!(ext.to_ascii_lowercase().as_str(), "step" | "stp"))
25262526+ .unwrap_or(false)
25272527+ }
25282528+25292529+ async fn convert_step_to_glb_url(step_url: String) -> Result<String, String> {
25302530+ let opts = web_sys::WorkerOptions::new();
25312531+ opts.set_type(web_sys::WorkerType::Module);
25322532+ let worker = web_sys::Worker::new_with_options("/step_worker_loader.js", &opts)
25332533+ .map_err(|e| format!("STEP preview worker: {e:?}"))?;
25342534+ let worker_for_message = worker.clone();
25352535+ let worker_for_error = worker.clone();
25362536+ let promise = js_sys::Promise::new(&mut move |resolve, reject| {
25372537+ let step_url = step_url.clone();
25382538+ let worker_for_message = worker_for_message.clone();
25392539+ let resolve_message = resolve.clone();
25402540+ let reject_message = reject.clone();
25412541+ let onmessage = Closure::wrap(Box::new(move |event: web_sys::MessageEvent| {
25422542+ let data = event.data();
25432543+ let kind = js_string_field(&data, "kind").unwrap_or_default();
25442544+ match kind.as_str() {
25452545+ "ready" => {
25462546+ let msg = js_sys::Object::new();
25472547+ let _ =
25482548+ js_sys::Reflect::set(&msg, &"command".into(), &"convert-step".into());
25492549+ let _ =
25502550+ js_sys::Reflect::set(&msg, &"stepUrl".into(), &step_url.clone().into());
25512551+ if let Err(e) = worker_for_message.post_message(&msg) {
25522552+ let _ = reject_message.call1(
25532553+ &JsValue::NULL,
25542554+ &JsValue::from_str(&format!("STEP preview command post: {e:?}")),
25552555+ );
25562556+ }
25572557+ }
25582558+ "converted" => {
25592559+ let Some(buffer) = js_value_field(&data, "buffer") else {
25602560+ let _ = reject_message.call1(
25612561+ &JsValue::NULL,
25622562+ &JsValue::from_str("STEP preview returned no GLB bytes"),
25632563+ );
25642564+ return;
25652565+ };
25662566+ let bytes = js_sys::Uint8Array::new(&buffer);
25672567+ let parts = js_sys::Array::new();
25682568+ parts.push(&bytes.buffer());
25692569+ let opts = web_sys::BlobPropertyBag::new();
25702570+ opts.set_type("model/gltf-binary");
25712571+ let blob = match web_sys::Blob::new_with_buffer_source_sequence_and_options(
25722572+ &parts, &opts,
25732573+ ) {
25742574+ Ok(blob) => blob,
25752575+ Err(e) => {
25762576+ let _ = reject_message.call1(
25772577+ &JsValue::NULL,
25782578+ &JsValue::from_str(&format!("STEP preview GLB blob: {e:?}")),
25792579+ );
25802580+ return;
25812581+ }
25822582+ };
25832583+ match web_sys::Url::create_object_url_with_blob(&blob) {
25842584+ Ok(url) => {
25852585+ let _ =
25862586+ resolve_message.call1(&JsValue::NULL, &JsValue::from_str(&url));
25872587+ }
25882588+ Err(e) => {
25892589+ let _ = reject_message.call1(
25902590+ &JsValue::NULL,
25912591+ &JsValue::from_str(&format!("STEP preview GLB URL: {e:?}")),
25922592+ );
25932593+ }
25942594+ }
25952595+ }
25962596+ "error" => {
25972597+ let message = js_string_field(&data, "message")
25982598+ .unwrap_or_else(|| "STEP preview conversion failed".into());
25992599+ let _ = reject_message.call1(&JsValue::NULL, &JsValue::from_str(&message));
26002600+ }
26012601+ _ => {}
26022602+ }
26032603+ }) as Box<dyn FnMut(web_sys::MessageEvent)>);
26042604+ let reject_error = reject.clone();
26052605+ let onerror = Closure::wrap(Box::new(move |event: web_sys::ErrorEvent| {
26062606+ let _ = reject_error.call1(&JsValue::NULL, &JsValue::from_str(&event.message()));
26072607+ }) as Box<dyn FnMut(web_sys::ErrorEvent)>);
26082608+ worker.set_onmessage(Some(onmessage.as_ref().unchecked_ref()));
26092609+ worker.set_onerror(Some(onerror.as_ref().unchecked_ref()));
26102610+ onmessage.forget();
26112611+ onerror.forget();
26122612+ });
26132613+ let output = wasm_bindgen_futures::JsFuture::from(promise)
26142614+ .await
26152615+ .map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))?;
26162616+ worker_for_error.terminate();
26172617+ output
26182618+ .as_string()
26192619+ .ok_or_else(|| "STEP preview returned a non-string GLB URL".to_string())
26202620+ }
26212621+26222622+ fn js_value_field(value: &wasm_bindgen::JsValue, name: &str) -> Option<wasm_bindgen::JsValue> {
26232623+ js_sys::Reflect::get(value, &name.into()).ok()
26242624+ }
26252625+26262626+ fn js_string_field(value: &wasm_bindgen::JsValue, name: &str) -> Option<String> {
26272627+ js_value_field(value, name).and_then(|v| v.as_string())
26282628+ }
26292629+13212630 async fn request_preview_png(
13222631 format: MeshFormat,
13232632 primary_url: String,
···14252734 }
1426273514272736 #[test]
27372737+ fn generated_preview_support_includes_renderer_formats_and_step() {
27382738+ assert!(supports_generated_preview("part.stl"));
27392739+ assert!(supports_generated_preview("part.obj"));
27402740+ assert!(supports_generated_preview("part.glb"));
27412741+ assert!(supports_generated_preview("part.step"));
27422742+ assert!(supports_generated_preview("part.STP"));
27432743+ assert!(!supports_generated_preview("part.3mf"));
27442744+ assert!(!supports_generated_preview("notes.txt"));
27452745+ }
27462746+27472747+ #[test]
14282748 fn human_size_scales_units() {
14292749 assert_eq!(human_size(512), "512 B");
14302750 assert_eq!(human_size(2048), "2.0 KB");
···14842804 alt_str(&part.previews.as_ref().unwrap()[0]),
14852805 "Rendered preview of Bracket.stl"
14862806 );
28072807+ }
28082808+28092809+ #[test]
28102810+ fn attach_generated_preview_sets_part_model_and_thing_preview() {
28112811+ let mut composition = DraftThingInput {
28122812+ models: vec![DraftModelInput::default()],
28132813+ ..Default::default()
28142814+ };
28152815+ let mut part = DraftPartInput::default();
28162816+ attach_generated_preview(
28172817+ &mut composition,
28182818+ 0,
28192819+ 0,
28202820+ &mut part,
28212821+ test_image("Rendered preview of part.step"),
28222822+ );
28232823+ assert_eq!(composition.cover.as_ref().unwrap().len(), 1);
28242824+ assert_eq!(composition.previews.as_ref().unwrap().len(), 1);
28252825+ assert_eq!(composition.models[0].previews.as_ref().unwrap().len(), 1);
28262826+ assert_eq!(part.previews.as_ref().unwrap().len(), 1);
14872827 }
1488282814892829 #[test]
+269-9
src/publish/draft.rs
···11-//! PM-43 publish-draft data model.
11+//! Publish-draft data model.
22//!
33//! A draft is a server-side, non-public, in-progress publish composition. It is
44//! persisted as a [`DraftThingInput`]: a mirror of the generated
···1919//! length/grapheme limits to the generated [`LexiconSchema::validate`], so draft
2020//! validation cannot drift from the lexicon.
21212222+use jacquard::common::types::string::AtUri;
2223use jacquard_common::DefaultStr;
2324use jacquard_lexicon::schema::LexiconSchema;
2425use polymodel_api::com_atproto::repo::strong_ref::StrongRef;
2626+2727+use crate::thing_detail::thing_uri_from_route;
2528use polymodel_api::space_polymodel::library::publish_thing::{ModelInput, PartInput, ThingInput};
2626-use polymodel_api::space_polymodel::library::{Bbox, File, Image};
2929+use polymodel_api::space_polymodel::library::{File, Image};
2730use serde::{Deserialize, Serialize};
28313232+// Re-export leaf types the publish UI edits directly.
3333+pub use polymodel_api::space_polymodel::library::Bbox;
3434+2935/// The owned string type the generated records use (`DefaultStr`).
3036type S = DefaultStr;
3137···8389 pub cover: Option<Vec<Image>>,
8490 #[serde(default, skip_serializing_if = "Option::is_none")]
8591 pub previews: Option<Vec<Image>>,
8686- /// Out of scope for the PM-43 UI, carried as an optional pass-through.
9292+ /// Remix provenance; design pending. Carried pass-through, not yet surfaced.
8793 #[serde(default, skip_serializing_if = "Option::is_none")]
8894 pub derived_from: Option<StrongRef>,
8989- /// Out of scope for the PM-43 UI, carried as an optional pass-through.
9595+ /// Thing-level compatibility links, surfaced as resolvable "Works with" chips.
9096 #[serde(default, skip_serializing_if = "Option::is_none")]
9197 pub intended_to_work_with: Option<Vec<StrongRef>>,
9298 #[serde(default)]
···102108 pub name: Option<String>,
103109 #[serde(default, skip_serializing_if = "Option::is_none")]
104110 pub summary: Option<String>,
105105- /// Per-model license override. Out of scope for the PM-43 UI.
111111+ /// Per-model license override.
106112 #[serde(default, skip_serializing_if = "Option::is_none")]
107113 pub license: Option<String>,
108114 #[serde(default, skip_serializing_if = "Option::is_none")]
···138144 pub format: Option<String>,
139145 #[serde(default, skip_serializing_if = "Option::is_none")]
140146 pub notes: Option<String>,
141141- /// Out of scope for the PM-43 UI, carried as an optional pass-through.
147147+ /// Unit label for this part's dimensions (distinct from `dimensions.unit`).
142148 #[serde(default, skip_serializing_if = "Option::is_none")]
143149 pub units: Option<String>,
144150 #[serde(default, skip_serializing_if = "Option::is_none")]
···147153 pub dimensions: Option<Bbox>,
148154 #[serde(default, skip_serializing_if = "Option::is_none")]
149155 pub previews: Option<Vec<Image>>,
150150- /// Out of scope for the PM-43 UI, carried as an optional pass-through.
156156+ /// Remix provenance; design pending. Carried pass-through, not yet surfaced.
151157 #[serde(default, skip_serializing_if = "Option::is_none")]
152158 pub derived_from: Option<StrongRef>,
153153- /// Out of scope for the PM-43 UI, carried as an optional pass-through.
159159+ /// Part-level compatibility links; not yet surfaced (thing-level only).
154160 #[serde(default, skip_serializing_if = "Option::is_none")]
155161 pub intended_to_work_with: Option<Vec<StrongRef>>,
156162}
···247253 }
248254249255 /// A part is publishable when it has a non-empty name and a staged file.
256256+ #[allow(dead_code)]
250257 pub fn is_publishable(&self) -> bool {
251258 nonempty(&self.name) && self.has_file()
252259 }
···255262impl DraftModelInput {
256263 /// A model is publishable when it has a non-empty name and at least one
257264 /// publishable part.
265265+ #[allow(dead_code)]
258266 pub fn is_publishable(&self) -> bool {
259267 nonempty(&self.name)
260268 && !self.parts.is_empty()
···278286279287 /// Basics is complete once the thing has a name. License has an effective
280288 /// default and is chosen in the details step, so it never gates Basics.
289289+ #[allow(dead_code)]
281290 pub fn basics_complete(&self) -> bool {
282291 nonempty(&self.name)
283292 }
284293285294 /// Step 2 (Files & parts) is complete when there is at least one model and
286295 /// every model is publishable.
296296+ #[allow(dead_code)]
287297 pub fn files_complete(&self) -> bool {
288298 !self.models.is_empty() && self.models.iter().all(|m| m.is_publishable())
289299 }
···327337 "Upload a file for this part.",
328338 );
329339 }
340340+ // Dimensions are all-or-none: if any axis is set, all three
341341+ // must be non-empty. The lexicon only requires the fields exist,
342342+ // so this presence check is enforced here, not by the validator.
343343+ if let Some(bbox) = &part.dimensions {
344344+ let axes = [bbox.x.as_str(), bbox.y.as_str(), bbox.z.as_str()];
345345+ let set_count = axes.iter().filter(|a| !a.trim().is_empty()).count();
346346+ if set_count != 0 && set_count != 3 {
347347+ errors.push(
348348+ format!("models[{mi}].parts[{pi}].dimensions"),
349349+ "Set all three dimensions (X, Y, Z), or none.",
350350+ );
351351+ }
352352+ }
330353 }
331354 }
332355···350373 notes: opt_s(&part.notes),
351374 units: opt_s(&part.units),
352375 print_settings: opt_vec_s(&part.print_settings),
353353- dimensions: part.dimensions.clone(),
376376+ dimensions: clean_bbox(part.dimensions.clone()),
354377 previews: part.previews.clone(),
355378 derived_from: part.derived_from.clone(),
356379 intended_to_work_with: part.intended_to_work_with.clone(),
···421444 format!("Exceeds a lexicon limit: {err}")
422445}
423446447447+/// Drop a `Bbox` whose axes are all empty so an in-progress edit does not
448448+/// serialize as a partial/empty dimensions object. Unit alone is not a dimension.
449449+fn clean_bbox(bbox: Option<Bbox>) -> Option<Bbox> {
450450+ bbox.and_then(|b| {
451451+ let empty = b.x.as_str().trim().is_empty()
452452+ && b.y.as_str().trim().is_empty()
453453+ && b.z.as_str().trim().is_empty();
454454+ if empty { None } else { Some(b) }
455455+ })
456456+}
457457+458458+/// Derive a thing/model name from an uploaded filename: drop the extension and
459459+/// trim surrounding whitespace. A bare extension (e.g. `.stl`) or extensionless
460460+/// name is returned trimmed.
461461+pub fn name_from_filename(filename: &str) -> String {
462462+ let trimmed = filename.trim();
463463+ match trimmed.rsplit_once('.') {
464464+ Some((stem, _ext)) if !stem.trim().is_empty() => stem.trim().to_string(),
465465+ _ => trimmed.to_string(),
466466+ }
467467+}
468468+469469+/// Give a freshly-uploaded-into draft the structure `assemble()` requires: a
470470+/// thing name derived from the first filename (only when currently unset) and at
471471+/// least one model carrying a non-empty name. Pure and idempotent — a later drop
472472+/// neither renames the thing nor adds a second default model.
473473+pub fn ensure_default_model_for_upload(thing: &mut DraftThingInput, first_filename: &str) {
474474+ let derived = name_from_filename(first_filename);
475475+ if !nonempty(&thing.name) && !derived.is_empty() {
476476+ thing.name = Some(derived.clone());
477477+ }
478478+ if thing.models.is_empty() {
479479+ let model_name = thing
480480+ .display_name()
481481+ .map(str::to_string)
482482+ .filter(|name| !name.is_empty())
483483+ .or_else(|| (!derived.is_empty()).then(|| derived.clone()))
484484+ .unwrap_or_else(|| "Model 1".to_string());
485485+ thing.models.push(DraftModelInput {
486486+ name: Some(model_name),
487487+ ..Default::default()
488488+ });
489489+ }
490490+}
491491+492492+/// Parse one "works with" token — an `at://` URI, a full Polymodel thing URL, or
493493+/// a bare `/{repo}/thing/{rkey}` route path — into a thing `AtUri`. Pure; the
494494+/// network resolution to a `StrongRef` happens separately. Query strings and
495495+/// fragments are ignored; anything that is not a thing link is rejected.
496496+pub fn parse_thing_token(token: &str) -> Result<AtUri, String> {
497497+ let token = token.trim();
498498+ if token.is_empty() {
499499+ return Err("Empty link.".into());
500500+ }
501501+ if token.starts_with("at://") {
502502+ let uri = AtUri::new_owned(token).map_err(|e| format!("Invalid at:// URI: {e}"))?;
503503+ // Only thing-record URIs are valid "Works with" links; reject model/part
504504+ // or other NSIDs early with a clear message rather than a decode error.
505505+ let is_thing = uri
506506+ .path()
507507+ .map(|p| p.collection.as_str() == "space.polymodel.library.thing")
508508+ .unwrap_or(false);
509509+ if !is_thing {
510510+ return Err("Not a thing link.".to_string());
511511+ }
512512+ return Ok(uri);
513513+ }
514514+ let rest = match token.split_once("://") {
515515+ Some((_scheme, rest)) => rest.split_once('/').map(|(_host, path)| path).unwrap_or(""),
516516+ None => token,
517517+ };
518518+ let path = rest
519519+ .split(['?', '#'])
520520+ .next()
521521+ .unwrap_or("")
522522+ .trim_matches('/');
523523+ let segments: Vec<&str> = path.split('/').collect();
524524+ match segments.as_slice() {
525525+ [repo, "thing", rkey] if !repo.is_empty() && !rkey.is_empty() => {
526526+ thing_uri_from_route(repo, rkey)
527527+ }
528528+ _ => Err("Not a Polymodel thing link.".to_string()),
529529+ }
530530+}
531531+424532#[cfg(test)]
425533mod tests {
426534 use super::*;
···546654 draft.name = Some(" ".to_string());
547655 assert!(!draft.basics_complete());
548656 assert!(draft.assemble().is_err());
657657+ }
658658+659659+ #[test]
660660+ fn name_from_filename_strips_extension_and_trims() {
661661+ assert_eq!(
662662+ name_from_filename(" Desk Organizer.stl "),
663663+ "Desk Organizer"
664664+ );
665665+ assert_eq!(name_from_filename("a.b.glb"), "a.b");
666666+ assert_eq!(name_from_filename("noext"), "noext");
667667+ // A bare dotfile keeps its name rather than collapsing to empty.
668668+ assert_eq!(name_from_filename(".stl"), ".stl");
669669+ }
670670+671671+ #[test]
672672+ fn ensure_default_model_makes_a_one_file_drop_assemblable() {
673673+ let mut draft = DraftThingInput::default();
674674+ ensure_default_model_for_upload(&mut draft, "Bracket.stl");
675675+ draft.models[0].parts.push(part_with_upload("Bracket.stl"));
676676+ assert_eq!(draft.name.as_deref(), Some("Bracket"));
677677+ assert_eq!(draft.models.len(), 1);
678678+ assert!(nonempty(&draft.models[0].name));
679679+ assert!(draft.assemble().is_ok(), "auto-created draft must publish");
680680+ }
681681+682682+ #[test]
683683+ fn ensure_default_model_does_not_overwrite_name_or_add_second_model() {
684684+ let mut draft = DraftThingInput {
685685+ name: Some("Chosen".to_string()),
686686+ ..Default::default()
687687+ };
688688+ ensure_default_model_for_upload(&mut draft, "First.stl");
689689+ ensure_default_model_for_upload(&mut draft, "Second.stl");
690690+ assert_eq!(draft.name.as_deref(), Some("Chosen"));
691691+ assert_eq!(draft.models.len(), 1);
692692+ }
693693+694694+ #[test]
695695+ fn parse_thing_token_accepts_at_uri_url_and_path() {
696696+ let at = parse_thing_token("at://did:plc:abc123/space.polymodel.library.thing/rkey1")
697697+ .expect("at:// passes through");
698698+ assert_eq!(at.authority().as_str(), "did:plc:abc123");
699699+700700+ let url = parse_thing_token("https://polymodel.app/alice.test/thing/rkey2")
701701+ .expect("full URL resolves");
702702+ assert_eq!(url.authority().as_str(), "alice.test");
703703+ assert_eq!(
704704+ url.path()
705705+ .and_then(|p| p.rkey)
706706+ .map(|r| r.as_ref().to_string()),
707707+ Some("rkey2".to_string())
708708+ );
709709+710710+ let path = parse_thing_token("/alice.test/thing/rkey3").expect("bare path resolves");
711711+ assert_eq!(path.authority().as_str(), "alice.test");
712712+713713+ // Query strings and fragments are ignored.
714714+ let q = parse_thing_token("https://host/alice.test/thing/rkey4?ref=x#frag")
715715+ .expect("query/fragment stripped");
716716+ assert_eq!(
717717+ q.path()
718718+ .and_then(|p| p.rkey)
719719+ .map(|r| r.as_ref().to_string()),
720720+ Some("rkey4".to_string())
721721+ );
722722+ }
723723+724724+ #[test]
725725+ fn parse_thing_token_rejects_non_thing_links() {
726726+ assert!(parse_thing_token("").is_err());
727727+ assert!(parse_thing_token("not a link").is_err());
728728+ assert!(parse_thing_token("https://host/alice.test/profile").is_err());
729729+ assert!(parse_thing_token("at://garbage").is_err());
730730+ }
731731+732732+ #[test]
733733+ fn per_model_and_per_part_fields_map_through_assemble() {
734734+ let draft = DraftThingInput {
735735+ name: Some("Rig".to_string()),
736736+ models: vec![DraftModelInput {
737737+ name: Some("Frame".to_string()),
738738+ summary: Some("Main frame".to_string()),
739739+ license: Some("MIT".to_string()),
740740+ tags: Some(vec!["frame".to_string()]),
741741+ instructions: Some(vec!["Print at 0.2mm".to_string()]),
742742+ parts: vec![DraftPartInput {
743743+ name: Some("frame.stl".to_string()),
744744+ upload_id: Some("abcdef0123456789-1".to_string()),
745745+ notes: Some("Supports off".to_string()),
746746+ units: Some("mm".to_string()),
747747+ print_settings: Some(vec!["0.2mm layers".to_string()]),
748748+ dimensions: Some(Bbox {
749749+ unit: Some("mm".into()),
750750+ x: "10".into(),
751751+ y: "20".into(),
752752+ z: "30".into(),
753753+ extra_data: None,
754754+ }),
755755+ ..Default::default()
756756+ }],
757757+ ..Default::default()
758758+ }],
759759+ ..Default::default()
760760+ };
761761+ let thing = draft.assemble().expect("rich draft assembles");
762762+ let model = &thing.models[0];
763763+ assert_eq!(model.summary.as_ref().unwrap().as_str(), "Main frame");
764764+ assert_eq!(model.license.as_ref().unwrap().as_str(), "MIT");
765765+ assert_eq!(model.tags.as_ref().unwrap()[0].as_str(), "frame");
766766+ let part = &model.parts[0];
767767+ assert_eq!(part.notes.as_ref().unwrap().as_str(), "Supports off");
768768+ assert_eq!(part.units.as_ref().unwrap().as_str(), "mm");
769769+ assert_eq!(
770770+ part.print_settings.as_ref().unwrap()[0].as_str(),
771771+ "0.2mm layers"
772772+ );
773773+ let bbox = part.dimensions.as_ref().unwrap();
774774+ assert_eq!(bbox.x.as_str(), "10");
775775+ assert_eq!(bbox.unit.as_ref().unwrap().as_str(), "mm");
776776+ assert!(thing.validate().is_ok());
777777+ }
778778+779779+ #[test]
780780+ fn partial_bbox_is_rejected_by_assemble() {
781781+ let mut draft = complete_draft();
782782+ // Only X set → must fail validation (all-or-none).
783783+ draft.models[0].parts[0].dimensions = Some(Bbox {
784784+ unit: Some("mm".into()),
785785+ x: "10".into(),
786786+ y: "".into(),
787787+ z: "".into(),
788788+ extra_data: None,
789789+ });
790790+ let errors = draft
791791+ .assemble()
792792+ .expect_err("partial bbox must fail assemble");
793793+ assert!(errors.has_field("models[0].parts[0].dimensions"));
794794+ }
795795+796796+ #[test]
797797+ fn empty_bbox_axes_are_dropped_at_assemble() {
798798+ let mut draft = complete_draft();
799799+ // All axes empty (unit set) → dimensions dropped entirely, no error.
800800+ draft.models[0].parts[0].dimensions = Some(Bbox {
801801+ unit: Some("mm".into()),
802802+ x: "".into(),
803803+ y: "".into(),
804804+ z: "".into(),
805805+ extra_data: None,
806806+ });
807807+ let thing = draft.assemble().expect("empty-axes bbox is dropped");
808808+ assert!(thing.models[0].parts[0].dimensions.is_none());
549809 }
550810}
+53-1
src/publish/draft_client.rs
···11-//! Same-origin client calls for the PM-43 publish wizard.
11+//! Same-origin client calls for the publish flow.
22//!
33//! Thin typed wrappers over [`PolymodelClient`]'s raw app-request helpers for
44//! the draft store, raw-byte file staging, and image upload. Every call rides
55//! the browser cookie session (same-origin), so these are authenticated without
66//! any explicit token handling.
77+//!
88+//! [`resolve_strong_ref`] is the exception: an unauthenticated, identity-resolving
99+//! `get_record` that reads a thing record directly from the target repo's own
1010+//! PDS, so a cross-repo "Works with" link resolves.
711812use http::Method;
1313+use jacquard::client::{AgentSessionExt, BasicClient};
1414+use jacquard::common::types::string::AtUri;
1515+use polymodel_api::com_atproto::repo::strong_ref::StrongRef;
916use polymodel_api::space_polymodel::library::Image;
1017use polymodel_api::space_polymodel::library::publish_thing::PublishThingOutput;
1118use polymodel_api::space_polymodel::library::stage_file::StageFileOutput;
1919+use polymodel_api::space_polymodel::library::thing::Thing;
12201321use crate::client::{PolymodelClient, RawError};
1422use crate::publish::draft::{
1523 DeleteDraftResponse, DraftIdResponse, DraftSummary, DraftThingInput, ImageUploadResponse,
1624};
2525+2626+/// A resolved compatibility target: the `StrongRef` to persist plus the thing's
2727+/// display name, shown on the chip.
2828+pub struct ResolvedRef {
2929+ pub strong_ref: StrongRef,
3030+ pub name: Option<String>,
3131+}
3232+3333+/// Resolve a thing `AtUri` to a current `StrongRef` (uri + cid) by reading the
3434+/// record directly from the target repo's own PDS.
3535+///
3636+/// Uses an unauthenticated, identity-resolving jacquard agent: `get_record`
3737+/// resolves the URI's authority (DID or handle) to its PDS and reads from there,
3838+/// so a thing in *another* maker's repo resolves (unlike the same-origin proxy,
3939+/// which is pinned to the signed-in user's PDS). A record with no `cid` is an
4040+/// error — a `StrongRef` requires one.
4141+pub async fn resolve_strong_ref(uri: &AtUri) -> Result<ResolvedRef, RawError> {
4242+ let agent = BasicClient::unauthenticated();
4343+ let response = agent
4444+ .get_record::<Thing, _>(uri)
4545+ .await
4646+ .map_err(|error| RawError::Transport(error.to_string()))?;
4747+ // `into_output()` fails for typed XRPC errors — a `RecordNotFound` for a
4848+ // missing record, or a decode/validation error otherwise. Both are surfaced
4949+ // as a decode failure carrying the real cause rather than a fake HTTP status.
5050+ let output = response
5151+ .into_output()
5252+ .map_err(|error| RawError::Decode(error.to_string()))?;
5353+ let cid = output
5454+ .cid
5555+ .ok_or_else(|| RawError::Decode("record has no cid".to_string()))?;
5656+ let name = {
5757+ let n = output.value.name.as_str().trim();
5858+ (!n.is_empty()).then(|| n.to_string())
5959+ };
6060+ Ok(ResolvedRef {
6161+ strong_ref: StrongRef {
6262+ cid,
6363+ uri: output.uri,
6464+ extra_data: None,
6565+ },
6666+ name,
6767+ })
6868+}
17691870/// stageFile XRPC path (raw-body upload), reused from the existing write path.
1971const STAGE_FILE_PATH: &str = "/xrpc/space.polymodel.library.stageFile";
+4-86
src/publish/state.rs
···11-//! PM-43 wizard step model and publish phase.
11+//! Publish lifecycle phase for the single-page publish flow.
22//!
33-//! The durable composition lives in [`DraftThingInput`](super::draft); these
44-//! types are the *ephemeral* wizard UI state — which step is active and where
55-//! the publish action is in its lifecycle.
66-77-/// The five wizard steps, in order.
88-#[derive(Clone, Copy, PartialEq, Eq, Debug)]
99-pub enum WizardStep {
1010- Basics,
1111- Files,
1212- Images,
1313- Details,
1414- Review,
1515-}
1616-1717-impl WizardStep {
1818- /// All steps in display order.
1919- pub const ALL: [WizardStep; 5] = [
2020- WizardStep::Basics,
2121- WizardStep::Files,
2222- WizardStep::Images,
2323- WizardStep::Details,
2424- WizardStep::Review,
2525- ];
2626-2727- /// Zero-based position in the flow.
2828- pub fn index(self) -> usize {
2929- Self::ALL.iter().position(|s| *s == self).unwrap_or(0)
3030- }
3131-3232- /// One-based step number for the stepper label.
3333- pub fn number(self) -> usize {
3434- self.index() + 1
3535- }
3636-3737- /// Full label for the stepper and headings.
3838- pub fn label(self) -> &'static str {
3939- match self {
4040- WizardStep::Basics => "Basics",
4141- WizardStep::Files => "Files & parts",
4242- WizardStep::Images => "Images",
4343- WizardStep::Details => "Instructions & license",
4444- WizardStep::Review => "Review & publish",
4545- }
4646- }
4747-4848- /// The next step, if any.
4949- pub fn next(self) -> Option<WizardStep> {
5050- Self::ALL.get(self.index() + 1).copied()
5151- }
5252-5353- /// The previous step, if any.
5454- pub fn prev(self) -> Option<WizardStep> {
5555- if self.index() == 0 {
5656- None
5757- } else {
5858- Self::ALL.get(self.index() - 1).copied()
5959- }
6060- }
6161-}
33+//! The durable composition lives in [`DraftThingInput`](super::draft); this
44+//! type is the *ephemeral* publish-action lifecycle, surfaced as the persistent
55+//! status indicator.
626637/// Lifecycle of the publish action, surfaced as the persistent status indicator.
648#[derive(Clone, PartialEq, Debug)]
···7216 /// The publish attempt failed; the draft is preserved.
7317 Failed(String),
7418}
7575-7676-#[cfg(test)]
7777-mod tests {
7878- use super::*;
7979-8080- #[test]
8181- fn steps_are_ordered_and_navigable() {
8282- assert_eq!(WizardStep::Basics.number(), 1);
8383- assert_eq!(WizardStep::Review.number(), 5);
8484- assert_eq!(WizardStep::Basics.prev(), None);
8585- assert_eq!(WizardStep::Review.next(), None);
8686- assert_eq!(WizardStep::Basics.next(), Some(WizardStep::Files));
8787- assert_eq!(WizardStep::Files.prev(), Some(WizardStep::Basics));
8888- }
8989-9090- #[test]
9191- fn all_steps_have_distinct_indices() {
9292- let mut seen = Vec::new();
9393- for step in WizardStep::ALL {
9494- let idx = step.index();
9595- assert!(!seen.contains(&idx), "duplicate index {idx}");
9696- seen.push(idx);
9797- }
9898- assert_eq!(seen, vec![0, 1, 2, 3, 4]);
9999- }
100100-}
+93-91
src/thing_card.rs
···194194 }
195195 }
196196 }
197197- if let Some(tags) = hydrated_tags(&thing) {
198198- div { class: "thing-card-chips", aria_label: "Hydrated display tags",
199199- for tag in tags.iter().take(3) {
200200- span { class: "status-pill status-muted", "#{tag}" }
197197+ div { class: "thing-card-details",
198198+ if let Some(tags) = hydrated_tags(&thing) {
199199+ div { class: "thing-card-chips", aria_label: "Hydrated display tags",
200200+ for tag in tags.iter().take(3) {
201201+ span { class: "status-pill status-muted", "#{tag}" }
202202+ }
201203 }
202204 }
203203- }
204204- dl { class: "thing-card-stats", aria_label: "Thing stats",
205205- for (label, value) in stats_with_social_counts(&stats, *like_count.read(), *save_count.read()) {
206206- div {
207207- dt { "{label}" }
208208- dd { "{value}" }
205205+ dl { class: "thing-card-stats", aria_label: "Thing stats",
206206+ for (label, value) in stats_with_social_counts(&stats, *like_count.read(), *save_count.read()) {
207207+ div {
208208+ dt { "{label}" }
209209+ dd { "{value}" }
210210+ }
209211 }
210212 }
211211- }
212212- div { class: "thing-card-actions", aria_label: "Thing card saved and liked state",
213213- if can_write {
214214- button {
215215- class: "thing-card-action",
216216- disabled: *action_pending.read(),
217217- aria_pressed: "{like_uri.read().is_some()}",
218218- aria_label: "{like_label} {title}",
219219- onclick: move |_| {
220220- let client = like_client.clone();
221221- let subject = like_subject.clone();
222222- let current = like_uri.read().clone();
223223- action_pending.set(true);
224224- action_error.set(None);
225225- spawn(async move {
226226- let result = if let Some(uri) = current {
227227- match client.send(DeleteLike { uri: Some(uri), subject: None, extra_data: None }).await {
228228- Ok(response) => response.into_output().map(|_| None).map_err(|error| error.to_string()),
229229- Err(error) => Err(error.to_string()),
230230- }
231231- } else {
232232- match client.send(CreateLike { subject, extra_data: None }).await {
233233- Ok(response) => response.into_output().map(|output| Some(output.uri)).map_err(|error| error.to_string()),
234234- Err(error) => Err(error.to_string()),
213213+ div { class: "thing-card-actions", aria_label: "Thing card saved and liked state",
214214+ if can_write {
215215+ button {
216216+ class: "thing-card-action",
217217+ disabled: *action_pending.read(),
218218+ aria_pressed: "{like_uri.read().is_some()}",
219219+ aria_label: "{like_label} {title}",
220220+ onclick: move |_| {
221221+ let client = like_client.clone();
222222+ let subject = like_subject.clone();
223223+ let current = like_uri.read().clone();
224224+ action_pending.set(true);
225225+ action_error.set(None);
226226+ spawn(async move {
227227+ let result = if let Some(uri) = current {
228228+ match client.send(DeleteLike { uri: Some(uri), subject: None, extra_data: None }).await {
229229+ Ok(response) => response.into_output().map(|_| None).map_err(|error| error.to_string()),
230230+ Err(error) => Err(error.to_string()),
231231+ }
232232+ } else {
233233+ match client.send(CreateLike { subject, extra_data: None }).await {
234234+ Ok(response) => response.into_output().map(|output| Some(output.uri)).map_err(|error| error.to_string()),
235235+ Err(error) => Err(error.to_string()),
236236+ }
237237+ };
238238+ match result {
239239+ Ok(next) => {
240240+ let was_liked = like_uri.read().is_some();
241241+ like_uri.set(next);
242242+ let current_count = *like_count.read();
243243+ let is_liked = like_uri.read().is_some();
244244+ like_count.set(adjust_count(current_count, was_liked, is_liked));
245245+ }
246246+ Err(error) => action_error.set(Some(format!("Like failed: {error}"))),
235247 }
236236- };
237237- match result {
238238- Ok(next) => {
239239- let was_liked = like_uri.read().is_some();
240240- like_uri.set(next);
241241- let current_count = *like_count.read();
242242- let is_liked = like_uri.read().is_some();
243243- like_count.set(adjust_count(current_count, was_liked, is_liked));
248248+ action_pending.set(false);
249249+ });
250250+ },
251251+ "♡ {like_label}"
252252+ }
253253+ button {
254254+ class: "thing-card-action",
255255+ disabled: *action_pending.read(),
256256+ aria_pressed: "{save_uri.read().is_some()}",
257257+ aria_label: "{save_label} {title}",
258258+ onclick: move |_| {
259259+ let client = save_client.clone();
260260+ let subject = save_subject.clone();
261261+ let current = save_uri.read().clone();
262262+ action_pending.set(true);
263263+ action_error.set(None);
264264+ spawn(async move {
265265+ let result = if let Some(uri) = current {
266266+ match client.send(DeleteSave { uri: Some(uri), subject: None, extra_data: None }).await {
267267+ Ok(response) => response.into_output().map(|_| None).map_err(|error| error.to_string()),
268268+ Err(error) => Err(error.to_string()),
269269+ }
270270+ } else {
271271+ match client.send(CreateSave { subject, note: None, extra_data: None }).await {
272272+ Ok(response) => response.into_output().map(|output| Some(output.uri)).map_err(|error| error.to_string()),
273273+ Err(error) => Err(error.to_string()),
274274+ }
275275+ };
276276+ match result {
277277+ Ok(next) => {
278278+ let was_saved = save_uri.read().is_some();
279279+ save_uri.set(next);
280280+ let current_count = *save_count.read();
281281+ let is_saved = save_uri.read().is_some();
282282+ save_count.set(adjust_count(current_count, was_saved, is_saved));
283283+ }
284284+ Err(error) => action_error.set(Some(format!("Save failed: {error}"))),
244285 }
245245- Err(error) => action_error.set(Some(format!("Like failed: {error}"))),
246246- }
247247- action_pending.set(false);
248248- });
249249- },
250250- "♡ {like_label}"
286286+ action_pending.set(false);
287287+ });
288288+ },
289289+ "☆ {save_label}"
290290+ }
291291+ } else {
292292+ a { class: "thing-card-action", href: "/oauth/start", aria_label: "Sign in to like {title}", "♡ Like" }
293293+ a { class: "thing-card-action", href: "/oauth/start", aria_label: "Sign in to save {title}", "☆ Save" }
251294 }
252252- button {
253253- class: "thing-card-action",
254254- disabled: *action_pending.read(),
255255- aria_pressed: "{save_uri.read().is_some()}",
256256- aria_label: "{save_label} {title}",
257257- onclick: move |_| {
258258- let client = save_client.clone();
259259- let subject = save_subject.clone();
260260- let current = save_uri.read().clone();
261261- action_pending.set(true);
262262- action_error.set(None);
263263- spawn(async move {
264264- let result = if let Some(uri) = current {
265265- match client.send(DeleteSave { uri: Some(uri), subject: None, extra_data: None }).await {
266266- Ok(response) => response.into_output().map(|_| None).map_err(|error| error.to_string()),
267267- Err(error) => Err(error.to_string()),
268268- }
269269- } else {
270270- match client.send(CreateSave { subject, note: None, extra_data: None }).await {
271271- Ok(response) => response.into_output().map(|output| Some(output.uri)).map_err(|error| error.to_string()),
272272- Err(error) => Err(error.to_string()),
273273- }
274274- };
275275- match result {
276276- Ok(next) => {
277277- let was_saved = save_uri.read().is_some();
278278- save_uri.set(next);
279279- let current_count = *save_count.read();
280280- let is_saved = save_uri.read().is_some();
281281- save_count.set(adjust_count(current_count, was_saved, is_saved));
282282- }
283283- Err(error) => action_error.set(Some(format!("Save failed: {error}"))),
284284- }
285285- action_pending.set(false);
286286- });
287287- },
288288- "☆ {save_label}"
295295+ if let Some(error) = action_error.read().as_ref() {
296296+ span { class: "thing-card-action-error", role: "status", "{error}" }
289297 }
290290- } else {
291291- a { class: "thing-card-action", href: "/oauth/start", aria_label: "Sign in to like {title}", "♡ Like" }
292292- a { class: "thing-card-action", href: "/oauth/start", aria_label: "Sign in to save {title}", "☆ Save" }
293293- }
294294- if let Some(error) = action_error.read().as_ref() {
295295- span { class: "thing-card-action-error", role: "status", "{error}" }
296298 }
297299 }
298300 }