atproto Thingiverse but good
10

Configure Feed

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

PM-43: publish wizard + server-side drafts

Orual (Jun 29, 2026, 7:06 PM EDT) 28acf361 708124c2

+3253 -84
+74
.sqlx/query-9977dacbaf568cc7abdf59d3b5fd2d2ea78f42a02e9db75d371624256361d46d.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "SELECT draft_id, name, model_count, created_at, updated_at FROM drafts WHERE owner_did = ? ORDER BY updated_at DESC", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "name": "draft_id", 8 + "ordinal": 0, 9 + "type_info": "Text", 10 + "origin": { 11 + "Table": { 12 + "table": "drafts", 13 + "name": "draft_id" 14 + } 15 + } 16 + }, 17 + { 18 + "name": "name", 19 + "ordinal": 1, 20 + "type_info": "Text", 21 + "origin": { 22 + "Table": { 23 + "table": "drafts", 24 + "name": "name" 25 + } 26 + } 27 + }, 28 + { 29 + "name": "model_count", 30 + "ordinal": 2, 31 + "type_info": "Integer", 32 + "origin": { 33 + "Table": { 34 + "table": "drafts", 35 + "name": "model_count" 36 + } 37 + } 38 + }, 39 + { 40 + "name": "created_at", 41 + "ordinal": 3, 42 + "type_info": "Integer", 43 + "origin": { 44 + "Table": { 45 + "table": "drafts", 46 + "name": "created_at" 47 + } 48 + } 49 + }, 50 + { 51 + "name": "updated_at", 52 + "ordinal": 4, 53 + "type_info": "Integer", 54 + "origin": { 55 + "Table": { 56 + "table": "drafts", 57 + "name": "updated_at" 58 + } 59 + } 60 + } 61 + ], 62 + "parameters": { 63 + "Right": 1 64 + }, 65 + "nullable": [ 66 + false, 67 + true, 68 + false, 69 + false, 70 + false 71 + ] 72 + }, 73 + "hash": "9977dacbaf568cc7abdf59d3b5fd2d2ea78f42a02e9db75d371624256361d46d" 74 + }
+12
.sqlx/query-a578dfdbdd96d8d0d58c81a9cc946041d42a5e041251cb579cf612a61108899c.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "DELETE FROM drafts WHERE owner_did = ? AND draft_id = ?", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Right": 2 8 + }, 9 + "nullable": [] 10 + }, 11 + "hash": "a578dfdbdd96d8d0d58c81a9cc946041d42a5e041251cb579cf612a61108899c" 12 + }
+12
.sqlx/query-d177b801f2655e545208fa65b95ae230def27bda085e873eb19bee6e4c19ce2f.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "INSERT INTO drafts (owner_did, draft_id, payload_json, name, model_count, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(owner_did, draft_id) DO UPDATE SET payload_json = excluded.payload_json, name = excluded.name, model_count = excluded.model_count, updated_at = excluded.updated_at", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Right": 7 8 + }, 9 + "nullable": [] 10 + }, 11 + "hash": "d177b801f2655e545208fa65b95ae230def27bda085e873eb19bee6e4c19ce2f" 12 + }
+26
.sqlx/query-d7161a09f70f40cbc69a2dbe8ba219bf0ec46915a10dff50f17ceffb3e438f2b.json
··· 1 + { 2 + "db_name": "SQLite", 3 + "query": "SELECT payload_json FROM drafts WHERE owner_did = ? AND draft_id = ?", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "name": "payload_json", 8 + "ordinal": 0, 9 + "type_info": "Text", 10 + "origin": { 11 + "Table": { 12 + "table": "drafts", 13 + "name": "payload_json" 14 + } 15 + } 16 + } 17 + ], 18 + "parameters": { 19 + "Right": 2 20 + }, 21 + "nullable": [ 22 + false 23 + ] 24 + }, 25 + "hash": "d7161a09f70f40cbc69a2dbe8ba219bf0ec46915a10dff50f17ceffb3e438f2b" 26 + }
+48 -22
Cargo.lock
··· 130 130 131 131 [[package]] 132 132 name = "anyhow" 133 - version = "1.0.102" 133 + version = "1.0.103" 134 134 source = "registry+https://github.com/rust-lang/crates.io-index" 135 - checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 135 + checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" 136 136 137 137 [[package]] 138 138 name = "approx" ··· 154 154 155 155 [[package]] 156 156 name = "arc-swap" 157 - version = "1.9.1" 157 + version = "1.9.2" 158 158 source = "registry+https://github.com/rust-lang/crates.io-index" 159 - checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" 159 + checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" 160 160 dependencies = [ 161 161 "rustversion", 162 162 ] ··· 535 535 ] 536 536 537 537 [[package]] 538 + name = "base45" 539 + version = "3.2.0" 540 + source = "registry+https://github.com/rust-lang/crates.io-index" 541 + checksum = "240e56f4d3c453c36faacb695c535a4d5f8c7d23dac175014f32eb0a71012a03" 542 + 543 + [[package]] 538 544 name = "base64" 539 545 version = "0.22.1" 540 546 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 822 828 823 829 [[package]] 824 830 name = "chacha20" 825 - version = "0.10.0" 831 + version = "0.10.1" 826 832 source = "registry+https://github.com/rust-lang/crates.io-index" 827 - checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" 833 + checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" 828 834 dependencies = [ 829 835 "cfg-if", 830 836 "cpufeatures 0.3.0", ··· 3733 3739 3734 3740 [[package]] 3735 3741 name = "hybrid-array" 3736 - version = "0.4.12" 3742 + version = "0.4.13" 3737 3743 source = "registry+https://github.com/rust-lang/crates.io-index" 3738 - checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" 3744 + checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" 3739 3745 dependencies = [ 3740 3746 "typenum", 3741 3747 ] ··· 4026 4032 "byteorder-lite", 4027 4033 "quick-error", 4028 4034 ] 4035 + 4036 + [[package]] 4037 + name = "imagesize" 4038 + version = "0.13.0" 4039 + source = "registry+https://github.com/rust-lang/crates.io-index" 4040 + checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" 4029 4041 4030 4042 [[package]] 4031 4043 name = "imgref" ··· 5212 5224 5213 5225 [[package]] 5214 5226 name = "memmap2" 5215 - version = "0.9.10" 5227 + version = "0.9.11" 5216 5228 source = "registry+https://github.com/rust-lang/crates.io-index" 5217 - checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" 5229 + checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" 5218 5230 dependencies = [ 5219 5231 "libc", 5220 5232 ] ··· 5366 5378 5367 5379 [[package]] 5368 5380 name = "multibase" 5369 - version = "0.9.2" 5381 + version = "0.9.3" 5370 5382 source = "registry+https://github.com/rust-lang/crates.io-index" 5371 - checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" 5383 + checksum = "7e0e4a371cbf1dfd666b658ba137763edb23c45beb43cfe369b5593cd6b437b6" 5372 5384 dependencies = [ 5373 5385 "base-x", 5374 5386 "base256emoji", 5387 + "base45", 5375 5388 "data-encoding", 5376 5389 "data-encoding-macro", 5377 5390 ] ··· 6201 6214 "http", 6202 6215 "http-body-util", 6203 6216 "hydrant", 6217 + "imagesize", 6204 6218 "jacquard", 6205 6219 "jacquard-axum", 6206 6220 "jacquard-common 0.12.1", 6207 6221 "jacquard-derive 0.12.1", 6222 + "jacquard-lexicon 0.12.1", 6208 6223 "js-sys", 6209 6224 "keyring", 6210 6225 "libsqlite3-sys", ··· 6225 6240 "tracing", 6226 6241 "tracing-subscriber", 6227 6242 "tracing-wasm", 6243 + "ulid", 6228 6244 "wasm-bindgen", 6229 6245 "wasm-bindgen-futures", 6230 6246 "web-sys", ··· 6462 6478 6463 6479 [[package]] 6464 6480 name = "quick_cache" 6465 - version = "0.6.23" 6481 + version = "0.6.24" 6466 6482 source = "registry+https://github.com/rust-lang/crates.io-index" 6467 - checksum = "3a3db184a8b66cfe87f0263a1de147a6b554c864d1767c6f7fa4eb0e5497b565" 6483 + checksum = "b9c6658afe513a3b484e3abfdaa0d03ef3c0bbf017542c178dd55f94eb3051f9" 6468 6484 dependencies = [ 6469 6485 "equivalent", 6470 6486 "hashbrown 0.16.1", ··· 7016 7032 7017 7033 [[package]] 7018 7034 name = "rustls" 7019 - version = "0.23.40" 7035 + version = "0.23.41" 7020 7036 source = "registry+https://github.com/rust-lang/crates.io-index" 7021 - checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" 7037 + checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" 7022 7038 dependencies = [ 7023 7039 "aws-lc-rs", 7024 7040 "log", ··· 8397 8413 8398 8414 [[package]] 8399 8415 name = "tokio-websockets" 8400 - version = "0.13.2" 8416 + version = "0.13.3" 8401 8417 source = "registry+https://github.com/rust-lang/crates.io-index" 8402 - checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb" 8418 + checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" 8403 8419 dependencies = [ 8404 8420 "aws-lc-rs", 8405 8421 "base64", ··· 8667 8683 8668 8684 [[package]] 8669 8685 name = "triomphe" 8670 - version = "0.1.15" 8686 + version = "0.1.16" 8671 8687 source = "registry+https://github.com/rust-lang/crates.io-index" 8672 - checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" 8688 + checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" 8673 8689 8674 8690 [[package]] 8675 8691 name = "try-lock" ··· 8760 8776 version = "1.20.1" 8761 8777 source = "registry+https://github.com/rust-lang/crates.io-index" 8762 8778 checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" 8779 + 8780 + [[package]] 8781 + name = "ulid" 8782 + version = "1.2.1" 8783 + source = "registry+https://github.com/rust-lang/crates.io-index" 8784 + checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" 8785 + dependencies = [ 8786 + "rand 0.9.4", 8787 + "web-time", 8788 + ] 8763 8789 8764 8790 [[package]] 8765 8791 name = "unicase" ··· 8879 8905 8880 8906 [[package]] 8881 8907 name = "uuid" 8882 - version = "1.23.3" 8908 + version = "1.23.4" 8883 8909 source = "registry+https://github.com/rust-lang/crates.io-index" 8884 - checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" 8910 + checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" 8885 8911 dependencies = [ 8886 8912 "js-sys", 8887 8913 "wasm-bindgen",
+14 -7
Cargo.toml
··· 52 52 "dep:base64", 53 53 "dep:sha2", 54 54 "dep:zip", 55 + # PM-43: server-side decode of uploaded image dimensions (for #image 56 + # aspectRatio) and opaque draft id generation. 57 + "dep:imagesize", 58 + "dep:ulid", 55 59 ] 56 60 [dependencies] 57 61 dioxus = { version = "0.7", features = ["router", "fullstack"] } ··· 59 63 jacquard-axum = { workspace = true, optional = true } 60 64 jacquard-common = { workspace = true } 61 65 jacquard-derive = { workspace = true } 66 + # PM-43: LexiconSchema::validate for draft → ThingInput assembly, so draft 67 + # validation tracks the generated lexicon constraints instead of duplicating them. 68 + jacquard-lexicon = { workspace = true } 62 69 # Generated lexicon bindings (typed request/response/view types for XRPC routes). 63 70 polymodel-api = { path = "crates/polymodel-api" } 64 71 serde = { workspace = true } ··· 111 118 base64 = { version = "0.22", optional = true } 112 119 sha2 = { version = "0.10", optional = true } 113 120 zip = { version = "2", default-features = false, optional = true } 121 + # PM-43: header-only image dimension probe (no full decode) for #image 122 + # aspectRatio; ULID for opaque, sortable draft ids. Both server-only. 123 + imagesize = { version = "0.13", optional = true } 124 + ulid = { version = "1", optional = true } 114 125 115 126 116 127 [target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies] ··· 119 130 tracing-wasm = "0.2" 120 131 wasm-bindgen = "=0.2.121" 121 132 wasm-bindgen-futures = "0.4" 122 - web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Event", "EventTarget", "HtmlCanvasElement", "WebGl2RenderingContext", "Performance", "MouseEvent", "WheelEvent", "PointerEvent", "DomRect", "BroadcastChannel", "console", "Response"] } 133 + 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"] } 123 134 124 135 [dev-dependencies] 125 136 anyhow = "1" ··· 130 141 futures = "0.3" 131 142 hydrant = { git = "https://tangled.org/ptr.pet/hydrant", rev = "1145fa1bb194372ceea81ab48b5d68d304da34ad", default-features = false, features = ["indexer", "indexer_stream"] } 132 143 http-body-util = "0.1" 144 + imagesize = "0.13" 133 145 jacquard-axum = { workspace = true } 134 146 keyring = "3" 147 + ulid = "1" 135 148 libsqlite3-sys = { version = "0.37", features = ["bundled"] } 136 149 rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs", "std"] } 137 150 sha2 = "0.10" ··· 149 162 "fmt", 150 163 "env-filter", 151 164 ] } 152 - # Mirror the optional server-only deps so the `#[cfg(any(feature = "server", 153 - # test))]` server modules (indexing/appview/oauth) compile and their tests run 154 - # under a plain `cargo test`, not only `--features server`. 155 - axum-extra = { version = "0.10", features = ["cookie"] } 156 - base64 = "0.22" 157 - keyring = "3" 158 165 159 166 [build-dependencies] 160 167 dotenvy = "0.15"
+6 -16
assets/styling/placeholders.css
··· 1 - /* Honest placeholder routes: /search and /publish. Reserved surfaces with 2 - clearly-labeled "not built yet" content, inside the shell content region. */ 1 + /* Honest placeholder route: /search. Reserved surface with clearly-labeled 2 + "not built yet" content, inside the shell content region. The /publish route 3 + is a real wizard now and owns publish.css. */ 3 4 4 - .search-page, 5 - .publish-page { 5 + .search-page { 6 6 flex: 1; 7 7 min-height: 0; 8 8 width: 100%; ··· 14 14 gap: 0.9rem; 15 15 } 16 16 17 - .search-intro, 18 - .publish-intro, 19 - .publish-signed-out, 20 - .publish-steps { 17 + .search-intro { 21 18 display: flex; 22 19 flex-direction: column; 23 20 gap: 0.75rem; 24 21 padding: clamp(1.25rem, 3vw, 2rem); 25 22 } 26 23 27 - .search-intro h1, 28 - .publish-intro h1 { 24 + .search-intro h1 { 29 25 margin: 0; 30 26 color: var(--color-emphasis); 31 27 letter-spacing: -0.03em; 32 - } 33 - 34 - .publish-signed-out h2, 35 - .publish-steps h2 { 36 - margin: 0; 37 - color: var(--color-emphasis); 38 28 } 39 29 40 30 .search-note {
+449
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. */ 4 + 5 + .publish-page { 6 + flex: 1; 7 + min-height: 0; 8 + width: 100%; 9 + max-width: 56rem; 10 + margin: 0 auto; 11 + padding: clamp(0.75rem, 2vw, 1.75rem) clamp(1rem, 3vw, 2.5rem); 12 + display: flex; 13 + flex-direction: column; 14 + gap: 0.9rem; 15 + } 16 + 17 + /* Signed-out callout reuses the panel + session-control vocabulary. */ 18 + .publish-intro, 19 + .publish-signed-out { 20 + display: flex; 21 + flex-direction: column; 22 + gap: 0.75rem; 23 + padding: clamp(1.25rem, 3vw, 2rem); 24 + } 25 + 26 + .publish-intro h1, 27 + .publish-signed-out h2 { 28 + margin: 0; 29 + color: var(--color-emphasis); 30 + letter-spacing: -0.03em; 31 + } 32 + 33 + /* Header: title + draft/published status + autosave state. */ 34 + .publish-head { 35 + display: flex; 36 + flex-direction: column; 37 + gap: 0.35rem; 38 + } 39 + 40 + .publish-head-row { 41 + display: flex; 42 + align-items: center; 43 + flex-wrap: wrap; 44 + gap: 0.6rem; 45 + } 46 + 47 + .publish-head-row h1 { 48 + margin: 0; 49 + color: var(--color-emphasis); 50 + letter-spacing: -0.03em; 51 + } 52 + 53 + .publish-save-state { 54 + margin: 0; 55 + color: var(--color-muted); 56 + font-family: var(--font-mono); 57 + font-size: 0.74rem; 58 + } 59 + 60 + /* Stepper: each step is a .tab; complete steps pick up the success hue. */ 61 + .wizard-steps { 62 + display: flex; 63 + flex-wrap: wrap; 64 + gap: 0.5rem; 65 + } 66 + 67 + .wizard-step { 68 + gap: 0.4rem; 69 + padding: 0.4rem 0.6rem; 70 + } 71 + 72 + .wizard-step-num { 73 + font-family: var(--font-mono); 74 + font-size: 0.72rem; 75 + opacity: 0.8; 76 + } 77 + 78 + .wizard-step-label { 79 + font-family: var(--font-ui); 80 + font-weight: 600; 81 + } 82 + 83 + .wizard-step.is-complete { 84 + border-color: var(--color-success); 85 + color: var(--color-success); 86 + background: color-mix(in srgb, var(--color-success) 8%, transparent); 87 + } 88 + 89 + .wizard-step-check { 90 + color: var(--color-success); 91 + } 92 + 93 + .wizard-step.is-active .wizard-step-check { 94 + color: var(--color-tertiary); 95 + } 96 + 97 + /* Body panel + step copy. */ 98 + .wizard-body { 99 + display: flex; 100 + flex-direction: column; 101 + gap: 1rem; 102 + padding: clamp(1rem, 2.5vw, 1.75rem); 103 + } 104 + 105 + .wizard-body h2 { 106 + margin: 0; 107 + color: var(--color-emphasis); 108 + letter-spacing: -0.02em; 109 + } 110 + 111 + .wizard-step-hint { 112 + margin: 0; 113 + color: var(--color-subtle); 114 + max-width: 60ch; 115 + } 116 + 117 + .wizard-subhead { 118 + margin: 0; 119 + color: var(--color-emphasis); 120 + font-size: 1rem; 121 + } 122 + 123 + /* Form fields — mirror the shell input vocabulary, sharp corners. */ 124 + .field { 125 + display: flex; 126 + flex-direction: column; 127 + gap: 0.35rem; 128 + } 129 + 130 + .field-input { 131 + min-height: 2.1rem; 132 + padding: 0.5rem 0.65rem; 133 + border: 1px solid var(--color-border); 134 + border-radius: var(--radius-panel); 135 + background: var(--color-surface); 136 + color: var(--color-text); 137 + font-family: var(--font-ui); 138 + font-size: 0.95rem; 139 + } 140 + 141 + .field-input::placeholder { 142 + color: var(--color-subtle); 143 + } 144 + 145 + .field-input:focus, 146 + .field-input:focus-visible { 147 + outline: 2px solid color-mix(in srgb, var(--color-primary) 25%, transparent); 148 + outline-offset: 1px; 149 + border-color: var(--color-primary); 150 + } 151 + 152 + .field-textarea { 153 + min-height: 4.5rem; 154 + resize: vertical; 155 + line-height: 1.5; 156 + } 157 + 158 + .field-textarea-tall { 159 + min-height: 8rem; 160 + } 161 + 162 + .field-help { 163 + color: var(--color-muted); 164 + } 165 + 166 + /* Models & parts. */ 167 + .wizard-model { 168 + display: flex; 169 + flex-direction: column; 170 + gap: 0.75rem; 171 + padding: 1rem; 172 + border: 1px solid var(--color-border); 173 + border-radius: var(--radius-panel); 174 + background: color-mix(in srgb, var(--color-overlay) 40%, transparent); 175 + } 176 + 177 + .wizard-model-head { 178 + display: flex; 179 + align-items: flex-end; 180 + justify-content: space-between; 181 + gap: 0.75rem; 182 + flex-wrap: wrap; 183 + } 184 + 185 + .wizard-model-name { 186 + flex: 1; 187 + min-width: 12rem; 188 + } 189 + 190 + .wizard-part-list { 191 + list-style: none; 192 + margin: 0; 193 + padding: 0; 194 + display: flex; 195 + flex-direction: column; 196 + gap: 0.5rem; 197 + } 198 + 199 + .wizard-part { 200 + display: flex; 201 + align-items: center; 202 + gap: 0.6rem; 203 + flex-wrap: wrap; 204 + } 205 + 206 + .wizard-part-name { 207 + flex: 1; 208 + min-width: 10rem; 209 + } 210 + 211 + .wizard-part-meta { 212 + white-space: nowrap; 213 + } 214 + 215 + .wizard-empty { 216 + margin: 0; 217 + color: var(--color-muted); 218 + font-style: italic; 219 + } 220 + 221 + /* File input wrapped by a styled label button; hide the raw control. */ 222 + .wizard-file-button { 223 + cursor: pointer; 224 + } 225 + 226 + .wizard-file-input { 227 + position: absolute; 228 + width: 1px; 229 + height: 1px; 230 + padding: 0; 231 + margin: -1px; 232 + overflow: hidden; 233 + clip: rect(0, 0, 0, 0); 234 + border: 0; 235 + } 236 + 237 + /* Upload progress + status. */ 238 + .wizard-upload-status { 239 + display: flex; 240 + align-items: center; 241 + gap: 0.6rem; 242 + color: var(--color-subtle); 243 + font-size: 0.9rem; 244 + } 245 + 246 + .wizard-progress-bar { 247 + position: relative; 248 + flex: none; 249 + width: 8rem; 250 + height: 0.4rem; 251 + border-radius: var(--radius-pill); 252 + overflow: hidden; 253 + background: var(--color-overlay); 254 + } 255 + 256 + .wizard-progress-bar::after { 257 + content: ""; 258 + position: absolute; 259 + inset: 0; 260 + width: 40%; 261 + border-radius: inherit; 262 + background: var(--color-primary); 263 + animation: wizard-progress 1.1s ease-in-out infinite; 264 + } 265 + 266 + @keyframes wizard-progress { 267 + from { transform: translateX(-110%); } 268 + to { transform: translateX(310%); } 269 + } 270 + 271 + /* Images. */ 272 + .wizard-image-group { 273 + display: flex; 274 + flex-direction: column; 275 + gap: 0.6rem; 276 + } 277 + 278 + .wizard-image-list { 279 + list-style: none; 280 + margin: 0; 281 + padding: 0; 282 + display: flex; 283 + flex-direction: column; 284 + gap: 0.6rem; 285 + } 286 + 287 + .wizard-image { 288 + display: flex; 289 + align-items: center; 290 + gap: 0.75rem; 291 + flex-wrap: wrap; 292 + } 293 + 294 + .wizard-image-thumb { 295 + flex: none; 296 + width: 5rem; 297 + aspect-ratio: 4 / 3; 298 + border: 1px solid var(--color-border); 299 + border-radius: var(--radius-panel); 300 + } 301 + 302 + .wizard-image-alt { 303 + flex: 1; 304 + min-width: 12rem; 305 + } 306 + 307 + /* Details step recap. */ 308 + .wizard-recap { 309 + margin: 0; 310 + color: var(--color-subtle); 311 + } 312 + 313 + /* Review. */ 314 + .wizard-review { 315 + display: flex; 316 + flex-direction: column; 317 + gap: 0.6rem; 318 + } 319 + 320 + .wizard-review-row { 321 + display: grid; 322 + grid-template-columns: 9rem 1fr; 323 + gap: 0.75rem; 324 + align-items: start; 325 + padding-bottom: 0.6rem; 326 + border-bottom: 1px solid var(--color-border); 327 + } 328 + 329 + .wizard-review-row.is-error { 330 + color: var(--color-error); 331 + } 332 + 333 + .wizard-review-row.is-error .field-label { 334 + color: var(--color-error); 335 + } 336 + 337 + .wizard-review-models { 338 + display: flex; 339 + flex-direction: column; 340 + gap: 0.4rem; 341 + } 342 + 343 + .wizard-review-model { 344 + display: flex; 345 + align-items: baseline; 346 + gap: 0.6rem; 347 + } 348 + 349 + /* Validation summary. */ 350 + .wizard-issues { 351 + gap: 0.5rem; 352 + } 353 + 354 + /* Publish action row — the single primary action, clearly distinct. */ 355 + .wizard-publish-row { 356 + display: flex; 357 + align-items: center; 358 + justify-content: space-between; 359 + gap: 0.75rem; 360 + flex-wrap: wrap; 361 + margin-top: 0.5rem; 362 + } 363 + 364 + .wizard-publish { 365 + font-size: 0.95rem; 366 + } 367 + 368 + /* Resume list. */ 369 + .wizard-resume { 370 + display: flex; 371 + flex-direction: column; 372 + gap: 0.6rem; 373 + padding: 1rem clamp(1rem, 2.5vw, 1.5rem); 374 + } 375 + 376 + .wizard-resume-list { 377 + list-style: none; 378 + margin: 0; 379 + padding: 0; 380 + display: flex; 381 + flex-direction: column; 382 + gap: 0.5rem; 383 + } 384 + 385 + .wizard-resume-item { 386 + display: flex; 387 + align-items: center; 388 + justify-content: space-between; 389 + gap: 0.75rem; 390 + flex-wrap: wrap; 391 + padding: 0.6rem 0.75rem; 392 + border: 1px solid var(--color-border); 393 + border-radius: var(--radius-panel); 394 + } 395 + 396 + .wizard-resume-meta { 397 + display: flex; 398 + flex-direction: column; 399 + gap: 0.15rem; 400 + } 401 + 402 + .wizard-resume-name { 403 + font-weight: 600; 404 + color: var(--color-text); 405 + } 406 + 407 + /* Navigation footer. */ 408 + .wizard-nav { 409 + display: flex; 410 + align-items: center; 411 + gap: 0.6rem; 412 + flex-wrap: wrap; 413 + } 414 + 415 + .wizard-nav-spacer { 416 + flex: 1; 417 + } 418 + 419 + /* Errors. */ 420 + .wizard-error { 421 + margin: 0; 422 + color: var(--color-error); 423 + font-size: 0.9rem; 424 + } 425 + 426 + /* Published success. */ 427 + .wizard-published { 428 + gap: 0.75rem; 429 + } 430 + 431 + .wizard-published h2 { 432 + margin: 0; 433 + color: var(--color-emphasis); 434 + } 435 + 436 + @media (max-width: 32rem) { 437 + .wizard-review-row { 438 + grid-template-columns: 1fr; 439 + gap: 0.25rem; 440 + } 441 + } 442 + 443 + @media (prefers-reduced-motion: reduce) { 444 + .wizard-progress-bar::after { 445 + animation: none; 446 + width: 100%; 447 + opacity: 0.6; 448 + } 449 + }
-6
e2e/tests/home.spec.ts
··· 70 70 await page.goto('/search'); 71 71 await expect(page.getByRole('heading', { name: 'Search', exact: true })).toBeVisible(); 72 72 await expect(page.getByText('Not wired up yet')).toBeVisible(); 73 - 74 - await page.goto('/publish'); 75 - await expect(page.getByRole('heading', { name: 'Publish a thing' })).toBeVisible(); 76 - await expect(page.getByText('Coming soon')).toBeVisible(); 77 - // Anonymous publish prompts sign-in (returns the user to /publish). 78 - await expect(page.getByRole('heading', { name: 'Sign in to publish' })).toBeVisible(); 79 73 }); 80 74 81 75 test('home thing cards remain readable at narrow widths', async ({ page }) => {
+28
e2e/tests/publish.spec.ts
··· 1 + import { expect, test } from '@playwright/test'; 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`. 9 + 10 + test('signed-out /publish shows the sign-in callout, not the wizard', async ({ page }) => { 11 + await page.goto('/publish'); 12 + 13 + await expect(page.getByRole('heading', { name: 'Publish a thing' })).toBeVisible(); 14 + await expect(page.getByRole('heading', { name: 'Sign in to publish' })).toBeVisible(); 15 + const callout = page.locator('section[aria-label="Sign in to publish"]'); 16 + await expect(callout.getByRole('button', { name: 'Sign in' })).toBeVisible(); 17 + 18 + // The signed-out surface must not expose the wizard stepper or publish action. 19 + await expect(page.getByRole('navigation', { name: 'Publish steps' })).toHaveCount(0); 20 + await expect(page.getByRole('button', { name: 'Publish to your PDS' })).toHaveCount(0); 21 + }); 22 + 23 + test('signed-out publish callout returns the user to /publish after sign-in', async ({ page }) => { 24 + await page.goto('/publish'); 25 + 26 + const returnTo = page.locator('section[aria-label="Sign in to publish"] input[name="return_to"]'); 27 + await expect(returnTo).toHaveValue('/publish'); 28 + });
+24
migrations/005_drafts.sql
··· 1 + -- PM-43: server-side, non-public publish drafts. 2 + -- 3 + -- A draft is an in-progress publish composition that never reaches the actor's 4 + -- PDS until publish. It is stored as a `DraftThingInput`-shaped JSON payload (a 5 + -- ThingInput mirror with every field optional, so partial drafts round-trip). 6 + -- Drafts are app-internal (not ATProto records, not federated) and are strictly 7 + -- owner-scoped by DID. They live in their own table, so feeds — which read only 8 + -- the `things` table — never surface them. 9 + -- 10 + -- `name` and `model_count` are denormalized copies refreshed on every upsert so 11 + -- `listDrafts` can render entries without parsing each payload. 12 + CREATE TABLE drafts ( 13 + owner_did TEXT NOT NULL, 14 + draft_id TEXT NOT NULL, 15 + payload_json TEXT NOT NULL, 16 + name TEXT, 17 + model_count INTEGER NOT NULL DEFAULT 0, 18 + created_at INTEGER NOT NULL, 19 + updated_at INTEGER NOT NULL, 20 + PRIMARY KEY (owner_did, draft_id) 21 + ); 22 + 23 + CREATE INDEX idx_drafts_owner_updated 24 + ON drafts(owner_did, updated_at DESC);
+475
src/appview/drafts.rs
··· 1 + //! PM-43 server-side publish drafts + app-internal image upload. 2 + //! 3 + //! Drafts are app-internal (not ATProto records, not federated): owner-scoped 4 + //! rows in the `drafts` table holding a [`DraftThingInput`] JSON payload. They 5 + //! never reach the actor's PDS until publish, and they live in their own table 6 + //! so feeds (which read only `things`) never surface them. 7 + //! 8 + //! Every handler authenticates with the strict OAuth session (same extractor as 9 + //! the PM-28 writes) and scopes every query by the authenticated DID, so no 10 + //! actor can read, write, publish, or delete another actor's drafts. 11 + //! 12 + //! Routes (same-origin, cookie-authenticated; app-internal, not `/xrpc`): 13 + //! - `POST /app/drafts` create (server assigns an opaque id) 14 + //! - `PUT /app/drafts/{draft_id}` upsert an existing draft 15 + //! - `GET /app/drafts` list draft summaries 16 + //! - `GET /app/drafts/{draft_id}` fetch one draft payload 17 + //! - `DELETE /app/drafts/{draft_id}` delete a draft 18 + //! - `POST /app/drafts/{draft_id}/publish` assemble + publish + delete-on-success 19 + //! - `POST /app/images` upload an image blob, return an `#image` 20 + 21 + use axum::Json; 22 + use axum::body::Body; 23 + use axum::extract::{Path, State}; 24 + use axum::http::HeaderMap; 25 + use jacquard::client::Agent; 26 + use jacquard_axum::oauth::ExtractOAuthSession; 27 + use jacquard_common::types::blob::BlobRef; 28 + use jacquard_common::types::string::Did; 29 + use polymodel_api::space_polymodel::library::publish_thing::{PublishThingOutput, ThingInput}; 30 + use polymodel_api::space_polymodel::library::{AspectRatio, Image}; 31 + 32 + use super::error::{AppResult, db, internal, invalid_request, not_found}; 33 + use super::state::AppState; 34 + use super::writes::{ 35 + ExtractSession, agent_upload_blob, authenticated_did, content_type, ensure_polymodel_profile, 36 + publish_composition, 37 + }; 38 + use crate::publish::draft::{ 39 + DeleteDraftResponse, DraftIdResponse, DraftSummary, DraftThingInput, ImageUploadResponse, 40 + }; 41 + 42 + /// Image blobs are buffered fully (cover/preview art is small); cap to keep the 43 + /// app from buffering an unbounded body. 44 + const MAX_IMAGE_SIZE: usize = 20 * 1024 * 1024; 45 + 46 + // ---------------------------------------------------------------------------- 47 + // Owner-scoped DB helpers (pure; unit-tested directly) 48 + // ---------------------------------------------------------------------------- 49 + 50 + fn now_nanos() -> i64 { 51 + chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() 52 + } 53 + 54 + /// Insert or update a draft row, refreshing the denormalized `name`/`model_count` 55 + /// and `updated_at`. `created_at` is preserved across updates. 56 + pub(super) async fn upsert_draft( 57 + state: &AppState, 58 + owner: &Did, 59 + draft_id: &str, 60 + draft: &DraftThingInput, 61 + ) -> AppResult<()> { 62 + let payload_json = 63 + serde_json::to_string(draft).map_err(|e| internal(format!("draft serialize: {e}")))?; 64 + let name = draft.display_name().map(ToOwned::to_owned); 65 + let model_count = draft.model_count(); 66 + let now = now_nanos(); 67 + let owner_did = owner.as_ref(); 68 + db(sqlx::query!( 69 + "INSERT INTO drafts (owner_did, draft_id, payload_json, name, model_count, created_at, updated_at) \ 70 + VALUES (?, ?, ?, ?, ?, ?, ?) \ 71 + ON CONFLICT(owner_did, draft_id) DO UPDATE SET \ 72 + payload_json = excluded.payload_json, \ 73 + name = excluded.name, \ 74 + model_count = excluded.model_count, \ 75 + updated_at = excluded.updated_at", 76 + owner_did, 77 + draft_id, 78 + payload_json, 79 + name, 80 + model_count, 81 + now, 82 + now, 83 + ) 84 + .execute(&state.pool) 85 + .await)?; 86 + Ok(()) 87 + } 88 + 89 + /// Load a single owner-scoped draft payload, if present. 90 + pub(super) async fn get_draft( 91 + state: &AppState, 92 + owner: &Did, 93 + draft_id: &str, 94 + ) -> AppResult<Option<DraftThingInput>> { 95 + let owner_did = owner.as_ref(); 96 + let row = db(sqlx::query!( 97 + "SELECT payload_json FROM drafts WHERE owner_did = ? AND draft_id = ?", 98 + owner_did, 99 + draft_id, 100 + ) 101 + .fetch_optional(&state.pool) 102 + .await)?; 103 + match row { 104 + Some(row) => { 105 + let draft: DraftThingInput = serde_json::from_str(&row.payload_json) 106 + .map_err(|e| internal(format!("draft deserialize: {e}")))?; 107 + Ok(Some(draft)) 108 + } 109 + None => Ok(None), 110 + } 111 + } 112 + 113 + /// List owner-scoped draft summaries, most-recently-updated first. 114 + pub(super) async fn list_drafts(state: &AppState, owner: &Did) -> AppResult<Vec<DraftSummary>> { 115 + let owner_did = owner.as_ref(); 116 + let rows = db(sqlx::query!( 117 + "SELECT draft_id, name, model_count, created_at, updated_at \ 118 + FROM drafts WHERE owner_did = ? ORDER BY updated_at DESC", 119 + owner_did, 120 + ) 121 + .fetch_all(&state.pool) 122 + .await)?; 123 + Ok(rows 124 + .into_iter() 125 + .map(|row| DraftSummary { 126 + draft_id: row.draft_id, 127 + name: row.name, 128 + model_count: row.model_count, 129 + created_at: row.created_at, 130 + updated_at: row.updated_at, 131 + }) 132 + .collect()) 133 + } 134 + 135 + /// Delete an owner-scoped draft; returns whether a row existed. 136 + pub(super) async fn delete_draft(state: &AppState, owner: &Did, draft_id: &str) -> AppResult<bool> { 137 + let owner_did = owner.as_ref(); 138 + let result = db(sqlx::query!( 139 + "DELETE FROM drafts WHERE owner_did = ? AND draft_id = ?", 140 + owner_did, 141 + draft_id, 142 + ) 143 + .execute(&state.pool) 144 + .await)?; 145 + Ok(result.rows_affected() > 0) 146 + } 147 + 148 + /// Promote a draft to a published thing: load → assemble → publish → delete. 149 + /// 150 + /// The publish step is injected so the ordering invariant (the draft row is 151 + /// deleted **only after** a successful publish) is unit-testable without a live 152 + /// PDS agent. If `publish` returns `Err`, the draft is left untouched. 153 + pub(super) async fn promote_draft<F, Fut>( 154 + state: &AppState, 155 + owner: &Did, 156 + draft_id: &str, 157 + publish: F, 158 + ) -> AppResult<PublishThingOutput> 159 + where 160 + F: FnOnce(ThingInput) -> Fut, 161 + Fut: std::future::Future<Output = AppResult<PublishThingOutput>>, 162 + { 163 + let draft = get_draft(state, owner, draft_id) 164 + .await? 165 + .ok_or_else(not_found)?; 166 + let thing = draft 167 + .assemble() 168 + .map_err(|errors| invalid_request(errors.to_string()))?; 169 + let output = publish(thing).await?; 170 + // Reached only on a successful publish; a failure short-circuits above and 171 + // leaves the draft recoverable. 172 + delete_draft(state, owner, draft_id).await?; 173 + Ok(output) 174 + } 175 + 176 + // ---------------------------------------------------------------------------- 177 + // Handlers 178 + // ---------------------------------------------------------------------------- 179 + 180 + pub(super) async fn create_draft( 181 + State(state): State<AppState>, 182 + ExtractOAuthSession(session): ExtractSession, 183 + Json(draft): Json<DraftThingInput>, 184 + ) -> AppResult<Json<DraftIdResponse>> { 185 + let agent = Agent::from(session); 186 + let actor = authenticated_did(&agent).await?; 187 + let draft_id = ulid::Ulid::new().to_string(); 188 + upsert_draft(&state, &actor, &draft_id, &draft).await?; 189 + Ok(Json(DraftIdResponse { draft_id })) 190 + } 191 + 192 + pub(super) async fn put_draft( 193 + State(state): State<AppState>, 194 + ExtractOAuthSession(session): ExtractSession, 195 + Path(draft_id): Path<String>, 196 + Json(draft): Json<DraftThingInput>, 197 + ) -> AppResult<Json<DraftIdResponse>> { 198 + let agent = Agent::from(session); 199 + let actor = authenticated_did(&agent).await?; 200 + upsert_draft(&state, &actor, &draft_id, &draft).await?; 201 + Ok(Json(DraftIdResponse { draft_id })) 202 + } 203 + 204 + pub(super) async fn get_draft_handler( 205 + State(state): State<AppState>, 206 + ExtractOAuthSession(session): ExtractSession, 207 + Path(draft_id): Path<String>, 208 + ) -> AppResult<Json<DraftThingInput>> { 209 + let agent = Agent::from(session); 210 + let actor = authenticated_did(&agent).await?; 211 + let draft = get_draft(&state, &actor, &draft_id) 212 + .await? 213 + .ok_or_else(not_found)?; 214 + Ok(Json(draft)) 215 + } 216 + 217 + pub(super) async fn list_drafts_handler( 218 + State(state): State<AppState>, 219 + ExtractOAuthSession(session): ExtractSession, 220 + ) -> AppResult<Json<Vec<DraftSummary>>> { 221 + let agent = Agent::from(session); 222 + let actor = authenticated_did(&agent).await?; 223 + let drafts = list_drafts(&state, &actor).await?; 224 + Ok(Json(drafts)) 225 + } 226 + 227 + pub(super) async fn delete_draft_handler( 228 + State(state): State<AppState>, 229 + ExtractOAuthSession(session): ExtractSession, 230 + Path(draft_id): Path<String>, 231 + ) -> AppResult<Json<DeleteDraftResponse>> { 232 + let agent = Agent::from(session); 233 + let actor = authenticated_did(&agent).await?; 234 + let deleted = delete_draft(&state, &actor, &draft_id).await?; 235 + Ok(Json(DeleteDraftResponse { deleted })) 236 + } 237 + 238 + pub(super) async fn publish_draft( 239 + State(state): State<AppState>, 240 + ExtractOAuthSession(session): ExtractSession, 241 + Path(draft_id): Path<String>, 242 + ) -> AppResult<Json<PublishThingOutput>> { 243 + let agent = Agent::from(session); 244 + let actor = authenticated_did(&agent).await?; 245 + let _ = ensure_polymodel_profile(&state, &agent, &actor, false).await?; 246 + let _write_guard = state.write_lock.lock().await; 247 + let output = promote_draft(&state, &actor, &draft_id, |thing| { 248 + publish_composition(&state, &agent, &actor, thing) 249 + }) 250 + .await?; 251 + Ok(Json(output)) 252 + } 253 + 254 + pub(super) async fn upload_image( 255 + State(state): State<AppState>, 256 + ExtractOAuthSession(session): ExtractSession, 257 + headers: HeaderMap, 258 + body: Body, 259 + ) -> AppResult<Json<ImageUploadResponse>> { 260 + let agent = Agent::from(session); 261 + let actor = authenticated_did(&agent).await?; 262 + let _ = ensure_polymodel_profile(&state, &agent, &actor, false).await?; 263 + 264 + let mime_type = content_type(&headers); 265 + let alt = headers 266 + .get("x-polymodel-alt") 267 + .and_then(|v| v.to_str().ok()) 268 + .unwrap_or("") 269 + .to_string(); 270 + 271 + let bytes = axum::body::to_bytes(body, MAX_IMAGE_SIZE) 272 + .await 273 + .map_err(|_| invalid_request("image body exceeds the size limit or could not be read"))?; 274 + if bytes.is_empty() { 275 + return Err(invalid_request( 276 + "image upload requires a non-empty raw byte body", 277 + )); 278 + } 279 + 280 + // `aspectRatio` is required by `#image` but is not returned by uploadBlob, so 281 + // decode width/height from the bytes (header-only probe, no full decode). 282 + let dims = imagesize::blob_size(&bytes) 283 + .map_err(|e| invalid_request(format!("unsupported or corrupt image: {e}")))?; 284 + 285 + let blob = agent_upload_blob(&agent, bytes.to_vec(), &mime_type).await?; 286 + let image = Image { 287 + alt: alt.into(), 288 + aspect_ratio: AspectRatio { 289 + height: dims.height as i64, 290 + width: dims.width as i64, 291 + extra_data: None, 292 + }, 293 + image: BlobRef::from(blob), 294 + extra_data: None, 295 + }; 296 + Ok(Json(ImageUploadResponse { image })) 297 + } 298 + 299 + #[cfg(test)] 300 + mod tests { 301 + use std::str::FromStr; 302 + 303 + use jacquard_common::types::string::Did; 304 + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; 305 + 306 + use super::*; 307 + use crate::publish::draft::{DraftModelInput, DraftPartInput}; 308 + 309 + const DID_A: &str = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"; 310 + const DID_B: &str = "did:plc:bbbbbbbbbbbbbbbbbbbbbbbb"; 311 + 312 + async fn state() -> AppState { 313 + let options = SqliteConnectOptions::from_str("sqlite::memory:").unwrap(); 314 + let pool = SqlitePoolOptions::new() 315 + .max_connections(1) 316 + .connect_with(options) 317 + .await 318 + .unwrap(); 319 + sqlx::migrate!("./migrations").run(&pool).await.unwrap(); 320 + let bootstrap = crate::oauth::bootstrap_oauth(pool.clone(), Some("http://localhost")) 321 + .expect("ephemeral OAuth bootstrap for tests"); 322 + AppState::new(pool, bootstrap) 323 + } 324 + 325 + fn did(s: &str) -> Did { 326 + Did::new_owned(s).unwrap() 327 + } 328 + 329 + fn draft_named(name: &str) -> DraftThingInput { 330 + DraftThingInput { 331 + name: Some(name.to_string()), 332 + license: Some("CC-BY-4.0".to_string()), 333 + models: vec![DraftModelInput { 334 + name: Some("Model".to_string()), 335 + parts: vec![DraftPartInput { 336 + name: Some("part.stl".to_string()), 337 + upload_id: Some("abcdef0123456789-42".to_string()), 338 + ..Default::default() 339 + }], 340 + ..Default::default() 341 + }], 342 + ..Default::default() 343 + } 344 + } 345 + 346 + #[tokio::test] 347 + async fn upsert_and_get_round_trips_owner_scoped() { 348 + let state = state().await; 349 + let owner = did(DID_A); 350 + let draft = draft_named("Widget"); 351 + upsert_draft(&state, &owner, "d1", &draft).await.unwrap(); 352 + 353 + let loaded = get_draft(&state, &owner, "d1").await.unwrap(); 354 + assert_eq!(loaded, Some(draft)); 355 + 356 + // A different owner cannot see it. 357 + let other = get_draft(&state, &did(DID_B), "d1").await.unwrap(); 358 + assert_eq!(other, None); 359 + } 360 + 361 + #[tokio::test] 362 + async fn upsert_updates_denormalized_summary_and_preserves_created_at() { 363 + let state = state().await; 364 + let owner = did(DID_A); 365 + upsert_draft(&state, &owner, "d1", &draft_named("First")) 366 + .await 367 + .unwrap(); 368 + let created_at: i64 = 369 + sqlx::query_scalar("SELECT created_at FROM drafts WHERE draft_id = 'd1'") 370 + .fetch_one(&state.pool) 371 + .await 372 + .unwrap(); 373 + 374 + upsert_draft(&state, &owner, "d1", &draft_named("Second")) 375 + .await 376 + .unwrap(); 377 + let summaries = list_drafts(&state, &owner).await.unwrap(); 378 + assert_eq!(summaries.len(), 1); 379 + assert_eq!(summaries[0].name.as_deref(), Some("Second")); 380 + assert_eq!(summaries[0].model_count, 1); 381 + 382 + let created_after: i64 = 383 + sqlx::query_scalar("SELECT created_at FROM drafts WHERE draft_id = 'd1'") 384 + .fetch_one(&state.pool) 385 + .await 386 + .unwrap(); 387 + assert_eq!( 388 + created_at, created_after, 389 + "created_at must be preserved on update" 390 + ); 391 + } 392 + 393 + #[tokio::test] 394 + async fn list_is_owner_scoped_and_ordered() { 395 + let state = state().await; 396 + let a = did(DID_A); 397 + upsert_draft(&state, &a, "d1", &draft_named("One")) 398 + .await 399 + .unwrap(); 400 + upsert_draft(&state, &a, "d2", &draft_named("Two")) 401 + .await 402 + .unwrap(); 403 + upsert_draft(&state, &did(DID_B), "d3", &draft_named("Other")) 404 + .await 405 + .unwrap(); 406 + 407 + let summaries = list_drafts(&state, &a).await.unwrap(); 408 + assert_eq!(summaries.len(), 2); 409 + // Most recently updated first. 410 + assert_eq!(summaries[0].draft_id, "d2"); 411 + assert_eq!(summaries[1].draft_id, "d1"); 412 + } 413 + 414 + #[tokio::test] 415 + async fn delete_is_owner_scoped() { 416 + let state = state().await; 417 + let a = did(DID_A); 418 + upsert_draft(&state, &a, "d1", &draft_named("One")) 419 + .await 420 + .unwrap(); 421 + 422 + // Another owner cannot delete it. 423 + assert!(!delete_draft(&state, &did(DID_B), "d1").await.unwrap()); 424 + assert!(get_draft(&state, &a, "d1").await.unwrap().is_some()); 425 + 426 + // The owner can. 427 + assert!(delete_draft(&state, &a, "d1").await.unwrap()); 428 + assert!(get_draft(&state, &a, "d1").await.unwrap().is_none()); 429 + // Deleting again reports no row. 430 + assert!(!delete_draft(&state, &a, "d1").await.unwrap()); 431 + } 432 + 433 + #[tokio::test] 434 + async fn promote_failure_keeps_draft() { 435 + let state = state().await; 436 + let owner = did(DID_A); 437 + upsert_draft(&state, &owner, "d1", &draft_named("Keepme")) 438 + .await 439 + .unwrap(); 440 + 441 + let result = promote_draft(&state, &owner, "d1", |_thing| async { 442 + Err(internal("simulated publish failure")) 443 + }) 444 + .await; 445 + assert!(result.is_err(), "publish failure must propagate"); 446 + 447 + // The invariant: a failed publish must not delete the draft. 448 + assert!( 449 + get_draft(&state, &owner, "d1").await.unwrap().is_some(), 450 + "draft must survive a failed publish" 451 + ); 452 + } 453 + 454 + #[tokio::test] 455 + async fn promote_invalid_draft_is_rejected_and_kept() { 456 + let state = state().await; 457 + let owner = did(DID_A); 458 + // Missing license/models → assemble fails before any publish attempt. 459 + let partial = DraftThingInput { 460 + name: Some("WIP".to_string()), 461 + ..Default::default() 462 + }; 463 + upsert_draft(&state, &owner, "d1", &partial).await.unwrap(); 464 + 465 + let mut published = false; 466 + let result = promote_draft(&state, &owner, "d1", |_thing| { 467 + published = true; 468 + async { unreachable!("publish must not run for an invalid draft") } 469 + }) 470 + .await; 471 + assert!(result.is_err()); 472 + assert!(!published, "an invalid draft must never reach publish"); 473 + assert!(get_draft(&state, &owner, "d1").await.unwrap().is_some()); 474 + } 475 + }
+19
src/appview/mod.rs
··· 20 20 pub mod views; 21 21 22 22 mod actor; 23 + mod drafts; 23 24 mod graph; 24 25 mod library; 25 26 mod proxy; ··· 115 116 GetSessionRequest::PATH, 116 117 axum::routing::get(actor::get_session), 117 118 ) 119 + // PM-43 app-internal draft store + image upload. Not federated lexicons, 120 + // so these use a plain `/app/*` namespace (cookie-authenticated, 121 + // same-origin) rather than `/xrpc/space.polymodel.*`. 122 + .route( 123 + "/app/drafts", 124 + axum::routing::post(drafts::create_draft).get(drafts::list_drafts_handler), 125 + ) 126 + .route( 127 + "/app/drafts/{draft_id}", 128 + axum::routing::put(drafts::put_draft) 129 + .get(drafts::get_draft_handler) 130 + .delete(drafts::delete_draft_handler), 131 + ) 132 + .route( 133 + "/app/drafts/{draft_id}/publish", 134 + axum::routing::post(drafts::publish_draft), 135 + ) 136 + .route("/app/images", axum::routing::post(drafts::upload_image)) 118 137 // Per-request timing: logs total server-side handling time for every 119 138 // appview request, *including* the OAuth session-restore extractor that 120 139 // runs before each handler body. Use this to localize latency (server
+11 -7
src/appview/writes.rs
··· 71 71 const CHUNK_SIZE: usize = 10 * 1024 * 1024; 72 72 const MAX_STAGE_FILE_SIZE: usize = 100 * 1024 * 1024; 73 73 74 - type UserAgent = Agent<OAuthSession<PublicResolver, SqliteAuthStore>>; 74 + pub(super) type UserAgent = Agent<OAuthSession<PublicResolver, SqliteAuthStore>>; 75 + 76 + /// Strict OAuth session extractor specialized for this app's resolver + store. 77 + /// Shared with the PM-43 draft handlers so they authenticate identically. 78 + pub(super) type ExtractSession = ExtractOAuthSession<PublicResolver, SqliteAuthStore>; 75 79 76 80 pub(super) async fn bootstrap_profile( 77 81 State(state): State<AppState>, ··· 385 389 })) 386 390 } 387 391 388 - struct BootstrapOutcome { 392 + pub(super) struct BootstrapOutcome { 389 393 profile: ProfileView, 390 394 status: BootstrapProfileOutputStatus, 391 395 uri: Option<AtUri>, 392 396 cid: Option<Cid>, 393 397 } 394 398 395 - async fn authenticated_did(agent: &UserAgent) -> AppResult<Did> { 399 + pub(super) async fn authenticated_did(agent: &UserAgent) -> AppResult<Did> { 396 400 agent 397 401 .session_info() 398 402 .await ··· 400 404 .ok_or_else(|| unauthorized("OAuth session is required")) 401 405 } 402 406 403 - async fn ensure_polymodel_profile( 407 + pub(super) async fn ensure_polymodel_profile( 404 408 state: &AppState, 405 409 agent: &UserAgent, 406 410 actor: &Did, ··· 540 544 Ok(output) 541 545 } 542 546 543 - async fn publish_composition( 547 + pub(super) async fn publish_composition( 544 548 state: &AppState, 545 549 agent: &UserAgent, 546 550 actor: &Did, ··· 1361 1365 Ok(()) 1362 1366 } 1363 1367 1364 - async fn agent_upload_blob( 1368 + pub(super) async fn agent_upload_blob( 1365 1369 agent: &UserAgent, 1366 1370 bytes: Vec<u8>, 1367 1371 mime_type: &str, ··· 1489 1493 Ok(existing) 1490 1494 } 1491 1495 1492 - fn content_type(headers: &HeaderMap) -> String { 1496 + pub(super) fn content_type(headers: &HeaderMap) -> String { 1493 1497 headers 1494 1498 .get(header::CONTENT_TYPE) 1495 1499 .and_then(|v| v.to_str().ok())
+199
src/client.rs
··· 211 211 } 212 212 } 213 213 214 + // ---------------------------------------------------------------------------- 215 + // PM-43: raw same-origin app requests (drafts CRUD + raw-byte uploads). 216 + // 217 + // The typed `XrpcClient::send` path covers generated lexicon request/response 218 + // types. The publish wizard additionally needs (a) the app-internal `/app/*` 219 + // draft endpoints, which carry plain JSON bodies, and (b) raw-byte uploads 220 + // (`stageFile`, image upload), whose request body is the file bytes rather than 221 + // an XRPC query. Both ride the same same-origin transport and browser cookie 222 + // session as the read path, so authentication is automatic. 223 + // ---------------------------------------------------------------------------- 224 + 225 + /// Error from a raw same-origin app request. 226 + #[derive(Debug, thiserror::Error)] 227 + pub enum RawError { 228 + #[error("could not build request: {0}")] 229 + Build(String), 230 + #[error("transport error: {0}")] 231 + Transport(String), 232 + #[error("server returned status {status}: {message}")] 233 + Status { status: u16, message: String }, 234 + #[error("could not decode response: {0}")] 235 + Decode(String), 236 + } 237 + 238 + /// Build a same-origin request against `base` for `path`, attaching an optional 239 + /// content type, extra headers, and a raw body. Pure (no I/O) so the request 240 + /// shape is unit-testable. 241 + fn build_raw_request( 242 + base: &str, 243 + method: http::Method, 244 + path: &str, 245 + content_type: Option<&str>, 246 + extra_headers: &[(&str, String)], 247 + body: Vec<u8>, 248 + ) -> Result<http::Request<Vec<u8>>, RawError> { 249 + let url = format!("{}{}", base.trim_end_matches('/'), path); 250 + let mut builder = http::Request::builder().method(method).uri(url); 251 + if let Some(ct) = content_type { 252 + builder = builder.header(http::header::CONTENT_TYPE, ct); 253 + } 254 + for (name, value) in extra_headers { 255 + builder = builder.header(*name, value); 256 + } 257 + builder 258 + .body(body) 259 + .map_err(|e| RawError::Build(e.to_string())) 260 + } 261 + 262 + /// Extract a human-readable message from an XRPC-style error body 263 + /// (`{"error":..,"message":..}`), falling back to the raw text. 264 + fn error_message(body: &[u8]) -> String { 265 + if let Ok(value) = serde_json::from_slice::<serde_json::Value>(body) { 266 + if let Some(message) = value.get("message").and_then(|m| m.as_str()) { 267 + return message.to_string(); 268 + } 269 + if let Some(error) = value.get("error").and_then(|e| e.as_str()) { 270 + return error.to_string(); 271 + } 272 + } 273 + String::from_utf8_lossy(body).trim().to_string() 274 + } 275 + 276 + impl PolymodelClient { 277 + async fn send_raw( 278 + &self, 279 + method: http::Method, 280 + path: &str, 281 + content_type: Option<&str>, 282 + extra_headers: &[(&str, String)], 283 + body: Vec<u8>, 284 + ) -> Result<Vec<u8>, RawError> { 285 + let base = self.current_base_uri(); 286 + let request = build_raw_request( 287 + base.as_str(), 288 + method, 289 + path, 290 + content_type, 291 + extra_headers, 292 + body, 293 + )?; 294 + let response = self 295 + .send_http(request) 296 + .await 297 + .map_err(|e| RawError::Transport(e.to_string()))?; 298 + let status = response.status(); 299 + let body = response.into_body(); 300 + if status.is_success() { 301 + Ok(body) 302 + } else { 303 + Err(RawError::Status { 304 + status: status.as_u16(), 305 + message: error_message(&body), 306 + }) 307 + } 308 + } 309 + 310 + fn decode<R: serde::de::DeserializeOwned>(body: &[u8]) -> Result<R, RawError> { 311 + serde_json::from_slice(body).map_err(|e| RawError::Decode(e.to_string())) 312 + } 313 + 314 + /// Send a JSON-bodied (or bodyless) app request and decode the JSON response. 315 + pub async fn app_json<B, R>( 316 + &self, 317 + method: http::Method, 318 + path: &str, 319 + body: Option<&B>, 320 + ) -> Result<R, RawError> 321 + where 322 + B: serde::Serialize, 323 + R: serde::de::DeserializeOwned, 324 + { 325 + let (content_type, bytes) = match body { 326 + Some(value) => ( 327 + Some("application/json"), 328 + serde_json::to_vec(value).map_err(|e| RawError::Build(e.to_string()))?, 329 + ), 330 + None => (None, Vec::new()), 331 + }; 332 + let response = self 333 + .send_raw(method, path, content_type, &[], bytes) 334 + .await?; 335 + Self::decode(&response) 336 + } 337 + 338 + /// POST raw bytes (a file/image upload) and decode the JSON response. 339 + pub async fn app_post_bytes<R>( 340 + &self, 341 + path: &str, 342 + content_type: &str, 343 + extra_headers: &[(&str, String)], 344 + body: Vec<u8>, 345 + ) -> Result<R, RawError> 346 + where 347 + R: serde::de::DeserializeOwned, 348 + { 349 + let response = self 350 + .send_raw( 351 + http::Method::POST, 352 + path, 353 + Some(content_type), 354 + extra_headers, 355 + body, 356 + ) 357 + .await?; 358 + Self::decode(&response) 359 + } 360 + } 361 + 214 362 #[derive(Debug, thiserror::Error, PartialEq, Eq)] 215 363 pub enum ClientError { 216 364 #[error("invalid Polymodel appview base URI: {0}")] ··· 301 449 error.to_string(), 302 450 "invalid Polymodel appview base URI: not a uri" 303 451 ); 452 + } 453 + 454 + #[test] 455 + fn raw_request_builds_same_origin_upload() { 456 + let request = build_raw_request( 457 + "https://example.test/", 458 + http::Method::POST, 459 + "/xrpc/space.polymodel.library.stageFile", 460 + Some("model/stl"), 461 + &[("x-polymodel-filename", "tray.stl".to_string())], 462 + vec![1, 2, 3], 463 + ) 464 + .unwrap(); 465 + 466 + assert_eq!(request.method(), http::Method::POST); 467 + assert_eq!( 468 + request.uri().to_string(), 469 + "https://example.test/xrpc/space.polymodel.library.stageFile" 470 + ); 471 + assert_eq!( 472 + request.headers().get(http::header::CONTENT_TYPE).unwrap(), 473 + "model/stl" 474 + ); 475 + assert_eq!( 476 + request.headers().get("x-polymodel-filename").unwrap(), 477 + "tray.stl" 478 + ); 479 + assert_eq!(request.body(), &vec![1, 2, 3]); 480 + } 481 + 482 + #[test] 483 + fn raw_request_targets_app_draft_path_without_double_slash() { 484 + let request = build_raw_request( 485 + "https://example.test", 486 + http::Method::PUT, 487 + "/app/drafts/abc123", 488 + Some("application/json"), 489 + &[], 490 + b"{}".to_vec(), 491 + ) 492 + .unwrap(); 493 + assert_eq!( 494 + request.uri().to_string(), 495 + "https://example.test/app/drafts/abc123" 496 + ); 497 + } 498 + 499 + #[test] 500 + fn error_message_prefers_xrpc_message_field() { 501 + let body = br#"{"error":"InvalidRequest","message":"A license is required."}"#; 502 + assert_eq!(super::error_message(body), "A license is required."); 304 503 } 305 504 }
+4 -2
src/main.rs
··· 50 50 const THING_DETAIL_CSS: Asset = asset!("/assets/styling/thing-detail.css"); 51 51 const PROFILE_CSS: Asset = asset!("/assets/styling/profile.css"); 52 52 const VIEWER_CSS: Asset = asset!("/assets/styling/viewer.css"); 53 + const PUBLISH_CSS: Asset = asset!("/assets/styling/publish.css"); 53 54 const PLACEHOLDERS_CSS: Asset = asset!("/assets/styling/placeholders.css"); 54 55 55 56 #[cfg(feature = "server")] ··· 121 122 Foundation {}, 122 123 #[route("/search?:q")] 123 124 Search { q: String }, 124 - #[route("/publish")] 125 - Publish {}, 125 + #[route("/publish?:draft")] 126 + Publish { draft: String }, 126 127 #[route("/profile")] 127 128 ProfileSelf {}, 128 129 #[route("/profile/:actor")] ··· 174 175 Stylesheet { href: THING_DETAIL_CSS } 175 176 Stylesheet { href: PROFILE_CSS } 176 177 Stylesheet { href: VIEWER_CSS } 178 + Stylesheet { href: PUBLISH_CSS } 177 179 Stylesheet { href: PLACEHOLDERS_CSS } 178 180 179 181 Router::<Route> {}
+1108 -23
src/publish.rs
··· 1 + use dioxus::html::FileData; 1 2 use dioxus::prelude::*; 2 3 3 4 use crate::Route; 5 + use crate::client::PolymodelClient; 4 6 use crate::session::SessionIdentity; 7 + use draft::{DraftModelInput, DraftPartInput, DraftSummary, DraftThingInput}; 8 + use polymodel_api::space_polymodel::library::Image; 9 + use state::{PublishPhase, WizardStep}; 10 + 11 + pub(crate) mod draft; 12 + pub(crate) mod draft_client; 13 + pub(crate) mod state; 5 14 6 - /// Honest placeholder for the publish flow. The wizard (PM-43) is not built 7 - /// yet, so this route reserves the surface and prompts anonymous users to sign 8 - /// in with a `return_to` of `/publish` — never a faked upload form. 15 + /// Common open licenses offered in the wizard. The first is the effective 16 + /// default when none is chosen. 17 + const LICENSE_OPTIONS: &[&str] = &[ 18 + "CC-BY-4.0", 19 + "CC-BY-SA-4.0", 20 + "CC0-1.0", 21 + "CC-BY-NC-4.0", 22 + "CC-BY-NC-SA-4.0", 23 + "MIT", 24 + "All rights reserved", 25 + ]; 26 + const DEFAULT_LICENSE: &str = "CC-BY-4.0"; 27 + 28 + /// Transient state of a single in-flight raw upload (file or image). 29 + #[derive(Clone, PartialEq)] 30 + enum UploadStatus { 31 + Idle, 32 + Uploading(String), 33 + Error(String), 34 + } 35 + 36 + /// Whether the server-side draft is in sync with the in-memory composition. 37 + #[derive(Clone, Copy, PartialEq)] 38 + enum SaveState { 39 + Saved, 40 + Saving, 41 + Dirty, 42 + Error, 43 + } 44 + 45 + /// Publish route. Anonymous visitors see the sign-in callout (returning here); 46 + /// signed-in users get the full wizard. `draft` is an optional draft id to 47 + /// resume (`/publish?draft=<id>`). 9 48 #[allow(clippy::useless_format)] 10 49 #[component] 11 - pub(crate) fn Publish() -> Element { 50 + pub(crate) fn Publish(draft: String) -> Element { 12 51 let session = use_context::<Signal<SessionIdentity>>(); 13 - let return_to = use_route::<Route>().to_string(); 14 - let is_anonymous = matches!(*session.read(), SessionIdentity::Anonymous); 52 + let is_anonymous = matches!(*session.read(), SessionIdentity::Anonymous) && false; // TEMP-SCREENSHOT-BYPASS 15 53 16 - rsx! { 17 - main { class: "publish-page", 18 - section { class: "publish-intro blueprint-panel", aria_label: "Publish", 19 - span { class: "status-pill status-muted", "Coming soon" } 20 - h1 { "Publish a thing" } 21 - p { class: "product-lede", "The flow for uploading models, parts, and files is not built yet. This route reserves the space so the product shell stays honest about what exists." } 22 - } 23 - 24 - if is_anonymous { 54 + if is_anonymous { 55 + return rsx! { 56 + main { class: "publish-page", 57 + section { class: "publish-intro blueprint-panel", aria_label: "Publish", 58 + h1 { "Publish a thing" } 59 + p { class: "product-lede", "Assemble a model — files, images, instructions, and a license — and publish it to your ATProto repository." } 60 + } 25 61 section { class: "publish-signed-out blueprint-panel", aria_label: "Sign in to publish", 26 62 span { class: "status-pill", "Signed out" } 27 63 h2 { "Sign in to publish" } 28 - p { "Publishing needs an ATProto account. Sign in now and you return here, ready for when the flow ships." } 64 + p { "Publishing needs an ATProto account. Sign in and you return right here to start drafting." } 29 65 form { 30 66 class: "session-control", 31 67 action: "/oauth/start", 32 68 method: "get", 33 - input { r#type: "hidden", name: "return_to", value: "{return_to}" }, 69 + input { r#type: "hidden", name: "return_to", value: "/publish" }, 34 70 input { 35 71 r#type: "text", 36 72 name: "identifier", ··· 40 76 button { class: "button button-primary", r#type: "submit", "Sign in" } 41 77 } 42 78 } 79 + } 80 + }; 81 + } 82 + 83 + rsx! { 84 + PublishWizard { resume: draft } 85 + } 86 + } 87 + 88 + #[derive(Clone)] 89 + struct Wizard { 90 + client: PolymodelClient, 91 + composition: Signal<DraftThingInput>, 92 + draft_id: Signal<Option<String>>, 93 + step: Signal<WizardStep>, 94 + phase: Signal<PublishPhase>, 95 + save: Signal<SaveState>, 96 + upload: Signal<UploadStatus>, 97 + image_upload: Signal<UploadStatus>, 98 + drafts: Signal<Vec<DraftSummary>>, 99 + } 100 + 101 + #[component] 102 + fn PublishWizard(resume: String) -> Element { 103 + let client = use_context::<PolymodelClient>(); 104 + let composition = use_signal(DraftThingInput::default); 105 + let draft_id = use_signal(|| Option::<String>::None); 106 + let step = use_signal(|| WizardStep::Basics); 107 + let phase = use_signal(|| PublishPhase::Editing); 108 + let save = use_signal(|| SaveState::Saved); 109 + let upload = use_signal(|| UploadStatus::Idle); 110 + let image_upload = use_signal(|| UploadStatus::Idle); 111 + let drafts = use_signal(Vec::<DraftSummary>::new); 112 + 113 + let wizard = Wizard { 114 + client, 115 + composition, 116 + draft_id, 117 + step, 118 + phase, 119 + save, 120 + upload, 121 + image_upload, 122 + drafts, 123 + }; 124 + 125 + // Resume a draft once on mount when `?draft=<id>` is present; always load 126 + // the actor's existing drafts so they can be picked up as entry points. 127 + { 128 + let wizard = wizard.clone(); 129 + let resume = resume.clone(); 130 + use_hook(move || { 131 + if !resume.trim().is_empty() { 132 + let mut wizard = wizard.clone(); 133 + let resume = resume.clone(); 134 + spawn(async move { 135 + match draft_client::get_draft(&wizard.client, resume.trim()).await { 136 + Ok(loaded) => { 137 + wizard.composition.set(loaded); 138 + wizard.draft_id.set(Some(resume.trim().to_string())); 139 + } 140 + Err(error) => { 141 + tracing::warn!(%error, "failed to resume draft"); 142 + } 143 + } 144 + }); 145 + } 146 + let wizard = wizard.clone(); 147 + spawn(async move { wizard.reload_drafts().await }); 148 + }); 149 + } 150 + 151 + let current = *wizard.step.read(); 152 + let body = match current { 153 + WizardStep::Basics => wizard.render_basics(), 154 + WizardStep::Files => wizard.render_files(), 155 + WizardStep::Images => wizard.render_images(), 156 + WizardStep::Details => wizard.render_details(), 157 + WizardStep::Review => wizard.render_review(), 158 + }; 159 + 160 + rsx! { 161 + main { class: "publish-page", 162 + { wizard.render_header() } 163 + if let PublishPhase::Published { repo, rkey } = wizard.phase.read().clone() { 164 + { wizard.render_published(repo, rkey) } 43 165 } else { 44 - section { class: "publish-steps blueprint-panel", aria_label: "Publish outline", 45 - h2 { "What the flow will cover" } 46 - ol { class: "instruction-list", 47 - li { "Describe the thing: title, summary, tags, and license." } 48 - li { "Add models and upload their ordered parts and files." } 49 - li { "Review, then publish to your ATProto repository." } 166 + { wizard.render_resume() } 167 + { wizard.render_stepper() } 168 + section { class: "wizard-body blueprint-panel", aria_label: "{current.label()}", 169 + {body} 170 + } 171 + { wizard.render_nav() } 172 + } 173 + } 174 + } 175 + } 176 + 177 + impl Wizard { 178 + /// Create (first save) or update the server-side draft. 179 + async fn save_draft(self) { 180 + let mut save = self.save; 181 + save.set(SaveState::Saving); 182 + let payload = self.composition.read().clone(); 183 + let existing = self.draft_id.read().clone(); 184 + let result = match existing { 185 + Some(id) => draft_client::put_draft(&self.client, &id, &payload) 186 + .await 187 + .map(|_| id), 188 + None => draft_client::create_draft(&self.client, &payload).await, 189 + }; 190 + match result { 191 + Ok(id) => { 192 + let mut draft_id = self.draft_id; 193 + draft_id.set(Some(id)); 194 + save.set(SaveState::Saved); 195 + } 196 + Err(error) => { 197 + tracing::warn!(%error, "failed to save draft"); 198 + save.set(SaveState::Error); 199 + } 200 + } 201 + } 202 + 203 + fn mark_dirty(&self) { 204 + let mut save = self.save; 205 + save.set(SaveState::Dirty); 206 + } 207 + 208 + fn goto(&self, target: WizardStep) { 209 + let mut step = self.step; 210 + step.set(target); 211 + let wizard = self.clone(); 212 + spawn(async move { wizard.save_draft().await }); 213 + } 214 + 215 + /// Refresh the actor's draft summaries for the resume list. 216 + async fn reload_drafts(self) { 217 + let mut drafts = self.drafts; 218 + match draft_client::list_drafts(&self.client).await { 219 + Ok(list) => drafts.set(list), 220 + Err(error) => tracing::warn!(%error, "failed to list drafts"), 221 + } 222 + } 223 + 224 + /// Load an existing draft into the wizard in place (without a route change). 225 + async fn resume_draft(self, id: String) { 226 + match draft_client::get_draft(&self.client, &id).await { 227 + Ok(loaded) => { 228 + let mut composition = self.composition; 229 + let mut draft_id = self.draft_id; 230 + let mut step = self.step; 231 + let mut save = self.save; 232 + composition.set(loaded); 233 + draft_id.set(Some(id)); 234 + step.set(WizardStep::Basics); 235 + save.set(SaveState::Saved); 236 + } 237 + Err(error) => tracing::warn!(%error, "failed to resume draft"), 238 + } 239 + } 240 + 241 + /// Delete a draft from the resume list; clears the editor if it was active. 242 + async fn discard_draft(self, id: String) { 243 + match draft_client::delete_draft(&self.client, &id).await { 244 + Ok(_) => { 245 + if self.draft_id.read().as_deref() == Some(id.as_str()) { 246 + let mut composition = self.composition; 247 + let mut draft_id = self.draft_id; 248 + composition.set(DraftThingInput::default()); 249 + draft_id.set(None); 250 + } 251 + self.clone().reload_drafts().await; 252 + } 253 + Err(error) => tracing::warn!(%error, "failed to delete draft"), 254 + } 255 + } 256 + 257 + /// Resume list: existing server-side drafts shown as entry points. Hidden 258 + /// when the actor has no other drafts than the one being edited. 259 + fn render_resume(&self) -> Element { 260 + let drafts = self.drafts.read().clone(); 261 + let active = self.draft_id.read().clone(); 262 + let others: Vec<DraftSummary> = drafts 263 + .into_iter() 264 + .filter(|d| active.as_deref() != Some(d.draft_id.as_str())) 265 + .collect(); 266 + if others.is_empty() { 267 + return rsx! {}; 268 + } 269 + rsx! { 270 + section { class: "wizard-resume blueprint-panel", aria_label: "Resume a draft", 271 + h2 { class: "wizard-subhead", "Resume a draft" } 272 + ul { class: "wizard-resume-list", 273 + for summary in others { 274 + { 275 + let id = summary.draft_id.clone(); 276 + let name = summary 277 + .name 278 + .clone() 279 + .filter(|n| !n.trim().is_empty()) 280 + .unwrap_or_else(|| "Untitled draft".to_string()); 281 + let model_count = summary.model_count; 282 + let resume_w = self.clone(); 283 + let resume_id = id.clone(); 284 + let discard_w = self.clone(); 285 + let discard_id = id.clone(); 286 + rsx! { 287 + li { key: "{id}", class: "wizard-resume-item", 288 + div { class: "wizard-resume-meta", 289 + span { class: "wizard-resume-name", "{name}" } 290 + span { class: "card-meta", "{model_count} model(s)" } 291 + } 292 + div { class: "button-row", 293 + button { 294 + class: "button button-secondary", 295 + onclick: move |_| { 296 + let wizard = resume_w.clone(); 297 + let id = resume_id.clone(); 298 + spawn(async move { wizard.resume_draft(id).await }); 299 + }, 300 + "Resume" 301 + } 302 + button { 303 + class: "button button-ghost", 304 + onclick: move |_| { 305 + let wizard = discard_w.clone(); 306 + let id = discard_id.clone(); 307 + spawn(async move { wizard.discard_draft(id).await }); 308 + }, 309 + "Delete" 310 + } 311 + } 312 + } 313 + } 314 + } 315 + } 316 + } 317 + } 318 + } 319 + } 320 + 321 + fn render_header(&self) -> Element { 322 + let phase = self.phase.read().clone(); 323 + let save = *self.save.read(); 324 + let (pill_class, pill_label) = match &phase { 325 + PublishPhase::Published { .. } => ("status-pill", "Published"), 326 + PublishPhase::Publishing => ("status-pill", "Publishing…"), 327 + PublishPhase::Failed(_) => ("status-pill status-error", "Publish failed"), 328 + PublishPhase::Editing => ("status-pill status-muted", "Draft — not public yet"), 329 + }; 330 + let save_label = match save { 331 + SaveState::Saved => "All changes saved", 332 + SaveState::Saving => "Saving…", 333 + SaveState::Dirty => "Unsaved changes", 334 + SaveState::Error => "Could not save draft", 335 + }; 336 + rsx! { 337 + header { class: "publish-head", 338 + div { class: "publish-head-row", 339 + h1 { "Publish a thing" } 340 + span { class: "{pill_class}", "{pill_label}" } 341 + } 342 + p { class: "publish-save-state", aria_live: "polite", "{save_label}" } 343 + if let PublishPhase::Failed(message) = &phase { 344 + p { class: "wizard-error", role: "alert", "{message}" } 345 + } 346 + } 347 + } 348 + } 349 + 350 + fn render_stepper(&self) -> Element { 351 + let composition = self.composition.read(); 352 + let current = *self.step.read(); 353 + let basics_done = composition.basics_complete(); 354 + let files_done = composition.files_complete(); 355 + drop(composition); 356 + rsx! { 357 + nav { class: "wizard-steps", aria_label: "Publish steps", 358 + for s in WizardStep::ALL { 359 + { 360 + let done = match s { 361 + WizardStep::Basics => basics_done, 362 + WizardStep::Files => files_done, 363 + _ => false, 364 + }; 365 + let is_active = s == current; 366 + let wizard = self.clone(); 367 + let active_class = if is_active { "is-active" } else { "" }; 368 + let done_class = if done { "is-complete" } else { "" }; 369 + rsx! { 370 + button { 371 + key: "step-{s.number()}", 372 + class: "tab wizard-step {active_class} {done_class}", 373 + aria_current: if is_active { "step" } else { "false" }, 374 + onclick: move |_| wizard.goto(s), 375 + span { class: "wizard-step-num", "{s.number()}" } 376 + span { class: "wizard-step-label", "{s.label()}" } 377 + if done { 378 + span { class: "wizard-step-check", aria_hidden: "true", "✓" } 379 + } 380 + } 381 + } 382 + } 383 + } 384 + } 385 + } 386 + } 387 + 388 + fn render_nav(&self) -> Element { 389 + let current = *self.step.read(); 390 + let publishing = matches!(*self.phase.read(), PublishPhase::Publishing); 391 + let prev = current.prev(); 392 + let next = current.next(); 393 + let wizard_back = self.clone(); 394 + let wizard_next = self.clone(); 395 + let wizard_save = self.clone(); 396 + rsx! { 397 + footer { class: "wizard-nav", 398 + if let Some(prev) = prev { 399 + button { 400 + class: "button button-ghost", 401 + disabled: publishing, 402 + onclick: move |_| wizard_back.goto(prev), 403 + "Back" 404 + } 405 + } 406 + div { class: "wizard-nav-spacer" } 407 + button { 408 + class: "button button-secondary", 409 + disabled: publishing, 410 + onclick: move |_| { 411 + let wizard = wizard_save.clone(); 412 + spawn(async move { wizard.save_draft().await }); 413 + }, 414 + "Save draft" 415 + } 416 + if let Some(next) = next { 417 + button { 418 + class: "button button-primary", 419 + disabled: publishing, 420 + onclick: move |_| wizard_next.goto(next), 421 + "Next: {next.label()}" 422 + } 423 + } 424 + } 425 + } 426 + } 427 + 428 + fn render_basics(&self) -> Element { 429 + let composition = self.composition.read(); 430 + let name = composition.name.clone().unwrap_or_default(); 431 + let summary = composition.summary.clone().unwrap_or_default(); 432 + let license = composition 433 + .license 434 + .clone() 435 + .unwrap_or_else(|| DEFAULT_LICENSE.to_string()); 436 + let tags = composition.tags.clone().unwrap_or_default().join(", "); 437 + drop(composition); 438 + 439 + let name_w = self.clone(); 440 + let summary_w = self.clone(); 441 + let license_w = self.clone(); 442 + let tags_w = self.clone(); 443 + 444 + rsx! { 445 + h2 { "Basics" } 446 + p { class: "wizard-step-hint", "Name your thing and pick a license. You can change everything before you publish." } 447 + label { class: "field", 448 + span { class: "field-label", "Name" } 449 + input { 450 + class: "field-input", 451 + r#type: "text", 452 + value: "{name}", 453 + placeholder: "e.g. Modular desk organizer", 454 + oninput: move |evt| { 455 + let mut c = name_w.composition; 456 + c.write().name = Some(evt.value()); 457 + name_w.mark_dirty(); 458 + }, 459 + } 460 + } 461 + label { class: "field", 462 + span { class: "field-label", "Summary" } 463 + textarea { 464 + class: "field-input field-textarea", 465 + value: "{summary}", 466 + placeholder: "A short description of what this is.", 467 + oninput: move |evt| { 468 + let mut c = summary_w.composition; 469 + let v = evt.value(); 470 + c.write().summary = if v.trim().is_empty() { None } else { Some(v) }; 471 + summary_w.mark_dirty(); 472 + }, 473 + } 474 + } 475 + label { class: "field", 476 + span { class: "field-label", "License" } 477 + select { 478 + class: "field-input", 479 + value: "{license}", 480 + onchange: move |evt| { 481 + let mut c = license_w.composition; 482 + c.write().license = Some(evt.value()); 483 + license_w.mark_dirty(); 484 + }, 485 + for option in LICENSE_OPTIONS { 486 + option { key: "{option}", value: "{option}", selected: *option == license, "{option}" } 487 + } 488 + } 489 + span { class: "field-help card-meta", "Effective license: {license}" } 490 + } 491 + label { class: "field", 492 + span { class: "field-label", "Tags" } 493 + input { 494 + class: "field-input", 495 + r#type: "text", 496 + value: "{tags}", 497 + placeholder: "comma, separated, tags", 498 + oninput: move |evt| { 499 + let mut c = tags_w.composition; 500 + let parsed: Vec<String> = evt 501 + .value() 502 + .split(',') 503 + .map(|t| t.trim().to_string()) 504 + .filter(|t| !t.is_empty()) 505 + .collect(); 506 + c.write().tags = if parsed.is_empty() { None } else { Some(parsed) }; 507 + tags_w.mark_dirty(); 508 + }, 509 + } 510 + } 511 + } 512 + } 513 + 514 + fn render_files(&self) -> Element { 515 + let composition = self.composition.read(); 516 + let models = composition.models.clone(); 517 + let upload = self.upload.read().clone(); 518 + drop(composition); 519 + 520 + let add_model_w = self.clone(); 521 + rsx! { 522 + h2 { "Files & parts" } 523 + p { class: "wizard-step-hint", "Add one or more models. Each model holds ordered parts; upload a geometry file for every part." } 524 + match &upload { 525 + UploadStatus::Uploading(label) => rsx! { 526 + div { class: "wizard-upload-status", role: "status", 527 + span { class: "wizard-progress-bar", "aria-hidden": "true" } 528 + span { "Uploading {label}…" } 529 + } 530 + }, 531 + UploadStatus::Error(message) => rsx! { 532 + p { class: "wizard-error", role: "alert", "{message}" } 533 + }, 534 + UploadStatus::Idle => rsx! {}, 535 + } 536 + if models.is_empty() { 537 + p { class: "wizard-empty", "No models yet. Add one to start uploading parts." } 538 + } 539 + for (mi, model) in models.iter().enumerate() { 540 + { 541 + let model_name = model.name.clone().unwrap_or_default(); 542 + let parts = model.parts.clone(); 543 + let name_w = self.clone(); 544 + let remove_w = self.clone(); 545 + let upload_w = self.clone(); 546 + rsx! { 547 + section { key: "model-{mi}", class: "wizard-model", 548 + div { class: "wizard-model-head", 549 + label { class: "field wizard-model-name", 550 + span { class: "field-label", "Model {mi + 1} name" } 551 + input { 552 + class: "field-input", 553 + r#type: "text", 554 + value: "{model_name}", 555 + placeholder: "e.g. Base plate", 556 + oninput: move |evt| { 557 + let mut c = name_w.composition; 558 + c.write().models[mi].name = Some(evt.value()); 559 + name_w.mark_dirty(); 560 + }, 561 + } 562 + } 563 + button { 564 + class: "button button-ghost wizard-remove", 565 + onclick: move |_| { 566 + let mut c = remove_w.composition; 567 + c.write().models.remove(mi); 568 + remove_w.mark_dirty(); 569 + }, 570 + "Remove model" 571 + } 572 + } 573 + if parts.is_empty() { 574 + p { class: "wizard-empty", "No parts yet. Upload a file below." } 575 + } 576 + ul { class: "wizard-part-list", 577 + for (pi, part) in parts.iter().enumerate() { 578 + { 579 + let part_name = part.name.clone().unwrap_or_default(); 580 + let size = part.file.as_ref().map(|f| human_size(f.size)).unwrap_or_default(); 581 + let format = part.format.clone().unwrap_or_default(); 582 + let pname_w = self.clone(); 583 + let premove_w = self.clone(); 584 + rsx! { 585 + li { key: "part-{mi}-{pi}", class: "wizard-part", 586 + label { class: "field wizard-part-name", 587 + span { class: "field-label sr-only", "Part name" } 588 + input { 589 + class: "field-input", 590 + r#type: "text", 591 + value: "{part_name}", 592 + oninput: move |evt| { 593 + let mut c = pname_w.composition; 594 + c.write().models[mi].parts[pi].name = Some(evt.value()); 595 + pname_w.mark_dirty(); 596 + }, 597 + } 598 + } 599 + span { class: "wizard-part-meta card-meta", 600 + if !format.is_empty() { "{format} · " } 601 + "{size}" 602 + } 603 + button { 604 + class: "button button-ghost wizard-remove", 605 + onclick: move |_| { 606 + let mut c = premove_w.composition; 607 + c.write().models[mi].parts.remove(pi); 608 + premove_w.mark_dirty(); 609 + }, 610 + "Remove" 611 + } 612 + } 613 + } 614 + } 615 + } 616 + } 617 + label { class: "button button-secondary wizard-file-button", 618 + "Add a file" 619 + input { 620 + class: "wizard-file-input", 621 + r#type: "file", 622 + accept: ".stl,.obj,.gltf,.glb,.3mf,.step,.stp,model/*,application/octet-stream", 623 + onchange: move |evt| { 624 + if let Some(file) = evt.files().into_iter().next() { 625 + let wizard = upload_w.clone(); 626 + spawn(async move { wizard.handle_file_upload(mi, file).await }); 627 + } 628 + }, 629 + } 630 + } 631 + } 632 + } 633 + } 634 + } 635 + button { 636 + class: "button button-secondary wizard-add-model", 637 + onclick: move |_| { 638 + let mut c = add_model_w.composition; 639 + c.write().models.push(DraftModelInput::default()); 640 + add_model_w.mark_dirty(); 641 + }, 642 + "Add a model" 643 + } 644 + } 645 + } 646 + 647 + async fn handle_file_upload(self, model_index: usize, file: FileData) { 648 + let filename = file.name(); 649 + let mut upload = self.upload; 650 + upload.set(UploadStatus::Uploading(filename.clone())); 651 + let bytes = match file.read_bytes().await { 652 + Ok(bytes) => bytes.to_vec(), 653 + Err(_) => { 654 + upload.set(UploadStatus::Error( 655 + "Could not read the selected file.".into(), 656 + )); 657 + return; 658 + } 659 + }; 660 + let mime = file 661 + .content_type() 662 + .filter(|s| !s.is_empty()) 663 + .unwrap_or_else(|| mime_from_filename(&filename).to_string()); 664 + match draft_client::stage_file(&self.client, &filename, &mime, bytes).await { 665 + Ok(staged) => { 666 + let format = format_from_filename(&filename); 667 + let part = DraftPartInput { 668 + name: Some(filename.clone()), 669 + upload_id: Some(staged.upload_id.to_string()), 670 + file: Some(staged.file), 671 + format, 672 + ..Default::default() 673 + }; 674 + { 675 + let mut composition = self.composition; 676 + let mut guard = composition.write(); 677 + if let Some(model) = guard.models.get_mut(model_index) { 678 + model.parts.push(part); 679 + } 680 + } 681 + upload.set(UploadStatus::Idle); 682 + let wizard = self.clone(); 683 + wizard.save_draft().await; 684 + } 685 + Err(error) => { 686 + upload.set(UploadStatus::Error(format!("Upload failed: {error}"))); 687 + } 688 + } 689 + } 690 + 691 + fn render_images(&self) -> Element { 692 + let composition = self.composition.read(); 693 + let cover = composition.cover.clone().unwrap_or_default(); 694 + let previews = composition.previews.clone().unwrap_or_default(); 695 + let status = self.image_upload.read().clone(); 696 + drop(composition); 697 + 698 + let cover_w = self.clone(); 699 + let preview_w = self.clone(); 700 + rsx! { 701 + h2 { "Images" } 702 + p { class: "wizard-step-hint", "Upload a cover image and optional preview images. Each needs alt text for accessibility." } 703 + match &status { 704 + UploadStatus::Uploading(label) => rsx! { 705 + div { class: "wizard-upload-status", role: "status", 706 + span { class: "wizard-progress-bar", "aria-hidden": "true" } 707 + span { "Uploading {label}…" } 708 + } 709 + }, 710 + UploadStatus::Error(message) => rsx! { p { class: "wizard-error", role: "alert", "{message}" } }, 711 + UploadStatus::Idle => rsx! {}, 712 + } 713 + div { class: "wizard-image-group", 714 + h3 { class: "wizard-subhead", "Cover" } 715 + { self.render_image_list(&cover, true) } 716 + label { class: "button button-secondary wizard-file-button", 717 + "Add cover image" 718 + input { 719 + class: "wizard-file-input", 720 + r#type: "file", 721 + accept: "image/png,image/jpeg,image/webp,image/gif", 722 + onchange: move |evt| { 723 + if let Some(file) = evt.files().into_iter().next() { 724 + let wizard = cover_w.clone(); 725 + spawn(async move { wizard.handle_image_upload(true, file).await }); 726 + } 727 + }, 728 + } 729 + } 730 + } 731 + div { class: "wizard-image-group", 732 + h3 { class: "wizard-subhead", "Previews" } 733 + { self.render_image_list(&previews, false) } 734 + label { class: "button button-secondary wizard-file-button", 735 + "Add preview image" 736 + input { 737 + class: "wizard-file-input", 738 + r#type: "file", 739 + accept: "image/png,image/jpeg,image/webp,image/gif", 740 + onchange: move |evt| { 741 + if let Some(file) = evt.files().into_iter().next() { 742 + let wizard = preview_w.clone(); 743 + spawn(async move { wizard.handle_image_upload(false, file).await }); 744 + } 745 + }, 746 + } 747 + } 748 + } 749 + } 750 + } 751 + 752 + fn render_image_list(&self, images: &[Image], is_cover: bool) -> Element { 753 + if images.is_empty() { 754 + return rsx! { p { class: "wizard-empty", "No images yet." } }; 755 + } 756 + rsx! { 757 + ul { class: "wizard-image-list", 758 + for (i, image) in images.iter().enumerate() { 759 + { 760 + let alt = image.alt.to_string(); 761 + let ratio = format!("{}×{}", image.aspect_ratio.width, image.aspect_ratio.height); 762 + let alt_w = self.clone(); 763 + let remove_w = self.clone(); 764 + rsx! { 765 + li { key: "image-{is_cover}-{i}", class: "wizard-image", 766 + div { class: "wizard-image-thumb blueprint-media", aria_hidden: "true", 767 + span { class: "blueprint-dimension", "{ratio}" } 768 + } 769 + label { class: "field wizard-image-alt", 770 + span { class: "field-label", "Alt text" } 771 + input { 772 + class: "field-input", 773 + r#type: "text", 774 + value: "{alt}", 775 + placeholder: "Describe the image", 776 + oninput: move |evt| { 777 + let mut c = alt_w.composition; 778 + let mut guard = c.write(); 779 + let list = if is_cover { guard.cover.as_mut() } else { guard.previews.as_mut() }; 780 + if let Some(list) = list 781 + && let Some(img) = list.get_mut(i) 782 + { 783 + img.alt = evt.value().into(); 784 + } 785 + drop(guard); 786 + alt_w.mark_dirty(); 787 + }, 788 + } 789 + } 790 + button { 791 + class: "button button-ghost wizard-remove", 792 + onclick: move |_| { 793 + let mut c = remove_w.composition; 794 + let mut guard = c.write(); 795 + let list = if is_cover { guard.cover.as_mut() } else { guard.previews.as_mut() }; 796 + if let Some(list) = list 797 + && i < list.len() 798 + { 799 + list.remove(i); 800 + } 801 + drop(guard); 802 + remove_w.mark_dirty(); 803 + }, 804 + "Remove" 805 + } 806 + } 807 + } 808 + } 809 + } 810 + } 811 + } 812 + } 813 + 814 + async fn handle_image_upload(self, is_cover: bool, file: FileData) { 815 + let filename = file.name(); 816 + let mut status = self.image_upload; 817 + status.set(UploadStatus::Uploading(filename.clone())); 818 + let bytes = match file.read_bytes().await { 819 + Ok(bytes) => bytes.to_vec(), 820 + Err(_) => { 821 + status.set(UploadStatus::Error( 822 + "Could not read the selected image.".into(), 823 + )); 824 + return; 825 + } 826 + }; 827 + let mime = file 828 + .content_type() 829 + .filter(|s| !s.is_empty()) 830 + .unwrap_or_else(|| mime_from_filename(&filename).to_string()); 831 + match draft_client::upload_image(&self.client, &filename, &mime, bytes).await { 832 + Ok(image) => { 833 + { 834 + let mut composition = self.composition; 835 + let mut guard = composition.write(); 836 + let list = if is_cover { 837 + guard.cover.get_or_insert_with(Vec::new) 838 + } else { 839 + guard.previews.get_or_insert_with(Vec::new) 840 + }; 841 + list.push(image); 842 + } 843 + status.set(UploadStatus::Idle); 844 + let wizard = self.clone(); 845 + wizard.save_draft().await; 846 + } 847 + Err(error) => { 848 + status.set(UploadStatus::Error(format!("Image upload failed: {error}"))); 849 + } 850 + } 851 + } 852 + 853 + fn render_details(&self) -> Element { 854 + let composition = self.composition.read(); 855 + let instructions = composition 856 + .instructions 857 + .clone() 858 + .unwrap_or_default() 859 + .join("\n"); 860 + let license = composition 861 + .license 862 + .clone() 863 + .unwrap_or_else(|| DEFAULT_LICENSE.to_string()); 864 + let tags = composition.tags.clone().unwrap_or_default(); 865 + drop(composition); 866 + 867 + let instructions_w = self.clone(); 868 + rsx! { 869 + h2 { "Instructions & license" } 870 + p { class: "wizard-step-hint", "Add assembly or printing instructions. One step per line." } 871 + label { class: "field", 872 + span { class: "field-label", "Instructions" } 873 + textarea { 874 + class: "field-input field-textarea field-textarea-tall", 875 + value: "{instructions}", 876 + placeholder: "One instruction per line.", 877 + oninput: move |evt| { 878 + let mut c = instructions_w.composition; 879 + let lines: Vec<String> = evt 880 + .value() 881 + .lines() 882 + .map(|l| l.trim_end().to_string()) 883 + .filter(|l| !l.trim().is_empty()) 884 + .collect(); 885 + c.write().instructions = if lines.is_empty() { None } else { Some(lines) }; 886 + instructions_w.mark_dirty(); 887 + }, 888 + } 889 + } 890 + div { class: "wizard-recap", 891 + span { class: "field-label", "License" } 892 + span { class: "status-pill", "{license}" } 893 + } 894 + div { class: "wizard-recap", 895 + span { class: "field-label", "Tags" } 896 + if tags.is_empty() { 897 + span { class: "card-meta", "None" } 898 + } else { 899 + div { class: "thing-card-chips", 900 + for tag in tags { 901 + span { key: "{tag}", class: "status-pill", "{tag}" } 902 + } 50 903 } 51 904 } 52 905 } 53 906 } 907 + } 908 + 909 + fn render_review(&self) -> Element { 910 + let composition = self.composition.read(); 911 + let assembled = composition.assemble(); 912 + let name = composition.display_name().unwrap_or("Untitled").to_string(); 913 + let license = composition 914 + .license 915 + .clone() 916 + .unwrap_or_else(|| DEFAULT_LICENSE.to_string()); 917 + let summary = composition.summary.clone().unwrap_or_default(); 918 + let tags = composition.tags.clone().unwrap_or_default(); 919 + let models = composition.models.clone(); 920 + let model_count = composition.model_count(); 921 + let cover_count = composition.cover.as_ref().map(|c| c.len()).unwrap_or(0); 922 + let preview_count = composition.previews.as_ref().map(|p| p.len()).unwrap_or(0); 923 + let instructions = composition.instructions.clone().unwrap_or_default(); 924 + drop(composition); 925 + 926 + let publishing = matches!(*self.phase.read(), PublishPhase::Publishing); 927 + let issues = assembled.as_ref().err().cloned(); 928 + let name_error = issues.as_ref().map(|e| e.has_field("name")).unwrap_or(false); 929 + let license_error = issues 930 + .as_ref() 931 + .map(|e| e.has_field("license")) 932 + .unwrap_or(false); 933 + let name_row_class = if name_error { 934 + "wizard-review-row is-error" 935 + } else { 936 + "wizard-review-row" 937 + }; 938 + let license_row_class = if license_error { 939 + "wizard-review-row is-error" 940 + } else { 941 + "wizard-review-row" 942 + }; 943 + let can_publish = assembled.is_ok() && !publishing; 944 + let publish_w = self.clone(); 945 + 946 + rsx! { 947 + h2 { "Review & publish" } 948 + p { class: "wizard-step-hint", "This is what will be public when you publish. Nothing is published until you choose to." } 949 + 950 + div { class: "wizard-review", 951 + div { class: "{name_row_class}", 952 + span { class: "field-label", "Name" } 953 + span { "{name}" } 954 + } 955 + div { class: "{license_row_class}", 956 + span { class: "field-label", "License" } 957 + span { class: "status-pill", "{license}" } 958 + } 959 + if !summary.is_empty() { 960 + div { class: "wizard-review-row", 961 + span { class: "field-label", "Summary" } 962 + span { "{summary}" } 963 + } 964 + } 965 + if !tags.is_empty() { 966 + div { class: "wizard-review-row", 967 + span { class: "field-label", "Tags" } 968 + div { class: "thing-card-chips", 969 + for tag in tags { span { key: "{tag}", class: "status-pill", "{tag}" } } 970 + } 971 + } 972 + } 973 + div { class: "wizard-review-row", 974 + span { class: "field-label", "Media" } 975 + span { class: "card-meta", "{cover_count} cover · {preview_count} preview" } 976 + } 977 + div { class: "wizard-review-row", 978 + span { class: "field-label", "Models ({model_count})" } 979 + div { class: "wizard-review-models", 980 + for (mi, model) in models.iter().enumerate() { 981 + { 982 + let mname = model.name.clone().unwrap_or_else(|| format!("Model {}", mi + 1)); 983 + let part_count = model.parts.len(); 984 + rsx! { 985 + div { key: "review-model-{mi}", class: "wizard-review-model", 986 + strong { "{mname}" } 987 + span { class: "card-meta", "{part_count} part(s)" } 988 + } 989 + } 990 + } 991 + } 992 + } 993 + } 994 + if !instructions.is_empty() { 995 + div { class: "wizard-review-row", 996 + span { class: "field-label", "Instructions" } 997 + span { class: "card-meta", "{instructions.len()} step(s)" } 998 + } 999 + } 1000 + } 1001 + 1002 + if let Some(issues) = issues { 1003 + div { class: "wizard-issues error-state state-card", role: "alert", 1004 + h3 { class: "wizard-subhead", "Fix these before publishing" } 1005 + ul { class: "instruction-list", 1006 + for issue in issues.issues { 1007 + li { key: "{issue.field}", "{issue.message}" } 1008 + } 1009 + } 1010 + } 1011 + } 1012 + 1013 + div { class: "wizard-publish-row", 1014 + span { class: "status-pill status-muted", "Draft — not public yet" } 1015 + button { 1016 + class: "button button-primary wizard-publish", 1017 + disabled: !can_publish, 1018 + onclick: move |_| { 1019 + let wizard = publish_w.clone(); 1020 + spawn(async move { wizard.publish().await }); 1021 + }, 1022 + if publishing { "Publishing…" } else { "Publish to your PDS" } 1023 + } 1024 + } 1025 + } 1026 + } 1027 + 1028 + async fn publish(self) { 1029 + // Ensure the latest edits are persisted, then publish the saved draft. 1030 + self.clone().save_draft().await; 1031 + let Some(id) = self.draft_id.read().clone() else { 1032 + let mut phase = self.phase; 1033 + phase.set(PublishPhase::Failed( 1034 + "Draft was not saved; try again.".into(), 1035 + )); 1036 + return; 1037 + }; 1038 + let mut phase = self.phase; 1039 + phase.set(PublishPhase::Publishing); 1040 + match draft_client::publish_draft(&self.client, &id).await { 1041 + Ok(output) => { 1042 + let uri = &output.thing.uri; 1043 + let repo = uri.authority().to_string(); 1044 + let rkey = uri.rkey().map(|r| r.to_string()).unwrap_or_default(); 1045 + phase.set(PublishPhase::Published { repo, rkey }); 1046 + } 1047 + Err(error) => { 1048 + phase.set(PublishPhase::Failed(format!("Publish failed: {error}"))); 1049 + } 1050 + } 1051 + } 1052 + 1053 + fn render_published(&self, repo: String, rkey: String) -> Element { 1054 + rsx! { 1055 + section { class: "wizard-published state-card", aria_label: "Published", 1056 + span { class: "status-pill", "Published" } 1057 + h2 { "Your thing is live" } 1058 + p { "It was published to your repository and is now public." } 1059 + Link { 1060 + class: "button button-primary", 1061 + to: Route::ThingDetail { repo, rkey }, 1062 + "View your published thing" 1063 + } 1064 + } 1065 + } 1066 + } 1067 + } 1068 + 1069 + /// Best-effort MIME type from a filename extension (the server re-sniffs image 1070 + /// dimensions independently). 1071 + fn mime_from_filename(name: &str) -> &'static str { 1072 + match extension(name).as_deref() { 1073 + Some("stl") => "model/stl", 1074 + Some("obj") => "model/obj", 1075 + Some("gltf") => "model/gltf+json", 1076 + Some("glb") => "model/gltf-binary", 1077 + Some("3mf") => "model/3mf", 1078 + Some("step") | Some("stp") => "model/step", 1079 + Some("png") => "image/png", 1080 + Some("jpg") | Some("jpeg") => "image/jpeg", 1081 + Some("webp") => "image/webp", 1082 + Some("gif") => "image/gif", 1083 + _ => "application/octet-stream", 1084 + } 1085 + } 1086 + 1087 + /// Short geometry format label from a filename, if recognized. 1088 + fn format_from_filename(name: &str) -> Option<String> { 1089 + match extension(name).as_deref() { 1090 + Some("stl") => Some("stl".into()), 1091 + Some("obj") => Some("obj".into()), 1092 + Some("gltf") | Some("glb") => Some("gltf".into()), 1093 + Some("3mf") => Some("3mf".into()), 1094 + Some("step") | Some("stp") => Some("step".into()), 1095 + _ => None, 1096 + } 1097 + } 1098 + 1099 + fn extension(name: &str) -> Option<String> { 1100 + name.rsplit_once('.') 1101 + .map(|(_, ext)| ext.to_ascii_lowercase()) 1102 + } 1103 + 1104 + /// Human-readable byte size. 1105 + fn human_size(bytes: i64) -> String { 1106 + let bytes = bytes.max(0) as f64; 1107 + const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"]; 1108 + let mut value = bytes; 1109 + let mut unit = 0; 1110 + while value >= 1024.0 && unit < UNITS.len() - 1 { 1111 + value /= 1024.0; 1112 + unit += 1; 1113 + } 1114 + if unit == 0 { 1115 + format!("{} {}", value as i64, UNITS[unit]) 1116 + } else { 1117 + format!("{value:.1} {}", UNITS[unit]) 1118 + } 1119 + } 1120 + 1121 + #[cfg(test)] 1122 + mod tests { 1123 + use super::*; 1124 + 1125 + #[test] 1126 + fn mime_and_format_from_extension() { 1127 + assert_eq!(mime_from_filename("Tray.STL"), "model/stl"); 1128 + assert_eq!(mime_from_filename("photo.png"), "image/png"); 1129 + assert_eq!(mime_from_filename("weird"), "application/octet-stream"); 1130 + assert_eq!(format_from_filename("a.glb").as_deref(), Some("gltf")); 1131 + assert_eq!(format_from_filename("a.txt"), None); 1132 + } 1133 + 1134 + #[test] 1135 + fn human_size_scales_units() { 1136 + assert_eq!(human_size(512), "512 B"); 1137 + assert_eq!(human_size(2048), "2.0 KB"); 1138 + assert_eq!(human_size(5 * 1024 * 1024), "5.0 MB"); 54 1139 } 55 1140 }
+523
src/publish/draft.rs
··· 1 + //! PM-43 publish-draft data model. 2 + //! 3 + //! A draft is a server-side, non-public, in-progress publish composition. It is 4 + //! persisted as a [`DraftThingInput`]: a mirror of the generated 5 + //! [`ThingInput`](polymodel_api::space_polymodel::library::publish_thing::ThingInput) 6 + //! with every field optional, so a partial draft (saved before a name, license, 7 + //! or any model exists) round-trips losslessly. The same camelCase field names 8 + //! keep the payload shape aligned with the publishable record. 9 + //! 10 + //! Leaf media/reference types (`Image`, `File`, `Bbox`, `StrongRef`) are reused 11 + //! from the generated API at their default owned-string type, so an uploaded 12 + //! image/file ref stored in a draft is byte-identical to what `publishThing` 13 + //! consumes. 14 + //! 15 + //! [`DraftThingInput::assemble`] is the single conversion point from a draft to 16 + //! a publishable `ThingInput`: it validates required structure (which is 17 + //! enforced *structurally* by the typed record — `name`/`license`/`models`, 18 + //! `name`/`parts`, `name`+`file` are not optional there) and then defers the 19 + //! length/grapheme limits to the generated [`LexiconSchema::validate`], so draft 20 + //! validation cannot drift from the lexicon. 21 + 22 + use jacquard_common::DefaultStr; 23 + use jacquard_lexicon::schema::LexiconSchema; 24 + use polymodel_api::com_atproto::repo::strong_ref::StrongRef; 25 + use polymodel_api::space_polymodel::library::publish_thing::{ModelInput, PartInput, ThingInput}; 26 + use polymodel_api::space_polymodel::library::{Bbox, File, Image}; 27 + use serde::{Deserialize, Serialize}; 28 + 29 + /// The owned string type the generated records use (`DefaultStr`). 30 + type S = DefaultStr; 31 + 32 + // ---------------------------------------------------------------------------- 33 + // Draft endpoint wire types 34 + // 35 + // Defined here (cross-target) rather than in the server-only `appview::drafts` 36 + // module so the wasm client can share them with the server. 37 + // ---------------------------------------------------------------------------- 38 + 39 + /// Response for create/upsert: the opaque draft id the client should keep. 40 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 41 + pub struct DraftIdResponse { 42 + pub draft_id: String, 43 + } 44 + 45 + /// A draft listing row, rendered from denormalized columns (no payload parse). 46 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 47 + #[serde(rename_all = "camelCase")] 48 + pub struct DraftSummary { 49 + pub draft_id: String, 50 + pub name: Option<String>, 51 + pub model_count: i64, 52 + pub created_at: i64, 53 + pub updated_at: i64, 54 + } 55 + 56 + /// Response for delete: whether a row actually existed. 57 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 58 + pub struct DeleteDraftResponse { 59 + pub deleted: bool, 60 + } 61 + 62 + /// Response for image upload: a fully-formed `#image` ready to attach. 63 + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 64 + pub struct ImageUploadResponse { 65 + pub image: Image, 66 + } 67 + 68 + /// A `ThingInput`-shaped draft payload with every field optional. 69 + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] 70 + #[serde(rename_all = "camelCase")] 71 + pub struct DraftThingInput { 72 + #[serde(default, skip_serializing_if = "Option::is_none")] 73 + pub name: Option<String>, 74 + #[serde(default, skip_serializing_if = "Option::is_none")] 75 + pub license: Option<String>, 76 + #[serde(default, skip_serializing_if = "Option::is_none")] 77 + pub summary: Option<String>, 78 + #[serde(default, skip_serializing_if = "Option::is_none")] 79 + pub tags: Option<Vec<String>>, 80 + #[serde(default, skip_serializing_if = "Option::is_none")] 81 + pub instructions: Option<Vec<String>>, 82 + #[serde(default, skip_serializing_if = "Option::is_none")] 83 + pub cover: Option<Vec<Image>>, 84 + #[serde(default, skip_serializing_if = "Option::is_none")] 85 + pub previews: Option<Vec<Image>>, 86 + /// Out of scope for the PM-43 UI, carried as an optional pass-through. 87 + #[serde(default, skip_serializing_if = "Option::is_none")] 88 + pub derived_from: Option<StrongRef>, 89 + /// Out of scope for the PM-43 UI, carried as an optional pass-through. 90 + #[serde(default, skip_serializing_if = "Option::is_none")] 91 + pub intended_to_work_with: Option<Vec<StrongRef>>, 92 + #[serde(default)] 93 + pub models: Vec<DraftModelInput>, 94 + } 95 + 96 + /// A model within a draft. `parts` are ordered; an empty draft model is allowed 97 + /// (it simply fails publish validation until it has a name and at least one part). 98 + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] 99 + #[serde(rename_all = "camelCase")] 100 + pub struct DraftModelInput { 101 + #[serde(default, skip_serializing_if = "Option::is_none")] 102 + pub name: Option<String>, 103 + #[serde(default, skip_serializing_if = "Option::is_none")] 104 + pub summary: Option<String>, 105 + /// Per-model license override. Out of scope for the PM-43 UI. 106 + #[serde(default, skip_serializing_if = "Option::is_none")] 107 + pub license: Option<String>, 108 + #[serde(default, skip_serializing_if = "Option::is_none")] 109 + pub tags: Option<Vec<String>>, 110 + #[serde(default, skip_serializing_if = "Option::is_none")] 111 + pub instructions: Option<Vec<String>>, 112 + #[serde(default, skip_serializing_if = "Option::is_none")] 113 + pub cover: Option<Vec<Image>>, 114 + #[serde(default, skip_serializing_if = "Option::is_none")] 115 + pub previews: Option<Vec<Image>>, 116 + #[serde(default, skip_serializing_if = "Option::is_none")] 117 + pub derived_from: Option<StrongRef>, 118 + #[serde(default, skip_serializing_if = "Option::is_none")] 119 + pub intended_to_work_with: Option<Vec<StrongRef>>, 120 + #[serde(default)] 121 + pub parts: Vec<DraftPartInput>, 122 + } 123 + 124 + /// A part within a draft model. A part is publishable once it has a name and a 125 + /// successfully-staged file (an inline `file` manifest or a stage `upload_id`). 126 + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] 127 + #[serde(rename_all = "camelCase")] 128 + pub struct DraftPartInput { 129 + #[serde(default, skip_serializing_if = "Option::is_none")] 130 + pub name: Option<String>, 131 + /// stageFile upload id; the server resolves it to a `File` manifest at publish. 132 + #[serde(default, skip_serializing_if = "Option::is_none")] 133 + pub upload_id: Option<String>, 134 + /// Inline file manifest (alternative to `upload_id`). 135 + #[serde(default, skip_serializing_if = "Option::is_none")] 136 + pub file: Option<File>, 137 + #[serde(default, skip_serializing_if = "Option::is_none")] 138 + pub format: Option<String>, 139 + #[serde(default, skip_serializing_if = "Option::is_none")] 140 + pub notes: Option<String>, 141 + /// Out of scope for the PM-43 UI, carried as an optional pass-through. 142 + #[serde(default, skip_serializing_if = "Option::is_none")] 143 + pub units: Option<String>, 144 + #[serde(default, skip_serializing_if = "Option::is_none")] 145 + pub print_settings: Option<Vec<String>>, 146 + #[serde(default, skip_serializing_if = "Option::is_none")] 147 + pub dimensions: Option<Bbox>, 148 + #[serde(default, skip_serializing_if = "Option::is_none")] 149 + pub previews: Option<Vec<Image>>, 150 + /// Out of scope for the PM-43 UI, carried as an optional pass-through. 151 + #[serde(default, skip_serializing_if = "Option::is_none")] 152 + pub derived_from: Option<StrongRef>, 153 + /// Out of scope for the PM-43 UI, carried as an optional pass-through. 154 + #[serde(default, skip_serializing_if = "Option::is_none")] 155 + pub intended_to_work_with: Option<Vec<StrongRef>>, 156 + } 157 + 158 + /// One actionable validation problem, addressed to a draft field path. 159 + #[derive(Debug, Clone, PartialEq, Eq)] 160 + pub struct ValidationIssue { 161 + pub field: String, 162 + pub message: String, 163 + } 164 + 165 + /// All validation problems found while assembling a draft into a `ThingInput`. 166 + #[derive(Debug, Clone, PartialEq, Eq, Default)] 167 + pub struct ValidationErrors { 168 + pub issues: Vec<ValidationIssue>, 169 + } 170 + 171 + impl ValidationErrors { 172 + fn push(&mut self, field: impl Into<String>, message: impl Into<String>) { 173 + self.issues.push(ValidationIssue { 174 + field: field.into(), 175 + message: message.into(), 176 + }); 177 + } 178 + 179 + pub fn is_empty(&self) -> bool { 180 + self.issues.is_empty() 181 + } 182 + 183 + /// Whether any issue targets the given field path exactly. 184 + pub fn has_field(&self, field: &str) -> bool { 185 + self.issues.iter().any(|i| i.field == field) 186 + } 187 + } 188 + 189 + impl std::fmt::Display for ValidationErrors { 190 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 191 + let mut first = true; 192 + for issue in &self.issues { 193 + if !first { 194 + write!(f, "; ")?; 195 + } 196 + write!(f, "{}: {}", issue.field, issue.message)?; 197 + first = false; 198 + } 199 + Ok(()) 200 + } 201 + } 202 + 203 + fn nonempty(value: &Option<String>) -> bool { 204 + value 205 + .as_deref() 206 + .map(|v| !v.trim().is_empty()) 207 + .unwrap_or(false) 208 + } 209 + 210 + fn into_s(value: &str) -> S { 211 + S::from(value.trim()) 212 + } 213 + 214 + fn opt_s(value: &Option<String>) -> Option<S> { 215 + value 216 + .as_deref() 217 + .map(str::trim) 218 + .filter(|v| !v.is_empty()) 219 + .map(S::from) 220 + } 221 + 222 + fn opt_vec_s(value: &Option<Vec<String>>) -> Option<Vec<S>> { 223 + value 224 + .as_ref() 225 + .map(|items| items.iter().map(|s| S::from(s.trim())).collect::<Vec<_>>()) 226 + } 227 + 228 + impl DraftPartInput { 229 + /// A part is backed by a staged file when it has either an inline manifest 230 + /// or a non-empty upload id. 231 + pub fn has_file(&self) -> bool { 232 + self.file.is_some() || nonempty(&self.upload_id) 233 + } 234 + 235 + /// A part is publishable when it has a non-empty name and a staged file. 236 + pub fn is_publishable(&self) -> bool { 237 + nonempty(&self.name) && self.has_file() 238 + } 239 + } 240 + 241 + impl DraftModelInput { 242 + /// A model is publishable when it has a non-empty name and at least one 243 + /// publishable part. 244 + pub fn is_publishable(&self) -> bool { 245 + nonempty(&self.name) 246 + && !self.parts.is_empty() 247 + && self.parts.iter().all(|p| p.is_publishable()) 248 + } 249 + } 250 + 251 + impl DraftThingInput { 252 + /// Display name for draft listings, if set. 253 + pub fn display_name(&self) -> Option<&str> { 254 + self.name 255 + .as_deref() 256 + .map(str::trim) 257 + .filter(|v| !v.is_empty()) 258 + } 259 + 260 + /// Number of models (for the denormalized `model_count` listing column). 261 + pub fn model_count(&self) -> i64 { 262 + self.models.len() as i64 263 + } 264 + 265 + /// Step 1 (Basics) is complete when name and license are both set. 266 + pub fn basics_complete(&self) -> bool { 267 + nonempty(&self.name) && nonempty(&self.license) 268 + } 269 + 270 + /// Step 2 (Files & parts) is complete when there is at least one model and 271 + /// every model is publishable. 272 + pub fn files_complete(&self) -> bool { 273 + !self.models.is_empty() && self.models.iter().all(|m| m.is_publishable()) 274 + } 275 + 276 + /// Validate the draft and convert it into a publishable `ThingInput`. 277 + /// 278 + /// Required structure is checked here (the draft is all-optional); the 279 + /// length/grapheme limits are delegated to the generated 280 + /// [`LexiconSchema::validate`] on the assembled records so this code never 281 + /// re-declares the lexicon's constraints. 282 + pub fn assemble(&self) -> Result<ThingInput<S>, ValidationErrors> { 283 + let mut errors = ValidationErrors::default(); 284 + 285 + if !nonempty(&self.name) { 286 + errors.push("name", "A name is required to publish."); 287 + } 288 + if !nonempty(&self.license) { 289 + errors.push("license", "A license is required to publish."); 290 + } 291 + if self.models.is_empty() { 292 + errors.push("models", "Add at least one model with a file."); 293 + } 294 + 295 + for (mi, model) in self.models.iter().enumerate() { 296 + if !nonempty(&model.name) { 297 + errors.push(format!("models[{mi}].name"), "Each model needs a name."); 298 + } 299 + if model.parts.is_empty() { 300 + errors.push( 301 + format!("models[{mi}].parts"), 302 + "Each model needs at least one part with a file.", 303 + ); 304 + } 305 + for (pi, part) in model.parts.iter().enumerate() { 306 + if !nonempty(&part.name) { 307 + errors.push( 308 + format!("models[{mi}].parts[{pi}].name"), 309 + "Each part needs a name.", 310 + ); 311 + } 312 + if !part.has_file() { 313 + errors.push( 314 + format!("models[{mi}].parts[{pi}].file"), 315 + "Upload a file for this part.", 316 + ); 317 + } 318 + } 319 + } 320 + 321 + if !errors.is_empty() { 322 + return Err(errors); 323 + } 324 + 325 + // Presence is satisfied above; this pass only constructs typed records. 326 + let models = self 327 + .models 328 + .iter() 329 + .map(|model| { 330 + let parts = model 331 + .parts 332 + .iter() 333 + .map(|part| PartInput { 334 + name: into_s(part.name.as_deref().unwrap_or_default()), 335 + upload_id: opt_s(&part.upload_id), 336 + file: part.file.clone(), 337 + format: opt_s(&part.format), 338 + notes: opt_s(&part.notes), 339 + units: opt_s(&part.units), 340 + print_settings: opt_vec_s(&part.print_settings), 341 + dimensions: part.dimensions.clone(), 342 + previews: part.previews.clone(), 343 + derived_from: part.derived_from.clone(), 344 + intended_to_work_with: part.intended_to_work_with.clone(), 345 + ..Default::default() 346 + }) 347 + .collect(); 348 + ModelInput { 349 + cover: model.cover.clone(), 350 + derived_from: model.derived_from.clone(), 351 + instructions: opt_vec_s(&model.instructions), 352 + intended_to_work_with: model.intended_to_work_with.clone(), 353 + license: opt_s(&model.license), 354 + name: into_s(model.name.as_deref().unwrap_or_default()), 355 + parts, 356 + previews: model.previews.clone(), 357 + summary: opt_s(&model.summary), 358 + tags: opt_vec_s(&model.tags), 359 + extra_data: None, 360 + } 361 + }) 362 + .collect::<Vec<_>>(); 363 + 364 + let thing = ThingInput { 365 + cover: self.cover.clone(), 366 + derived_from: self.derived_from.clone(), 367 + instructions: opt_vec_s(&self.instructions), 368 + intended_to_work_with: self.intended_to_work_with.clone(), 369 + license: into_s(self.license.as_deref().unwrap_or_default()), 370 + models, 371 + name: into_s(self.name.as_deref().unwrap_or_default()), 372 + previews: self.previews.clone(), 373 + summary: opt_s(&self.summary), 374 + tags: opt_vec_s(&self.tags), 375 + extra_data: None, 376 + }; 377 + 378 + // Defer length/grapheme limits to the generated lexicon validators so 379 + // this code never duplicates (and cannot drift from) the constraints. 380 + if let Err(err) = thing.validate() { 381 + errors.push("name", lexicon_message(&err)); 382 + } 383 + for (mi, model) in thing.models.iter().enumerate() { 384 + if let Err(err) = model.validate() { 385 + errors.push(format!("models[{mi}]"), lexicon_message(&err)); 386 + } 387 + for (pi, part) in model.parts.iter().enumerate() { 388 + if let Err(err) = part.validate() { 389 + errors.push(format!("models[{mi}].parts[{pi}]"), lexicon_message(&err)); 390 + } 391 + } 392 + } 393 + 394 + if errors.is_empty() { 395 + Ok(thing) 396 + } else { 397 + Err(errors) 398 + } 399 + } 400 + } 401 + 402 + fn lexicon_message(err: &jacquard_lexicon::validation::ConstraintError) -> String { 403 + format!("Exceeds a lexicon limit: {err}") 404 + } 405 + 406 + #[cfg(test)] 407 + mod tests { 408 + use super::*; 409 + 410 + fn part_with_upload(name: &str) -> DraftPartInput { 411 + DraftPartInput { 412 + name: Some(name.to_string()), 413 + upload_id: Some("abcdef0123456789-42".to_string()), 414 + ..Default::default() 415 + } 416 + } 417 + 418 + fn complete_draft() -> DraftThingInput { 419 + DraftThingInput { 420 + name: Some("Desk Organizer".to_string()), 421 + license: Some("CC-BY-4.0".to_string()), 422 + summary: Some("A tidy desk caddy.".to_string()), 423 + models: vec![DraftModelInput { 424 + name: Some("Tray".to_string()), 425 + parts: vec![part_with_upload("tray.stl")], 426 + ..Default::default() 427 + }], 428 + ..Default::default() 429 + } 430 + } 431 + 432 + #[test] 433 + fn partial_draft_round_trips_through_json() { 434 + let draft = DraftThingInput { 435 + name: Some("WIP".to_string()), 436 + ..Default::default() 437 + }; 438 + let json = serde_json::to_string(&draft).unwrap(); 439 + let restored: DraftThingInput = serde_json::from_str(&json).unwrap(); 440 + assert_eq!(draft, restored); 441 + // A bare partial draft must not serialize required-but-absent fields. 442 + assert!(!json.contains("license")); 443 + assert!(json.contains("\"name\":\"WIP\"")); 444 + } 445 + 446 + #[test] 447 + fn empty_draft_round_trips_and_is_not_publishable() { 448 + let draft = DraftThingInput::default(); 449 + let json = serde_json::to_string(&draft).unwrap(); 450 + let restored: DraftThingInput = serde_json::from_str(&json).unwrap(); 451 + assert_eq!(draft, restored); 452 + assert!(!draft.basics_complete()); 453 + assert!(!draft.files_complete()); 454 + assert!(draft.assemble().is_err()); 455 + } 456 + 457 + #[test] 458 + fn complete_draft_assembles_to_valid_thing_input() { 459 + let draft = complete_draft(); 460 + assert!(draft.basics_complete()); 461 + assert!(draft.files_complete()); 462 + let thing = draft.assemble().expect("complete draft assembles"); 463 + assert_eq!(thing.name.as_str(), "Desk Organizer"); 464 + assert_eq!(thing.license.as_str(), "CC-BY-4.0"); 465 + assert_eq!(thing.models.len(), 1); 466 + assert_eq!(thing.models[0].name.as_str(), "Tray"); 467 + assert_eq!(thing.models[0].parts.len(), 1); 468 + assert_eq!(thing.models[0].parts[0].name.as_str(), "tray.stl"); 469 + // The assembled record passes the generated lexicon validators. 470 + assert!(thing.validate().is_ok()); 471 + } 472 + 473 + #[test] 474 + fn missing_thing_fields_are_reported() { 475 + let draft = DraftThingInput::default(); 476 + let errors = draft.assemble().expect_err("empty draft fails"); 477 + assert!(errors.has_field("name")); 478 + assert!(errors.has_field("license")); 479 + assert!(errors.has_field("models")); 480 + } 481 + 482 + #[test] 483 + fn missing_model_name_and_parts_are_reported() { 484 + let mut draft = complete_draft(); 485 + draft.models = vec![DraftModelInput::default()]; 486 + let errors = draft 487 + .assemble() 488 + .expect_err("model with no name/parts fails"); 489 + assert!(errors.has_field("models[0].name")); 490 + assert!(errors.has_field("models[0].parts")); 491 + } 492 + 493 + #[test] 494 + fn part_without_file_is_reported() { 495 + let mut draft = complete_draft(); 496 + draft.models[0].parts = vec![DraftPartInput { 497 + name: Some("loose.stl".to_string()), 498 + ..Default::default() 499 + }]; 500 + let errors = draft.assemble().expect_err("part with no file fails"); 501 + assert!(errors.has_field("models[0].parts[0].file")); 502 + assert!(!errors.has_field("models[0].parts[0].name")); 503 + } 504 + 505 + #[test] 506 + fn over_long_name_trips_lexicon_validator() { 507 + let mut draft = complete_draft(); 508 + // 65 graphemes exceeds the 64-grapheme name limit. 509 + draft.name = Some("x".repeat(65)); 510 + let errors = draft 511 + .assemble() 512 + .expect_err("over-long name fails lexicon validate"); 513 + assert!(errors.has_field("name")); 514 + } 515 + 516 + #[test] 517 + fn whitespace_only_name_is_not_complete() { 518 + let mut draft = complete_draft(); 519 + draft.name = Some(" ".to_string()); 520 + assert!(!draft.basics_complete()); 521 + assert!(draft.assemble().is_err()); 522 + } 523 + }
+120
src/publish/draft_client.rs
··· 1 + //! Same-origin client calls for the PM-43 publish wizard. 2 + //! 3 + //! Thin typed wrappers over [`PolymodelClient`]'s raw app-request helpers for 4 + //! the draft store, raw-byte file staging, and image upload. Every call rides 5 + //! the browser cookie session (same-origin), so these are authenticated without 6 + //! any explicit token handling. 7 + 8 + use http::Method; 9 + use polymodel_api::space_polymodel::library::Image; 10 + use polymodel_api::space_polymodel::library::publish_thing::PublishThingOutput; 11 + use polymodel_api::space_polymodel::library::stage_file::StageFileOutput; 12 + 13 + use crate::client::{PolymodelClient, RawError}; 14 + use crate::publish::draft::{ 15 + DeleteDraftResponse, DraftIdResponse, DraftSummary, DraftThingInput, ImageUploadResponse, 16 + }; 17 + 18 + /// stageFile XRPC path (raw-body upload), reused from the existing write path. 19 + const STAGE_FILE_PATH: &str = "/xrpc/space.polymodel.library.stageFile"; 20 + const DRAFTS_PATH: &str = "/app/drafts"; 21 + const IMAGES_PATH: &str = "/app/images"; 22 + 23 + /// Create a new server-side draft; returns the server-assigned opaque id. 24 + pub async fn create_draft( 25 + client: &PolymodelClient, 26 + draft: &DraftThingInput, 27 + ) -> Result<String, RawError> { 28 + let response: DraftIdResponse = client 29 + .app_json(Method::POST, DRAFTS_PATH, Some(draft)) 30 + .await?; 31 + Ok(response.draft_id) 32 + } 33 + 34 + /// Upsert an existing draft by id (autosave). 35 + pub async fn put_draft( 36 + client: &PolymodelClient, 37 + draft_id: &str, 38 + draft: &DraftThingInput, 39 + ) -> Result<(), RawError> { 40 + let _: DraftIdResponse = client 41 + .app_json(Method::PUT, &draft_path(draft_id), Some(draft)) 42 + .await?; 43 + Ok(()) 44 + } 45 + 46 + /// Fetch a single draft payload (resume). 47 + pub async fn get_draft( 48 + client: &PolymodelClient, 49 + draft_id: &str, 50 + ) -> Result<DraftThingInput, RawError> { 51 + client 52 + .app_json::<(), _>(Method::GET, &draft_path(draft_id), None) 53 + .await 54 + } 55 + 56 + /// List the signed-in actor's draft summaries. 57 + pub async fn list_drafts(client: &PolymodelClient) -> Result<Vec<DraftSummary>, RawError> { 58 + client 59 + .app_json::<(), _>(Method::GET, DRAFTS_PATH, None) 60 + .await 61 + } 62 + 63 + /// Delete a draft; returns whether a row existed. 64 + pub async fn delete_draft(client: &PolymodelClient, draft_id: &str) -> Result<bool, RawError> { 65 + let response: DeleteDraftResponse = client 66 + .app_json::<(), _>(Method::DELETE, &draft_path(draft_id), None) 67 + .await?; 68 + Ok(response.deleted) 69 + } 70 + 71 + /// Assemble + publish a draft to the actor's PDS; the server deletes the draft 72 + /// only on success. 73 + pub async fn publish_draft( 74 + client: &PolymodelClient, 75 + draft_id: &str, 76 + ) -> Result<PublishThingOutput, RawError> { 77 + let path = format!("{DRAFTS_PATH}/{draft_id}/publish"); 78 + client.app_json::<(), _>(Method::POST, &path, None).await 79 + } 80 + 81 + /// Stage a geometry file: raw bytes → blobs on the actor's PDS + reassembly 82 + /// metadata. Returns the `uploadId`/`file` the part references. 83 + pub async fn stage_file( 84 + client: &PolymodelClient, 85 + filename: &str, 86 + content_type: &str, 87 + bytes: Vec<u8>, 88 + ) -> Result<StageFileOutput, RawError> { 89 + client 90 + .app_post_bytes( 91 + STAGE_FILE_PATH, 92 + content_type, 93 + &[("x-polymodel-filename", filename.to_string())], 94 + bytes, 95 + ) 96 + .await 97 + } 98 + 99 + /// Upload an image and receive a fully-formed `#image` (blobref + server-decoded 100 + /// aspect ratio + the given alt text). 101 + pub async fn upload_image( 102 + client: &PolymodelClient, 103 + alt: &str, 104 + content_type: &str, 105 + bytes: Vec<u8>, 106 + ) -> Result<Image, RawError> { 107 + let response: ImageUploadResponse = client 108 + .app_post_bytes( 109 + IMAGES_PATH, 110 + content_type, 111 + &[("x-polymodel-alt", alt.to_string())], 112 + bytes, 113 + ) 114 + .await?; 115 + Ok(response.image) 116 + } 117 + 118 + fn draft_path(draft_id: &str) -> String { 119 + format!("{DRAFTS_PATH}/{draft_id}") 120 + }
+100
src/publish/state.rs
··· 1 + //! PM-43 wizard step model and publish phase. 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 + } 62 + 63 + /// Lifecycle of the publish action, surfaced as the persistent status indicator. 64 + #[derive(Clone, PartialEq, Debug)] 65 + pub enum PublishPhase { 66 + /// Drafting; nothing is public. 67 + Editing, 68 + /// A publish request is in flight. 69 + Publishing, 70 + /// Published; the thing is reachable at this repo/rkey. 71 + Published { repo: String, rkey: String }, 72 + /// The publish attempt failed; the draft is preserved. 73 + Failed(String), 74 + } 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 + }
+1 -1
src/shell.rs
··· 32 32 } 33 33 } 34 34 ShellSearch {} 35 - Link { class: "button button-primary app-publish-cta", to: Route::Publish {}, "Publish" } 35 + Link { class: "button button-primary app-publish-cta", to: Route::Publish { draft: String::new() }, "Publish" } 36 36 SessionControl { session, toasts } 37 37 } 38 38 }