atproto Thingiverse but good
10

Configure Feed

Select the types of activity you want to include in your feed.

PM-57: publish flow redesign (upload-first, single page)

Orual (Jun 29, 2026, 7:06 PM EDT) 27257c70 fbe988ef

+2643 -832
+22
Cargo.lock
··· 2206 2206 ] 2207 2207 2208 2208 [[package]] 2209 + name = "dioxus-sdk" 2210 + version = "0.7.0" 2211 + source = "registry+https://github.com/rust-lang/crates.io-index" 2212 + checksum = "79a653986dc3151f00a2be4cf04f83ec6c2af24b600ab926d5fcbcd159c6dadf" 2213 + dependencies = [ 2214 + "dioxus-sdk-time", 2215 + ] 2216 + 2217 + [[package]] 2218 + name = "dioxus-sdk-time" 2219 + version = "0.7.0" 2220 + source = "registry+https://github.com/rust-lang/crates.io-index" 2221 + checksum = "80c25ae93a3f72e734873b97fbd09d9b1b6adff97205fb0ffd8543e3564fb78e" 2222 + dependencies = [ 2223 + "dioxus", 2224 + "futures", 2225 + "gloo-timers", 2226 + "tokio", 2227 + ] 2228 + 2229 + [[package]] 2209 2230 name = "dioxus-server" 2210 2231 version = "0.7.9" 2211 2232 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6275 6296 "chrono", 6276 6297 "console_error_panic_hook", 6277 6298 "dioxus", 6299 + "dioxus-sdk", 6278 6300 "dotenvy", 6279 6301 "futures", 6280 6302 "http",
+2 -1
Cargo.toml
··· 60 60 ] 61 61 [dependencies] 62 62 dioxus = { version = "0.7", features = ["router", "fullstack"] } 63 + dioxus-sdk = { version = "0.7", default-features = false, features = ["time"] } 63 64 jacquard = { workspace = true } 64 65 jacquard-axum = { workspace = true, optional = true } 65 66 jacquard-common = { workspace = true } ··· 131 132 tracing-wasm = "0.2" 132 133 wasm-bindgen = "=0.2.121" 133 134 wasm-bindgen-futures = "0.4" 134 - web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Event", "EventTarget", "HtmlCanvasElement", "WebGl2RenderingContext", "Performance", "MouseEvent", "WheelEvent", "PointerEvent", "DomRect", "BroadcastChannel", "console", "Response", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlInputElement", "Worker", "WorkerOptions", "WorkerType", "OffscreenCanvas", "MessageEvent", "ErrorEvent"] } 135 + web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Event", "EventTarget", "HtmlCanvasElement", "WebGl2RenderingContext", "Performance", "MouseEvent", "WheelEvent", "PointerEvent", "DomRect", "BroadcastChannel", "console", "Response", "File", "FileList", "Blob", "HtmlInputElement", "HtmlDetailsElement", "Worker", "WorkerOptions", "WorkerType", "OffscreenCanvas", "MessageEvent", "ErrorEvent"] } 135 136 136 137 [dev-dependencies] 137 138 anyhow = "1"
+21 -11
assets/styling/primitives.css
··· 42 42 font-weight: 600; 43 43 color: var(--color-text); 44 44 margin: 0; 45 + text-wrap: balance; 45 46 font-size: 1rem; 46 47 line-height: 1.3; 47 48 } 48 49 49 50 .card-body { 50 51 padding: 1rem; 51 - display: grid; 52 + display: flex; 53 + flex-direction: column; 54 + justify-content: space-between; 52 55 gap: 0.65rem; 53 56 } 54 57 ··· 108 111 /* Thing cards */ 109 112 110 113 .thing-card { 111 - display: grid; 112 - grid-template-rows: auto 1fr; 113 114 min-width: 0; 114 115 overflow: hidden; 116 + display: flex; 117 + flex-direction: column; 115 118 } 116 119 117 120 .thing-card-media { ··· 146 149 } 147 150 148 151 .thing-card-body { 149 - align-content: start; 150 - grid-template-rows: auto auto auto minmax(0, 1fr) auto; 152 + flex: 1; 153 + justify-content: flex-start; 151 154 } 152 155 153 156 .thing-card-heading { 154 - display: grid; 155 157 gap: 0.25rem; 156 158 min-width: 0; 157 159 } ··· 171 173 gap: 0.45rem; 172 174 } 173 175 176 + .thing-card-details { 177 + display: flex; 178 + flex-direction: column; 179 + gap: 0.25rem; 180 + margin-top: auto; 181 + } 182 + 174 183 .thing-card-actions { 175 - grid-row: 5; 176 - align-self: end; 184 + align-self: stretch; 177 185 display: flex; 178 186 justify-content: space-between; 179 187 gap: 0.65rem; 180 - margin-top: auto; 181 188 } 182 189 183 190 .thing-card-actions a { ··· 228 235 } 229 236 230 237 .thing-card-action { 238 + margin: -0.18rem -0.28rem; 231 239 padding: 0.18rem 0.28rem; 232 240 cursor: pointer; 233 241 color: var(--color-muted); 234 242 background: transparent; 235 - border: 1px solid transparent; 243 + border: 0; 236 244 font-family: var(--font-mono); 237 245 font-size: 0.72rem; 246 + line-height: 1.2; 238 247 letter-spacing: 0.04em; 239 248 text-transform: uppercase; 240 249 } ··· 242 251 .thing-card-action:hover, 243 252 .thing-card-action[aria-pressed="true"] { 244 253 color: var(--color-tertiary); 245 - border-color: var(--color-primary); 246 254 background: color-mix(in srgb, var(--color-primary) 8%, transparent); 255 + outline: 1px solid var(--color-primary); 256 + outline-offset: 0; 247 257 } 248 258 249 259 .thing-card-action:focus-visible {
+227 -49
assets/styling/publish.css
··· 1 - /* PM-43 publish wizard. Depends on theme.css tokens, base.css reset, and the 2 - primitives layer (.blueprint-panel, .button, .tab, .status-pill, .field-label, 3 - .card-meta, .blueprint-media). Page layer — loads after the shell. */ 1 + /* Single-page publish flow. Depends on theme.css tokens, base.css reset, 2 + and the primitives layer (.blueprint-panel, .button, .tab, .status-pill, 3 + .field-label, .card-meta, .blueprint-media). Page layer — loads after the 4 + shell. */ 4 5 5 6 .publish-page { 6 7 flex: 1; ··· 31 32 letter-spacing: -0.03em; 32 33 } 33 34 34 - /* Header: title + draft/published status + autosave state. */ 35 + /* Header: title + draft/published status + autosave state + save affordance. */ 35 36 .publish-head { 36 37 display: flex; 37 38 flex-direction: column; ··· 51 52 letter-spacing: -0.03em; 52 53 } 53 54 55 + .publish-save { 56 + margin-left: auto; 57 + font-size: 0.85rem; 58 + } 59 + 54 60 .publish-save-state { 55 61 margin: 0; 56 62 color: var(--color-muted); ··· 58 64 font-size: 0.74rem; 59 65 } 60 66 61 - /* Stepper: each step is a .tab; complete steps pick up the success hue. */ 62 - .wizard-steps { 67 + /* Hero upload zone — the primary element of the signed-in page. */ 68 + .publish-hero { 63 69 display: flex; 64 - flex-wrap: wrap; 65 - gap: 0.5rem; 70 + flex-direction: column; 71 + gap: 0.75rem; 72 + padding: clamp(1rem, 2.5vw, 1.75rem); 66 73 } 67 74 68 - .wizard-step { 69 - gap: 0.4rem; 70 - padding: 0.4rem 0.6rem; 75 + .publish-dropzone { 76 + position: relative; 77 + display: block; 78 + cursor: pointer; 79 + border: 1px dashed var(--color-border); 80 + border-radius: var(--radius-panel); 81 + background: color-mix(in srgb, var(--color-overlay) 30%, transparent); 82 + padding: clamp(1.5rem, 4vw, 2.5rem); 83 + transition: border-color 150ms ease-out, background 150ms ease-out; 71 84 } 72 85 73 - .wizard-step-num { 74 - font-family: var(--font-mono); 75 - font-size: 0.72rem; 76 - opacity: 0.8; 86 + .publish-dropzone:hover, 87 + .publish-dropzone:focus-within { 88 + border-color: var(--color-primary); 89 + background: color-mix(in srgb, var(--color-primary) 6%, transparent); 77 90 } 78 91 79 - .wizard-step-label { 80 - font-family: var(--font-ui); 81 - font-weight: 600; 92 + .publish-dropzone.is-drag-over { 93 + border-color: var(--color-action); 94 + border-style: solid; 95 + background: color-mix(in srgb, var(--color-action) 10%, transparent); 82 96 } 83 97 84 - .wizard-step.is-complete { 85 - border-color: var(--color-success); 86 - color: var(--color-success); 87 - background: color-mix(in srgb, var(--color-success) 8%, transparent); 98 + .publish-dropzone-body { 99 + display: flex; 100 + flex-direction: column; 101 + align-items: center; 102 + gap: 0.35rem; 103 + text-align: center; 104 + pointer-events: none; 88 105 } 89 106 90 - .wizard-step-check { 91 - color: var(--color-success); 107 + .publish-dropzone-title { 108 + margin: 0; 109 + color: var(--color-emphasis); 110 + font-weight: 600; 92 111 } 93 112 94 - .wizard-step.is-active .wizard-step-check { 95 - color: var(--color-tertiary); 113 + .publish-dropzone-hint { 114 + margin: 0; 96 115 } 97 116 98 - /* Body panel + step copy. */ 117 + /* (Drag-and-drop target is the dropzone label itself; the body overlay stays 118 + pointer-transparent so drops and label-clicks reach the input.) */ 119 + 120 + /* Body panel + section copy. */ 99 121 .wizard-body { 100 122 display: flex; 101 123 flex-direction: column; ··· 107 129 margin: 0; 108 130 color: var(--color-emphasis); 109 131 letter-spacing: -0.02em; 110 - } 111 - 112 - .wizard-step-hint { 113 - margin: 0; 114 - color: var(--color-subtle); 115 - max-width: 60ch; 116 132 } 117 133 118 134 .wizard-subhead { ··· 165 181 color: var(--color-muted); 166 182 } 167 183 184 + .publish-license-field { 185 + display: flex; 186 + flex-direction: row; 187 + align-items: center; 188 + flex-wrap: wrap; 189 + gap: 0.5rem 0.75rem; 190 + } 191 + 192 + .publish-license-field .field-label { 193 + flex: none; 194 + } 195 + 196 + .publish-license-controls { 197 + display: flex; 198 + align-items: center; 199 + flex-wrap: wrap; 200 + gap: 0.5rem; 201 + flex: 1; 202 + min-width: min(100%, 31rem); 203 + } 204 + 205 + .publish-license-select { 206 + flex: 0 0 12rem; 207 + width: 12rem; 208 + min-width: 12rem; 209 + } 210 + 211 + .publish-license-custom { 212 + flex: 1 1 16rem; 213 + min-width: 14rem; 214 + max-width: 22rem; 215 + } 216 + 217 + /* Inline name field — prominent, the first edit after a drop. */ 218 + .publish-name-field { 219 + max-width: 32rem; 220 + } 221 + 222 + /* Files list — also a drop target for adding more parts. */ 223 + .publish-files { 224 + display: flex; 225 + flex-direction: column; 226 + gap: 0.6rem; 227 + border: 1px dashed transparent; 228 + border-radius: var(--radius-panel); 229 + padding: 0.5rem; 230 + transition: border-color 150ms ease-out, background 150ms ease-out; 231 + } 232 + 233 + .publish-files.is-drag-over { 234 + border-color: var(--color-action); 235 + background: color-mix(in srgb, var(--color-action) 8%, transparent); 236 + } 237 + 168 238 /* Models & parts. */ 169 239 .wizard-model { 170 240 display: flex; ··· 205 275 flex-wrap: wrap; 206 276 } 207 277 278 + .wizard-part-advanced { 279 + flex-direction: column; 280 + align-items: stretch; 281 + gap: 0.75rem; 282 + padding: 0.75rem; 283 + border: 1px solid var(--color-border); 284 + border-radius: var(--radius-panel); 285 + } 286 + 287 + .wizard-part-main { 288 + display: flex; 289 + align-items: center; 290 + gap: 0.6rem; 291 + flex-wrap: wrap; 292 + } 293 + 208 294 .wizard-part-name { 209 295 flex: 1; 210 296 min-width: 10rem; ··· 212 298 213 299 .wizard-part-meta { 214 300 white-space: nowrap; 301 + } 302 + 303 + .wizard-part-meta-grid { 304 + display: grid; 305 + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); 306 + gap: 0.6rem; 307 + } 308 + 309 + .publish-field-wide { 310 + grid-column: 1 / -1; 215 311 } 216 312 217 313 .wizard-empty { ··· 318 414 min-width: 12rem; 319 415 } 320 416 321 - /* Details step recap. */ 322 - .wizard-recap { 417 + /* Progressive disclosure sections. */ 418 + .publish-sections { 419 + display: flex; 420 + flex-direction: column; 421 + gap: 0.5rem; 422 + } 423 + 424 + .publish-disclosure { 425 + border: 1px solid var(--color-border); 426 + border-radius: var(--radius-panel); 427 + background: var(--color-surface); 428 + } 429 + 430 + .publish-disclosure-summary { 431 + display: flex; 432 + align-items: baseline; 433 + gap: 0.6rem; 434 + flex-wrap: wrap; 435 + padding: 0.6rem 0.75rem; 436 + cursor: pointer; 437 + list-style: none; 438 + } 439 + 440 + .publish-disclosure-summary::-webkit-details-marker { 441 + display: none; 442 + } 443 + 444 + .publish-disclosure-title { 323 445 margin: 0; 324 - color: var(--color-subtle); 446 + color: var(--color-emphasis); 447 + font-size: 0.95rem; 448 + font-weight: 600; 325 449 } 326 450 327 - /* Review. */ 451 + .publish-disclosure-hint { 452 + margin: 0; 453 + } 454 + 455 + .publish-disclosure-body { 456 + padding: 0.75rem; 457 + border-top: 1px solid var(--color-border); 458 + display: flex; 459 + flex-direction: column; 460 + gap: 0.75rem; 461 + } 462 + 463 + /* Dimensions (bbox) inputs. */ 464 + .publish-bbox { 465 + border: 0; 466 + padding: 0; 467 + margin: 0; 468 + } 469 + 470 + .publish-bbox-row { 471 + display: flex; 472 + gap: 0.5rem; 473 + flex-wrap: wrap; 474 + } 475 + 476 + .publish-bbox-row .field { 477 + flex: 1; 478 + min-width: 4.5rem; 479 + } 480 + 481 + /* "Works with" compatibility chips. */ 482 + .publish-compat-list { 483 + list-style: none; 484 + margin: 0; 485 + padding: 0; 486 + display: flex; 487 + flex-wrap: wrap; 488 + gap: 0.4rem; 489 + } 490 + 491 + .publish-compat-chip { 492 + display: inline-flex; 493 + align-items: center; 494 + gap: 0.35rem; 495 + padding: 0.25rem 0.5rem; 496 + border: 1px solid var(--color-border); 497 + border-radius: var(--radius-pill); 498 + background: color-mix(in srgb, var(--color-overlay) 40%, transparent); 499 + font-size: 0.85rem; 500 + } 501 + 502 + .publish-compat-remove { 503 + padding: 0 0.35rem; 504 + line-height: 1; 505 + border: 0; 506 + background: none; 507 + color: var(--color-muted); 508 + } 509 + 510 + .publish-compat-remove:hover { 511 + color: var(--color-error); 512 + } 513 + 514 + /* Review/confirm. */ 328 515 .wizard-review { 329 516 display: flex; 330 517 flex-direction: column; ··· 418 605 color: var(--color-text); 419 606 } 420 607 421 - /* Navigation footer. */ 422 - .wizard-nav { 423 - display: flex; 424 - align-items: center; 425 - gap: 0.6rem; 426 - flex-wrap: wrap; 427 - } 428 - 429 - .wizard-nav-spacer { 430 - flex: 1; 431 - } 432 - 433 608 /* Errors. */ 434 609 .wizard-error { 435 610 margin: 0; ··· 459 634 animation: none; 460 635 width: 100%; 461 636 opacity: 0.6; 637 + } 638 + .publish-dropzone { 639 + transition: none; 462 640 } 463 641 }
+17 -5
assets/styling/thing-detail.css
··· 10 10 padding: clamp(0.75rem, 2vw, 1.75rem) clamp(1rem, 3vw, 2.5rem); 11 11 } 12 12 13 - .thing-detail-header, 14 13 .thing-detail-summary, 15 14 .thing-detail-copy, 16 15 .detail-panel, ··· 22 21 } 23 22 24 23 .thing-detail-header { 25 - gap: 0.5rem; 24 + display: flex; 25 + flex-direction: column; 26 + gap: 0.35rem; 26 27 } 27 28 28 29 .thing-detail-title-row { ··· 32 33 gap: 1rem; 33 34 } 34 35 35 - .thing-detail-title-row h1, 36 36 .detail-panel h2, 37 37 .selected-model-panel h2, 38 38 .part-row h3 { ··· 41 41 letter-spacing: -0.03em; 42 42 } 43 43 44 - .thing-detail-title-row h1 { 44 + .thing-detail-title { 45 45 font-size: clamp(2.1rem, 5vw, 4rem); 46 - line-height: 0.95; 46 + line-height: 1; 47 + margin: 0; 48 + color: var(--color-emphasis); 49 + letter-spacing: -0.03em; 50 + text-wrap: balance; 51 + } 52 + 53 + .thing-detail-title.is-long { 54 + font-size: clamp(1.95rem, 4vw, 3.25rem); 55 + } 56 + 57 + .thing-detail-title.is-very-long { 58 + font-size: clamp(1.75rem, 3.3vw, 2.7rem); 47 59 } 48 60 49 61 .thing-detail-creator,
+10 -9
e2e/tests/publish.spec.ts
··· 1 1 import { expect, test } from '@playwright/test'; 2 2 3 - // Coverage boundary: the publish wizard renders only for an authenticated 4 - // ATProto session, and a live PDS publish needs real OAuth, which this CI e2e 5 - // harness cannot establish. So e2e covers the signed-out callout (no wizard); 6 - // the signed-in wizard assembly, draft round-trip, validation, and promote-on- 7 - // publish are covered by the Rust unit/integration suites (src/publish/draft.rs, 8 - // src/appview/drafts.rs) plus the manual Playwright pass against `just serve`. 3 + // Coverage boundary: the publish flow renders only for an authenticated ATProto 4 + // session, and a live PDS publish needs real OAuth, which this CI e2e harness 5 + // cannot establish. So e2e covers the signed-out callout (no flow); the 6 + // signed-in upload-first assembly, draft round-trip, validation, and 7 + // promote-on-publish are covered by the Rust unit/integration suites 8 + // (src/publish/draft.rs, src/appview/drafts.rs) plus the manual Playwright pass 9 + // against `just serve`. 9 10 10 - test('signed-out /publish shows the sign-in callout, not the wizard', async ({ page }) => { 11 + test('signed-out /publish shows the sign-in callout, not the flow', async ({ page }) => { 11 12 await page.goto('/publish'); 12 13 13 14 await expect(page.getByRole('heading', { name: 'Share your models' })).toBeVisible(); 14 15 const callout = page.locator('section[aria-label="Sign in"]'); 15 16 await expect(callout.getByRole('button', { name: 'Sign in' })).toBeVisible(); 16 17 17 - // The signed-out surface must not expose the wizard stepper or publish action. 18 - await expect(page.getByRole('navigation', { name: 'Publish steps' })).toHaveCount(0); 18 + // The signed-out surface must not expose the hero upload zone or publish action. 19 + await expect(page.getByLabel('Upload model files')).toHaveCount(0); 19 20 await expect(page.getByRole('button', { name: 'Publish', exact: true })).toHaveCount(0); 20 21 }); 21 22
+1878 -538
src/publish.rs
··· 1 - use dioxus::html::FileData; 2 - use std::collections::HashMap; 1 + //! Single-page, upload-first publish flow. 2 + //! 3 + //! Dropping geometry files is the hero action: it auto-creates the 4 + //! Thing→Model→Part structure (one default model, each file a part) and 5 + //! auto-fills the thing name from the first filename. All optional metadata — 6 + //! thing-level fields, per-model/per-part fields, and thing-level 7 + //! `intended_to_work_with` — lives behind progressive disclosure that 8 + //! auto-expands for multi-model use, ending in a compact "what's public" 9 + //! confirm + Publish. The server-side draft/publish backend is unchanged. 10 + //! 11 + //! Validation stays in [`DraftThingInput::assemble`](draft::DraftThingInput); 12 + //! every exposed field is already carried on the draft types and mapped there. 3 13 14 + use dioxus::html::{FileData, HasFileData}; 4 15 use dioxus::prelude::*; 16 + use std::collections::{HashMap, HashSet}; 5 17 6 18 use crate::Route; 7 19 use crate::client::PolymodelClient; 8 20 use crate::session::SessionIdentity; 9 - use draft::{DraftModelInput, DraftPartInput, DraftSummary, DraftThingInput}; 21 + use draft::{ 22 + Bbox, DraftModelInput, DraftPartInput, DraftSummary, DraftThingInput, 23 + ensure_default_model_for_upload, parse_thing_token, 24 + }; 10 25 use jacquard_common::deps::smol_str::SmolStr; 26 + use polymodel_api::com_atproto::repo::strong_ref::StrongRef; 11 27 use polymodel_api::space_polymodel::library::Image; 12 - use state::{PublishPhase, WizardStep}; 28 + use state::PublishPhase; 13 29 14 30 pub(crate) mod draft; 15 31 pub(crate) mod draft_client; 16 32 pub(crate) mod state; 17 33 18 - /// Common open licenses offered in the wizard. The first is the effective 34 + /// Common open licenses offered in the flow. The first is the effective 19 35 /// default when none is chosen. 20 36 const LICENSE_OPTIONS: &[&str] = &[ 21 37 "CC-BY-4.0", ··· 27 43 "All rights reserved", 28 44 ]; 29 45 const DEFAULT_LICENSE: &str = "CC-BY-4.0"; 46 + const CUSTOM_LICENSE_VALUE: &str = "__custom__"; 30 47 31 48 /// Bluesky CDN thumbnail URL for an uploaded image blob (mirrors the appview's 32 - /// `blob_cdn_url`) so the wizard can preview an image right after upload. 49 + /// `blob_cdn_url`) so the flow can preview an image right after upload. 33 50 fn image_cdn_url(did: &str, image: &Image) -> String { 34 51 let cid = image.image.blob().r#ref.as_str(); 35 52 format!("https://cdn.bsky.app/img/feed_thumbnail/plain/{did}/{cid}@jpeg") ··· 64 81 } 65 82 66 83 /// Publish route. Anonymous visitors see the sign-in callout (returning here); 67 - /// signed-in users get the full wizard. `draft` is an optional draft id to 84 + /// signed-in users get the full flow. `draft` is an optional draft id to 68 85 /// resume (`/publish?draft=<id>`). 69 86 #[allow(clippy::useless_format)] 70 87 #[component] ··· 112 129 } 113 130 114 131 rsx! { 115 - PublishWizard { resume: draft } 132 + PublishFlow { resume: draft } 116 133 } 117 134 } 118 135 119 136 #[derive(Clone)] 120 - struct Wizard { 137 + struct Flow { 121 138 client: PolymodelClient, 122 139 composition: Signal<DraftThingInput>, 123 140 draft_id: Signal<Option<String>>, 124 - step: Signal<WizardStep>, 125 141 phase: Signal<PublishPhase>, 126 142 save: Signal<SaveState>, 127 143 upload: Signal<UploadStatus>, ··· 133 149 /// blobref CID. CDN URLs for just-uploaded blobs aren't available until 134 150 /// replication completes; this shows the image immediately. 135 151 image_data_uris: Signal<HashMap<SmolStr, SmolStr>>, 152 + /// Advanced (multi-model) disclosure open-state, shared with the auto-expand 153 + /// at the add-model site and `resume_draft`. Controlled-<details> `ontoggle` 154 + /// keeps it honest. 155 + advanced_open: Signal<bool>, 156 + /// One-way flag: once Advanced has auto-expanded (or a multi-model draft was 157 + /// resumed expanded), it is never auto-opened again — so a user who collapses 158 + /// Advanced and later removes/re-adds a model stays collapsed. 159 + advanced_auto_expanded: Signal<bool>, 160 + /// Live text of the "Works with" paste field, so blur can resolve it. 161 + works_with_text: Signal<String>, 162 + /// Whether a file drag is over the hero dropzone (drives the drag-over class). 163 + drag_over: Signal<bool>, 164 + /// Resolved display names for "Works with" chips, keyed by thing URI string. 165 + /// The persisted `intended_to_work_with` holds only `StrongRef`s (no name), so 166 + /// the human-readable label lives here alongside the refs. 167 + compat_names: Signal<std::collections::HashMap<String, String>>, 168 + /// License fields where the user explicitly opened the custom-license input. 169 + custom_license_targets: Signal<HashSet<String>>, 170 + /// Status of the "Works with" resolve flow (kept separate from file-upload 171 + /// status so the two do not overwrite each other's feedback). 172 + works_with_status: Signal<UploadStatus>, 173 + /// Guards against concurrent saves: while a save is in flight, further 174 + /// `mark_dirty_and_save` calls just mark Dirty; the completing save re-runs 175 + /// if still Dirty. Prevents duplicate-draft creation on rapid edits. 176 + save_in_flight: Signal<bool>, 177 + /// The mounted name input element, focused once after the first upload. 178 + name_element: Signal<Option<std::rc::Rc<MountedData>>>, 136 179 } 137 180 138 181 #[component] 139 - fn PublishWizard(resume: String) -> Element { 182 + fn PublishFlow(resume: String) -> Element { 140 183 let client = use_context::<PolymodelClient>(); 141 184 let session = use_context::<Signal<SessionIdentity>>(); 142 185 let did = match &*session.read() { ··· 150 193 ..DraftThingInput::default() 151 194 }); 152 195 let draft_id = use_signal(|| Option::<String>::None); 153 - let step = use_signal(|| WizardStep::Basics); 154 196 let phase = use_signal(|| PublishPhase::Editing); 155 197 let save = use_signal(|| SaveState::New); 156 198 let upload = use_signal(|| UploadStatus::Idle); ··· 158 200 let drafts = use_signal(Vec::<DraftSummary>::new); 159 201 let image_data_uris = use_signal(HashMap::<SmolStr, SmolStr>::new); 160 202 161 - let wizard = Wizard { 203 + // Disclosure open-state for the Advanced (multi-model) section, opened at the 204 + // add-model site. Controlled-<details> `ontoggle` keeps it honest about 205 + // manual collapses. 206 + let advanced_open = use_signal(|| false); 207 + // One-way flag: once Advanced has auto-expanded, it is never auto-opened again. 208 + let advanced_auto_expanded = use_signal(|| false); 209 + // Live text of the "Works with" paste field, so blur can resolve it. 210 + let works_with_text = use_signal(String::new); 211 + // Whether a file drag is over the hero dropzone (drives the drag-over class). 212 + let drag_over = use_signal(|| false); 213 + // Resolved display names for "Works with" chips (uri → name). 214 + let compat_names = use_signal(std::collections::HashMap::new); 215 + let custom_license_targets = use_signal(HashSet::<String>::new); 216 + // "Works with" resolve status, separate from file-upload status. 217 + let works_with_status = use_signal(|| UploadStatus::Idle); 218 + // Guards against concurrent saves (see Flow::save_draft). 219 + let save_in_flight = use_signal(|| false); 220 + // The mounted name input element, focused once after the first upload. 221 + let name_element: Signal<Option<std::rc::Rc<MountedData>>> = use_signal(|| None); 222 + 223 + let flow = Flow { 162 224 client, 163 225 composition, 164 226 draft_id, 165 - step, 166 227 phase, 167 228 save, 168 229 upload, ··· 170 231 drafts, 171 232 did, 172 233 image_data_uris, 234 + advanced_open, 235 + advanced_auto_expanded, 236 + works_with_text, 237 + drag_over, 238 + compat_names, 239 + custom_license_targets, 240 + works_with_status, 241 + save_in_flight, 242 + name_element, 173 243 }; 174 244 175 245 // Resume a draft once on mount when `?draft=<id>` is present; always load 176 246 // the actor's existing drafts so they can be picked up as entry points. 177 247 { 178 - let wizard = wizard.clone(); 248 + let flow = flow.clone(); 179 249 let resume = resume.clone(); 180 250 use_hook(move || { 181 251 if !resume.trim().is_empty() { 182 - let mut wizard = wizard.clone(); 252 + let flow = flow.clone(); 183 253 let resume = resume.clone(); 184 254 spawn(async move { 185 - match draft_client::get_draft(&wizard.client, resume.trim()).await { 255 + match draft_client::get_draft(&flow.client, resume.trim()).await { 186 256 Ok(loaded) => { 187 - wizard.composition.set(loaded); 188 - wizard.draft_id.set(Some(resume.trim().to_string())); 257 + // A resumed multi-model draft should land with its 258 + // Advanced section expanded. 259 + let multi = loaded.models.len() > 1; 260 + { 261 + let mut composition = flow.composition; 262 + composition.set(loaded); 263 + } 264 + let mut advanced_open = advanced_open; 265 + let mut auto_expanded = advanced_auto_expanded; 266 + advanced_open.set(multi); 267 + auto_expanded.set(multi); 268 + let mut draft_id = flow.draft_id; 269 + draft_id.set(Some(resume.trim().to_string())); 189 270 } 190 271 Err(error) => { 191 272 tracing::warn!(%error, "failed to resume draft"); ··· 193 274 } 194 275 }); 195 276 } 196 - let wizard = wizard.clone(); 197 - spawn(async move { wizard.reload_drafts().await }); 277 + let flow = flow.clone(); 278 + spawn(async move { flow.reload_drafts().await }); 198 279 }); 199 280 } 200 281 ··· 203 284 // draft via `?draft=<id>` instead of dropping you on a blank `/publish`. 204 285 // replace() avoids stacking a history entry on every autosave. 205 286 { 206 - let draft_id = wizard.draft_id; 287 + let draft_id = flow.draft_id; 207 288 use_effect(move || { 208 289 if let Some(id) = draft_id.read().clone() { 209 290 navigator().replace(Route::Publish { draft: id }); ··· 211 292 }); 212 293 } 213 294 214 - let current = *wizard.step.read(); 215 - let body = match current { 216 - WizardStep::Basics => wizard.render_basics(), 217 - WizardStep::Files => wizard.render_files(), 218 - WizardStep::Images => wizard.render_images(), 219 - WizardStep::Details => wizard.render_details(), 220 - WizardStep::Review => wizard.render_review(), 221 - }; 295 + // (Auto-expand and focus-on-first-upload are handled at the mutation site, 296 + // not via effects — observing signals to fire one-shot side effects re-runs 297 + // on every change and needs fragile prev-state bookkeeping.) 222 298 223 299 rsx! { 224 300 main { class: "publish-page", 225 - { wizard.render_header() } 226 - if let PublishPhase::Published { repo, rkey } = wizard.phase.read().clone() { 227 - { wizard.render_published(repo, rkey) } 301 + { flow.render_header() } 302 + if let PublishPhase::Published { repo, rkey } = flow.phase.read().clone() { 303 + { flow.render_published(repo, rkey) } 228 304 } else { 229 - { wizard.render_resume() } 230 - { wizard.render_stepper() } 231 - section { class: "wizard-body blueprint-panel", aria_label: "{current.label()}", 232 - {body} 233 - } 234 - { wizard.render_nav() } 305 + { flow.render_resume() } 306 + { flow.render_editor() } 307 + { flow.render_confirm() } 235 308 } 236 309 } 237 310 } 238 311 } 239 312 240 - impl Wizard { 241 - /// Create (first save) or update the server-side draft. 313 + /// The level a shared field renderer targets: the thing, a model, or a part. 314 + #[derive(Clone, Copy)] 315 + enum FieldTarget { 316 + Thing, 317 + Model(usize), 318 + Part(usize, usize), 319 + } 320 + 321 + /// Which bbox axis/unit a single dimension input edits. 322 + #[derive(Clone, Copy)] 323 + enum BboxField { 324 + X, 325 + Y, 326 + Z, 327 + Unit, 328 + } 329 + 330 + impl Flow { 331 + /// Create (first save) or update the server-side draft. Serializes saves: if 332 + /// a save is already in flight, callers should mark Dirty and let the 333 + /// in-flight save re-run — `mark_dirty_and_save` enforces that, and this 334 + /// method re-spawns when it finishes with Dirty still set, so rapid edits 335 + /// coalesce instead of racing into duplicate drafts. 242 336 async fn save_draft(self) { 337 + { 338 + let mut guard = self.save_in_flight; 339 + if *guard.read() { 340 + // Another save is mid-flight; mark dirty and let it pick this up. 341 + let mut save = self.save; 342 + save.set(SaveState::Dirty); 343 + return; 344 + } 345 + guard.set(true); 346 + } 243 347 let mut save = self.save; 244 348 save.set(SaveState::Saving); 245 349 let payload = self.composition.read().clone(); 246 350 let existing = self.draft_id.read().clone(); 351 + if existing.is_none() && is_initial_composition(&payload) { 352 + save.set(SaveState::New); 353 + let mut guard = self.save_in_flight; 354 + guard.set(false); 355 + return; 356 + } 247 357 let result = match existing { 248 358 Some(id) => draft_client::put_draft(&self.client, &id, &payload) 249 359 .await ··· 254 364 Ok(id) => { 255 365 let mut draft_id = self.draft_id; 256 366 draft_id.set(Some(id)); 257 - save.set(SaveState::Saved); 367 + // Only mark Saved if no edit landed while the request was in 368 + // flight — otherwise leave Dirty so the re-run below persists it. 369 + if !matches!(*save.read(), SaveState::Dirty) { 370 + save.set(SaveState::Saved); 371 + } 258 372 } 259 373 Err(error) => { 260 374 tracing::warn!(%error, "failed to save draft"); 261 375 save.set(SaveState::Error); 262 376 } 263 377 } 378 + // Release the guard; if edits landed during the save, re-run once. 379 + let mut guard = self.save_in_flight; 380 + guard.set(false); 381 + if matches!(*self.save.read(), SaveState::Dirty) { 382 + let flow = self.clone(); 383 + spawn(async move { flow.save_draft().await }); 384 + } 385 + } 386 + 387 + /// Mark the composition dirty and persist it. Used after every structural or 388 + /// committed-text mutation now that the step-change save trigger is gone. 389 + /// Coalesces with any in-flight save (see `save_draft`). 390 + fn mark_dirty_and_save(&self) { 391 + let mut save = self.save; 392 + save.set(SaveState::Dirty); 393 + if *self.save_in_flight.read() { 394 + return; 395 + } 396 + let flow = self.clone(); 397 + spawn(async move { flow.save_draft().await }); 264 398 } 265 399 266 400 fn mark_dirty(&self) { ··· 268 402 save.set(SaveState::Dirty); 269 403 } 270 404 271 - fn goto(&self, target: WizardStep) { 272 - let mut step = self.step; 273 - step.set(target); 274 - let wizard = self.clone(); 275 - spawn(async move { wizard.save_draft().await }); 405 + fn set_license_value(&self, target: FieldTarget, value: String) { 406 + let mut composition = self.composition; 407 + let mut guard = composition.write(); 408 + let value = if value.trim().is_empty() { 409 + None 410 + } else { 411 + Some(value) 412 + }; 413 + match target { 414 + FieldTarget::Thing => guard.license = value, 415 + FieldTarget::Model(mi) => { 416 + if let Some(model) = guard.models.get_mut(mi) { 417 + model.license = value; 418 + } 419 + } 420 + FieldTarget::Part(_, _) => {} 421 + } 276 422 } 277 423 278 424 /// Refresh the actor's draft summaries for the resume list. ··· 284 430 } 285 431 } 286 432 287 - /// Load an existing draft into the wizard in place (without a route change). 433 + /// Load an existing draft into the editor in place (without a route change). 288 434 async fn resume_draft(self, id: String) { 289 435 match draft_client::get_draft(&self.client, &id).await { 290 436 Ok(loaded) => { 291 - let mut composition = self.composition; 292 - let mut draft_id = self.draft_id; 293 - let mut step = self.step; 437 + let multi = loaded.models.len() > 1; 438 + { 439 + let mut composition = self.composition; 440 + composition.set(loaded); 441 + } 442 + { 443 + let mut draft_id = self.draft_id; 444 + draft_id.set(Some(id)); 445 + } 446 + let mut advanced_open = self.advanced_open; 447 + let mut auto_expanded = self.advanced_auto_expanded; 448 + advanced_open.set(multi); 449 + auto_expanded.set(multi); 294 450 let mut save = self.save; 295 - composition.set(loaded); 296 - draft_id.set(Some(id)); 297 - step.set(WizardStep::Basics); 298 451 save.set(SaveState::Saved); 299 452 } 300 453 Err(error) => tracing::warn!(%error, "failed to resume draft"), ··· 317 470 } 318 471 } 319 472 320 - /// Resume list: existing server-side drafts shown as entry points. Hidden 321 - /// when the actor has no other drafts than the one being edited. 322 473 fn render_resume(&self) -> Element { 323 474 let drafts = self.drafts.read().clone(); 324 475 let active = self.draft_id.read().clone(); ··· 342 493 .filter(|n| !n.trim().is_empty()) 343 494 .unwrap_or_else(|| "Untitled draft".to_string()); 344 495 let model_count = summary.model_count; 345 - let resume_w = self.clone(); 496 + let resume_f = self.clone(); 346 497 let resume_id = id.clone(); 347 - let discard_w = self.clone(); 498 + let discard_f = self.clone(); 348 499 let discard_id = id.clone(); 349 500 rsx! { 350 501 li { key: "{id}", class: "wizard-resume-item", ··· 356 507 button { 357 508 class: "button button-secondary", 358 509 onclick: move |_| { 359 - let wizard = resume_w.clone(); 510 + let flow = resume_f.clone(); 360 511 let id = resume_id.clone(); 361 - spawn(async move { wizard.resume_draft(id).await }); 512 + spawn(async move { flow.resume_draft(id).await }); 362 513 }, 363 514 "Resume" 364 515 } 365 516 button { 366 517 class: "button button-ghost", 367 518 onclick: move |_| { 368 - let wizard = discard_w.clone(); 519 + let flow = discard_f.clone(); 369 520 let id = discard_id.clone(); 370 - spawn(async move { wizard.discard_draft(id).await }); 521 + spawn(async move { flow.discard_draft(id).await }); 371 522 }, 372 523 "Delete" 373 524 } ··· 397 548 SaveState::Dirty => "Unsaved changes", 398 549 SaveState::Error => "Save failed", 399 550 }; 551 + let save_f = self.clone(); 400 552 rsx! { 401 553 header { class: "publish-head", 402 554 div { class: "publish-head-row", 403 555 h1 { "Publish a thing" } 404 556 span { class: "{pill_class}", "{pill_label}" } 557 + button { 558 + class: "button button-secondary publish-save", 559 + disabled: matches!(save, SaveState::Saving), 560 + onclick: move |_| { 561 + let flow = save_f.clone(); 562 + spawn(async move { flow.save_draft().await }); 563 + }, 564 + "Save draft" 565 + } 405 566 } 406 567 p { class: "publish-save-state", aria_live: "polite", "{save_label}" } 407 568 if let PublishPhase::Failed(message) = &phase { ··· 411 572 } 412 573 } 413 574 414 - fn render_stepper(&self) -> Element { 415 - let composition = self.composition.read(); 416 - let current = *self.step.read(); 417 - let basics_done = composition.basics_complete(); 418 - let files_done = composition.files_complete(); 419 - drop(composition); 575 + /// Upload/resolve status (progress or error), shown wherever file or 576 + /// "Works with" staging can fail — both the hero (empty state) and the files 577 + /// list (post-upload), so failures are never hidden by the hero swap. 578 + fn render_upload_status(&self) -> Element { 579 + let status = self.upload.read().clone(); 580 + match status { 581 + UploadStatus::Uploading(label) => rsx! { 582 + div { class: "wizard-upload-status", role: "status", 583 + span { class: "wizard-progress-bar", "aria-hidden": "true" } 584 + span { "Uploading {label}…" } 585 + } 586 + }, 587 + UploadStatus::Error(message) => rsx! { 588 + p { class: "wizard-error", role: "alert", "{message}" } 589 + }, 590 + UploadStatus::Idle => rsx! {}, 591 + } 592 + } 593 + 594 + /// "Works with" resolve status, shown inside that disclosure only — kept 595 + /// separate from the file-upload status so the two flows don't clobber each 596 + /// other's feedback. 597 + fn render_works_with_status(&self) -> Element { 598 + let status = self.works_with_status.read().clone(); 599 + match status { 600 + UploadStatus::Error(message) => rsx! { 601 + p { class: "wizard-error", role: "alert", "{message}" } 602 + }, 603 + _ => rsx! {}, 604 + } 605 + } 606 + 607 + /// Image-upload status (cover/previews), distinct from geometry-file status. 608 + fn render_image_upload_status(&self) -> Element { 609 + let status = self.image_upload.read().clone(); 610 + match status { 611 + UploadStatus::Uploading(label) => rsx! { 612 + div { class: "wizard-upload-status", role: "status", 613 + span { class: "wizard-progress-bar", "aria-hidden": "true" } 614 + span { "Uploading {label}…" } 615 + } 616 + }, 617 + UploadStatus::Error(message) => rsx! { 618 + p { class: "wizard-error", role: "alert", "{message}" } 619 + }, 620 + UploadStatus::Idle => rsx! {}, 621 + } 622 + } 623 + 624 + /// The hero upload zone — the empty-state dropzone, shown only before any 625 + /// file is staged. A large, keyboard-reachable label wraps a `multiple` 626 + /// file picker, and accepts dragged geometry files. 627 + fn render_hero(&self) -> Element { 628 + let drag_over = *self.drag_over.read(); 629 + let hero_w = self.clone(); 630 + let over_w = self.clone(); 631 + let leave_w = self.clone(); 632 + let drop_w = self.clone(); 633 + let drag_class = if drag_over { "is-drag-over" } else { "" }; 420 634 rsx! { 421 - nav { class: "wizard-steps", aria_label: "Publish steps", 422 - for s in WizardStep::ALL { 423 - { 424 - let done = match s { 425 - WizardStep::Basics => basics_done, 426 - WizardStep::Files => files_done, 427 - _ => false, 428 - }; 429 - let is_active = s == current; 430 - let wizard = self.clone(); 431 - let active_class = if is_active { "is-active" } else { "" }; 432 - let done_class = if done { "is-complete" } else { "" }; 433 - rsx! { 434 - button { 435 - key: "step-{s.number()}", 436 - class: "tab wizard-step {active_class} {done_class}", 437 - aria_current: if is_active { "step" } else { "false" }, 438 - onclick: move |_| wizard.goto(s), 439 - span { class: "wizard-step-num", "{s.number()}" } 440 - span { class: "wizard-step-label", "{s.label()}" } 441 - if done { 442 - span { class: "wizard-step-check", aria_hidden: "true", "✓" } 443 - } 635 + section { class: "publish-hero blueprint-panel", aria_label: "Upload model files", 636 + label { 637 + class: "publish-dropzone {drag_class}", 638 + ondragover: move |evt| { 639 + evt.prevent_default(); 640 + let mut drag_over = over_w.drag_over; 641 + drag_over.set(true); 642 + }, 643 + ondragleave: move |_| { 644 + let mut drag_over = leave_w.drag_over; 645 + drag_over.set(false); 646 + }, 647 + ondrop: move |evt| { 648 + evt.prevent_default(); 649 + let mut drag_over = drop_w.drag_over; 650 + drag_over.set(false); 651 + let files = evt.files(); 652 + if !files.is_empty() { 653 + let flow = drop_w.clone(); 654 + spawn(async move { flow.handle_files_upload(files).await }); 655 + } 656 + }, 657 + input { 658 + r#type: "file", 659 + multiple: true, 660 + accept: ".stl,.obj,.gltf,.glb,.3mf,.step,.stp,model/*,application/octet-stream", 661 + class: "wizard-file-input", 662 + aria_label: "Upload model files", 663 + onchange: move |evt| { 664 + let files = evt.files(); 665 + if !files.is_empty() { 666 + let flow = hero_w.clone(); 667 + spawn(async move { flow.handle_files_upload(files).await }); 444 668 } 669 + }, 670 + } 671 + div { class: "publish-dropzone-body", 672 + p { class: "publish-dropzone-title", "Drop geometry files to begin" } 673 + p { class: "publish-dropzone-hint card-meta", 674 + "STL · OBJ · glTF · GLB · 3MF · STEP — or click to choose" 445 675 } 446 676 } 447 677 } 678 + { self.render_upload_status() } 448 679 } 449 680 } 450 681 } 451 682 452 - fn render_nav(&self) -> Element { 453 - let current = *self.step.read(); 454 - let publishing = matches!(*self.phase.read(), PublishPhase::Publishing); 455 - let prev = current.prev(); 456 - let next = current.next(); 457 - let wizard_back = self.clone(); 458 - let wizard_next = self.clone(); 459 - let wizard_save = self.clone(); 683 + /// Inline editor: the thing name + uploaded-files list (default model's 684 + /// parts), then progressive-disclosure "more" sections and the Advanced 685 + /// multi-model disclosure with per-model/per-part metadata. 686 + fn render_editor(&self) -> Element { 687 + let composition = self.composition.read(); 688 + let has_parts = composition.models.iter().any(|m| !m.parts.is_empty()); 689 + drop(composition); 690 + 460 691 rsx! { 461 - footer { class: "wizard-nav", 462 - if let Some(prev) = prev { 463 - button { 464 - class: "button button-ghost", 465 - disabled: publishing, 466 - onclick: move |_| wizard_back.goto(prev), 467 - "Back" 468 - } 469 - } 470 - div { class: "wizard-nav-spacer" } 471 - button { 472 - class: "button button-secondary", 473 - disabled: publishing, 474 - onclick: move |_| { 475 - let wizard = wizard_save.clone(); 476 - spawn(async move { wizard.save_draft().await }); 477 - }, 478 - "Save draft" 479 - } 480 - if let Some(next) = next { 481 - button { 482 - class: "button button-primary", 483 - disabled: publishing, 484 - onclick: move |_| wizard_next.goto(next), 485 - "Next: {next.label()}" 486 - } 692 + section { class: "wizard-body blueprint-panel", aria_label: "Publish", 693 + { self.render_inline_name() } 694 + { self.render_thing_sections() } 695 + if has_parts { 696 + { self.render_files_list() } 697 + } else { 698 + { self.render_hero() } 487 699 } 700 + { self.render_advanced() } 488 701 } 489 702 } 490 703 } 491 704 492 - fn render_basics(&self) -> Element { 705 + /// The editable thing name, shown once the first file is staged. Focuses 706 + /// itself on first mount so the user lands on the most likely next edit. 707 + fn render_inline_name(&self) -> Element { 493 708 let composition = self.composition.read(); 494 709 let name = composition.name.clone().unwrap_or_default(); 495 - let summary = composition.summary.clone().unwrap_or_default(); 496 710 drop(composition); 497 - 498 711 let name_w = self.clone(); 499 - let summary_w = self.clone(); 500 - 712 + let name_element = self.name_element; 501 713 rsx! { 502 - h2 { "Basics" } 503 - label { class: "field", 714 + label { class: "field publish-name-field", 504 715 span { class: "field-label", "Name" } 505 716 input { 506 717 class: "field-input", 507 718 r#type: "text", 508 719 value: "{name}", 509 720 placeholder: "e.g. Modular desk organizer", 721 + onmounted: move |element| { 722 + let mut name_element = name_element; 723 + name_element.set(Some(element.data())); 724 + }, 510 725 oninput: move |evt| { 511 726 let mut c = name_w.composition; 512 727 c.write().name = Some(evt.value()); 513 - name_w.mark_dirty(); 514 728 }, 729 + onblur: move |_| name_w.clone().mark_dirty_and_save(), 515 730 } 516 731 } 517 - label { class: "field", 518 - span { class: "field-label", "Summary" } 519 - textarea { 520 - class: "field-input field-textarea", 521 - value: "{summary}", 522 - placeholder: "What is it?", 523 - oninput: move |evt| { 524 - let mut c = summary_w.composition; 525 - let v = evt.value(); 526 - c.write().summary = if v.trim().is_empty() { None } else { Some(v) }; 527 - summary_w.mark_dirty(); 528 - }, 732 + } 733 + } 734 + 735 + /// The default model's parts as an editable files list (rename + remove + 736 + /// size/format meta), plus "Add more files" routed through the same batch 737 + /// handler as the hero. 738 + fn render_files_list(&self) -> Element { 739 + let composition = self.composition.read(); 740 + let parts = composition 741 + .models 742 + .first() 743 + .map(|m| m.parts.clone()) 744 + .unwrap_or_default(); 745 + drop(composition); 746 + let add_w = self.clone(); 747 + let over_w = self.clone(); 748 + let leave_w = self.clone(); 749 + let drop_w = self.clone(); 750 + let drag_over = *self.drag_over.read(); 751 + let drag_class = if drag_over { "is-drag-over" } else { "" }; 752 + rsx! { 753 + div { 754 + class: "publish-files {drag_class}", 755 + ondragover: move |evt| { 756 + evt.prevent_default(); 757 + let mut drag_over = over_w.drag_over; 758 + drag_over.set(true); 759 + }, 760 + ondragleave: move |_| { 761 + let mut drag_over = leave_w.drag_over; 762 + drag_over.set(false); 763 + }, 764 + ondrop: move |evt| { 765 + evt.prevent_default(); 766 + let mut drag_over = drop_w.drag_over; 767 + drag_over.set(false); 768 + let files = evt.files(); 769 + if !files.is_empty() { 770 + let flow = drop_w.clone(); 771 + spawn(async move { flow.handle_files_upload(files).await }); 772 + } 773 + }, 774 + h2 { "Files" } 775 + { self.render_upload_status() } 776 + ul { class: "wizard-part-list", 777 + for (pi, part) in parts.iter().enumerate() { 778 + { 779 + let part_name = part.name.clone().unwrap_or_default(); 780 + let size = part.file.as_ref().map(|f| human_size(f.size)).unwrap_or_default(); 781 + let format = part.format.clone().unwrap_or_default(); 782 + let pname_w = self.clone(); 783 + let premove_w = self.clone(); 784 + rsx! { 785 + li { key: "part-0-{pi}", class: "wizard-part", 786 + label { class: "field wizard-part-name", 787 + span { class: "field-label sr-only", "Part name" } 788 + input { 789 + class: "field-input", 790 + r#type: "text", 791 + value: "{part_name}", 792 + oninput: move |evt| { 793 + let mut c = pname_w.composition; 794 + c.write().models[0].parts[pi].name = Some(evt.value()); 795 + }, 796 + onblur: move |_| pname_w.clone().mark_dirty_and_save(), 797 + } 798 + } 799 + span { class: "wizard-part-meta card-meta", 800 + if !format.is_empty() { "{format} · " } 801 + "{size}" 802 + } 803 + button { 804 + class: "button button-ghost wizard-remove", 805 + onclick: move |_| { 806 + let mut c = premove_w.composition; 807 + c.write().models[0].parts.remove(pi); 808 + premove_w.clone().mark_dirty_and_save(); 809 + }, 810 + "Remove" 811 + } 812 + } 813 + } 814 + } 815 + } 816 + } 817 + label { class: "button button-secondary wizard-file-button", 818 + "Add more files" 819 + input { 820 + class: "wizard-file-input", 821 + r#type: "file", 822 + multiple: true, 823 + accept: ".stl,.obj,.gltf,.glb,.3mf,.step,.stp,model/*,application/octet-stream", 824 + onchange: move |evt| { 825 + let files = evt.files(); 826 + if !files.is_empty() { 827 + let flow = add_w.clone(); 828 + spawn(async move { flow.handle_files_upload(files).await }); 829 + } 830 + }, 831 + } 529 832 } 530 833 } 531 834 } 532 835 } 533 836 534 - fn render_files(&self) -> Element { 837 + /// Thing-level "more" sections behind progressive disclosure: Summary, 838 + /// Tags, Images, Instructions, License, and Works with. 839 + fn render_thing_sections(&self) -> Element { 840 + rsx! { 841 + div { class: "publish-sections", 842 + { self.render_summary_section(FieldTarget::Thing, false) } 843 + { self.render_tags_section(FieldTarget::Thing, false) } 844 + { self.render_license_section(FieldTarget::Thing, false) } 845 + { self.render_cover_inline() } 846 + { self.render_instructions_section(FieldTarget::Thing) } 847 + { self.render_images_section(FieldTarget::Thing) } 848 + { self.render_works_with_section() } 849 + } 850 + } 851 + } 852 + 853 + /// Advanced multi-model disclosure: add/remove models, per-model metadata, 854 + /// and per-part metadata via the shared field renderers. 855 + fn render_advanced(&self) -> Element { 535 856 let composition = self.composition.read(); 536 857 let models = composition.models.clone(); 537 - let upload = self.upload.read().clone(); 538 858 drop(composition); 859 + let add_w = self.clone(); 860 + rsx! { 861 + Disclosure { 862 + title: "Advanced", 863 + hint: "Multiple models and per-part metadata", 864 + open: Some(self.advanced_open), 865 + body: rsx! { 866 + for (mi, model) in models.iter().enumerate() { 867 + { self.render_model_block(mi, model) } 868 + } 869 + button { 870 + class: "button button-secondary wizard-add-model", 871 + onclick: move |_| { 872 + let was_count = { 873 + let g = add_w.composition.read(); 874 + g.models.len() 875 + }; 876 + let next_name = format!("Model {}", was_count + 1); 877 + let mut c = add_w.composition; 878 + c.write().models.push(DraftModelInput { 879 + name: Some(next_name), 880 + ..Default::default() 881 + }); 882 + // First time reaching a second model: auto-open 883 + // Advanced once. The one-way flag means a later 884 + // remove→re-add after a manual collapse stays put. 885 + if was_count == 1 && !*add_w.advanced_auto_expanded.read() { 886 + let mut advanced_open = add_w.advanced_open; 887 + advanced_open.set(true); 888 + let mut auto_expanded = add_w.advanced_auto_expanded; 889 + auto_expanded.set(true); 890 + } 891 + add_w.clone().mark_dirty_and_save(); 892 + }, 893 + "Add a model" 894 + } 895 + } 896 + } 897 + } 898 + } 539 899 540 - let add_model_w = self.clone(); 900 + fn render_model_block(&self, mi: usize, model: &DraftModelInput) -> Element { 901 + let model_name = model.name.clone().unwrap_or_default(); 902 + let parts = model.parts.clone(); 903 + let name_w = self.clone(); 904 + let remove_w = self.clone(); 905 + let addfile_w = self.clone(); 541 906 rsx! { 542 - h2 { "Files & parts" } 543 - p { class: "wizard-step-hint", "Add one or more models. Each model holds ordered parts; upload a geometry file for every part." } 544 - match &upload { 545 - UploadStatus::Uploading(label) => rsx! { 546 - div { class: "wizard-upload-status", role: "status", 547 - span { class: "wizard-progress-bar", "aria-hidden": "true" } 548 - span { "Uploading {label}…" } 907 + section { key: "model-{mi}", class: "wizard-model", 908 + div { class: "wizard-model-head", 909 + label { class: "field wizard-model-name", 910 + span { class: "field-label", "Model {mi + 1} name" } 911 + input { 912 + class: "field-input", 913 + r#type: "text", 914 + value: "{model_name}", 915 + placeholder: "e.g. Base plate", 916 + oninput: move |evt| { 917 + let mut c = name_w.composition; 918 + c.write().models[mi].name = Some(evt.value()); 919 + }, 920 + onblur: move |_| name_w.clone().mark_dirty_and_save(), 921 + } 922 + } 923 + button { 924 + class: "button button-ghost wizard-remove", 925 + onclick: move |_| { 926 + let mut c = remove_w.composition; 927 + c.write().models.remove(mi); 928 + remove_w.clone().mark_dirty_and_save(); 929 + }, 930 + "Remove model" 549 931 } 550 - }, 551 - UploadStatus::Error(message) => rsx! { 552 - p { class: "wizard-error", role: "alert", "{message}" } 553 - }, 554 - UploadStatus::Idle => rsx! {}, 932 + } 933 + if parts.is_empty() { 934 + p { class: "wizard-empty", "No parts yet. Upload a file below." } 935 + } 936 + ul { class: "wizard-part-list", 937 + for (pi, part) in parts.iter().enumerate() { 938 + { self.render_part_row(mi, pi, part) } 939 + } 940 + } 941 + label { class: "button button-secondary wizard-file-button", 942 + "Add a file to this model" 943 + input { 944 + class: "wizard-file-input", 945 + r#type: "file", 946 + accept: ".stl,.obj,.gltf,.glb,.3mf,.step,.stp,model/*,application/octet-stream", 947 + onchange: move |evt| { 948 + if let Some(file) = evt.files().into_iter().next() { 949 + let flow = addfile_w.clone(); 950 + spawn(async move { flow.stage_file_into(mi, file).await }); 951 + } 952 + }, 953 + } 954 + } 955 + { self.render_summary_section(FieldTarget::Model(mi), true) } 956 + { self.render_tags_section(FieldTarget::Model(mi), true) } 957 + { self.render_instructions_section(FieldTarget::Model(mi)) } 958 + { self.render_license_section(FieldTarget::Model(mi), true) } 959 + { self.render_images_section(FieldTarget::Model(mi)) } 555 960 } 556 - if models.is_empty() { 557 - p { class: "wizard-empty", "No models yet. Add one to start uploading parts." } 961 + } 962 + } 963 + 964 + fn render_part_row(&self, mi: usize, pi: usize, part: &DraftPartInput) -> Element { 965 + let part_name = part.name.clone().unwrap_or_default(); 966 + let size = part 967 + .file 968 + .as_ref() 969 + .map(|f| human_size(f.size)) 970 + .unwrap_or_default(); 971 + let format = part.format.clone().unwrap_or_default(); 972 + let notes = part.notes.clone().unwrap_or_default(); 973 + let units = part.units.clone().unwrap_or_default(); 974 + let print_settings = part.print_settings.clone().unwrap_or_default().join("\n"); 975 + let bbox = part.dimensions.clone(); 976 + let pname_w = self.clone(); 977 + let premove_w = self.clone(); 978 + let notes_w = self.clone(); 979 + let units_w = self.clone(); 980 + let prints_w = self.clone(); 981 + rsx! { 982 + li { key: "part-{mi}-{pi}", class: "wizard-part wizard-part-advanced", 983 + div { class: "wizard-part-main", 984 + label { class: "field wizard-part-name", 985 + span { class: "field-label sr-only", "Part name" } 986 + input { 987 + class: "field-input", 988 + r#type: "text", 989 + value: "{part_name}", 990 + oninput: move |evt| { 991 + let mut c = pname_w.composition; 992 + c.write().models[mi].parts[pi].name = Some(evt.value()); 993 + }, 994 + onblur: move |_| pname_w.clone().mark_dirty_and_save(), 995 + } 996 + } 997 + span { class: "wizard-part-meta card-meta", 998 + if !format.is_empty() { "{format} · " } 999 + "{size}" 1000 + } 1001 + button { 1002 + class: "button button-ghost wizard-remove", 1003 + onclick: move |_| { 1004 + let mut c = premove_w.composition; 1005 + c.write().models[mi].parts.remove(pi); 1006 + premove_w.clone().mark_dirty_and_save(); 1007 + }, 1008 + "Remove" 1009 + } 1010 + } 1011 + div { class: "wizard-part-meta-grid", 1012 + label { class: "field", 1013 + span { class: "field-label", "Notes" } 1014 + input { 1015 + class: "field-input", 1016 + r#type: "text", 1017 + value: "{notes}", 1018 + placeholder: "e.g. Print supports off", 1019 + oninput: move |evt| { 1020 + let mut c = notes_w.composition; 1021 + let v = evt.value(); 1022 + c.write().models[mi].parts[pi].notes = if v.trim().is_empty() { None } else { Some(v) }; 1023 + }, 1024 + onblur: move |_| notes_w.clone().mark_dirty_and_save(), 1025 + } 1026 + } 1027 + label { class: "field", 1028 + span { class: "field-label", "Units" } 1029 + input { 1030 + class: "field-input", 1031 + r#type: "text", 1032 + value: "{units}", 1033 + placeholder: "mm", 1034 + oninput: move |evt| { 1035 + let mut c = units_w.composition; 1036 + let v = evt.value(); 1037 + c.write().models[mi].parts[pi].units = if v.trim().is_empty() { None } else { Some(v) }; 1038 + }, 1039 + onblur: move |_| units_w.clone().mark_dirty_and_save(), 1040 + } 1041 + } 1042 + label { class: "field publish-field-wide", 1043 + span { class: "field-label", "Print settings" } 1044 + textarea { 1045 + class: "field-input field-textarea", 1046 + value: "{print_settings}", 1047 + placeholder: "One setting per line", 1048 + oninput: move |evt| { 1049 + let mut c = prints_w.composition; 1050 + let lines: Vec<String> = evt.value().split('\n').map(str::to_string).collect(); 1051 + c.write().models[mi].parts[pi].print_settings = if lines.iter().all(|l| l.trim().is_empty()) { None } else { Some(lines) }; 1052 + }, 1053 + onblur: move |_| prints_w.clone().mark_dirty_and_save(), 1054 + } 1055 + } 1056 + fieldset { class: "field publish-bbox", 1057 + legend { class: "field-label", "Dimensions" } 1058 + { self.render_bbox(mi, pi, &bbox) } 1059 + } 1060 + } 1061 + { self.render_images_section(FieldTarget::Part(mi, pi)) } 558 1062 } 559 - for (mi, model) in models.iter().enumerate() { 560 - { 561 - let model_name = model.name.clone().unwrap_or_default(); 562 - let parts = model.parts.clone(); 563 - let name_w = self.clone(); 564 - let remove_w = self.clone(); 565 - let upload_w = self.clone(); 566 - rsx! { 567 - section { key: "model-{mi}", class: "wizard-model", 568 - div { class: "wizard-model-head", 569 - label { class: "field wizard-model-name", 570 - span { class: "field-label", "Model {mi + 1} name" } 571 - input { 572 - class: "field-input", 573 - r#type: "text", 574 - value: "{model_name}", 575 - placeholder: "e.g. Base plate", 576 - oninput: move |evt| { 577 - let mut c = name_w.composition; 578 - c.write().models[mi].name = Some(evt.value()); 579 - name_w.mark_dirty(); 580 - }, 581 - } 582 - } 583 - button { 584 - class: "button button-ghost wizard-remove", 585 - onclick: move |_| { 586 - let mut c = remove_w.composition; 587 - c.write().models.remove(mi); 588 - remove_w.mark_dirty(); 589 - }, 590 - "Remove model" 1063 + } 1064 + } 1065 + 1066 + /// Dimensions (bbox) inputs for a part. Each axis/unit is written live into 1067 + /// the part's `dimensions` (creating an empty bbox on first edit); all-or-none 1068 + /// is enforced at `assemble()` via the lexicon validator, so partial values 1069 + /// simply block publish until complete — no hand-rolled float checks here. 1070 + /// Existing `dimensions.unit` is preserved and defaults to `"mm"`. 1071 + fn render_bbox(&self, mi: usize, pi: usize, bbox: &Option<Bbox>) -> Element { 1072 + let (bx, by, bz, bunit) = match bbox { 1073 + Some(b) => ( 1074 + b.x.as_str().to_string(), 1075 + b.y.as_str().to_string(), 1076 + b.z.as_str().to_string(), 1077 + b.unit 1078 + .as_ref() 1079 + .map(|u| u.as_str().to_string()) 1080 + .unwrap_or_else(|| "mm".to_string()), 1081 + ), 1082 + None => ( 1083 + String::new(), 1084 + String::new(), 1085 + String::new(), 1086 + "mm".to_string(), 1087 + ), 1088 + }; 1089 + 1090 + let x_in = self.clone(); 1091 + let x_blur = self.clone(); 1092 + let y_in = self.clone(); 1093 + let y_blur = self.clone(); 1094 + let z_in = self.clone(); 1095 + let z_blur = self.clone(); 1096 + let u_in = self.clone(); 1097 + let u_blur = self.clone(); 1098 + rsx! { 1099 + div { class: "publish-bbox-row", 1100 + label { class: "field", 1101 + span { class: "field-label sr-only", "Width (X)" } 1102 + input { 1103 + class: "field-input", 1104 + r#type: "text", 1105 + inputmode: "decimal", 1106 + value: "{bx}", 1107 + placeholder: "X", 1108 + aria_label: "Width", 1109 + oninput: move |evt| Flow::set_bbox_axis(&x_in, mi, pi, BboxField::X, evt.value()), 1110 + onblur: move |_| x_blur.clone().mark_dirty_and_save(), 1111 + } 1112 + } 1113 + label { class: "field", 1114 + span { class: "field-label sr-only", "Depth (Y)" } 1115 + input { 1116 + class: "field-input", 1117 + r#type: "text", 1118 + inputmode: "decimal", 1119 + value: "{by}", 1120 + placeholder: "Y", 1121 + aria_label: "Depth", 1122 + oninput: move |evt| Flow::set_bbox_axis(&y_in, mi, pi, BboxField::Y, evt.value()), 1123 + onblur: move |_| y_blur.clone().mark_dirty_and_save(), 1124 + } 1125 + } 1126 + label { class: "field", 1127 + span { class: "field-label sr-only", "Height (Z)" } 1128 + input { 1129 + class: "field-input", 1130 + r#type: "text", 1131 + inputmode: "decimal", 1132 + value: "{bz}", 1133 + placeholder: "Z", 1134 + aria_label: "Height", 1135 + oninput: move |evt| Flow::set_bbox_axis(&z_in, mi, pi, BboxField::Z, evt.value()), 1136 + onblur: move |_| z_blur.clone().mark_dirty_and_save(), 1137 + } 1138 + } 1139 + label { class: "field", 1140 + span { class: "field-label sr-only", "Dimension unit" } 1141 + input { 1142 + class: "field-input", 1143 + r#type: "text", 1144 + value: "{bunit}", 1145 + placeholder: "mm", 1146 + aria_label: "Dimension unit", 1147 + oninput: move |evt| Flow::set_bbox_axis(&u_in, mi, pi, BboxField::Unit, evt.value()), 1148 + onblur: move |_| u_blur.clone().mark_dirty_and_save(), 1149 + } 1150 + } 1151 + } 1152 + } 1153 + } 1154 + 1155 + /// Mutate a single bbox field on a part, initializing an empty bbox when 1156 + /// none exists yet. A bbox that ends up fully empty is dropped so the draft 1157 + /// stays clean and does not block publish with a partial/empty dimensions. 1158 + fn set_bbox_axis(&self, mi: usize, pi: usize, field: BboxField, value: String) { 1159 + let mut c = self.composition; 1160 + let mut g = c.write(); 1161 + let Some(part) = g.models.get_mut(mi).and_then(|m| m.parts.get_mut(pi)) else { 1162 + return; 1163 + }; 1164 + let bbox = part.dimensions.get_or_insert_with(|| Bbox { 1165 + unit: None, 1166 + x: "".into(), 1167 + y: "".into(), 1168 + z: "".into(), 1169 + extra_data: None, 1170 + }); 1171 + match field { 1172 + BboxField::X => bbox.x = value.into(), 1173 + BboxField::Y => bbox.y = value.into(), 1174 + BboxField::Z => bbox.z = value.into(), 1175 + BboxField::Unit => { 1176 + bbox.unit = if value.trim().is_empty() { 1177 + None 1178 + } else { 1179 + Some(value.into()) 1180 + }; 1181 + } 1182 + } 1183 + // Drop a dimensions with no axes set so it does not serialize or block 1184 + // publish with a partial (unit-only) bbox — unit alone is not a dimension. 1185 + if bbox.x.as_str().trim().is_empty() 1186 + && bbox.y.as_str().trim().is_empty() 1187 + && bbox.z.as_str().trim().is_empty() 1188 + { 1189 + part.dimensions = None; 1190 + } 1191 + } 1192 + 1193 + // ---- Shared field renderers (one implementation per field type) ---- 1194 + 1195 + fn render_summary_section(&self, target: FieldTarget, wrapped: bool) -> Element { 1196 + let composition = self.composition.read(); 1197 + let value = match target { 1198 + FieldTarget::Thing => composition.summary.clone().unwrap_or_default(), 1199 + FieldTarget::Model(mi) => composition 1200 + .models 1201 + .get(mi) 1202 + .and_then(|m| m.summary.clone()) 1203 + .unwrap_or_default(), 1204 + FieldTarget::Part(_, _) => return rsx! {}, 1205 + }; 1206 + drop(composition); 1207 + let w = self.clone(); 1208 + let field = rsx! { 1209 + label { class: "field", 1210 + span { class: "field-label", "Summary" } 1211 + textarea { 1212 + class: "field-input field-textarea", 1213 + value: "{value}", 1214 + placeholder: "What is it?", 1215 + oninput: move |evt| { 1216 + let v = evt.value(); 1217 + let mut c = w.composition; 1218 + let mut g = c.write(); 1219 + match target { 1220 + FieldTarget::Thing => g.summary = if v.trim().is_empty() { None } else { Some(v) }, 1221 + FieldTarget::Model(mi) => { 1222 + if let Some(m) = g.models.get_mut(mi) { 1223 + m.summary = if v.trim().is_empty() { None } else { Some(v) }; 591 1224 } 592 1225 } 593 - if parts.is_empty() { 594 - p { class: "wizard-empty", "No parts yet. Upload a file below." } 595 - } 596 - ul { class: "wizard-part-list", 597 - for (pi, part) in parts.iter().enumerate() { 598 - { 599 - let part_name = part.name.clone().unwrap_or_default(); 600 - let size = part.file.as_ref().map(|f| human_size(f.size)).unwrap_or_default(); 601 - let format = part.format.clone().unwrap_or_default(); 602 - let pname_w = self.clone(); 603 - let premove_w = self.clone(); 604 - rsx! { 605 - li { key: "part-{mi}-{pi}", class: "wizard-part", 606 - label { class: "field wizard-part-name", 607 - span { class: "field-label sr-only", "Part name" } 608 - input { 609 - class: "field-input", 610 - r#type: "text", 611 - value: "{part_name}", 612 - oninput: move |evt| { 613 - let mut c = pname_w.composition; 614 - c.write().models[mi].parts[pi].name = Some(evt.value()); 615 - pname_w.mark_dirty(); 616 - }, 617 - } 618 - } 619 - span { class: "wizard-part-meta card-meta", 620 - if !format.is_empty() { "{format} · " } 621 - "{size}" 622 - } 623 - button { 624 - class: "button button-ghost wizard-remove", 625 - onclick: move |_| { 626 - let mut c = premove_w.composition; 627 - c.write().models[mi].parts.remove(pi); 628 - premove_w.mark_dirty(); 629 - }, 630 - "Remove" 631 - } 632 - } 633 - } 634 - } 1226 + FieldTarget::Part(_, _) => {} 1227 + } 1228 + }, 1229 + onblur: move |_| w.clone().mark_dirty_and_save(), 1230 + } 1231 + } 1232 + }; 1233 + if wrapped { 1234 + rsx! { 1235 + Disclosure { 1236 + title: "Summary", 1237 + hint: Some("A short description".to_string()), 1238 + open: None, 1239 + body: field, 1240 + } 1241 + } 1242 + } else { 1243 + field 1244 + } 1245 + } 1246 + 1247 + fn render_tags_section(&self, target: FieldTarget, wrapped: bool) -> Element { 1248 + let composition = self.composition.read(); 1249 + let tags = match target { 1250 + FieldTarget::Thing => composition.tags.clone().unwrap_or_default(), 1251 + FieldTarget::Model(mi) => composition 1252 + .models 1253 + .get(mi) 1254 + .and_then(|m| m.tags.clone()) 1255 + .unwrap_or_default(), 1256 + FieldTarget::Part(_, _) => return rsx! {}, 1257 + }; 1258 + drop(composition); 1259 + let tags = tags.join(", "); 1260 + let w = self.clone(); 1261 + let field = rsx! { 1262 + label { class: "field", 1263 + span { class: "field-label", "Tags" } 1264 + input { 1265 + class: "field-input", 1266 + r#type: "text", 1267 + value: "{tags}", 1268 + placeholder: "comma, separated", 1269 + oninput: move |evt| { 1270 + let parsed: Vec<String> = evt.value().split(',').map(|t| t.trim().to_string()).filter(|t| !t.is_empty()).collect(); 1271 + let mut c = w.composition; 1272 + let mut g = c.write(); 1273 + match target { 1274 + FieldTarget::Thing => g.tags = if parsed.is_empty() { None } else { Some(parsed) }, 1275 + FieldTarget::Model(mi) => { 1276 + if let Some(m) = g.models.get_mut(mi) { 1277 + m.tags = if parsed.is_empty() { None } else { Some(parsed) }; 635 1278 } 636 1279 } 637 - label { class: "button button-secondary wizard-file-button", 638 - "Add a file" 639 - input { 640 - class: "wizard-file-input", 641 - r#type: "file", 642 - accept: ".stl,.obj,.gltf,.glb,.3mf,.step,.stp,model/*,application/octet-stream", 643 - onchange: move |evt| { 644 - if let Some(file) = evt.files().into_iter().next() { 645 - let wizard = upload_w.clone(); 646 - spawn(async move { wizard.handle_file_upload(mi, file).await }); 1280 + FieldTarget::Part(_, _) => {} 1281 + } 1282 + }, 1283 + onblur: move |_| w.clone().mark_dirty_and_save(), 1284 + } 1285 + } 1286 + }; 1287 + if wrapped { 1288 + rsx! { 1289 + Disclosure { 1290 + title: "Tags", 1291 + hint: Some("Comma-separated".to_string()), 1292 + open: None, 1293 + body: field, 1294 + } 1295 + } 1296 + } else { 1297 + field 1298 + } 1299 + } 1300 + 1301 + fn render_instructions_section(&self, target: FieldTarget) -> Element { 1302 + let composition = self.composition.read(); 1303 + let value = match target { 1304 + FieldTarget::Thing => composition 1305 + .instructions 1306 + .clone() 1307 + .unwrap_or_default() 1308 + .join("\n"), 1309 + FieldTarget::Model(mi) => composition 1310 + .models 1311 + .get(mi) 1312 + .and_then(|m| m.instructions.clone()) 1313 + .unwrap_or_default() 1314 + .join("\n"), 1315 + FieldTarget::Part(_, _) => return rsx! {}, 1316 + }; 1317 + drop(composition); 1318 + let w = self.clone(); 1319 + rsx! { 1320 + Disclosure { 1321 + title: "Instructions", 1322 + hint: "One step per line", 1323 + open: None, 1324 + body: rsx! { 1325 + label { class: "field", 1326 + span { class: "field-label sr-only", "Instructions" } 1327 + textarea { 1328 + class: "field-input field-textarea field-textarea-tall", 1329 + value: "{value}", 1330 + placeholder: "One step per line", 1331 + oninput: move |evt| { 1332 + let value = evt.value(); 1333 + let lines: Vec<String> = value.split('\n').map(str::to_string).collect(); 1334 + let mut c = w.composition; 1335 + let mut g = c.write(); 1336 + let opt = if value.trim().is_empty() { None } else { Some(lines) }; 1337 + match target { 1338 + FieldTarget::Thing => g.instructions = opt, 1339 + FieldTarget::Model(mi) => { 1340 + if let Some(m) = g.models.get_mut(mi) { 1341 + m.instructions = opt; 647 1342 } 648 - }, 1343 + } 1344 + FieldTarget::Part(_, _) => {} 649 1345 } 650 - } 1346 + }, 1347 + onblur: move |_| w.clone().mark_dirty_and_save(), 651 1348 } 652 1349 } 653 1350 } 654 1351 } 655 - button { 656 - class: "button button-secondary wizard-add-model", 657 - onclick: move |_| { 658 - let mut c = add_model_w.composition; 659 - c.write().models.push(DraftModelInput::default()); 660 - add_model_w.mark_dirty(); 661 - }, 662 - "Add a model" 663 - } 664 1352 } 665 1353 } 666 1354 667 - async fn handle_file_upload(self, model_index: usize, file: FileData) { 668 - let filename = file.name(); 669 - let mut upload = self.upload; 670 - upload.set(UploadStatus::Uploading(filename.clone())); 671 - let bytes = match file.read_bytes().await { 672 - Ok(bytes) => bytes.to_vec(), 673 - Err(_) => { 674 - upload.set(UploadStatus::Error( 675 - "Could not read the selected file.".into(), 676 - )); 677 - return; 1355 + fn render_license_section(&self, target: FieldTarget, wrapped: bool) -> Element { 1356 + let composition = self.composition.read(); 1357 + let (value, is_default_thing) = match target { 1358 + FieldTarget::Thing => { 1359 + let v = composition 1360 + .license 1361 + .clone() 1362 + .unwrap_or_else(|| DEFAULT_LICENSE.to_string()); 1363 + (v, true) 678 1364 } 1365 + FieldTarget::Model(mi) => { 1366 + let v = composition 1367 + .models 1368 + .get(mi) 1369 + .and_then(|m| m.license.clone()) 1370 + .unwrap_or_default(); 1371 + (v, false) 1372 + } 1373 + FieldTarget::Part(_, _) => return rsx! {}, 679 1374 }; 680 - let mime = file 681 - .content_type() 682 - .filter(|s| !s.is_empty()) 683 - .unwrap_or_else(|| mime_from_filename(&filename).to_string()); 684 - // Clone before staging: `stage_file` takes `bytes` by value, but the 685 - // worker preview request still needs a temporary Blob URL for the same bytes. 686 - match draft_client::stage_file(&self.client, &filename, &mime, bytes.clone()).await { 687 - Ok(staged) => { 688 - let format = format_from_filename(&filename); 689 - let mut part = DraftPartInput { 690 - name: Some(filename.clone()), 691 - upload_id: Some(staged.upload_id.to_string()), 692 - file: Some(staged.file), 693 - format: format.clone(), 694 - ..Default::default() 695 - }; 696 - // Best-effort preview generation (wasm-only; no-op off-wasm). 697 - // v1 supports STL only in the publish wizard — skip preview for other formats. 698 - let preview = if format.as_deref() == Some("stl") { 699 - let client = self.client.clone(); 700 - let upload_sig = self.upload; 701 - let data_uris_sig = self.image_data_uris; 702 - generate_preview(client, filename.clone(), bytes, upload_sig, data_uris_sig) 703 - .await 704 - } else { 705 - None 706 - }; 707 - { 708 - let mut composition = self.composition; 709 - let mut guard = composition.write(); 710 - if let Some(image) = preview { 711 - let parts_before_push = 712 - guard.models.get(model_index).map_or(0, |m| m.parts.len()); 713 - maybe_seed_cover(&mut guard, model_index, parts_before_push, &image); 714 - attach_preview(&mut part, image); 1375 + drop(composition); 1376 + let license_target = license_target_key(target); 1377 + let explicit_custom = self.custom_license_targets.read().contains(&license_target); 1378 + let known_license = LICENSE_OPTIONS.contains(&value.as_str()); 1379 + let is_custom = explicit_custom || (!value.trim().is_empty() && !known_license); 1380 + let selected = if is_custom { 1381 + CUSTOM_LICENSE_VALUE 1382 + } else { 1383 + value.as_str() 1384 + }; 1385 + let custom_value = if is_custom && known_license { 1386 + "" 1387 + } else { 1388 + value.as_str() 1389 + }; 1390 + let select_w = self.clone(); 1391 + let custom_w = self.clone(); 1392 + let custom_blur_w = self.clone(); 1393 + let field = rsx! { 1394 + div { class: "field publish-license-field", 1395 + label { class: "field-label", r#for: "license-{license_target}", "License" } 1396 + div { class: "publish-license-controls", 1397 + select { 1398 + id: "license-{license_target}", 1399 + class: "field-input publish-license-select", 1400 + value: "{selected}", 1401 + onchange: move |evt| { 1402 + let selected = evt.value(); 1403 + let mut custom_targets = select_w.custom_license_targets; 1404 + if selected == CUSTOM_LICENSE_VALUE { 1405 + custom_targets.write().insert(license_target.clone()); 1406 + return; 1407 + } 1408 + custom_targets.write().remove(&license_target); 1409 + select_w.set_license_value(target, selected); 1410 + select_w.clone().mark_dirty_and_save(); 1411 + }, 1412 + if !is_default_thing { 1413 + option { value: "", "Use thing license" } 1414 + } 1415 + for option in LICENSE_OPTIONS { 1416 + option { key: "{option}", value: "{option}", "{option}" } 1417 + } 1418 + option { value: CUSTOM_LICENSE_VALUE, "Custom…" } 715 1419 } 716 - if let Some(model) = guard.models.get_mut(model_index) { 717 - model.parts.push(part); 1420 + if is_custom { 1421 + input { 1422 + class: "field-input publish-license-custom", 1423 + r#type: "text", 1424 + value: "{custom_value}", 1425 + placeholder: "Custom license string", 1426 + aria_label: "Custom license", 1427 + oninput: move |evt| { 1428 + custom_w.set_license_value(target, evt.value()); 1429 + custom_w.mark_dirty(); 1430 + }, 1431 + onblur: move |_| custom_blur_w.clone().mark_dirty_and_save(), 1432 + } 718 1433 } 719 1434 } 720 - upload.set(UploadStatus::Idle); 721 - let wizard = self.clone(); 722 - wizard.save_draft().await; 723 1435 } 724 - Err(error) => { 725 - upload.set(UploadStatus::Error(format!("Upload failed: {error}"))); 1436 + }; 1437 + if wrapped { 1438 + let quiet = is_default_thing && value == DEFAULT_LICENSE; 1439 + rsx! { 1440 + Disclosure { 1441 + title: "License", 1442 + hint: if quiet { Some(DEFAULT_LICENSE.to_string()) } else { None }, 1443 + open: None, 1444 + body: field, 1445 + } 726 1446 } 1447 + } else { 1448 + field 727 1449 } 728 1450 } 729 1451 730 - fn render_images(&self) -> Element { 1452 + /// Inline cover-image editor for the thing level (always visible, not 1453 + /// collapsed) — the cover is primary product media. 1454 + fn render_cover_inline(&self) -> Element { 731 1455 let composition = self.composition.read(); 732 1456 let cover = composition.cover.clone().unwrap_or_default(); 733 - let previews = composition.previews.clone().unwrap_or_default(); 734 - let status = self.image_upload.read().clone(); 735 1457 drop(composition); 736 - 1458 + let target = FieldTarget::Thing; 737 1459 let cover_w = self.clone(); 738 - let preview_w = self.clone(); 739 1460 rsx! { 740 - h2 { "Images" } 741 - match &status { 742 - UploadStatus::Uploading(label) => rsx! { 743 - div { class: "wizard-upload-status", role: "status", 744 - span { class: "wizard-progress-bar", "aria-hidden": "true" } 745 - span { "Uploading {label}…" } 746 - } 747 - }, 748 - UploadStatus::Error(message) => rsx! { p { class: "wizard-error", role: "alert", "{message}" } }, 749 - UploadStatus::Idle => rsx! {}, 750 - } 751 - div { class: "wizard-image-group", 752 - h3 { class: "wizard-subhead", "Cover" } 753 - { self.render_image_list(&cover, true) } 1461 + div { class: "wizard-image-group publish-cover-inline", 1462 + span { class: "field-label", "Cover image" } 1463 + { self.render_image_upload_status() } 1464 + { self.render_image_list(&cover, target, true) } 754 1465 label { class: "button button-secondary wizard-file-button", 755 1466 "Add cover image" 756 1467 input { ··· 759 1470 accept: "image/png,image/jpeg,image/webp,image/gif", 760 1471 onchange: move |evt| { 761 1472 if let Some(file) = evt.files().into_iter().next() { 762 - let wizard = cover_w.clone(); 763 - spawn(async move { wizard.handle_image_upload(true, file).await }); 1473 + let flow = cover_w.clone(); 1474 + spawn(async move { flow.handle_image_upload(target, true, file).await }); 764 1475 } 765 1476 }, 766 1477 } 767 1478 } 768 1479 } 769 - div { class: "wizard-image-group", 770 - h3 { class: "wizard-subhead", "Previews" } 771 - { self.render_image_list(&previews, false) } 772 - label { class: "button button-secondary wizard-file-button", 773 - "Add preview image" 774 - input { 775 - class: "wizard-file-input", 776 - r#type: "file", 777 - accept: "image/png,image/jpeg,image/webp,image/gif", 778 - onchange: move |evt| { 779 - if let Some(file) = evt.files().into_iter().next() { 780 - let wizard = preview_w.clone(); 781 - spawn(async move { wizard.handle_image_upload(false, file).await }); 1480 + } 1481 + } 1482 + 1483 + fn render_images_section(&self, target: FieldTarget) -> Element { 1484 + let composition = self.composition.read(); 1485 + // Cover is rendered inline at the thing level (render_cover_inline), so 1486 + // this section only renders previews there; per-model it renders both. 1487 + let cover = match target { 1488 + FieldTarget::Model(mi) => composition 1489 + .models 1490 + .get(mi) 1491 + .and_then(|m| m.cover.clone()) 1492 + .unwrap_or_default(), 1493 + _ => Vec::new(), 1494 + }; 1495 + let previews = match target { 1496 + FieldTarget::Thing => composition.previews.clone().unwrap_or_default(), 1497 + FieldTarget::Model(mi) => composition 1498 + .models 1499 + .get(mi) 1500 + .and_then(|m| m.previews.clone()) 1501 + .unwrap_or_default(), 1502 + FieldTarget::Part(mi, pi) => composition 1503 + .models 1504 + .get(mi) 1505 + .and_then(|m| m.parts.get(pi)) 1506 + .and_then(|p| p.previews.clone()) 1507 + .unwrap_or_default(), 1508 + }; 1509 + let has_cover_group = matches!(target, FieldTarget::Model(_)); 1510 + drop(composition); 1511 + let cover_w = self.clone(); 1512 + let preview_w = self.clone(); 1513 + let (title, hint) = if has_cover_group { 1514 + ("Images".to_string(), Some("Cover and previews".to_string())) 1515 + } else { 1516 + ( 1517 + "Preview images".to_string(), 1518 + Some("Additional views".to_string()), 1519 + ) 1520 + }; 1521 + rsx! { 1522 + Disclosure { 1523 + title, 1524 + hint, 1525 + open: None, 1526 + body: rsx! { 1527 + if has_cover_group { 1528 + { self.render_image_upload_status() } 1529 + } 1530 + if has_cover_group { 1531 + div { class: "wizard-image-group", 1532 + h3 { class: "wizard-subhead", "Cover" } 1533 + { self.render_image_list(&cover, target, true) } 1534 + label { class: "button button-secondary wizard-file-button", 1535 + "Add cover image" 1536 + input { 1537 + class: "wizard-file-input", 1538 + r#type: "file", 1539 + accept: "image/png,image/jpeg,image/webp,image/gif", 1540 + onchange: move |evt| { 1541 + if let Some(file) = evt.files().into_iter().next() { 1542 + let flow = cover_w.clone(); 1543 + spawn(async move { flow.handle_image_upload(target, true, file).await }); 1544 + } 1545 + }, 1546 + } 782 1547 } 783 - }, 1548 + } 1549 + } 1550 + div { class: "wizard-image-group", 1551 + h3 { class: "wizard-subhead", "Previews" } 1552 + { self.render_image_list(&previews, target, false) } 1553 + label { class: "button button-secondary wizard-file-button", 1554 + "Add preview image" 1555 + input { 1556 + class: "wizard-file-input", 1557 + r#type: "file", 1558 + accept: "image/png,image/jpeg,image/webp,image/gif", 1559 + onchange: move |evt| { 1560 + if let Some(file) = evt.files().into_iter().next() { 1561 + let flow = preview_w.clone(); 1562 + spawn(async move { flow.handle_image_upload(target, false, file).await }); 1563 + } 1564 + }, 1565 + } 1566 + } 784 1567 } 785 1568 } 786 1569 } 787 1570 } 788 1571 } 789 1572 790 - fn render_image_list(&self, images: &[Image], is_cover: bool) -> Element { 1573 + fn render_image_list(&self, images: &[Image], target: FieldTarget, is_cover: bool) -> Element { 791 1574 if images.is_empty() { 792 1575 return rsx! { p { class: "wizard-empty", "No images yet." } }; 793 1576 } ··· 802 1585 .map(|uri| uri.to_string()) 803 1586 .or_else(|| self.did.as_ref().map(|did| image_cdn_url(did, image))); 804 1587 let alt_w = self.clone(); 1588 + let alt_blur_w = self.clone(); 805 1589 let remove_w = self.clone(); 806 1590 rsx! { 807 1591 li { key: "image-{is_cover}-{i}", class: "wizard-image", ··· 822 1606 oninput: move |evt| { 823 1607 let mut c = alt_w.composition; 824 1608 let mut guard = c.write(); 825 - let list = if is_cover { guard.cover.as_mut() } else { guard.previews.as_mut() }; 1609 + let list = image_list_mut(&mut guard, target, is_cover); 826 1610 if let Some(list) = list 827 1611 && let Some(img) = list.get_mut(i) 828 1612 { ··· 831 1615 drop(guard); 832 1616 alt_w.mark_dirty(); 833 1617 }, 1618 + onblur: move |_| alt_blur_w.clone().mark_dirty_and_save(), 834 1619 } 835 1620 } 836 1621 button { ··· 838 1623 onclick: move |_| { 839 1624 let mut c = remove_w.composition; 840 1625 let mut guard = c.write(); 841 - let list = if is_cover { guard.cover.as_mut() } else { guard.previews.as_mut() }; 842 - if let Some(list) = list 843 - && i < list.len() 844 - { 1626 + let list = image_list_mut(&mut guard, target, is_cover); 1627 + if let Some(list) = list && i < list.len() { 845 1628 list.remove(i); 846 1629 } 847 1630 drop(guard); 848 - remove_w.mark_dirty(); 1631 + remove_w.clone().mark_dirty_and_save(); 849 1632 }, 850 1633 "Remove" 851 1634 } ··· 857 1640 } 858 1641 } 859 1642 860 - async fn handle_image_upload(self, is_cover: bool, file: FileData) { 861 - let filename = file.name(); 862 - let mut status = self.image_upload; 863 - status.set(UploadStatus::Uploading(filename.clone())); 864 - let bytes = match file.read_bytes().await { 865 - Ok(bytes) => bytes.to_vec(), 866 - Err(_) => { 867 - status.set(UploadStatus::Error( 868 - "Could not read the selected image.".into(), 869 - )); 870 - return; 871 - } 872 - }; 873 - let mime = file 874 - .content_type() 875 - .filter(|s| !s.is_empty()) 876 - .unwrap_or_else(|| mime_from_filename(&filename).to_string()); 877 - match draft_client::upload_image(&self.client, &filename, &mime, bytes.clone()).await { 878 - Ok(image) => { 879 - { 880 - let cid = image.image.blob().r#ref.as_str().to_string(); 881 - let data_uri = data_uri_from_bytes(&bytes, &mime); 882 - let mut data_uris = self.image_data_uris; 883 - data_uris.write().insert(cid.into(), data_uri); 884 - let mut composition = self.composition; 885 - let mut guard = composition.write(); 886 - let list = if is_cover { 887 - guard.cover.get_or_insert_with(Vec::new) 888 - } else { 889 - guard.previews.get_or_insert_with(Vec::new) 890 - }; 891 - list.push(image); 1643 + /// "Works with" — paste one or more thing URLs or `at://` URIs; each resolves 1644 + /// to a `StrongRef` and renders as a removable chip showing the thing's name. 1645 + fn render_works_with_section(&self) -> Element { 1646 + let composition = self.composition.read(); 1647 + let refs = composition 1648 + .intended_to_work_with 1649 + .clone() 1650 + .unwrap_or_default(); 1651 + drop(composition); 1652 + rsx! { 1653 + Disclosure { 1654 + title: "Works with", 1655 + hint: "Compatibility links to other things", 1656 + open: None, 1657 + body: rsx! { 1658 + { self.render_works_with_status() } 1659 + if !refs.is_empty() { 1660 + ul { class: "publish-compat-list", 1661 + for (i, r) in refs.iter().enumerate() { 1662 + { 1663 + let label = chip_label(self, r); 1664 + let remove_w = self.clone(); 1665 + rsx! { 1666 + li { key: "{i}", class: "publish-compat-chip", 1667 + span { "{label}" } 1668 + button { 1669 + class: "button button-ghost publish-compat-remove", 1670 + aria_label: "Remove {label}", 1671 + onclick: move |_| { 1672 + let mut c = remove_w.composition; 1673 + let mut g = c.write(); 1674 + if let Some(list) = g.intended_to_work_with.as_mut() { 1675 + list.remove(i); 1676 + if list.is_empty() { 1677 + g.intended_to_work_with = None; 1678 + } 1679 + } 1680 + drop(g); 1681 + remove_w.clone().mark_dirty_and_save(); 1682 + }, 1683 + "×" 1684 + } 1685 + } 1686 + } 1687 + } 1688 + } 1689 + } 1690 + } 1691 + { self.render_works_with_input() } 892 1692 } 893 - status.set(UploadStatus::Idle); 894 - let wizard = self.clone(); 895 - wizard.save_draft().await; 896 - } 897 - Err(error) => { 898 - status.set(UploadStatus::Error(format!("Image upload failed: {error}"))); 899 1693 } 900 1694 } 901 1695 } 902 1696 903 - fn render_details(&self) -> Element { 904 - let composition = self.composition.read(); 905 - let instructions = composition 906 - .instructions 907 - .clone() 908 - .unwrap_or_default() 909 - .join("\n"); 910 - let license = composition 911 - .license 912 - .clone() 913 - .unwrap_or_else(|| DEFAULT_LICENSE.to_string()); 914 - let tags = composition.tags.clone().unwrap_or_default(); 915 - drop(composition); 916 - 917 - let tags = tags.join(", "); 918 - let license_w = self.clone(); 919 - let tags_w = self.clone(); 920 - let instructions_w = self.clone(); 1697 + /// The "Works with" paste field, bound to `works_with_text`. 1698 + fn render_works_with_input(&self) -> Element { 1699 + let current = self.works_with_text.read().clone(); 1700 + let text_sig = self.works_with_text; 1701 + let flow = self.clone(); 921 1702 rsx! { 922 - h2 { "Details" } 923 1703 label { class: "field", 924 - span { class: "field-label", "License" } 925 - input { 926 - class: "field-input", 927 - r#type: "text", 928 - list: "license-options", 929 - value: "{license}", 930 - placeholder: "{DEFAULT_LICENSE}", 1704 + span { class: "field-label sr-only", "Add compatible things" } 1705 + textarea { 1706 + class: "field-input field-textarea", 1707 + placeholder: "Paste a thing URL or at:// URI (one per line)", 1708 + value: "{current}", 931 1709 oninput: move |evt| { 932 - let mut c = license_w.composition; 933 - let v = evt.value(); 934 - c.write().license = if v.trim().is_empty() { None } else { Some(v) }; 935 - license_w.mark_dirty(); 1710 + let mut text_sig = text_sig; 1711 + text_sig.set(evt.value()); 936 1712 }, 937 - } 938 - datalist { id: "license-options", 939 - for option in LICENSE_OPTIONS { 940 - option { key: "{option}", value: "{option}" } 941 - } 942 - } 943 - } 944 - label { class: "field", 945 - span { class: "field-label", "Tags" } 946 - input { 947 - class: "field-input", 948 - r#type: "text", 949 - value: "{tags}", 950 - placeholder: "comma, separated", 951 - oninput: move |evt| { 952 - let mut c = tags_w.composition; 953 - let parsed: Vec<String> = evt 954 - .value() 955 - .split(',') 956 - .map(|t| t.trim().to_string()) 957 - .filter(|t| !t.is_empty()) 958 - .collect(); 959 - c.write().tags = if parsed.is_empty() { None } else { Some(parsed) }; 960 - tags_w.mark_dirty(); 1713 + onblur: move |_| { 1714 + let value = text_sig.read().clone(); 1715 + if value.trim().is_empty() { 1716 + return; 1717 + } 1718 + let flow = flow.clone(); 1719 + spawn(async move { flow.resolve_works_with(value).await }); 961 1720 }, 962 1721 } 963 1722 } 964 - label { class: "field", 965 - span { class: "field-label", "Instructions" } 966 - textarea { 967 - class: "field-input field-textarea field-textarea-tall", 968 - value: "{instructions}", 969 - placeholder: "One step per line", 970 - oninput: move |evt| { 971 - let mut c = instructions_w.composition; 972 - let value = evt.value(); 973 - // Store the text exactly as typed (split only on newlines) so 974 - // spaces and blank lines survive editing; empty lines are 975 - // dropped at assembly. 976 - let lines: Vec<String> = value.split('\n').map(str::to_string).collect(); 977 - c.write().instructions = if value.trim().is_empty() { None } else { Some(lines) }; 978 - instructions_w.mark_dirty(); 979 - }, 1723 + } 1724 + } 1725 + 1726 + async fn resolve_works_with(self, text: String) { 1727 + { 1728 + let mut status = self.works_with_status; 1729 + status.set(UploadStatus::Idle); 1730 + } 1731 + let tokens: Vec<String> = text.split_whitespace().map(str::to_string).collect(); 1732 + let mut added = false; 1733 + let mut unresolved: Vec<String> = Vec::new(); 1734 + for token in tokens { 1735 + match parse_thing_token(&token) { 1736 + Ok(uri) => match draft_client::resolve_strong_ref(&uri).await { 1737 + Ok(resolved) => { 1738 + let uri_str = resolved.strong_ref.uri.as_str().to_string(); 1739 + let name = resolved.name.clone(); 1740 + let already_present = { 1741 + let g = self.composition.read(); 1742 + g.intended_to_work_with 1743 + .as_ref() 1744 + .map(|list| list.iter().any(|r| r.uri.as_str() == uri_str)) 1745 + .unwrap_or(false) 1746 + }; 1747 + if !already_present { 1748 + let mut c = self.composition; 1749 + let mut g = c.write(); 1750 + let list = g.intended_to_work_with.get_or_insert_with(Vec::new); 1751 + list.push(resolved.strong_ref); 1752 + if let Some(name) = name { 1753 + let mut names = self.compat_names; 1754 + names.write().insert(uri_str, name); 1755 + } 1756 + added = true; 1757 + } 1758 + } 1759 + Err(error) => { 1760 + unresolved.push(token.clone()); 1761 + let mut status = self.works_with_status; 1762 + status.set(UploadStatus::Error(format!( 1763 + "Could not resolve “{token}”: {error}" 1764 + ))); 1765 + } 1766 + }, 1767 + Err(error) => { 1768 + unresolved.push(token.clone()); 1769 + let mut status = self.works_with_status; 1770 + status.set(UploadStatus::Error(format!("“{token}”: {error}"))); 980 1771 } 981 1772 } 1773 + } 1774 + // Keep only the tokens that did not resolve, so the user can fix them; 1775 + // clear the field once everything resolved. 1776 + let mut text_sig = self.works_with_text; 1777 + text_sig.set(unresolved.join("\n")); 1778 + if added { 1779 + self.mark_dirty_and_save(); 982 1780 } 983 1781 } 984 1782 985 - fn render_review(&self) -> Element { 1783 + /// Compact "what's public" confirm + Publish at the page bottom. 1784 + fn render_confirm(&self) -> Element { 986 1785 let composition = self.composition.read(); 987 1786 let assembled = composition.assemble(); 988 1787 let name = composition.display_name().unwrap_or("Untitled").to_string(); ··· 997 1796 let cover_count = composition.cover.as_ref().map(|c| c.len()).unwrap_or(0); 998 1797 let preview_count = composition.previews.as_ref().map(|p| p.len()).unwrap_or(0); 999 1798 let instructions = composition.instructions.clone().unwrap_or_default(); 1799 + let works_count = composition 1800 + .intended_to_work_with 1801 + .as_ref() 1802 + .map(|w| w.len()) 1803 + .unwrap_or(0); 1000 1804 drop(composition); 1001 1805 1002 1806 let publishing = matches!(*self.phase.read(), PublishPhase::Publishing); ··· 1005 1809 .as_ref() 1006 1810 .map(|e| e.has_field("name")) 1007 1811 .unwrap_or(false); 1008 - let license_error = issues 1009 - .as_ref() 1010 - .map(|e| e.has_field("license")) 1011 - .unwrap_or(false); 1012 1812 let name_row_class = if name_error { 1013 - "wizard-review-row is-error" 1014 - } else { 1015 - "wizard-review-row" 1016 - }; 1017 - let license_row_class = if license_error { 1018 1813 "wizard-review-row is-error" 1019 1814 } else { 1020 1815 "wizard-review-row" ··· 1023 1818 let publish_w = self.clone(); 1024 1819 1025 1820 rsx! { 1026 - h2 { "Review & publish" } 1027 - p { class: "wizard-step-hint", "This is what becomes public when you publish." } 1028 - 1029 - div { class: "wizard-review", 1030 - div { class: "{name_row_class}", 1031 - span { class: "field-label", "Name" } 1032 - span { "{name}" } 1033 - } 1034 - div { class: "{license_row_class}", 1035 - span { class: "field-label", "License" } 1036 - span { class: "status-pill", "{license}" } 1821 + section { class: "wizard-body blueprint-panel publish-confirm", aria_label: "Review and publish", 1822 + if let Some(issues) = issues { 1823 + div { class: "wizard-issues error-state state-card", role: "alert", 1824 + h3 { class: "wizard-subhead", "Fix these before publishing" } 1825 + ul { class: "instruction-list", 1826 + for issue in issues.issues { 1827 + li { key: "{issue.field}", "{issue.message}" } 1828 + } 1829 + } 1830 + } 1037 1831 } 1038 - if !summary.is_empty() { 1832 + div { class: "wizard-review", 1833 + div { class: "{name_row_class}", 1834 + span { class: "field-label", "Name" } 1835 + span { "{name}" } 1836 + } 1039 1837 div { class: "wizard-review-row", 1040 - span { class: "field-label", "Summary" } 1041 - span { "{summary}" } 1838 + span { class: "field-label", "License" } 1839 + span { class: "status-pill", "{license}" } 1840 + } 1841 + if !summary.is_empty() { 1842 + div { class: "wizard-review-row", 1843 + span { class: "field-label", "Summary" } 1844 + span { "{summary}" } 1845 + } 1846 + } 1847 + if !tags.is_empty() { 1848 + div { class: "wizard-review-row", 1849 + span { class: "field-label", "Tags" } 1850 + div { class: "thing-card-chips", 1851 + for tag in tags { span { key: "{tag}", class: "status-pill", "{tag}" } } 1852 + } 1853 + } 1042 1854 } 1043 - } 1044 - if !tags.is_empty() { 1045 1855 div { class: "wizard-review-row", 1046 - span { class: "field-label", "Tags" } 1047 - div { class: "thing-card-chips", 1048 - for tag in tags { span { key: "{tag}", class: "status-pill", "{tag}" } } 1049 - } 1856 + span { class: "field-label", "Media" } 1857 + span { class: "card-meta", "{cover_count} cover · {preview_count} preview" } 1050 1858 } 1051 - } 1052 - div { class: "wizard-review-row", 1053 - span { class: "field-label", "Media" } 1054 - span { class: "card-meta", "{cover_count} cover · {preview_count} preview" } 1055 - } 1056 - div { class: "wizard-review-row", 1057 - span { class: "field-label", "Models ({model_count})" } 1058 - div { class: "wizard-review-models", 1059 - for (mi, model) in models.iter().enumerate() { 1060 - { 1061 - let mname = model.name.clone().unwrap_or_else(|| format!("Model {}", mi + 1)); 1062 - let part_count = model.parts.len(); 1063 - rsx! { 1064 - div { key: "review-model-{mi}", class: "wizard-review-model", 1065 - strong { "{mname}" } 1066 - span { class: "card-meta", "{part_count} part(s)" } 1859 + div { class: "wizard-review-row", 1860 + span { class: "field-label", "Models ({model_count})" } 1861 + div { class: "wizard-review-models", 1862 + for (mi, model) in models.iter().enumerate() { 1863 + { 1864 + let mname = model.name.clone().unwrap_or_else(|| format!("Model {}", mi + 1)); 1865 + let part_count = model.parts.len(); 1866 + rsx! { 1867 + div { key: "review-model-{mi}", class: "wizard-review-model", 1868 + strong { "{mname}" } 1869 + span { class: "card-meta", "{part_count} part(s)" } 1870 + } 1067 1871 } 1068 1872 } 1069 1873 } 1070 1874 } 1071 1875 } 1072 - } 1073 - if !instructions.is_empty() { 1074 - div { class: "wizard-review-row", 1075 - span { class: "field-label", "Instructions" } 1076 - span { class: "card-meta", "{instructions.len()} step(s)" } 1876 + if !instructions.is_empty() { 1877 + div { class: "wizard-review-row", 1878 + span { class: "field-label", "Instructions" } 1879 + span { class: "card-meta", "{instructions.len()} step(s)" } 1880 + } 1077 1881 } 1078 - } 1079 - } 1080 - 1081 - if let Some(issues) = issues { 1082 - div { class: "wizard-issues error-state state-card", role: "alert", 1083 - h3 { class: "wizard-subhead", "Fix these before publishing" } 1084 - ul { class: "instruction-list", 1085 - for issue in issues.issues { 1086 - li { key: "{issue.field}", "{issue.message}" } 1882 + if works_count > 0 { 1883 + div { class: "wizard-review-row", 1884 + span { class: "field-label", "Works with" } 1885 + span { class: "card-meta", "{works_count} link(s)" } 1087 1886 } 1088 1887 } 1089 1888 } 1090 - } 1889 + 1091 1890 1092 - div { class: "wizard-publish-row", 1093 - button { 1094 - class: "button button-primary wizard-publish", 1095 - disabled: !can_publish, 1096 - onclick: move |_| { 1097 - let wizard = publish_w.clone(); 1098 - spawn(async move { wizard.publish().await }); 1099 - }, 1100 - if publishing { "Publishing…" } else { "Publish" } 1891 + 1892 + div { class: "wizard-publish-row", 1893 + button { 1894 + class: "button button-primary wizard-publish", 1895 + disabled: !can_publish, 1896 + onclick: move |_| { 1897 + let wizard = publish_w.clone(); 1898 + spawn(async move { wizard.publish().await }); 1899 + }, 1900 + if publishing { "Publishing…" } else { "Publish" } 1901 + } 1101 1902 } 1102 1903 } 1103 1904 } ··· 1105 1906 1106 1907 async fn publish(self) { 1107 1908 // Persist the latest edits first. If that save fails, do NOT publish — 1108 - // otherwise we would publish stale server-side draft state. 1109 - self.clone().save_draft().await; 1110 - if matches!(*self.save.read(), SaveState::Error) { 1111 - let mut phase = self.phase; 1112 - phase.set(PublishPhase::Failed( 1113 - "Could not save your latest changes, so nothing was published. Try again.".into(), 1114 - )); 1115 - return; 1909 + // otherwise we would publish stale server-side draft state. Re-save 1910 + // until the state settles (an in-flight blur-save can leave us Dirty), 1911 + // so the server-side draft reflects every edit before we publish it. 1912 + loop { 1913 + self.clone().save_draft().await; 1914 + match *self.save.read() { 1915 + SaveState::Error => { 1916 + let mut phase = self.phase; 1917 + phase.set(PublishPhase::Failed( 1918 + "Could not save your latest changes, so nothing was published. Try again." 1919 + .into(), 1920 + )); 1921 + return; 1922 + } 1923 + SaveState::Saved => break, 1924 + // Dirty/Saving/New: a save is in flight or coalesced our edit. 1925 + // Yield to the executor so that save's future can complete and 1926 + // clear the guard, then loop to persist the latest state. 1927 + SaveState::Dirty | SaveState::Saving | SaveState::New => { 1928 + dioxus_sdk::time::sleep(std::time::Duration::from_millis(50)).await; 1929 + continue; 1930 + } 1931 + } 1116 1932 } 1117 1933 let Some(id) = self.draft_id.read().clone() else { 1118 1934 let mut phase = self.phase; ··· 1151 1967 } 1152 1968 } 1153 1969 1970 + impl Flow { 1971 + /// Stage every dropped/picked file through one batch: ensure the default 1972 + /// model + auto name, stage each file (stop at first failure), then save once. 1973 + async fn handle_files_upload(self, files: Vec<FileData>) { 1974 + let mut upload = self.upload; 1975 + let first_name = files.first().map(|f| f.name()).unwrap_or_default(); 1976 + { 1977 + let mut composition = self.composition; 1978 + let mut g = composition.write(); 1979 + ensure_default_model_for_upload(&mut g, &first_name); 1980 + } 1981 + let count = files.len(); 1982 + upload.set(UploadStatus::Uploading(if count == 1 { 1983 + first_name.clone() 1984 + } else { 1985 + format!("{count} files") 1986 + })); 1987 + for file in files { 1988 + let filename = file.name(); 1989 + match file.read_bytes().await { 1990 + Ok(bytes) => { 1991 + let bytes = bytes.to_vec(); 1992 + let mime = file 1993 + .content_type() 1994 + .filter(|s| !s.is_empty()) 1995 + .unwrap_or_else(|| mime_from_filename(&filename).to_string()); 1996 + match draft_client::stage_file(&self.client, &filename, &mime, bytes.clone()) 1997 + .await 1998 + { 1999 + Ok(staged) => { 2000 + let format = format_from_filename(&filename); 2001 + let mut part = DraftPartInput { 2002 + name: Some(filename.clone()), 2003 + upload_id: Some(staged.upload_id.to_string()), 2004 + file: Some(staged.file), 2005 + format: format.clone(), 2006 + ..Default::default() 2007 + }; 2008 + let preview = if supports_generated_preview(&filename) { 2009 + generate_preview( 2010 + self.client.clone(), 2011 + filename.clone(), 2012 + bytes, 2013 + self.upload, 2014 + self.image_data_uris, 2015 + ) 2016 + .await 2017 + } else { 2018 + None 2019 + }; 2020 + let was_first = { 2021 + let g = self.composition.read(); 2022 + g.models.first().map(|m| m.parts.is_empty()).unwrap_or(true) 2023 + }; 2024 + let mut composition = self.composition; 2025 + let mut g = composition.write(); 2026 + if let Some(image) = preview { 2027 + let parts_before_push = 2028 + g.models.first().map_or(0, |m| m.parts.len()); 2029 + attach_generated_preview( 2030 + &mut g, 2031 + 0, 2032 + parts_before_push, 2033 + &mut part, 2034 + image, 2035 + ); 2036 + } 2037 + if let Some(model) = g.models.get_mut(0) { 2038 + model.parts.push(part); 2039 + } 2040 + drop(g); 2041 + // Focus the name field once, on the first staged 2042 + // file, so the user lands on the next likely edit. 2043 + if was_first && let Some(element) = self.name_element.read().clone() { 2044 + let name_element = self.name_element; 2045 + spawn(async move { 2046 + let _ = element.set_focus(true).await; 2047 + let _ = name_element; 2048 + }); 2049 + } 2050 + } 2051 + Err(error) => { 2052 + upload.set(UploadStatus::Error(format!( 2053 + "Upload failed for {filename}: {error}" 2054 + ))); 2055 + let flow = self.clone(); 2056 + flow.save_draft().await; 2057 + return; 2058 + } 2059 + } 2060 + } 2061 + Err(_) => { 2062 + upload.set(UploadStatus::Error(format!("Could not read {filename}."))); 2063 + let flow = self.clone(); 2064 + flow.save_draft().await; 2065 + return; 2066 + } 2067 + } 2068 + } 2069 + upload.set(UploadStatus::Idle); 2070 + let flow = self.clone(); 2071 + flow.save_draft().await; 2072 + } 2073 + 2074 + /// Stage a single file into a specific model (the per-model "Add a file" 2075 + /// button). Shares the per-file routine; saves once. 2076 + async fn stage_file_into(self, model_index: usize, file: FileData) { 2077 + let filename = file.name(); 2078 + let mut upload = self.upload; 2079 + upload.set(UploadStatus::Uploading(filename.clone())); 2080 + let bytes = match file.read_bytes().await { 2081 + Ok(bytes) => bytes.to_vec(), 2082 + Err(_) => { 2083 + upload.set(UploadStatus::Error( 2084 + "Could not read the selected file.".into(), 2085 + )); 2086 + return; 2087 + } 2088 + }; 2089 + let mime = file 2090 + .content_type() 2091 + .filter(|s| !s.is_empty()) 2092 + .unwrap_or_else(|| mime_from_filename(&filename).to_string()); 2093 + match draft_client::stage_file(&self.client, &filename, &mime, bytes.clone()).await { 2094 + Ok(staged) => { 2095 + let format = format_from_filename(&filename); 2096 + let mut part = DraftPartInput { 2097 + name: Some(filename.clone()), 2098 + upload_id: Some(staged.upload_id.to_string()), 2099 + file: Some(staged.file), 2100 + format: format.clone(), 2101 + ..Default::default() 2102 + }; 2103 + let preview = if supports_generated_preview(&filename) { 2104 + generate_preview( 2105 + self.client.clone(), 2106 + filename.clone(), 2107 + bytes, 2108 + self.upload, 2109 + self.image_data_uris, 2110 + ) 2111 + .await 2112 + } else { 2113 + None 2114 + }; 2115 + { 2116 + let mut composition = self.composition; 2117 + let mut guard = composition.write(); 2118 + if let Some(image) = preview { 2119 + let parts_before_push = 2120 + guard.models.get(model_index).map_or(0, |m| m.parts.len()); 2121 + attach_generated_preview( 2122 + &mut guard, 2123 + model_index, 2124 + parts_before_push, 2125 + &mut part, 2126 + image, 2127 + ); 2128 + } 2129 + if let Some(model) = guard.models.get_mut(model_index) { 2130 + model.parts.push(part); 2131 + } 2132 + } 2133 + upload.set(UploadStatus::Idle); 2134 + let flow = self.clone(); 2135 + flow.save_draft().await; 2136 + } 2137 + Err(error) => { 2138 + upload.set(UploadStatus::Error(format!("Upload failed: {error}"))); 2139 + } 2140 + } 2141 + } 2142 + 2143 + /// Upload an image and attach it to the target's cover/previews, then save 2144 + /// once after success. The image-upload status is its own signal so it does 2145 + /// not race the file-upload status. 2146 + async fn handle_image_upload(self, target: FieldTarget, is_cover: bool, file: FileData) { 2147 + let filename = file.name(); 2148 + let mut status = self.image_upload; 2149 + status.set(UploadStatus::Uploading(filename.clone())); 2150 + let bytes = match file.read_bytes().await { 2151 + Ok(bytes) => bytes.to_vec(), 2152 + Err(_) => { 2153 + status.set(UploadStatus::Error( 2154 + "Could not read the selected image.".into(), 2155 + )); 2156 + return; 2157 + } 2158 + }; 2159 + let mime = file 2160 + .content_type() 2161 + .filter(|s| !s.is_empty()) 2162 + .unwrap_or_else(|| mime_from_filename(&filename).to_string()); 2163 + match draft_client::upload_image(&self.client, &filename, &mime, bytes.clone()).await { 2164 + Ok(image) => { 2165 + { 2166 + let cid = image.image.blob().r#ref.as_str().to_string(); 2167 + let data_uri = data_uri_from_bytes(&bytes, &mime); 2168 + let mut data_uris = self.image_data_uris; 2169 + data_uris.write().insert(cid.into(), data_uri); 2170 + let mut composition = self.composition; 2171 + let mut guard = composition.write(); 2172 + let list = image_list_mut(&mut guard, target, is_cover); 2173 + if let Some(list) = list { 2174 + list.push(image); 2175 + } 2176 + } 2177 + status.set(UploadStatus::Idle); 2178 + self.mark_dirty_and_save(); 2179 + } 2180 + Err(error) => { 2181 + status.set(UploadStatus::Error(format!("Image upload failed: {error}"))); 2182 + } 2183 + } 2184 + } 2185 + } 2186 + 2187 + /// A controlled `<details>` disclosure. When `open` is `Some(signal)` the caller 2188 + /// drives open-state (the Advanced section, opened by the auto-expand effect); 2189 + /// when `None` the disclosure manages its own open-state. In both cases 2190 + /// `ontoggle` reads the real DOM open-state back into the owning signal so the 2191 + /// program and the user share one synced source of truth without fighting on 2192 + /// re-render. 2193 + #[component] 2194 + fn Disclosure( 2195 + title: String, 2196 + hint: Option<String>, 2197 + open: Option<Signal<bool>>, 2198 + body: Element, 2199 + ) -> Element { 2200 + let local = use_signal(|| false); 2201 + let on_toggle = move |evt: ToggleEvent| { 2202 + if let Some(now_open) = read_details_open(&evt) { 2203 + match open { 2204 + Some(signal) => { 2205 + let mut signal = signal; 2206 + signal.set(now_open); 2207 + } 2208 + None => { 2209 + let mut local = local; 2210 + local.set(now_open); 2211 + } 2212 + } 2213 + } 2214 + }; 2215 + let is_open = match open { 2216 + Some(signal) => *signal.read(), 2217 + None => *local.read(), 2218 + }; 2219 + rsx! { 2220 + details { class: "publish-disclosure", open: is_open, ontoggle: on_toggle, 2221 + summary { class: "publish-disclosure-summary", 2222 + h3 { class: "publish-disclosure-title", "{title}" } 2223 + if let Some(hint) = &hint { 2224 + span { class: "publish-disclosure-hint card-meta", "{hint}" } 2225 + } 2226 + } 2227 + div { class: "publish-disclosure-body", {body} } 2228 + } 2229 + } 2230 + } 2231 + 2232 + /// Read the real open-state of a toggled `<details>` from its DOM target. Wasm 2233 + /// only; returns `None` off-wasm (where the toggle event has no web backing). 2234 + #[cfg(target_arch = "wasm32")] 2235 + fn read_details_open(evt: &ToggleEvent) -> Option<bool> { 2236 + use dioxus::web::WebEventExt; 2237 + use wasm_bindgen::JsCast; 2238 + evt.try_as_web_event() 2239 + .and_then(|e| e.target()) 2240 + .and_then(|t| t.dyn_into::<web_sys::HtmlDetailsElement>().ok()) 2241 + .map(|el| el.open()) 2242 + } 2243 + 2244 + #[cfg(not(target_arch = "wasm32"))] 2245 + fn read_details_open(_evt: &ToggleEvent) -> Option<bool> { 2246 + None 2247 + } 2248 + 2249 + fn image_list_mut( 2250 + thing: &mut DraftThingInput, 2251 + target: FieldTarget, 2252 + is_cover: bool, 2253 + ) -> Option<&mut Vec<Image>> { 2254 + match target { 2255 + FieldTarget::Thing => { 2256 + if is_cover { 2257 + Some(thing.cover.get_or_insert_with(Vec::new)) 2258 + } else { 2259 + Some(thing.previews.get_or_insert_with(Vec::new)) 2260 + } 2261 + } 2262 + FieldTarget::Model(mi) => { 2263 + let model = thing.models.get_mut(mi)?; 2264 + if is_cover { 2265 + Some(model.cover.get_or_insert_with(Vec::new)) 2266 + } else { 2267 + Some(model.previews.get_or_insert_with(Vec::new)) 2268 + } 2269 + } 2270 + FieldTarget::Part(mi, pi) => { 2271 + let part = thing.models.get_mut(mi)?.parts.get_mut(pi)?; 2272 + Some(part.previews.get_or_insert_with(Vec::new)) 2273 + } 2274 + } 2275 + } 2276 + 2277 + fn license_target_key(target: FieldTarget) -> String { 2278 + match target { 2279 + FieldTarget::Thing => "thing".to_string(), 2280 + FieldTarget::Model(mi) => format!("model-{mi}"), 2281 + FieldTarget::Part(mi, pi) => format!("part-{mi}-{pi}"), 2282 + } 2283 + } 2284 + 2285 + fn is_initial_composition(composition: &DraftThingInput) -> bool { 2286 + composition.name.is_none() 2287 + && matches!(composition.license.as_deref(), None | Some(DEFAULT_LICENSE)) 2288 + && composition.summary.is_none() 2289 + && composition.tags.is_none() 2290 + && composition.instructions.is_none() 2291 + && composition.cover.is_none() 2292 + && composition.previews.is_none() 2293 + && composition.derived_from.is_none() 2294 + && composition.intended_to_work_with.is_none() 2295 + && composition.models.is_empty() 2296 + } 2297 + 2298 + /// A short human label for a resolved "works with" chip: the resolved thing's 2299 + /// name when known, else the rkey (never a raw URI or CID). 2300 + fn chip_label(flow: &Flow, r: &StrongRef) -> String { 2301 + if let Some(name) = flow.compat_names.read().get(r.uri.as_str()) { 2302 + return name.clone(); 2303 + } 2304 + if let Some(rkey) = r.uri.rkey() { 2305 + rkey.as_str().to_string() 2306 + } else { 2307 + "thing".to_string() 2308 + } 2309 + } 2310 + 1154 2311 /// Best-effort MIME type from a filename extension (the server re-sniffs image 1155 2312 /// dimensions independently). 1156 2313 fn mime_from_filename(name: &str) -> &'static str { ··· 1181 2338 } 1182 2339 } 1183 2340 2341 + fn supports_generated_preview(name: &str) -> bool { 2342 + matches!( 2343 + extension(name).as_deref(), 2344 + Some("stl" | "obj" | "gltf" | "glb" | "step" | "stp") 2345 + ) 2346 + } 2347 + 1184 2348 fn extension(name: &str) -> Option<String> { 1185 2349 name.rsplit_once('.') 1186 2350 .map(|(_, ext)| ext.to_ascii_lowercase()) ··· 1210 2374 part.previews = Some(vec![image]); 1211 2375 } 1212 2376 2377 + fn attach_generated_preview( 2378 + composition: &mut DraftThingInput, 2379 + model_index: usize, 2380 + parts_before_push: usize, 2381 + part: &mut DraftPartInput, 2382 + image: Image, 2383 + ) { 2384 + maybe_seed_cover(composition, model_index, parts_before_push, &image); 2385 + composition 2386 + .previews 2387 + .get_or_insert_with(Vec::new) 2388 + .push(image.clone()); 2389 + if let Some(model) = composition.models.get_mut(model_index) { 2390 + model 2391 + .previews 2392 + .get_or_insert_with(Vec::new) 2393 + .push(image.clone()); 2394 + } 2395 + attach_preview(part, image); 2396 + } 2397 + 1213 2398 /// Seed the thing-level cover from the first file of the first model when no 1214 2399 /// cover exists. Only fires for `model_index == 0` when the model has zero 1215 2400 /// parts *before* pushing the new one (the first file overall) and cover is ··· 1289 2474 width: u32, 1290 2475 height: u32, 1291 2476 ) -> Result<Vec<u8>, String> { 2477 + if is_step_filename(filename) { 2478 + let step_url = blob_url_for_bytes(bytes, "model/step")?; 2479 + let glb_url = match convert_step_to_glb_url(step_url.clone()).await { 2480 + Ok(url) => url, 2481 + Err(error) => { 2482 + let _ = web_sys::Url::revoke_object_url(&step_url); 2483 + return Err(error); 2484 + } 2485 + }; 2486 + let result = 2487 + request_preview_png(MeshFormat::Gltf, glb_url.clone(), width, height).await; 2488 + let _ = web_sys::Url::revoke_object_url(&glb_url); 2489 + let _ = web_sys::Url::revoke_object_url(&step_url); 2490 + return result; 2491 + } 2492 + 1292 2493 let format = MeshFormat::from_extension(filename) 1293 2494 .ok_or_else(|| "unsupported preview format".to_string())?; 1294 2495 let blob_url = blob_url_for_bytes(bytes, mime_from_format(format))?; ··· 1318 2519 .map_err(|e| format!("preview blob URL: {e:?}")) 1319 2520 } 1320 2521 2522 + fn is_step_filename(filename: &str) -> bool { 2523 + filename 2524 + .rsplit_once('.') 2525 + .map(|(_, ext)| matches!(ext.to_ascii_lowercase().as_str(), "step" | "stp")) 2526 + .unwrap_or(false) 2527 + } 2528 + 2529 + async fn convert_step_to_glb_url(step_url: String) -> Result<String, String> { 2530 + let opts = web_sys::WorkerOptions::new(); 2531 + opts.set_type(web_sys::WorkerType::Module); 2532 + let worker = web_sys::Worker::new_with_options("/step_worker_loader.js", &opts) 2533 + .map_err(|e| format!("STEP preview worker: {e:?}"))?; 2534 + let worker_for_message = worker.clone(); 2535 + let worker_for_error = worker.clone(); 2536 + let promise = js_sys::Promise::new(&mut move |resolve, reject| { 2537 + let step_url = step_url.clone(); 2538 + let worker_for_message = worker_for_message.clone(); 2539 + let resolve_message = resolve.clone(); 2540 + let reject_message = reject.clone(); 2541 + let onmessage = Closure::wrap(Box::new(move |event: web_sys::MessageEvent| { 2542 + let data = event.data(); 2543 + let kind = js_string_field(&data, "kind").unwrap_or_default(); 2544 + match kind.as_str() { 2545 + "ready" => { 2546 + let msg = js_sys::Object::new(); 2547 + let _ = 2548 + js_sys::Reflect::set(&msg, &"command".into(), &"convert-step".into()); 2549 + let _ = 2550 + js_sys::Reflect::set(&msg, &"stepUrl".into(), &step_url.clone().into()); 2551 + if let Err(e) = worker_for_message.post_message(&msg) { 2552 + let _ = reject_message.call1( 2553 + &JsValue::NULL, 2554 + &JsValue::from_str(&format!("STEP preview command post: {e:?}")), 2555 + ); 2556 + } 2557 + } 2558 + "converted" => { 2559 + let Some(buffer) = js_value_field(&data, "buffer") else { 2560 + let _ = reject_message.call1( 2561 + &JsValue::NULL, 2562 + &JsValue::from_str("STEP preview returned no GLB bytes"), 2563 + ); 2564 + return; 2565 + }; 2566 + let bytes = js_sys::Uint8Array::new(&buffer); 2567 + let parts = js_sys::Array::new(); 2568 + parts.push(&bytes.buffer()); 2569 + let opts = web_sys::BlobPropertyBag::new(); 2570 + opts.set_type("model/gltf-binary"); 2571 + let blob = match web_sys::Blob::new_with_buffer_source_sequence_and_options( 2572 + &parts, &opts, 2573 + ) { 2574 + Ok(blob) => blob, 2575 + Err(e) => { 2576 + let _ = reject_message.call1( 2577 + &JsValue::NULL, 2578 + &JsValue::from_str(&format!("STEP preview GLB blob: {e:?}")), 2579 + ); 2580 + return; 2581 + } 2582 + }; 2583 + match web_sys::Url::create_object_url_with_blob(&blob) { 2584 + Ok(url) => { 2585 + let _ = 2586 + resolve_message.call1(&JsValue::NULL, &JsValue::from_str(&url)); 2587 + } 2588 + Err(e) => { 2589 + let _ = reject_message.call1( 2590 + &JsValue::NULL, 2591 + &JsValue::from_str(&format!("STEP preview GLB URL: {e:?}")), 2592 + ); 2593 + } 2594 + } 2595 + } 2596 + "error" => { 2597 + let message = js_string_field(&data, "message") 2598 + .unwrap_or_else(|| "STEP preview conversion failed".into()); 2599 + let _ = reject_message.call1(&JsValue::NULL, &JsValue::from_str(&message)); 2600 + } 2601 + _ => {} 2602 + } 2603 + }) as Box<dyn FnMut(web_sys::MessageEvent)>); 2604 + let reject_error = reject.clone(); 2605 + let onerror = Closure::wrap(Box::new(move |event: web_sys::ErrorEvent| { 2606 + let _ = reject_error.call1(&JsValue::NULL, &JsValue::from_str(&event.message())); 2607 + }) as Box<dyn FnMut(web_sys::ErrorEvent)>); 2608 + worker.set_onmessage(Some(onmessage.as_ref().unchecked_ref())); 2609 + worker.set_onerror(Some(onerror.as_ref().unchecked_ref())); 2610 + onmessage.forget(); 2611 + onerror.forget(); 2612 + }); 2613 + let output = wasm_bindgen_futures::JsFuture::from(promise) 2614 + .await 2615 + .map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))?; 2616 + worker_for_error.terminate(); 2617 + output 2618 + .as_string() 2619 + .ok_or_else(|| "STEP preview returned a non-string GLB URL".to_string()) 2620 + } 2621 + 2622 + fn js_value_field(value: &wasm_bindgen::JsValue, name: &str) -> Option<wasm_bindgen::JsValue> { 2623 + js_sys::Reflect::get(value, &name.into()).ok() 2624 + } 2625 + 2626 + fn js_string_field(value: &wasm_bindgen::JsValue, name: &str) -> Option<String> { 2627 + js_value_field(value, name).and_then(|v| v.as_string()) 2628 + } 2629 + 1321 2630 async fn request_preview_png( 1322 2631 format: MeshFormat, 1323 2632 primary_url: String, ··· 1425 2734 } 1426 2735 1427 2736 #[test] 2737 + fn generated_preview_support_includes_renderer_formats_and_step() { 2738 + assert!(supports_generated_preview("part.stl")); 2739 + assert!(supports_generated_preview("part.obj")); 2740 + assert!(supports_generated_preview("part.glb")); 2741 + assert!(supports_generated_preview("part.step")); 2742 + assert!(supports_generated_preview("part.STP")); 2743 + assert!(!supports_generated_preview("part.3mf")); 2744 + assert!(!supports_generated_preview("notes.txt")); 2745 + } 2746 + 2747 + #[test] 1428 2748 fn human_size_scales_units() { 1429 2749 assert_eq!(human_size(512), "512 B"); 1430 2750 assert_eq!(human_size(2048), "2.0 KB"); ··· 1484 2804 alt_str(&part.previews.as_ref().unwrap()[0]), 1485 2805 "Rendered preview of Bracket.stl" 1486 2806 ); 2807 + } 2808 + 2809 + #[test] 2810 + fn attach_generated_preview_sets_part_model_and_thing_preview() { 2811 + let mut composition = DraftThingInput { 2812 + models: vec![DraftModelInput::default()], 2813 + ..Default::default() 2814 + }; 2815 + let mut part = DraftPartInput::default(); 2816 + attach_generated_preview( 2817 + &mut composition, 2818 + 0, 2819 + 0, 2820 + &mut part, 2821 + test_image("Rendered preview of part.step"), 2822 + ); 2823 + assert_eq!(composition.cover.as_ref().unwrap().len(), 1); 2824 + assert_eq!(composition.previews.as_ref().unwrap().len(), 1); 2825 + assert_eq!(composition.models[0].previews.as_ref().unwrap().len(), 1); 2826 + assert_eq!(part.previews.as_ref().unwrap().len(), 1); 1487 2827 } 1488 2828 1489 2829 #[test]
+269 -9
src/publish/draft.rs
··· 1 - //! PM-43 publish-draft data model. 1 + //! Publish-draft data model. 2 2 //! 3 3 //! A draft is a server-side, non-public, in-progress publish composition. It is 4 4 //! persisted as a [`DraftThingInput`]: a mirror of the generated ··· 19 19 //! length/grapheme limits to the generated [`LexiconSchema::validate`], so draft 20 20 //! validation cannot drift from the lexicon. 21 21 22 + use jacquard::common::types::string::AtUri; 22 23 use jacquard_common::DefaultStr; 23 24 use jacquard_lexicon::schema::LexiconSchema; 24 25 use polymodel_api::com_atproto::repo::strong_ref::StrongRef; 26 + 27 + use crate::thing_detail::thing_uri_from_route; 25 28 use polymodel_api::space_polymodel::library::publish_thing::{ModelInput, PartInput, ThingInput}; 26 - use polymodel_api::space_polymodel::library::{Bbox, File, Image}; 29 + use polymodel_api::space_polymodel::library::{File, Image}; 27 30 use serde::{Deserialize, Serialize}; 28 31 32 + // Re-export leaf types the publish UI edits directly. 33 + pub use polymodel_api::space_polymodel::library::Bbox; 34 + 29 35 /// The owned string type the generated records use (`DefaultStr`). 30 36 type S = DefaultStr; 31 37 ··· 83 89 pub cover: Option<Vec<Image>>, 84 90 #[serde(default, skip_serializing_if = "Option::is_none")] 85 91 pub previews: Option<Vec<Image>>, 86 - /// Out of scope for the PM-43 UI, carried as an optional pass-through. 92 + /// Remix provenance; design pending. Carried pass-through, not yet surfaced. 87 93 #[serde(default, skip_serializing_if = "Option::is_none")] 88 94 pub derived_from: Option<StrongRef>, 89 - /// Out of scope for the PM-43 UI, carried as an optional pass-through. 95 + /// Thing-level compatibility links, surfaced as resolvable "Works with" chips. 90 96 #[serde(default, skip_serializing_if = "Option::is_none")] 91 97 pub intended_to_work_with: Option<Vec<StrongRef>>, 92 98 #[serde(default)] ··· 102 108 pub name: Option<String>, 103 109 #[serde(default, skip_serializing_if = "Option::is_none")] 104 110 pub summary: Option<String>, 105 - /// Per-model license override. Out of scope for the PM-43 UI. 111 + /// Per-model license override. 106 112 #[serde(default, skip_serializing_if = "Option::is_none")] 107 113 pub license: Option<String>, 108 114 #[serde(default, skip_serializing_if = "Option::is_none")] ··· 138 144 pub format: Option<String>, 139 145 #[serde(default, skip_serializing_if = "Option::is_none")] 140 146 pub notes: Option<String>, 141 - /// Out of scope for the PM-43 UI, carried as an optional pass-through. 147 + /// Unit label for this part's dimensions (distinct from `dimensions.unit`). 142 148 #[serde(default, skip_serializing_if = "Option::is_none")] 143 149 pub units: Option<String>, 144 150 #[serde(default, skip_serializing_if = "Option::is_none")] ··· 147 153 pub dimensions: Option<Bbox>, 148 154 #[serde(default, skip_serializing_if = "Option::is_none")] 149 155 pub previews: Option<Vec<Image>>, 150 - /// Out of scope for the PM-43 UI, carried as an optional pass-through. 156 + /// Remix provenance; design pending. Carried pass-through, not yet surfaced. 151 157 #[serde(default, skip_serializing_if = "Option::is_none")] 152 158 pub derived_from: Option<StrongRef>, 153 - /// Out of scope for the PM-43 UI, carried as an optional pass-through. 159 + /// Part-level compatibility links; not yet surfaced (thing-level only). 154 160 #[serde(default, skip_serializing_if = "Option::is_none")] 155 161 pub intended_to_work_with: Option<Vec<StrongRef>>, 156 162 } ··· 247 253 } 248 254 249 255 /// A part is publishable when it has a non-empty name and a staged file. 256 + #[allow(dead_code)] 250 257 pub fn is_publishable(&self) -> bool { 251 258 nonempty(&self.name) && self.has_file() 252 259 } ··· 255 262 impl DraftModelInput { 256 263 /// A model is publishable when it has a non-empty name and at least one 257 264 /// publishable part. 265 + #[allow(dead_code)] 258 266 pub fn is_publishable(&self) -> bool { 259 267 nonempty(&self.name) 260 268 && !self.parts.is_empty() ··· 278 286 279 287 /// Basics is complete once the thing has a name. License has an effective 280 288 /// default and is chosen in the details step, so it never gates Basics. 289 + #[allow(dead_code)] 281 290 pub fn basics_complete(&self) -> bool { 282 291 nonempty(&self.name) 283 292 } 284 293 285 294 /// Step 2 (Files & parts) is complete when there is at least one model and 286 295 /// every model is publishable. 296 + #[allow(dead_code)] 287 297 pub fn files_complete(&self) -> bool { 288 298 !self.models.is_empty() && self.models.iter().all(|m| m.is_publishable()) 289 299 } ··· 327 337 "Upload a file for this part.", 328 338 ); 329 339 } 340 + // Dimensions are all-or-none: if any axis is set, all three 341 + // must be non-empty. The lexicon only requires the fields exist, 342 + // so this presence check is enforced here, not by the validator. 343 + if let Some(bbox) = &part.dimensions { 344 + let axes = [bbox.x.as_str(), bbox.y.as_str(), bbox.z.as_str()]; 345 + let set_count = axes.iter().filter(|a| !a.trim().is_empty()).count(); 346 + if set_count != 0 && set_count != 3 { 347 + errors.push( 348 + format!("models[{mi}].parts[{pi}].dimensions"), 349 + "Set all three dimensions (X, Y, Z), or none.", 350 + ); 351 + } 352 + } 330 353 } 331 354 } 332 355 ··· 350 373 notes: opt_s(&part.notes), 351 374 units: opt_s(&part.units), 352 375 print_settings: opt_vec_s(&part.print_settings), 353 - dimensions: part.dimensions.clone(), 376 + dimensions: clean_bbox(part.dimensions.clone()), 354 377 previews: part.previews.clone(), 355 378 derived_from: part.derived_from.clone(), 356 379 intended_to_work_with: part.intended_to_work_with.clone(), ··· 421 444 format!("Exceeds a lexicon limit: {err}") 422 445 } 423 446 447 + /// Drop a `Bbox` whose axes are all empty so an in-progress edit does not 448 + /// serialize as a partial/empty dimensions object. Unit alone is not a dimension. 449 + fn clean_bbox(bbox: Option<Bbox>) -> Option<Bbox> { 450 + bbox.and_then(|b| { 451 + let empty = b.x.as_str().trim().is_empty() 452 + && b.y.as_str().trim().is_empty() 453 + && b.z.as_str().trim().is_empty(); 454 + if empty { None } else { Some(b) } 455 + }) 456 + } 457 + 458 + /// Derive a thing/model name from an uploaded filename: drop the extension and 459 + /// trim surrounding whitespace. A bare extension (e.g. `.stl`) or extensionless 460 + /// name is returned trimmed. 461 + pub fn name_from_filename(filename: &str) -> String { 462 + let trimmed = filename.trim(); 463 + match trimmed.rsplit_once('.') { 464 + Some((stem, _ext)) if !stem.trim().is_empty() => stem.trim().to_string(), 465 + _ => trimmed.to_string(), 466 + } 467 + } 468 + 469 + /// Give a freshly-uploaded-into draft the structure `assemble()` requires: a 470 + /// thing name derived from the first filename (only when currently unset) and at 471 + /// least one model carrying a non-empty name. Pure and idempotent — a later drop 472 + /// neither renames the thing nor adds a second default model. 473 + pub fn ensure_default_model_for_upload(thing: &mut DraftThingInput, first_filename: &str) { 474 + let derived = name_from_filename(first_filename); 475 + if !nonempty(&thing.name) && !derived.is_empty() { 476 + thing.name = Some(derived.clone()); 477 + } 478 + if thing.models.is_empty() { 479 + let model_name = thing 480 + .display_name() 481 + .map(str::to_string) 482 + .filter(|name| !name.is_empty()) 483 + .or_else(|| (!derived.is_empty()).then(|| derived.clone())) 484 + .unwrap_or_else(|| "Model 1".to_string()); 485 + thing.models.push(DraftModelInput { 486 + name: Some(model_name), 487 + ..Default::default() 488 + }); 489 + } 490 + } 491 + 492 + /// Parse one "works with" token — an `at://` URI, a full Polymodel thing URL, or 493 + /// a bare `/{repo}/thing/{rkey}` route path — into a thing `AtUri`. Pure; the 494 + /// network resolution to a `StrongRef` happens separately. Query strings and 495 + /// fragments are ignored; anything that is not a thing link is rejected. 496 + pub fn parse_thing_token(token: &str) -> Result<AtUri, String> { 497 + let token = token.trim(); 498 + if token.is_empty() { 499 + return Err("Empty link.".into()); 500 + } 501 + if token.starts_with("at://") { 502 + let uri = AtUri::new_owned(token).map_err(|e| format!("Invalid at:// URI: {e}"))?; 503 + // Only thing-record URIs are valid "Works with" links; reject model/part 504 + // or other NSIDs early with a clear message rather than a decode error. 505 + let is_thing = uri 506 + .path() 507 + .map(|p| p.collection.as_str() == "space.polymodel.library.thing") 508 + .unwrap_or(false); 509 + if !is_thing { 510 + return Err("Not a thing link.".to_string()); 511 + } 512 + return Ok(uri); 513 + } 514 + let rest = match token.split_once("://") { 515 + Some((_scheme, rest)) => rest.split_once('/').map(|(_host, path)| path).unwrap_or(""), 516 + None => token, 517 + }; 518 + let path = rest 519 + .split(['?', '#']) 520 + .next() 521 + .unwrap_or("") 522 + .trim_matches('/'); 523 + let segments: Vec<&str> = path.split('/').collect(); 524 + match segments.as_slice() { 525 + [repo, "thing", rkey] if !repo.is_empty() && !rkey.is_empty() => { 526 + thing_uri_from_route(repo, rkey) 527 + } 528 + _ => Err("Not a Polymodel thing link.".to_string()), 529 + } 530 + } 531 + 424 532 #[cfg(test)] 425 533 mod tests { 426 534 use super::*; ··· 546 654 draft.name = Some(" ".to_string()); 547 655 assert!(!draft.basics_complete()); 548 656 assert!(draft.assemble().is_err()); 657 + } 658 + 659 + #[test] 660 + fn name_from_filename_strips_extension_and_trims() { 661 + assert_eq!( 662 + name_from_filename(" Desk Organizer.stl "), 663 + "Desk Organizer" 664 + ); 665 + assert_eq!(name_from_filename("a.b.glb"), "a.b"); 666 + assert_eq!(name_from_filename("noext"), "noext"); 667 + // A bare dotfile keeps its name rather than collapsing to empty. 668 + assert_eq!(name_from_filename(".stl"), ".stl"); 669 + } 670 + 671 + #[test] 672 + fn ensure_default_model_makes_a_one_file_drop_assemblable() { 673 + let mut draft = DraftThingInput::default(); 674 + ensure_default_model_for_upload(&mut draft, "Bracket.stl"); 675 + draft.models[0].parts.push(part_with_upload("Bracket.stl")); 676 + assert_eq!(draft.name.as_deref(), Some("Bracket")); 677 + assert_eq!(draft.models.len(), 1); 678 + assert!(nonempty(&draft.models[0].name)); 679 + assert!(draft.assemble().is_ok(), "auto-created draft must publish"); 680 + } 681 + 682 + #[test] 683 + fn ensure_default_model_does_not_overwrite_name_or_add_second_model() { 684 + let mut draft = DraftThingInput { 685 + name: Some("Chosen".to_string()), 686 + ..Default::default() 687 + }; 688 + ensure_default_model_for_upload(&mut draft, "First.stl"); 689 + ensure_default_model_for_upload(&mut draft, "Second.stl"); 690 + assert_eq!(draft.name.as_deref(), Some("Chosen")); 691 + assert_eq!(draft.models.len(), 1); 692 + } 693 + 694 + #[test] 695 + fn parse_thing_token_accepts_at_uri_url_and_path() { 696 + let at = parse_thing_token("at://did:plc:abc123/space.polymodel.library.thing/rkey1") 697 + .expect("at:// passes through"); 698 + assert_eq!(at.authority().as_str(), "did:plc:abc123"); 699 + 700 + let url = parse_thing_token("https://polymodel.app/alice.test/thing/rkey2") 701 + .expect("full URL resolves"); 702 + assert_eq!(url.authority().as_str(), "alice.test"); 703 + assert_eq!( 704 + url.path() 705 + .and_then(|p| p.rkey) 706 + .map(|r| r.as_ref().to_string()), 707 + Some("rkey2".to_string()) 708 + ); 709 + 710 + let path = parse_thing_token("/alice.test/thing/rkey3").expect("bare path resolves"); 711 + assert_eq!(path.authority().as_str(), "alice.test"); 712 + 713 + // Query strings and fragments are ignored. 714 + let q = parse_thing_token("https://host/alice.test/thing/rkey4?ref=x#frag") 715 + .expect("query/fragment stripped"); 716 + assert_eq!( 717 + q.path() 718 + .and_then(|p| p.rkey) 719 + .map(|r| r.as_ref().to_string()), 720 + Some("rkey4".to_string()) 721 + ); 722 + } 723 + 724 + #[test] 725 + fn parse_thing_token_rejects_non_thing_links() { 726 + assert!(parse_thing_token("").is_err()); 727 + assert!(parse_thing_token("not a link").is_err()); 728 + assert!(parse_thing_token("https://host/alice.test/profile").is_err()); 729 + assert!(parse_thing_token("at://garbage").is_err()); 730 + } 731 + 732 + #[test] 733 + fn per_model_and_per_part_fields_map_through_assemble() { 734 + let draft = DraftThingInput { 735 + name: Some("Rig".to_string()), 736 + models: vec![DraftModelInput { 737 + name: Some("Frame".to_string()), 738 + summary: Some("Main frame".to_string()), 739 + license: Some("MIT".to_string()), 740 + tags: Some(vec!["frame".to_string()]), 741 + instructions: Some(vec!["Print at 0.2mm".to_string()]), 742 + parts: vec![DraftPartInput { 743 + name: Some("frame.stl".to_string()), 744 + upload_id: Some("abcdef0123456789-1".to_string()), 745 + notes: Some("Supports off".to_string()), 746 + units: Some("mm".to_string()), 747 + print_settings: Some(vec!["0.2mm layers".to_string()]), 748 + dimensions: Some(Bbox { 749 + unit: Some("mm".into()), 750 + x: "10".into(), 751 + y: "20".into(), 752 + z: "30".into(), 753 + extra_data: None, 754 + }), 755 + ..Default::default() 756 + }], 757 + ..Default::default() 758 + }], 759 + ..Default::default() 760 + }; 761 + let thing = draft.assemble().expect("rich draft assembles"); 762 + let model = &thing.models[0]; 763 + assert_eq!(model.summary.as_ref().unwrap().as_str(), "Main frame"); 764 + assert_eq!(model.license.as_ref().unwrap().as_str(), "MIT"); 765 + assert_eq!(model.tags.as_ref().unwrap()[0].as_str(), "frame"); 766 + let part = &model.parts[0]; 767 + assert_eq!(part.notes.as_ref().unwrap().as_str(), "Supports off"); 768 + assert_eq!(part.units.as_ref().unwrap().as_str(), "mm"); 769 + assert_eq!( 770 + part.print_settings.as_ref().unwrap()[0].as_str(), 771 + "0.2mm layers" 772 + ); 773 + let bbox = part.dimensions.as_ref().unwrap(); 774 + assert_eq!(bbox.x.as_str(), "10"); 775 + assert_eq!(bbox.unit.as_ref().unwrap().as_str(), "mm"); 776 + assert!(thing.validate().is_ok()); 777 + } 778 + 779 + #[test] 780 + fn partial_bbox_is_rejected_by_assemble() { 781 + let mut draft = complete_draft(); 782 + // Only X set → must fail validation (all-or-none). 783 + draft.models[0].parts[0].dimensions = Some(Bbox { 784 + unit: Some("mm".into()), 785 + x: "10".into(), 786 + y: "".into(), 787 + z: "".into(), 788 + extra_data: None, 789 + }); 790 + let errors = draft 791 + .assemble() 792 + .expect_err("partial bbox must fail assemble"); 793 + assert!(errors.has_field("models[0].parts[0].dimensions")); 794 + } 795 + 796 + #[test] 797 + fn empty_bbox_axes_are_dropped_at_assemble() { 798 + let mut draft = complete_draft(); 799 + // All axes empty (unit set) → dimensions dropped entirely, no error. 800 + draft.models[0].parts[0].dimensions = Some(Bbox { 801 + unit: Some("mm".into()), 802 + x: "".into(), 803 + y: "".into(), 804 + z: "".into(), 805 + extra_data: None, 806 + }); 807 + let thing = draft.assemble().expect("empty-axes bbox is dropped"); 808 + assert!(thing.models[0].parts[0].dimensions.is_none()); 549 809 } 550 810 }
+53 -1
src/publish/draft_client.rs
··· 1 - //! Same-origin client calls for the PM-43 publish wizard. 1 + //! Same-origin client calls for the publish flow. 2 2 //! 3 3 //! Thin typed wrappers over [`PolymodelClient`]'s raw app-request helpers for 4 4 //! the draft store, raw-byte file staging, and image upload. Every call rides 5 5 //! the browser cookie session (same-origin), so these are authenticated without 6 6 //! any explicit token handling. 7 + //! 8 + //! [`resolve_strong_ref`] is the exception: an unauthenticated, identity-resolving 9 + //! `get_record` that reads a thing record directly from the target repo's own 10 + //! PDS, so a cross-repo "Works with" link resolves. 7 11 8 12 use http::Method; 13 + use jacquard::client::{AgentSessionExt, BasicClient}; 14 + use jacquard::common::types::string::AtUri; 15 + use polymodel_api::com_atproto::repo::strong_ref::StrongRef; 9 16 use polymodel_api::space_polymodel::library::Image; 10 17 use polymodel_api::space_polymodel::library::publish_thing::PublishThingOutput; 11 18 use polymodel_api::space_polymodel::library::stage_file::StageFileOutput; 19 + use polymodel_api::space_polymodel::library::thing::Thing; 12 20 13 21 use crate::client::{PolymodelClient, RawError}; 14 22 use crate::publish::draft::{ 15 23 DeleteDraftResponse, DraftIdResponse, DraftSummary, DraftThingInput, ImageUploadResponse, 16 24 }; 25 + 26 + /// A resolved compatibility target: the `StrongRef` to persist plus the thing's 27 + /// display name, shown on the chip. 28 + pub struct ResolvedRef { 29 + pub strong_ref: StrongRef, 30 + pub name: Option<String>, 31 + } 32 + 33 + /// Resolve a thing `AtUri` to a current `StrongRef` (uri + cid) by reading the 34 + /// record directly from the target repo's own PDS. 35 + /// 36 + /// Uses an unauthenticated, identity-resolving jacquard agent: `get_record` 37 + /// resolves the URI's authority (DID or handle) to its PDS and reads from there, 38 + /// so a thing in *another* maker's repo resolves (unlike the same-origin proxy, 39 + /// which is pinned to the signed-in user's PDS). A record with no `cid` is an 40 + /// error — a `StrongRef` requires one. 41 + pub async fn resolve_strong_ref(uri: &AtUri) -> Result<ResolvedRef, RawError> { 42 + let agent = BasicClient::unauthenticated(); 43 + let response = agent 44 + .get_record::<Thing, _>(uri) 45 + .await 46 + .map_err(|error| RawError::Transport(error.to_string()))?; 47 + // `into_output()` fails for typed XRPC errors — a `RecordNotFound` for a 48 + // missing record, or a decode/validation error otherwise. Both are surfaced 49 + // as a decode failure carrying the real cause rather than a fake HTTP status. 50 + let output = response 51 + .into_output() 52 + .map_err(|error| RawError::Decode(error.to_string()))?; 53 + let cid = output 54 + .cid 55 + .ok_or_else(|| RawError::Decode("record has no cid".to_string()))?; 56 + let name = { 57 + let n = output.value.name.as_str().trim(); 58 + (!n.is_empty()).then(|| n.to_string()) 59 + }; 60 + Ok(ResolvedRef { 61 + strong_ref: StrongRef { 62 + cid, 63 + uri: output.uri, 64 + extra_data: None, 65 + }, 66 + name, 67 + }) 68 + } 17 69 18 70 /// stageFile XRPC path (raw-body upload), reused from the existing write path. 19 71 const STAGE_FILE_PATH: &str = "/xrpc/space.polymodel.library.stageFile";
+4 -86
src/publish/state.rs
··· 1 - //! PM-43 wizard step model and publish phase. 1 + //! Publish lifecycle phase for the single-page publish flow. 2 2 //! 3 - //! The durable composition lives in [`DraftThingInput`](super::draft); these 4 - //! types are the *ephemeral* wizard UI state — which step is active and where 5 - //! the publish action is in its lifecycle. 6 - 7 - /// The five wizard steps, in order. 8 - #[derive(Clone, Copy, PartialEq, Eq, Debug)] 9 - pub enum WizardStep { 10 - Basics, 11 - Files, 12 - Images, 13 - Details, 14 - Review, 15 - } 16 - 17 - impl WizardStep { 18 - /// All steps in display order. 19 - pub const ALL: [WizardStep; 5] = [ 20 - WizardStep::Basics, 21 - WizardStep::Files, 22 - WizardStep::Images, 23 - WizardStep::Details, 24 - WizardStep::Review, 25 - ]; 26 - 27 - /// Zero-based position in the flow. 28 - pub fn index(self) -> usize { 29 - Self::ALL.iter().position(|s| *s == self).unwrap_or(0) 30 - } 31 - 32 - /// One-based step number for the stepper label. 33 - pub fn number(self) -> usize { 34 - self.index() + 1 35 - } 36 - 37 - /// Full label for the stepper and headings. 38 - pub fn label(self) -> &'static str { 39 - match self { 40 - WizardStep::Basics => "Basics", 41 - WizardStep::Files => "Files & parts", 42 - WizardStep::Images => "Images", 43 - WizardStep::Details => "Instructions & license", 44 - WizardStep::Review => "Review & publish", 45 - } 46 - } 47 - 48 - /// The next step, if any. 49 - pub fn next(self) -> Option<WizardStep> { 50 - Self::ALL.get(self.index() + 1).copied() 51 - } 52 - 53 - /// The previous step, if any. 54 - pub fn prev(self) -> Option<WizardStep> { 55 - if self.index() == 0 { 56 - None 57 - } else { 58 - Self::ALL.get(self.index() - 1).copied() 59 - } 60 - } 61 - } 3 + //! The durable composition lives in [`DraftThingInput`](super::draft); this 4 + //! type is the *ephemeral* publish-action lifecycle, surfaced as the persistent 5 + //! status indicator. 62 6 63 7 /// Lifecycle of the publish action, surfaced as the persistent status indicator. 64 8 #[derive(Clone, PartialEq, Debug)] ··· 72 16 /// The publish attempt failed; the draft is preserved. 73 17 Failed(String), 74 18 } 75 - 76 - #[cfg(test)] 77 - mod tests { 78 - use super::*; 79 - 80 - #[test] 81 - fn steps_are_ordered_and_navigable() { 82 - assert_eq!(WizardStep::Basics.number(), 1); 83 - assert_eq!(WizardStep::Review.number(), 5); 84 - assert_eq!(WizardStep::Basics.prev(), None); 85 - assert_eq!(WizardStep::Review.next(), None); 86 - assert_eq!(WizardStep::Basics.next(), Some(WizardStep::Files)); 87 - assert_eq!(WizardStep::Files.prev(), Some(WizardStep::Basics)); 88 - } 89 - 90 - #[test] 91 - fn all_steps_have_distinct_indices() { 92 - let mut seen = Vec::new(); 93 - for step in WizardStep::ALL { 94 - let idx = step.index(); 95 - assert!(!seen.contains(&idx), "duplicate index {idx}"); 96 - seen.push(idx); 97 - } 98 - assert_eq!(seen, vec![0, 1, 2, 3, 4]); 99 - } 100 - }
+93 -91
src/thing_card.rs
··· 194 194 } 195 195 } 196 196 } 197 - if let Some(tags) = hydrated_tags(&thing) { 198 - div { class: "thing-card-chips", aria_label: "Hydrated display tags", 199 - for tag in tags.iter().take(3) { 200 - span { class: "status-pill status-muted", "#{tag}" } 197 + div { class: "thing-card-details", 198 + if let Some(tags) = hydrated_tags(&thing) { 199 + div { class: "thing-card-chips", aria_label: "Hydrated display tags", 200 + for tag in tags.iter().take(3) { 201 + span { class: "status-pill status-muted", "#{tag}" } 202 + } 201 203 } 202 204 } 203 - } 204 - dl { class: "thing-card-stats", aria_label: "Thing stats", 205 - for (label, value) in stats_with_social_counts(&stats, *like_count.read(), *save_count.read()) { 206 - div { 207 - dt { "{label}" } 208 - dd { "{value}" } 205 + dl { class: "thing-card-stats", aria_label: "Thing stats", 206 + for (label, value) in stats_with_social_counts(&stats, *like_count.read(), *save_count.read()) { 207 + div { 208 + dt { "{label}" } 209 + dd { "{value}" } 210 + } 209 211 } 210 212 } 211 - } 212 - div { class: "thing-card-actions", aria_label: "Thing card saved and liked state", 213 - if can_write { 214 - button { 215 - class: "thing-card-action", 216 - disabled: *action_pending.read(), 217 - aria_pressed: "{like_uri.read().is_some()}", 218 - aria_label: "{like_label} {title}", 219 - onclick: move |_| { 220 - let client = like_client.clone(); 221 - let subject = like_subject.clone(); 222 - let current = like_uri.read().clone(); 223 - action_pending.set(true); 224 - action_error.set(None); 225 - spawn(async move { 226 - let result = if let Some(uri) = current { 227 - match client.send(DeleteLike { uri: Some(uri), subject: None, extra_data: None }).await { 228 - Ok(response) => response.into_output().map(|_| None).map_err(|error| error.to_string()), 229 - Err(error) => Err(error.to_string()), 230 - } 231 - } else { 232 - match client.send(CreateLike { subject, extra_data: None }).await { 233 - Ok(response) => response.into_output().map(|output| Some(output.uri)).map_err(|error| error.to_string()), 234 - Err(error) => Err(error.to_string()), 213 + div { class: "thing-card-actions", aria_label: "Thing card saved and liked state", 214 + if can_write { 215 + button { 216 + class: "thing-card-action", 217 + disabled: *action_pending.read(), 218 + aria_pressed: "{like_uri.read().is_some()}", 219 + aria_label: "{like_label} {title}", 220 + onclick: move |_| { 221 + let client = like_client.clone(); 222 + let subject = like_subject.clone(); 223 + let current = like_uri.read().clone(); 224 + action_pending.set(true); 225 + action_error.set(None); 226 + spawn(async move { 227 + let result = if let Some(uri) = current { 228 + match client.send(DeleteLike { uri: Some(uri), subject: None, extra_data: None }).await { 229 + Ok(response) => response.into_output().map(|_| None).map_err(|error| error.to_string()), 230 + Err(error) => Err(error.to_string()), 231 + } 232 + } else { 233 + match client.send(CreateLike { subject, extra_data: None }).await { 234 + Ok(response) => response.into_output().map(|output| Some(output.uri)).map_err(|error| error.to_string()), 235 + Err(error) => Err(error.to_string()), 236 + } 237 + }; 238 + match result { 239 + Ok(next) => { 240 + let was_liked = like_uri.read().is_some(); 241 + like_uri.set(next); 242 + let current_count = *like_count.read(); 243 + let is_liked = like_uri.read().is_some(); 244 + like_count.set(adjust_count(current_count, was_liked, is_liked)); 245 + } 246 + Err(error) => action_error.set(Some(format!("Like failed: {error}"))), 235 247 } 236 - }; 237 - match result { 238 - Ok(next) => { 239 - let was_liked = like_uri.read().is_some(); 240 - like_uri.set(next); 241 - let current_count = *like_count.read(); 242 - let is_liked = like_uri.read().is_some(); 243 - like_count.set(adjust_count(current_count, was_liked, is_liked)); 248 + action_pending.set(false); 249 + }); 250 + }, 251 + "♡ {like_label}" 252 + } 253 + button { 254 + class: "thing-card-action", 255 + disabled: *action_pending.read(), 256 + aria_pressed: "{save_uri.read().is_some()}", 257 + aria_label: "{save_label} {title}", 258 + onclick: move |_| { 259 + let client = save_client.clone(); 260 + let subject = save_subject.clone(); 261 + let current = save_uri.read().clone(); 262 + action_pending.set(true); 263 + action_error.set(None); 264 + spawn(async move { 265 + let result = if let Some(uri) = current { 266 + match client.send(DeleteSave { uri: Some(uri), subject: None, extra_data: None }).await { 267 + Ok(response) => response.into_output().map(|_| None).map_err(|error| error.to_string()), 268 + Err(error) => Err(error.to_string()), 269 + } 270 + } else { 271 + match client.send(CreateSave { subject, note: None, extra_data: None }).await { 272 + Ok(response) => response.into_output().map(|output| Some(output.uri)).map_err(|error| error.to_string()), 273 + Err(error) => Err(error.to_string()), 274 + } 275 + }; 276 + match result { 277 + Ok(next) => { 278 + let was_saved = save_uri.read().is_some(); 279 + save_uri.set(next); 280 + let current_count = *save_count.read(); 281 + let is_saved = save_uri.read().is_some(); 282 + save_count.set(adjust_count(current_count, was_saved, is_saved)); 283 + } 284 + Err(error) => action_error.set(Some(format!("Save failed: {error}"))), 244 285 } 245 - Err(error) => action_error.set(Some(format!("Like failed: {error}"))), 246 - } 247 - action_pending.set(false); 248 - }); 249 - }, 250 - "♡ {like_label}" 286 + action_pending.set(false); 287 + }); 288 + }, 289 + "☆ {save_label}" 290 + } 291 + } else { 292 + a { class: "thing-card-action", href: "/oauth/start", aria_label: "Sign in to like {title}", "♡ Like" } 293 + a { class: "thing-card-action", href: "/oauth/start", aria_label: "Sign in to save {title}", "☆ Save" } 251 294 } 252 - button { 253 - class: "thing-card-action", 254 - disabled: *action_pending.read(), 255 - aria_pressed: "{save_uri.read().is_some()}", 256 - aria_label: "{save_label} {title}", 257 - onclick: move |_| { 258 - let client = save_client.clone(); 259 - let subject = save_subject.clone(); 260 - let current = save_uri.read().clone(); 261 - action_pending.set(true); 262 - action_error.set(None); 263 - spawn(async move { 264 - let result = if let Some(uri) = current { 265 - match client.send(DeleteSave { uri: Some(uri), subject: None, extra_data: None }).await { 266 - Ok(response) => response.into_output().map(|_| None).map_err(|error| error.to_string()), 267 - Err(error) => Err(error.to_string()), 268 - } 269 - } else { 270 - match client.send(CreateSave { subject, note: None, extra_data: None }).await { 271 - Ok(response) => response.into_output().map(|output| Some(output.uri)).map_err(|error| error.to_string()), 272 - Err(error) => Err(error.to_string()), 273 - } 274 - }; 275 - match result { 276 - Ok(next) => { 277 - let was_saved = save_uri.read().is_some(); 278 - save_uri.set(next); 279 - let current_count = *save_count.read(); 280 - let is_saved = save_uri.read().is_some(); 281 - save_count.set(adjust_count(current_count, was_saved, is_saved)); 282 - } 283 - Err(error) => action_error.set(Some(format!("Save failed: {error}"))), 284 - } 285 - action_pending.set(false); 286 - }); 287 - }, 288 - "☆ {save_label}" 295 + if let Some(error) = action_error.read().as_ref() { 296 + span { class: "thing-card-action-error", role: "status", "{error}" } 289 297 } 290 - } else { 291 - a { class: "thing-card-action", href: "/oauth/start", aria_label: "Sign in to like {title}", "♡ Like" } 292 - a { class: "thing-card-action", href: "/oauth/start", aria_label: "Sign in to save {title}", "☆ Save" } 293 - } 294 - if let Some(error) = action_error.read().as_ref() { 295 - span { class: "thing-card-action-error", role: "status", "{error}" } 296 298 } 297 299 } 298 300 }
+18 -13
src/thing_detail.rs
··· 17 17 use crate::client::PolymodelClient; 18 18 use crate::profile::profile_href; 19 19 use crate::session::SessionIdentity; 20 - use crate::viewer::{ 21 - AssetViewer, ViewerFallbackImage, ViewerMeshSource, ViewerSourceFormat, demo_models, 22 - }; 20 + use crate::viewer::{AssetViewer, ViewerFallbackImage, ViewerMeshSource, ViewerSourceFormat}; 23 21 24 22 #[derive(Clone, Debug, PartialEq, Eq)] 25 23 enum DetailState { ··· 158 156 } 159 157 160 158 #[cfg_attr(not(feature = "server"), allow(dead_code))] 161 - fn thing_uri_from_route(repo: &str, rkey: &str) -> Result<AtUri, String> { 159 + /// Build a `space.polymodel.library.thing` `at://` URI from route components. 160 + /// Shared with the publish flow's "Works with" link parser. 161 + pub(crate) fn thing_uri_from_route(repo: &str, rkey: &str) -> Result<AtUri, String> { 162 162 let repo: AtIdentifier<String> = AtIdentifier::new_owned(repo) 163 163 .map_err(|error| format!("Invalid route: unsupported repository identifier: {error}"))?; 164 164 let rkey: Rkey<String> = Rkey::new_owned(rkey) ··· 326 326 let title = clean_text(Some(thing.name.as_ref())) 327 327 .unwrap_or("Untitled thing") 328 328 .to_string(); 329 + let title_len = title.chars().count(); 330 + let title_class = if title_len >= 48 { 331 + "thing-detail-title is-very-long" 332 + } else if title_len >= 28 { 333 + "thing-detail-title is-long" 334 + } else { 335 + "thing-detail-title" 336 + }; 329 337 let creator = actor_label(&thing.author).to_string(); 330 338 let summary = clean_text(thing.summary.as_ref().map(AsRef::as_ref)) 331 339 .or_else(|| clean_text(thing_record.summary.as_ref().map(AsRef::as_ref))) ··· 383 391 384 392 rsx! { 385 393 header { class: "thing-detail-header", 394 + h1 { class: title_class, "{title}" } 386 395 div { class: "thing-detail-title-row", 387 - div { 388 - h1 { "{title}" } 389 - p { class: "thing-detail-creator", 390 - if let Some(href) = profile_href(&thing.author) { 391 - a { class: "thing-detail-creator-link", href: "{href}", "by {creator}" } 392 - } else { 393 - "by {creator}" 394 - } 396 + p { class: "thing-detail-creator", 397 + if let Some(href) = profile_href(&thing.author) { 398 + a { class: "thing-detail-creator-link", href: "{href}", "by {creator}" } 399 + } else { 400 + "by {creator}" 395 401 } 396 402 } 397 403 dl { class: "thing-detail-stats", aria_label: "Thing stats", ··· 406 412 section { class: "thing-detail-shell blueprint-panel", aria_label: "Thing overview", 407 413 div { class: "thing-detail-viewer", 408 414 AssetViewer { 409 - initial_model: demo_models()[0], 410 415 fallback: media.map(|media| ViewerFallbackImage { url: media.url, alt: media.alt }), 411 416 source: viewer_source, 412 417 }
+29 -19
src/viewer.rs
··· 345 345 } 346 346 347 347 ModelViewer { 348 - model, 348 + model: Some(model), 349 349 render_source: step_render_source, 350 350 fallback_to_demo: true, 351 351 mesh_stats, ··· 362 362 363 363 #[component] 364 364 pub fn AssetViewer( 365 - initial_model: DemoModel, 365 + #[props(default)] initial_model: Option<DemoModel>, 366 366 fallback: Option<ViewerFallbackImage>, 367 367 #[props(default)] source: Option<ViewerMeshSource>, 368 368 ) -> Element { ··· 375 375 let renderer_failed = use_signal(|| false); 376 376 let mesh_stats: Signal<Option<MeshStats>> = use_signal(|| None); 377 377 let status: Signal<ViewerStatus> = use_signal(|| { 378 - ViewerStatus::Loading(SessionKey::new(initial_model.id, initial_model.generation)) 378 + let key = source 379 + .as_ref() 380 + .map(|source| SessionKey::new(source.key.clone(), 1)) 381 + .or_else(|| initial_model.map(|model| SessionKey::new(model.id, model.generation))) 382 + .unwrap_or_else(|| SessionKey::new("asset-viewer", 1)); 383 + ViewerStatus::Loading(key) 379 384 }); 380 - let fallback_to_demo = source.is_none(); 385 + let fallback_to_demo = source.is_none() && initial_model.is_some(); 381 386 382 387 let _mesh_load = use_resource(move || { 383 388 let model = initial_model; ··· 390 395 async move { 391 396 let session_key = source 392 397 .as_ref() 393 - .map(|source| SessionKey::new(source.key.clone(), model.generation)) 394 - .unwrap_or_else(|| SessionKey::new(model.id, model.generation)); 398 + .map(|source| SessionKey::new(source.key.clone(), 1)) 399 + .or_else(|| model.map(|model| SessionKey::new(model.id, model.generation))) 400 + .unwrap_or_else(|| SessionKey::new("asset-viewer", 1)); 395 401 let using_source = source.is_some(); 396 402 status.set(ViewerStatus::Loading(session_key.clone())); 397 403 if let Some(source) = source { ··· 430 436 None 431 437 } else { 432 438 Some(MeshStats { 433 - vertices: model.vertices as u32, 434 - triangles: model.triangles as u32, 439 + vertices: model.map_or(0, |model| model.vertices as u32), 440 + triangles: model.map_or(0, |model| model.triangles as u32), 435 441 }) 436 442 }); 437 443 } ··· 685 691 686 692 #[derive(Props, Clone, PartialEq)] 687 693 pub struct ModelViewerProps { 688 - model: DemoModel, 694 + model: Option<DemoModel>, 689 695 render_source: Signal<Option<RenderSource>>, 690 696 fallback_to_demo: bool, 691 697 mesh_stats: Signal<Option<MeshStats>>, ··· 717 723 load_status 718 724 }; 719 725 let stats = *mesh_stats.read(); 720 - let summary = Some(mesh_summary_for(model)); 726 + let summary = model.map(mesh_summary_for); 721 727 let asset_path = summary.map(|s| s.asset_path).unwrap_or(""); 722 - let session_label = format!("{}#{}", model.id, model.generation); 728 + let session_label = model 729 + .map(|model| format!("{}#{}", model.id, model.generation)) 730 + .unwrap_or_else(|| "asset-viewer#1".to_string()); 723 731 let acquisitions = *context_acquisitions.read(); 724 732 let renderer_active = acquisitions > 0 && !*renderer_failed.read(); 725 733 let renderer_kind = if renderer_active { ··· 811 819 /// worker (and its GL context). 812 820 #[component] 813 821 fn WorkerRendererSession( 814 - model: DemoModel, 822 + model: Option<DemoModel>, 815 823 render_source: Signal<Option<RenderSource>>, 816 824 fallback_to_demo: bool, 817 825 status: Signal<ViewerStatus>, ··· 839 847 #[cfg(not(target_arch = "wasm32"))] 840 848 #[component] 841 849 fn WorkerSessionInner( 842 - model: DemoModel, 850 + model: Option<DemoModel>, 843 851 render_source: Signal<Option<RenderSource>>, 844 852 fallback_to_demo: bool, 845 853 status: Signal<ViewerStatus>, ··· 866 874 #[cfg(target_arch = "wasm32")] 867 875 #[component] 868 876 fn WorkerSessionInner( 869 - model: DemoModel, 877 + model: Option<DemoModel>, 870 878 render_source: Signal<Option<RenderSource>>, 871 879 fallback_to_demo: bool, 872 880 status: Signal<ViewerStatus>, ··· 948 956 let source = render_source.read().clone().or_else(|| { 949 957 fallback_to_demo 950 958 .then(|| { 951 - pending_load_for(model_val).ok().map(|load| RenderSource { 952 - key: load.model.id.to_string(), 953 - format: load.format, 954 - primary_url: load.model.asset_path.to_string(), 955 - }) 959 + model_val 960 + .and_then(|model| pending_load_for(model).ok()) 961 + .map(|load| RenderSource { 962 + key: load.model.id.to_string(), 963 + format: load.format, 964 + primary_url: load.model.asset_path.to_string(), 965 + }) 956 966 }) 957 967 .flatten() 958 968 });