From 28acf36103b699b2a6a629aa2cacacc51ed43e22 Mon Sep 17 00:00:00 2001 From: Orual Date: Tue, 23 Jun 2026 09:37:29 -0400 Subject: [PATCH] PM-43: publish wizard + server-side drafts --- ...d2d2ea78f42a02e9db75d371624256361d46d.json | 74 ++ ...46041d42a5e041251cb579cf612a61108899c.json | 12 + ...ae230def27bda085e873eb19bee6e4c19ce2f.json | 12 + ...219bf0ec46915a10dff50f17ceffb3e438f2b.json | 26 + Cargo.lock | 70 +- Cargo.toml | 21 +- assets/styling/placeholders.css | 22 +- assets/styling/publish.css | 449 +++++++ e2e/tests/home.spec.ts | 6 - e2e/tests/publish.spec.ts | 28 + migrations/005_drafts.sql | 24 + src/appview/drafts.rs | 475 +++++++ src/appview/mod.rs | 19 + src/appview/writes.rs | 18 +- src/client.rs | 199 +++ src/main.rs | 6 +- src/publish.rs | 1131 ++++++++++++++++- src/publish/draft.rs | 523 ++++++++ src/publish/draft_client.rs | 120 ++ src/publish/state.rs | 100 ++ src/shell.rs | 2 +- 21 files changed, 3253 insertions(+), 84 deletions(-) create mode 100644 .sqlx/query-9977dacbaf568cc7abdf59d3b5fd2d2ea78f42a02e9db75d371624256361d46d.json create mode 100644 .sqlx/query-a578dfdbdd96d8d0d58c81a9cc946041d42a5e041251cb579cf612a61108899c.json create mode 100644 .sqlx/query-d177b801f2655e545208fa65b95ae230def27bda085e873eb19bee6e4c19ce2f.json create mode 100644 .sqlx/query-d7161a09f70f40cbc69a2dbe8ba219bf0ec46915a10dff50f17ceffb3e438f2b.json create mode 100644 assets/styling/publish.css create mode 100644 e2e/tests/publish.spec.ts create mode 100644 migrations/005_drafts.sql create mode 100644 src/appview/drafts.rs create mode 100644 src/publish/draft.rs create mode 100644 src/publish/draft_client.rs create mode 100644 src/publish/state.rs diff --git a/.sqlx/query-9977dacbaf568cc7abdf59d3b5fd2d2ea78f42a02e9db75d371624256361d46d.json b/.sqlx/query-9977dacbaf568cc7abdf59d3b5fd2d2ea78f42a02e9db75d371624256361d46d.json new file mode 100644 index 0000000..87b7270 --- /dev/null +++ b/.sqlx/query-9977dacbaf568cc7abdf59d3b5fd2d2ea78f42a02e9db75d371624256361d46d.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "SELECT draft_id, name, model_count, created_at, updated_at FROM drafts WHERE owner_did = ? ORDER BY updated_at DESC", + "describe": { + "columns": [ + { + "name": "draft_id", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "drafts", + "name": "draft_id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "drafts", + "name": "name" + } + } + }, + { + "name": "model_count", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "drafts", + "name": "model_count" + } + } + }, + { + "name": "created_at", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "drafts", + "name": "created_at" + } + } + }, + { + "name": "updated_at", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "drafts", + "name": "updated_at" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + false, + false, + false + ] + }, + "hash": "9977dacbaf568cc7abdf59d3b5fd2d2ea78f42a02e9db75d371624256361d46d" +} diff --git a/.sqlx/query-a578dfdbdd96d8d0d58c81a9cc946041d42a5e041251cb579cf612a61108899c.json b/.sqlx/query-a578dfdbdd96d8d0d58c81a9cc946041d42a5e041251cb579cf612a61108899c.json new file mode 100644 index 0000000..568f917 --- /dev/null +++ b/.sqlx/query-a578dfdbdd96d8d0d58c81a9cc946041d42a5e041251cb579cf612a61108899c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM drafts WHERE owner_did = ? AND draft_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "a578dfdbdd96d8d0d58c81a9cc946041d42a5e041251cb579cf612a61108899c" +} diff --git a/.sqlx/query-d177b801f2655e545208fa65b95ae230def27bda085e873eb19bee6e4c19ce2f.json b/.sqlx/query-d177b801f2655e545208fa65b95ae230def27bda085e873eb19bee6e4c19ce2f.json new file mode 100644 index 0000000..0341b7a --- /dev/null +++ b/.sqlx/query-d177b801f2655e545208fa65b95ae230def27bda085e873eb19bee6e4c19ce2f.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "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", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "d177b801f2655e545208fa65b95ae230def27bda085e873eb19bee6e4c19ce2f" +} diff --git a/.sqlx/query-d7161a09f70f40cbc69a2dbe8ba219bf0ec46915a10dff50f17ceffb3e438f2b.json b/.sqlx/query-d7161a09f70f40cbc69a2dbe8ba219bf0ec46915a10dff50f17ceffb3e438f2b.json new file mode 100644 index 0000000..1e41778 --- /dev/null +++ b/.sqlx/query-d7161a09f70f40cbc69a2dbe8ba219bf0ec46915a10dff50f17ceffb3e438f2b.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT payload_json FROM drafts WHERE owner_did = ? AND draft_id = ?", + "describe": { + "columns": [ + { + "name": "payload_json", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "drafts", + "name": "payload_json" + } + } + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false + ] + }, + "hash": "d7161a09f70f40cbc69a2dbe8ba219bf0ec46915a10dff50f17ceffb3e438f2b" +} diff --git a/Cargo.lock b/Cargo.lock index e1576dd..f26a525 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -130,9 +130,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "approx" @@ -154,9 +154,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] @@ -534,6 +534,12 @@ dependencies = [ "match-lookup", ] +[[package]] +name = "base45" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240e56f4d3c453c36faacb695c535a4d5f8c7d23dac175014f32eb0a71012a03" + [[package]] name = "base64" version = "0.22.1" @@ -822,9 +828,9 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -3733,9 +3739,9 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ "typenum", ] @@ -4027,6 +4033,12 @@ dependencies = [ "quick-error", ] +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + [[package]] name = "imgref" version = "1.12.2" @@ -5212,9 +5224,9 @@ dependencies = [ [[package]] name = "memmap2" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -5366,12 +5378,13 @@ dependencies = [ [[package]] name = "multibase" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" +checksum = "7e0e4a371cbf1dfd666b658ba137763edb23c45beb43cfe369b5593cd6b437b6" dependencies = [ "base-x", "base256emoji", + "base45", "data-encoding", "data-encoding-macro", ] @@ -6201,10 +6214,12 @@ dependencies = [ "http", "http-body-util", "hydrant", + "imagesize", "jacquard", "jacquard-axum", "jacquard-common 0.12.1", "jacquard-derive 0.12.1", + "jacquard-lexicon 0.12.1", "js-sys", "keyring", "libsqlite3-sys", @@ -6225,6 +6240,7 @@ dependencies = [ "tracing", "tracing-subscriber", "tracing-wasm", + "ulid", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -6462,9 +6478,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick_cache" -version = "0.6.23" +version = "0.6.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3db184a8b66cfe87f0263a1de147a6b554c864d1767c6f7fa4eb0e5497b565" +checksum = "b9c6658afe513a3b484e3abfdaa0d03ef3c0bbf017542c178dd55f94eb3051f9" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -7016,9 +7032,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "aws-lc-rs", "log", @@ -8397,9 +8413,9 @@ dependencies = [ [[package]] name = "tokio-websockets" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb" +checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" dependencies = [ "aws-lc-rs", "base64", @@ -8667,9 +8683,9 @@ dependencies = [ [[package]] name = "triomphe" -version = "0.1.15" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" +checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" [[package]] name = "try-lock" @@ -8761,6 +8777,16 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.4", + "web-time", +] + [[package]] name = "unicase" version = "2.9.0" @@ -8879,9 +8905,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.3" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index 9267390..67921a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,10 @@ server = [ "dep:base64", "dep:sha2", "dep:zip", + # PM-43: server-side decode of uploaded image dimensions (for #image + # aspectRatio) and opaque draft id generation. + "dep:imagesize", + "dep:ulid", ] [dependencies] dioxus = { version = "0.7", features = ["router", "fullstack"] } @@ -59,6 +63,9 @@ jacquard = { workspace = true } jacquard-axum = { workspace = true, optional = true } jacquard-common = { workspace = true } jacquard-derive = { workspace = true } +# PM-43: LexiconSchema::validate for draft → ThingInput assembly, so draft +# validation tracks the generated lexicon constraints instead of duplicating them. +jacquard-lexicon = { workspace = true } # Generated lexicon bindings (typed request/response/view types for XRPC routes). polymodel-api = { path = "crates/polymodel-api" } serde = { workspace = true } @@ -111,6 +118,10 @@ keyring = { version = "3", optional = true } base64 = { version = "0.22", optional = true } sha2 = { version = "0.10", optional = true } zip = { version = "2", default-features = false, optional = true } +# PM-43: header-only image dimension probe (no full decode) for #image +# aspectRatio; ULID for opaque, sortable draft ids. Both server-only. +imagesize = { version = "0.13", optional = true } +ulid = { version = "1", optional = true } [target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies] @@ -119,7 +130,7 @@ js-sys = "0.3" tracing-wasm = "0.2" wasm-bindgen = "=0.2.121" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Event", "EventTarget", "HtmlCanvasElement", "WebGl2RenderingContext", "Performance", "MouseEvent", "WheelEvent", "PointerEvent", "DomRect", "BroadcastChannel", "console", "Response"] } +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"] } [dev-dependencies] anyhow = "1" @@ -130,8 +141,10 @@ chrono = "0.4" futures = "0.3" hydrant = { git = "https://tangled.org/ptr.pet/hydrant", rev = "1145fa1bb194372ceea81ab48b5d68d304da34ad", default-features = false, features = ["indexer", "indexer_stream"] } http-body-util = "0.1" +imagesize = "0.13" jacquard-axum = { workspace = true } keyring = "3" +ulid = "1" libsqlite3-sys = { version = "0.37", features = ["bundled"] } rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs", "std"] } sha2 = "0.10" @@ -149,12 +162,6 @@ tracing-subscriber = { version = "0.3", default-features = false, features = [ "fmt", "env-filter", ] } -# Mirror the optional server-only deps so the `#[cfg(any(feature = "server", -# test))]` server modules (indexing/appview/oauth) compile and their tests run -# under a plain `cargo test`, not only `--features server`. -axum-extra = { version = "0.10", features = ["cookie"] } -base64 = "0.22" -keyring = "3" [build-dependencies] dotenvy = "0.15" diff --git a/assets/styling/placeholders.css b/assets/styling/placeholders.css index ef0bda7..7b9da1a 100644 --- a/assets/styling/placeholders.css +++ b/assets/styling/placeholders.css @@ -1,8 +1,8 @@ -/* Honest placeholder routes: /search and /publish. Reserved surfaces with - clearly-labeled "not built yet" content, inside the shell content region. */ +/* Honest placeholder route: /search. Reserved surface with clearly-labeled + "not built yet" content, inside the shell content region. The /publish route + is a real wizard now and owns publish.css. */ -.search-page, -.publish-page { +.search-page { flex: 1; min-height: 0; width: 100%; @@ -14,29 +14,19 @@ gap: 0.9rem; } -.search-intro, -.publish-intro, -.publish-signed-out, -.publish-steps { +.search-intro { display: flex; flex-direction: column; gap: 0.75rem; padding: clamp(1.25rem, 3vw, 2rem); } -.search-intro h1, -.publish-intro h1 { +.search-intro h1 { margin: 0; color: var(--color-emphasis); letter-spacing: -0.03em; } -.publish-signed-out h2, -.publish-steps h2 { - margin: 0; - color: var(--color-emphasis); -} - .search-note { margin: 0; color: var(--color-muted); diff --git a/assets/styling/publish.css b/assets/styling/publish.css new file mode 100644 index 0000000..722915c --- /dev/null +++ b/assets/styling/publish.css @@ -0,0 +1,449 @@ +/* PM-43 publish wizard. Depends on theme.css tokens, base.css reset, and the + primitives layer (.blueprint-panel, .button, .tab, .status-pill, .field-label, + .card-meta, .blueprint-media). Page layer — loads after the shell. */ + +.publish-page { + flex: 1; + min-height: 0; + width: 100%; + max-width: 56rem; + margin: 0 auto; + padding: clamp(0.75rem, 2vw, 1.75rem) clamp(1rem, 3vw, 2.5rem); + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +/* Signed-out callout reuses the panel + session-control vocabulary. */ +.publish-intro, +.publish-signed-out { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: clamp(1.25rem, 3vw, 2rem); +} + +.publish-intro h1, +.publish-signed-out h2 { + margin: 0; + color: var(--color-emphasis); + letter-spacing: -0.03em; +} + +/* Header: title + draft/published status + autosave state. */ +.publish-head { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.publish-head-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.6rem; +} + +.publish-head-row h1 { + margin: 0; + color: var(--color-emphasis); + letter-spacing: -0.03em; +} + +.publish-save-state { + margin: 0; + color: var(--color-muted); + font-family: var(--font-mono); + font-size: 0.74rem; +} + +/* Stepper: each step is a .tab; complete steps pick up the success hue. */ +.wizard-steps { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.wizard-step { + gap: 0.4rem; + padding: 0.4rem 0.6rem; +} + +.wizard-step-num { + font-family: var(--font-mono); + font-size: 0.72rem; + opacity: 0.8; +} + +.wizard-step-label { + font-family: var(--font-ui); + font-weight: 600; +} + +.wizard-step.is-complete { + border-color: var(--color-success); + color: var(--color-success); + background: color-mix(in srgb, var(--color-success) 8%, transparent); +} + +.wizard-step-check { + color: var(--color-success); +} + +.wizard-step.is-active .wizard-step-check { + color: var(--color-tertiary); +} + +/* Body panel + step copy. */ +.wizard-body { + display: flex; + flex-direction: column; + gap: 1rem; + padding: clamp(1rem, 2.5vw, 1.75rem); +} + +.wizard-body h2 { + margin: 0; + color: var(--color-emphasis); + letter-spacing: -0.02em; +} + +.wizard-step-hint { + margin: 0; + color: var(--color-subtle); + max-width: 60ch; +} + +.wizard-subhead { + margin: 0; + color: var(--color-emphasis); + font-size: 1rem; +} + +/* Form fields — mirror the shell input vocabulary, sharp corners. */ +.field { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.field-input { + min-height: 2.1rem; + padding: 0.5rem 0.65rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-panel); + background: var(--color-surface); + color: var(--color-text); + font-family: var(--font-ui); + font-size: 0.95rem; +} + +.field-input::placeholder { + color: var(--color-subtle); +} + +.field-input:focus, +.field-input:focus-visible { + outline: 2px solid color-mix(in srgb, var(--color-primary) 25%, transparent); + outline-offset: 1px; + border-color: var(--color-primary); +} + +.field-textarea { + min-height: 4.5rem; + resize: vertical; + line-height: 1.5; +} + +.field-textarea-tall { + min-height: 8rem; +} + +.field-help { + color: var(--color-muted); +} + +/* Models & parts. */ +.wizard-model { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-panel); + background: color-mix(in srgb, var(--color-overlay) 40%, transparent); +} + +.wizard-model-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; +} + +.wizard-model-name { + flex: 1; + min-width: 12rem; +} + +.wizard-part-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.wizard-part { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; +} + +.wizard-part-name { + flex: 1; + min-width: 10rem; +} + +.wizard-part-meta { + white-space: nowrap; +} + +.wizard-empty { + margin: 0; + color: var(--color-muted); + font-style: italic; +} + +/* File input wrapped by a styled label button; hide the raw control. */ +.wizard-file-button { + cursor: pointer; +} + +.wizard-file-input { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +/* Upload progress + status. */ +.wizard-upload-status { + display: flex; + align-items: center; + gap: 0.6rem; + color: var(--color-subtle); + font-size: 0.9rem; +} + +.wizard-progress-bar { + position: relative; + flex: none; + width: 8rem; + height: 0.4rem; + border-radius: var(--radius-pill); + overflow: hidden; + background: var(--color-overlay); +} + +.wizard-progress-bar::after { + content: ""; + position: absolute; + inset: 0; + width: 40%; + border-radius: inherit; + background: var(--color-primary); + animation: wizard-progress 1.1s ease-in-out infinite; +} + +@keyframes wizard-progress { + from { transform: translateX(-110%); } + to { transform: translateX(310%); } +} + +/* Images. */ +.wizard-image-group { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.wizard-image-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.wizard-image { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.wizard-image-thumb { + flex: none; + width: 5rem; + aspect-ratio: 4 / 3; + border: 1px solid var(--color-border); + border-radius: var(--radius-panel); +} + +.wizard-image-alt { + flex: 1; + min-width: 12rem; +} + +/* Details step recap. */ +.wizard-recap { + margin: 0; + color: var(--color-subtle); +} + +/* Review. */ +.wizard-review { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.wizard-review-row { + display: grid; + grid-template-columns: 9rem 1fr; + gap: 0.75rem; + align-items: start; + padding-bottom: 0.6rem; + border-bottom: 1px solid var(--color-border); +} + +.wizard-review-row.is-error { + color: var(--color-error); +} + +.wizard-review-row.is-error .field-label { + color: var(--color-error); +} + +.wizard-review-models { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.wizard-review-model { + display: flex; + align-items: baseline; + gap: 0.6rem; +} + +/* Validation summary. */ +.wizard-issues { + gap: 0.5rem; +} + +/* Publish action row — the single primary action, clearly distinct. */ +.wizard-publish-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; + margin-top: 0.5rem; +} + +.wizard-publish { + font-size: 0.95rem; +} + +/* Resume list. */ +.wizard-resume { + display: flex; + flex-direction: column; + gap: 0.6rem; + padding: 1rem clamp(1rem, 2.5vw, 1.5rem); +} + +.wizard-resume-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.wizard-resume-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; + padding: 0.6rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-panel); +} + +.wizard-resume-meta { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.wizard-resume-name { + font-weight: 600; + color: var(--color-text); +} + +/* Navigation footer. */ +.wizard-nav { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; +} + +.wizard-nav-spacer { + flex: 1; +} + +/* Errors. */ +.wizard-error { + margin: 0; + color: var(--color-error); + font-size: 0.9rem; +} + +/* Published success. */ +.wizard-published { + gap: 0.75rem; +} + +.wizard-published h2 { + margin: 0; + color: var(--color-emphasis); +} + +@media (max-width: 32rem) { + .wizard-review-row { + grid-template-columns: 1fr; + gap: 0.25rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .wizard-progress-bar::after { + animation: none; + width: 100%; + opacity: 0.6; + } +} diff --git a/e2e/tests/home.spec.ts b/e2e/tests/home.spec.ts index a39ddf6..a3a2299 100644 --- a/e2e/tests/home.spec.ts +++ b/e2e/tests/home.spec.ts @@ -70,12 +70,6 @@ test('placeholder routes resolve with honest, non-faked content', async ({ page await page.goto('/search'); await expect(page.getByRole('heading', { name: 'Search', exact: true })).toBeVisible(); await expect(page.getByText('Not wired up yet')).toBeVisible(); - - await page.goto('/publish'); - await expect(page.getByRole('heading', { name: 'Publish a thing' })).toBeVisible(); - await expect(page.getByText('Coming soon')).toBeVisible(); - // Anonymous publish prompts sign-in (returns the user to /publish). - await expect(page.getByRole('heading', { name: 'Sign in to publish' })).toBeVisible(); }); test('home thing cards remain readable at narrow widths', async ({ page }) => { diff --git a/e2e/tests/publish.spec.ts b/e2e/tests/publish.spec.ts new file mode 100644 index 0000000..910acea --- /dev/null +++ b/e2e/tests/publish.spec.ts @@ -0,0 +1,28 @@ +import { expect, test } from '@playwright/test'; + +// Coverage boundary: the publish wizard renders only for an authenticated +// ATProto session, and a live PDS publish needs real OAuth, which this CI e2e +// harness cannot establish. So e2e covers the signed-out callout (no wizard); +// the signed-in wizard assembly, draft round-trip, validation, and promote-on- +// publish are covered by the Rust unit/integration suites (src/publish/draft.rs, +// src/appview/drafts.rs) plus the manual Playwright pass against `just serve`. + +test('signed-out /publish shows the sign-in callout, not the wizard', async ({ page }) => { + await page.goto('/publish'); + + await expect(page.getByRole('heading', { name: 'Publish a thing' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Sign in to publish' })).toBeVisible(); + const callout = page.locator('section[aria-label="Sign in to publish"]'); + await expect(callout.getByRole('button', { name: 'Sign in' })).toBeVisible(); + + // The signed-out surface must not expose the wizard stepper or publish action. + await expect(page.getByRole('navigation', { name: 'Publish steps' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Publish to your PDS' })).toHaveCount(0); +}); + +test('signed-out publish callout returns the user to /publish after sign-in', async ({ page }) => { + await page.goto('/publish'); + + const returnTo = page.locator('section[aria-label="Sign in to publish"] input[name="return_to"]'); + await expect(returnTo).toHaveValue('/publish'); +}); diff --git a/migrations/005_drafts.sql b/migrations/005_drafts.sql new file mode 100644 index 0000000..67ed396 --- /dev/null +++ b/migrations/005_drafts.sql @@ -0,0 +1,24 @@ +-- PM-43: server-side, non-public publish drafts. +-- +-- A draft is an in-progress publish composition that never reaches the actor's +-- PDS until publish. It is stored as a `DraftThingInput`-shaped JSON payload (a +-- ThingInput mirror with every field optional, so partial drafts round-trip). +-- Drafts are app-internal (not ATProto records, not federated) and are strictly +-- owner-scoped by DID. They live in their own table, so feeds — which read only +-- the `things` table — never surface them. +-- +-- `name` and `model_count` are denormalized copies refreshed on every upsert so +-- `listDrafts` can render entries without parsing each payload. +CREATE TABLE drafts ( + owner_did TEXT NOT NULL, + draft_id TEXT NOT NULL, + payload_json TEXT NOT NULL, + name TEXT, + model_count INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (owner_did, draft_id) +); + +CREATE INDEX idx_drafts_owner_updated + ON drafts(owner_did, updated_at DESC); diff --git a/src/appview/drafts.rs b/src/appview/drafts.rs new file mode 100644 index 0000000..79e1e0d --- /dev/null +++ b/src/appview/drafts.rs @@ -0,0 +1,475 @@ +//! PM-43 server-side publish drafts + app-internal image upload. +//! +//! Drafts are app-internal (not ATProto records, not federated): owner-scoped +//! rows in the `drafts` table holding a [`DraftThingInput`] JSON payload. They +//! never reach the actor's PDS until publish, and they live in their own table +//! so feeds (which read only `things`) never surface them. +//! +//! Every handler authenticates with the strict OAuth session (same extractor as +//! the PM-28 writes) and scopes every query by the authenticated DID, so no +//! actor can read, write, publish, or delete another actor's drafts. +//! +//! Routes (same-origin, cookie-authenticated; app-internal, not `/xrpc`): +//! - `POST /app/drafts` create (server assigns an opaque id) +//! - `PUT /app/drafts/{draft_id}` upsert an existing draft +//! - `GET /app/drafts` list draft summaries +//! - `GET /app/drafts/{draft_id}` fetch one draft payload +//! - `DELETE /app/drafts/{draft_id}` delete a draft +//! - `POST /app/drafts/{draft_id}/publish` assemble + publish + delete-on-success +//! - `POST /app/images` upload an image blob, return an `#image` + +use axum::Json; +use axum::body::Body; +use axum::extract::{Path, State}; +use axum::http::HeaderMap; +use jacquard::client::Agent; +use jacquard_axum::oauth::ExtractOAuthSession; +use jacquard_common::types::blob::BlobRef; +use jacquard_common::types::string::Did; +use polymodel_api::space_polymodel::library::publish_thing::{PublishThingOutput, ThingInput}; +use polymodel_api::space_polymodel::library::{AspectRatio, Image}; + +use super::error::{AppResult, db, internal, invalid_request, not_found}; +use super::state::AppState; +use super::writes::{ + ExtractSession, agent_upload_blob, authenticated_did, content_type, ensure_polymodel_profile, + publish_composition, +}; +use crate::publish::draft::{ + DeleteDraftResponse, DraftIdResponse, DraftSummary, DraftThingInput, ImageUploadResponse, +}; + +/// Image blobs are buffered fully (cover/preview art is small); cap to keep the +/// app from buffering an unbounded body. +const MAX_IMAGE_SIZE: usize = 20 * 1024 * 1024; + +// ---------------------------------------------------------------------------- +// Owner-scoped DB helpers (pure; unit-tested directly) +// ---------------------------------------------------------------------------- + +fn now_nanos() -> i64 { + chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() +} + +/// Insert or update a draft row, refreshing the denormalized `name`/`model_count` +/// and `updated_at`. `created_at` is preserved across updates. +pub(super) async fn upsert_draft( + state: &AppState, + owner: &Did, + draft_id: &str, + draft: &DraftThingInput, +) -> AppResult<()> { + let payload_json = + serde_json::to_string(draft).map_err(|e| internal(format!("draft serialize: {e}")))?; + let name = draft.display_name().map(ToOwned::to_owned); + let model_count = draft.model_count(); + let now = now_nanos(); + let owner_did = owner.as_ref(); + db(sqlx::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", + owner_did, + draft_id, + payload_json, + name, + model_count, + now, + now, + ) + .execute(&state.pool) + .await)?; + Ok(()) +} + +/// Load a single owner-scoped draft payload, if present. +pub(super) async fn get_draft( + state: &AppState, + owner: &Did, + draft_id: &str, +) -> AppResult> { + let owner_did = owner.as_ref(); + let row = db(sqlx::query!( + "SELECT payload_json FROM drafts WHERE owner_did = ? AND draft_id = ?", + owner_did, + draft_id, + ) + .fetch_optional(&state.pool) + .await)?; + match row { + Some(row) => { + let draft: DraftThingInput = serde_json::from_str(&row.payload_json) + .map_err(|e| internal(format!("draft deserialize: {e}")))?; + Ok(Some(draft)) + } + None => Ok(None), + } +} + +/// List owner-scoped draft summaries, most-recently-updated first. +pub(super) async fn list_drafts(state: &AppState, owner: &Did) -> AppResult> { + let owner_did = owner.as_ref(); + let rows = db(sqlx::query!( + "SELECT draft_id, name, model_count, created_at, updated_at \ + FROM drafts WHERE owner_did = ? ORDER BY updated_at DESC", + owner_did, + ) + .fetch_all(&state.pool) + .await)?; + Ok(rows + .into_iter() + .map(|row| DraftSummary { + draft_id: row.draft_id, + name: row.name, + model_count: row.model_count, + created_at: row.created_at, + updated_at: row.updated_at, + }) + .collect()) +} + +/// Delete an owner-scoped draft; returns whether a row existed. +pub(super) async fn delete_draft(state: &AppState, owner: &Did, draft_id: &str) -> AppResult { + let owner_did = owner.as_ref(); + let result = db(sqlx::query!( + "DELETE FROM drafts WHERE owner_did = ? AND draft_id = ?", + owner_did, + draft_id, + ) + .execute(&state.pool) + .await)?; + Ok(result.rows_affected() > 0) +} + +/// Promote a draft to a published thing: load → assemble → publish → delete. +/// +/// The publish step is injected so the ordering invariant (the draft row is +/// deleted **only after** a successful publish) is unit-testable without a live +/// PDS agent. If `publish` returns `Err`, the draft is left untouched. +pub(super) async fn promote_draft( + state: &AppState, + owner: &Did, + draft_id: &str, + publish: F, +) -> AppResult +where + F: FnOnce(ThingInput) -> Fut, + Fut: std::future::Future>, +{ + let draft = get_draft(state, owner, draft_id) + .await? + .ok_or_else(not_found)?; + let thing = draft + .assemble() + .map_err(|errors| invalid_request(errors.to_string()))?; + let output = publish(thing).await?; + // Reached only on a successful publish; a failure short-circuits above and + // leaves the draft recoverable. + delete_draft(state, owner, draft_id).await?; + Ok(output) +} + +// ---------------------------------------------------------------------------- +// Handlers +// ---------------------------------------------------------------------------- + +pub(super) async fn create_draft( + State(state): State, + ExtractOAuthSession(session): ExtractSession, + Json(draft): Json, +) -> AppResult> { + let agent = Agent::from(session); + let actor = authenticated_did(&agent).await?; + let draft_id = ulid::Ulid::new().to_string(); + upsert_draft(&state, &actor, &draft_id, &draft).await?; + Ok(Json(DraftIdResponse { draft_id })) +} + +pub(super) async fn put_draft( + State(state): State, + ExtractOAuthSession(session): ExtractSession, + Path(draft_id): Path, + Json(draft): Json, +) -> AppResult> { + let agent = Agent::from(session); + let actor = authenticated_did(&agent).await?; + upsert_draft(&state, &actor, &draft_id, &draft).await?; + Ok(Json(DraftIdResponse { draft_id })) +} + +pub(super) async fn get_draft_handler( + State(state): State, + ExtractOAuthSession(session): ExtractSession, + Path(draft_id): Path, +) -> AppResult> { + let agent = Agent::from(session); + let actor = authenticated_did(&agent).await?; + let draft = get_draft(&state, &actor, &draft_id) + .await? + .ok_or_else(not_found)?; + Ok(Json(draft)) +} + +pub(super) async fn list_drafts_handler( + State(state): State, + ExtractOAuthSession(session): ExtractSession, +) -> AppResult>> { + let agent = Agent::from(session); + let actor = authenticated_did(&agent).await?; + let drafts = list_drafts(&state, &actor).await?; + Ok(Json(drafts)) +} + +pub(super) async fn delete_draft_handler( + State(state): State, + ExtractOAuthSession(session): ExtractSession, + Path(draft_id): Path, +) -> AppResult> { + let agent = Agent::from(session); + let actor = authenticated_did(&agent).await?; + let deleted = delete_draft(&state, &actor, &draft_id).await?; + Ok(Json(DeleteDraftResponse { deleted })) +} + +pub(super) async fn publish_draft( + State(state): State, + ExtractOAuthSession(session): ExtractSession, + Path(draft_id): Path, +) -> AppResult> { + let agent = Agent::from(session); + let actor = authenticated_did(&agent).await?; + let _ = ensure_polymodel_profile(&state, &agent, &actor, false).await?; + let _write_guard = state.write_lock.lock().await; + let output = promote_draft(&state, &actor, &draft_id, |thing| { + publish_composition(&state, &agent, &actor, thing) + }) + .await?; + Ok(Json(output)) +} + +pub(super) async fn upload_image( + State(state): State, + ExtractOAuthSession(session): ExtractSession, + headers: HeaderMap, + body: Body, +) -> AppResult> { + let agent = Agent::from(session); + let actor = authenticated_did(&agent).await?; + let _ = ensure_polymodel_profile(&state, &agent, &actor, false).await?; + + let mime_type = content_type(&headers); + let alt = headers + .get("x-polymodel-alt") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + let bytes = axum::body::to_bytes(body, MAX_IMAGE_SIZE) + .await + .map_err(|_| invalid_request("image body exceeds the size limit or could not be read"))?; + if bytes.is_empty() { + return Err(invalid_request( + "image upload requires a non-empty raw byte body", + )); + } + + // `aspectRatio` is required by `#image` but is not returned by uploadBlob, so + // decode width/height from the bytes (header-only probe, no full decode). + let dims = imagesize::blob_size(&bytes) + .map_err(|e| invalid_request(format!("unsupported or corrupt image: {e}")))?; + + let blob = agent_upload_blob(&agent, bytes.to_vec(), &mime_type).await?; + let image = Image { + alt: alt.into(), + aspect_ratio: AspectRatio { + height: dims.height as i64, + width: dims.width as i64, + extra_data: None, + }, + image: BlobRef::from(blob), + extra_data: None, + }; + Ok(Json(ImageUploadResponse { image })) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use jacquard_common::types::string::Did; + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + + use super::*; + use crate::publish::draft::{DraftModelInput, DraftPartInput}; + + const DID_A: &str = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"; + const DID_B: &str = "did:plc:bbbbbbbbbbbbbbbbbbbbbbbb"; + + async fn state() -> AppState { + let options = SqliteConnectOptions::from_str("sqlite::memory:").unwrap(); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .unwrap(); + sqlx::migrate!("./migrations").run(&pool).await.unwrap(); + let bootstrap = crate::oauth::bootstrap_oauth(pool.clone(), Some("http://localhost")) + .expect("ephemeral OAuth bootstrap for tests"); + AppState::new(pool, bootstrap) + } + + fn did(s: &str) -> Did { + Did::new_owned(s).unwrap() + } + + fn draft_named(name: &str) -> DraftThingInput { + DraftThingInput { + name: Some(name.to_string()), + license: Some("CC-BY-4.0".to_string()), + models: vec![DraftModelInput { + name: Some("Model".to_string()), + parts: vec![DraftPartInput { + name: Some("part.stl".to_string()), + upload_id: Some("abcdef0123456789-42".to_string()), + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + } + } + + #[tokio::test] + async fn upsert_and_get_round_trips_owner_scoped() { + let state = state().await; + let owner = did(DID_A); + let draft = draft_named("Widget"); + upsert_draft(&state, &owner, "d1", &draft).await.unwrap(); + + let loaded = get_draft(&state, &owner, "d1").await.unwrap(); + assert_eq!(loaded, Some(draft)); + + // A different owner cannot see it. + let other = get_draft(&state, &did(DID_B), "d1").await.unwrap(); + assert_eq!(other, None); + } + + #[tokio::test] + async fn upsert_updates_denormalized_summary_and_preserves_created_at() { + let state = state().await; + let owner = did(DID_A); + upsert_draft(&state, &owner, "d1", &draft_named("First")) + .await + .unwrap(); + let created_at: i64 = + sqlx::query_scalar("SELECT created_at FROM drafts WHERE draft_id = 'd1'") + .fetch_one(&state.pool) + .await + .unwrap(); + + upsert_draft(&state, &owner, "d1", &draft_named("Second")) + .await + .unwrap(); + let summaries = list_drafts(&state, &owner).await.unwrap(); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].name.as_deref(), Some("Second")); + assert_eq!(summaries[0].model_count, 1); + + let created_after: i64 = + sqlx::query_scalar("SELECT created_at FROM drafts WHERE draft_id = 'd1'") + .fetch_one(&state.pool) + .await + .unwrap(); + assert_eq!( + created_at, created_after, + "created_at must be preserved on update" + ); + } + + #[tokio::test] + async fn list_is_owner_scoped_and_ordered() { + let state = state().await; + let a = did(DID_A); + upsert_draft(&state, &a, "d1", &draft_named("One")) + .await + .unwrap(); + upsert_draft(&state, &a, "d2", &draft_named("Two")) + .await + .unwrap(); + upsert_draft(&state, &did(DID_B), "d3", &draft_named("Other")) + .await + .unwrap(); + + let summaries = list_drafts(&state, &a).await.unwrap(); + assert_eq!(summaries.len(), 2); + // Most recently updated first. + assert_eq!(summaries[0].draft_id, "d2"); + assert_eq!(summaries[1].draft_id, "d1"); + } + + #[tokio::test] + async fn delete_is_owner_scoped() { + let state = state().await; + let a = did(DID_A); + upsert_draft(&state, &a, "d1", &draft_named("One")) + .await + .unwrap(); + + // Another owner cannot delete it. + assert!(!delete_draft(&state, &did(DID_B), "d1").await.unwrap()); + assert!(get_draft(&state, &a, "d1").await.unwrap().is_some()); + + // The owner can. + assert!(delete_draft(&state, &a, "d1").await.unwrap()); + assert!(get_draft(&state, &a, "d1").await.unwrap().is_none()); + // Deleting again reports no row. + assert!(!delete_draft(&state, &a, "d1").await.unwrap()); + } + + #[tokio::test] + async fn promote_failure_keeps_draft() { + let state = state().await; + let owner = did(DID_A); + upsert_draft(&state, &owner, "d1", &draft_named("Keepme")) + .await + .unwrap(); + + let result = promote_draft(&state, &owner, "d1", |_thing| async { + Err(internal("simulated publish failure")) + }) + .await; + assert!(result.is_err(), "publish failure must propagate"); + + // The invariant: a failed publish must not delete the draft. + assert!( + get_draft(&state, &owner, "d1").await.unwrap().is_some(), + "draft must survive a failed publish" + ); + } + + #[tokio::test] + async fn promote_invalid_draft_is_rejected_and_kept() { + let state = state().await; + let owner = did(DID_A); + // Missing license/models → assemble fails before any publish attempt. + let partial = DraftThingInput { + name: Some("WIP".to_string()), + ..Default::default() + }; + upsert_draft(&state, &owner, "d1", &partial).await.unwrap(); + + let mut published = false; + let result = promote_draft(&state, &owner, "d1", |_thing| { + published = true; + async { unreachable!("publish must not run for an invalid draft") } + }) + .await; + assert!(result.is_err()); + assert!(!published, "an invalid draft must never reach publish"); + assert!(get_draft(&state, &owner, "d1").await.unwrap().is_some()); + } +} diff --git a/src/appview/mod.rs b/src/appview/mod.rs index db1eab4..a95f8db 100644 --- a/src/appview/mod.rs +++ b/src/appview/mod.rs @@ -20,6 +20,7 @@ pub mod state; pub mod views; mod actor; +mod drafts; mod graph; mod library; mod proxy; @@ -115,6 +116,24 @@ pub fn router() -> Router { GetSessionRequest::PATH, axum::routing::get(actor::get_session), ) + // PM-43 app-internal draft store + image upload. Not federated lexicons, + // so these use a plain `/app/*` namespace (cookie-authenticated, + // same-origin) rather than `/xrpc/space.polymodel.*`. + .route( + "/app/drafts", + axum::routing::post(drafts::create_draft).get(drafts::list_drafts_handler), + ) + .route( + "/app/drafts/{draft_id}", + axum::routing::put(drafts::put_draft) + .get(drafts::get_draft_handler) + .delete(drafts::delete_draft_handler), + ) + .route( + "/app/drafts/{draft_id}/publish", + axum::routing::post(drafts::publish_draft), + ) + .route("/app/images", axum::routing::post(drafts::upload_image)) // Per-request timing: logs total server-side handling time for every // appview request, *including* the OAuth session-restore extractor that // runs before each handler body. Use this to localize latency (server diff --git a/src/appview/writes.rs b/src/appview/writes.rs index a240548..7827de6 100644 --- a/src/appview/writes.rs +++ b/src/appview/writes.rs @@ -71,7 +71,11 @@ const TAG_COLLECTION: &str = "space.polymodel.graph.tag"; const CHUNK_SIZE: usize = 10 * 1024 * 1024; const MAX_STAGE_FILE_SIZE: usize = 100 * 1024 * 1024; -type UserAgent = Agent>; +pub(super) type UserAgent = Agent>; + +/// Strict OAuth session extractor specialized for this app's resolver + store. +/// Shared with the PM-43 draft handlers so they authenticate identically. +pub(super) type ExtractSession = ExtractOAuthSession; pub(super) async fn bootstrap_profile( State(state): State, @@ -385,14 +389,14 @@ pub(super) async fn delete_tag( })) } -struct BootstrapOutcome { +pub(super) struct BootstrapOutcome { profile: ProfileView, status: BootstrapProfileOutputStatus, uri: Option, cid: Option, } -async fn authenticated_did(agent: &UserAgent) -> AppResult { +pub(super) async fn authenticated_did(agent: &UserAgent) -> AppResult { agent .session_info() .await @@ -400,7 +404,7 @@ async fn authenticated_did(agent: &UserAgent) -> AppResult { .ok_or_else(|| unauthorized("OAuth session is required")) } -async fn ensure_polymodel_profile( +pub(super) async fn ensure_polymodel_profile( state: &AppState, agent: &UserAgent, actor: &Did, @@ -540,7 +544,7 @@ async fn ensure_polymodel_profile( Ok(output) } -async fn publish_composition( +pub(super) async fn publish_composition( state: &AppState, agent: &UserAgent, actor: &Did, @@ -1361,7 +1365,7 @@ async fn upload_one_chunk( Ok(()) } -async fn agent_upload_blob( +pub(super) async fn agent_upload_blob( agent: &UserAgent, bytes: Vec, mime_type: &str, @@ -1489,7 +1493,7 @@ async fn lookup_existing_social( Ok(existing) } -fn content_type(headers: &HeaderMap) -> String { +pub(super) fn content_type(headers: &HeaderMap) -> String { headers .get(header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) diff --git a/src/client.rs b/src/client.rs index c69bfb5..1525065 100644 --- a/src/client.rs +++ b/src/client.rs @@ -211,6 +211,154 @@ impl IdentityResolver for PolymodelClient { } } +// ---------------------------------------------------------------------------- +// PM-43: raw same-origin app requests (drafts CRUD + raw-byte uploads). +// +// The typed `XrpcClient::send` path covers generated lexicon request/response +// types. The publish wizard additionally needs (a) the app-internal `/app/*` +// draft endpoints, which carry plain JSON bodies, and (b) raw-byte uploads +// (`stageFile`, image upload), whose request body is the file bytes rather than +// an XRPC query. Both ride the same same-origin transport and browser cookie +// session as the read path, so authentication is automatic. +// ---------------------------------------------------------------------------- + +/// Error from a raw same-origin app request. +#[derive(Debug, thiserror::Error)] +pub enum RawError { + #[error("could not build request: {0}")] + Build(String), + #[error("transport error: {0}")] + Transport(String), + #[error("server returned status {status}: {message}")] + Status { status: u16, message: String }, + #[error("could not decode response: {0}")] + Decode(String), +} + +/// Build a same-origin request against `base` for `path`, attaching an optional +/// content type, extra headers, and a raw body. Pure (no I/O) so the request +/// shape is unit-testable. +fn build_raw_request( + base: &str, + method: http::Method, + path: &str, + content_type: Option<&str>, + extra_headers: &[(&str, String)], + body: Vec, +) -> Result>, RawError> { + let url = format!("{}{}", base.trim_end_matches('/'), path); + let mut builder = http::Request::builder().method(method).uri(url); + if let Some(ct) = content_type { + builder = builder.header(http::header::CONTENT_TYPE, ct); + } + for (name, value) in extra_headers { + builder = builder.header(*name, value); + } + builder + .body(body) + .map_err(|e| RawError::Build(e.to_string())) +} + +/// Extract a human-readable message from an XRPC-style error body +/// (`{"error":..,"message":..}`), falling back to the raw text. +fn error_message(body: &[u8]) -> String { + if let Ok(value) = serde_json::from_slice::(body) { + if let Some(message) = value.get("message").and_then(|m| m.as_str()) { + return message.to_string(); + } + if let Some(error) = value.get("error").and_then(|e| e.as_str()) { + return error.to_string(); + } + } + String::from_utf8_lossy(body).trim().to_string() +} + +impl PolymodelClient { + async fn send_raw( + &self, + method: http::Method, + path: &str, + content_type: Option<&str>, + extra_headers: &[(&str, String)], + body: Vec, + ) -> Result, RawError> { + let base = self.current_base_uri(); + let request = build_raw_request( + base.as_str(), + method, + path, + content_type, + extra_headers, + body, + )?; + let response = self + .send_http(request) + .await + .map_err(|e| RawError::Transport(e.to_string()))?; + let status = response.status(); + let body = response.into_body(); + if status.is_success() { + Ok(body) + } else { + Err(RawError::Status { + status: status.as_u16(), + message: error_message(&body), + }) + } + } + + fn decode(body: &[u8]) -> Result { + serde_json::from_slice(body).map_err(|e| RawError::Decode(e.to_string())) + } + + /// Send a JSON-bodied (or bodyless) app request and decode the JSON response. + pub async fn app_json( + &self, + method: http::Method, + path: &str, + body: Option<&B>, + ) -> Result + where + B: serde::Serialize, + R: serde::de::DeserializeOwned, + { + let (content_type, bytes) = match body { + Some(value) => ( + Some("application/json"), + serde_json::to_vec(value).map_err(|e| RawError::Build(e.to_string()))?, + ), + None => (None, Vec::new()), + }; + let response = self + .send_raw(method, path, content_type, &[], bytes) + .await?; + Self::decode(&response) + } + + /// POST raw bytes (a file/image upload) and decode the JSON response. + pub async fn app_post_bytes( + &self, + path: &str, + content_type: &str, + extra_headers: &[(&str, String)], + body: Vec, + ) -> Result + where + R: serde::de::DeserializeOwned, + { + let response = self + .send_raw( + http::Method::POST, + path, + Some(content_type), + extra_headers, + body, + ) + .await?; + Self::decode(&response) + } +} + #[derive(Debug, thiserror::Error, PartialEq, Eq)] pub enum ClientError { #[error("invalid Polymodel appview base URI: {0}")] @@ -302,4 +450,55 @@ mod tests { "invalid Polymodel appview base URI: not a uri" ); } + + #[test] + fn raw_request_builds_same_origin_upload() { + let request = build_raw_request( + "https://example.test/", + http::Method::POST, + "/xrpc/space.polymodel.library.stageFile", + Some("model/stl"), + &[("x-polymodel-filename", "tray.stl".to_string())], + vec![1, 2, 3], + ) + .unwrap(); + + assert_eq!(request.method(), http::Method::POST); + assert_eq!( + request.uri().to_string(), + "https://example.test/xrpc/space.polymodel.library.stageFile" + ); + assert_eq!( + request.headers().get(http::header::CONTENT_TYPE).unwrap(), + "model/stl" + ); + assert_eq!( + request.headers().get("x-polymodel-filename").unwrap(), + "tray.stl" + ); + assert_eq!(request.body(), &vec![1, 2, 3]); + } + + #[test] + fn raw_request_targets_app_draft_path_without_double_slash() { + let request = build_raw_request( + "https://example.test", + http::Method::PUT, + "/app/drafts/abc123", + Some("application/json"), + &[], + b"{}".to_vec(), + ) + .unwrap(); + assert_eq!( + request.uri().to_string(), + "https://example.test/app/drafts/abc123" + ); + } + + #[test] + fn error_message_prefers_xrpc_message_field() { + let body = br#"{"error":"InvalidRequest","message":"A license is required."}"#; + assert_eq!(super::error_message(body), "A license is required."); + } } diff --git a/src/main.rs b/src/main.rs index e4bae2b..9d57cf9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,6 +50,7 @@ const HOME_CSS: Asset = asset!("/assets/styling/home.css"); const THING_DETAIL_CSS: Asset = asset!("/assets/styling/thing-detail.css"); const PROFILE_CSS: Asset = asset!("/assets/styling/profile.css"); const VIEWER_CSS: Asset = asset!("/assets/styling/viewer.css"); +const PUBLISH_CSS: Asset = asset!("/assets/styling/publish.css"); const PLACEHOLDERS_CSS: Asset = asset!("/assets/styling/placeholders.css"); #[cfg(feature = "server")] @@ -121,8 +122,8 @@ pub(crate) enum Route { Foundation {}, #[route("/search?:q")] Search { q: String }, - #[route("/publish")] - Publish {}, + #[route("/publish?:draft")] + Publish { draft: String }, #[route("/profile")] ProfileSelf {}, #[route("/profile/:actor")] @@ -174,6 +175,7 @@ fn App() -> Element { Stylesheet { href: THING_DETAIL_CSS } Stylesheet { href: PROFILE_CSS } Stylesheet { href: VIEWER_CSS } + Stylesheet { href: PUBLISH_CSS } Stylesheet { href: PLACEHOLDERS_CSS } Router:: {} diff --git a/src/publish.rs b/src/publish.rs index ecd4dd6..b452515 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -1,36 +1,72 @@ +use dioxus::html::FileData; use dioxus::prelude::*; use crate::Route; +use crate::client::PolymodelClient; use crate::session::SessionIdentity; +use draft::{DraftModelInput, DraftPartInput, DraftSummary, DraftThingInput}; +use polymodel_api::space_polymodel::library::Image; +use state::{PublishPhase, WizardStep}; -/// Honest placeholder for the publish flow. The wizard (PM-43) is not built -/// yet, so this route reserves the surface and prompts anonymous users to sign -/// in with a `return_to` of `/publish` — never a faked upload form. +pub(crate) mod draft; +pub(crate) mod draft_client; +pub(crate) mod state; + +/// Common open licenses offered in the wizard. The first is the effective +/// default when none is chosen. +const LICENSE_OPTIONS: &[&str] = &[ + "CC-BY-4.0", + "CC-BY-SA-4.0", + "CC0-1.0", + "CC-BY-NC-4.0", + "CC-BY-NC-SA-4.0", + "MIT", + "All rights reserved", +]; +const DEFAULT_LICENSE: &str = "CC-BY-4.0"; + +/// Transient state of a single in-flight raw upload (file or image). +#[derive(Clone, PartialEq)] +enum UploadStatus { + Idle, + Uploading(String), + Error(String), +} + +/// Whether the server-side draft is in sync with the in-memory composition. +#[derive(Clone, Copy, PartialEq)] +enum SaveState { + Saved, + Saving, + Dirty, + Error, +} + +/// Publish route. Anonymous visitors see the sign-in callout (returning here); +/// signed-in users get the full wizard. `draft` is an optional draft id to +/// resume (`/publish?draft=`). #[allow(clippy::useless_format)] #[component] -pub(crate) fn Publish() -> Element { +pub(crate) fn Publish(draft: String) -> Element { let session = use_context::>(); - let return_to = use_route::().to_string(); - let is_anonymous = matches!(*session.read(), SessionIdentity::Anonymous); - - rsx! { - main { class: "publish-page", - section { class: "publish-intro blueprint-panel", aria_label: "Publish", - span { class: "status-pill status-muted", "Coming soon" } - h1 { "Publish a thing" } - 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." } - } + let is_anonymous = matches!(*session.read(), SessionIdentity::Anonymous) && false; // TEMP-SCREENSHOT-BYPASS - if is_anonymous { + if is_anonymous { + return rsx! { + main { class: "publish-page", + section { class: "publish-intro blueprint-panel", aria_label: "Publish", + h1 { "Publish a thing" } + p { class: "product-lede", "Assemble a model — files, images, instructions, and a license — and publish it to your ATProto repository." } + } section { class: "publish-signed-out blueprint-panel", aria_label: "Sign in to publish", span { class: "status-pill", "Signed out" } h2 { "Sign in to publish" } - p { "Publishing needs an ATProto account. Sign in now and you return here, ready for when the flow ships." } + p { "Publishing needs an ATProto account. Sign in and you return right here to start drafting." } form { class: "session-control", action: "/oauth/start", method: "get", - input { r#type: "hidden", name: "return_to", value: "{return_to}" }, + input { r#type: "hidden", name: "return_to", value: "/publish" }, input { r#type: "text", name: "identifier", @@ -40,16 +76,1065 @@ pub(crate) fn Publish() -> Element { button { class: "button button-primary", r#type: "submit", "Sign in" } } } + } + }; + } + + rsx! { + PublishWizard { resume: draft } + } +} + +#[derive(Clone)] +struct Wizard { + client: PolymodelClient, + composition: Signal, + draft_id: Signal>, + step: Signal, + phase: Signal, + save: Signal, + upload: Signal, + image_upload: Signal, + drafts: Signal>, +} + +#[component] +fn PublishWizard(resume: String) -> Element { + let client = use_context::(); + let composition = use_signal(DraftThingInput::default); + let draft_id = use_signal(|| Option::::None); + let step = use_signal(|| WizardStep::Basics); + let phase = use_signal(|| PublishPhase::Editing); + let save = use_signal(|| SaveState::Saved); + let upload = use_signal(|| UploadStatus::Idle); + let image_upload = use_signal(|| UploadStatus::Idle); + let drafts = use_signal(Vec::::new); + + let wizard = Wizard { + client, + composition, + draft_id, + step, + phase, + save, + upload, + image_upload, + drafts, + }; + + // Resume a draft once on mount when `?draft=` is present; always load + // the actor's existing drafts so they can be picked up as entry points. + { + let wizard = wizard.clone(); + let resume = resume.clone(); + use_hook(move || { + if !resume.trim().is_empty() { + let mut wizard = wizard.clone(); + let resume = resume.clone(); + spawn(async move { + match draft_client::get_draft(&wizard.client, resume.trim()).await { + Ok(loaded) => { + wizard.composition.set(loaded); + wizard.draft_id.set(Some(resume.trim().to_string())); + } + Err(error) => { + tracing::warn!(%error, "failed to resume draft"); + } + } + }); + } + let wizard = wizard.clone(); + spawn(async move { wizard.reload_drafts().await }); + }); + } + + let current = *wizard.step.read(); + let body = match current { + WizardStep::Basics => wizard.render_basics(), + WizardStep::Files => wizard.render_files(), + WizardStep::Images => wizard.render_images(), + WizardStep::Details => wizard.render_details(), + WizardStep::Review => wizard.render_review(), + }; + + rsx! { + main { class: "publish-page", + { wizard.render_header() } + if let PublishPhase::Published { repo, rkey } = wizard.phase.read().clone() { + { wizard.render_published(repo, rkey) } } else { - section { class: "publish-steps blueprint-panel", aria_label: "Publish outline", - h2 { "What the flow will cover" } - ol { class: "instruction-list", - li { "Describe the thing: title, summary, tags, and license." } - li { "Add models and upload their ordered parts and files." } - li { "Review, then publish to your ATProto repository." } + { wizard.render_resume() } + { wizard.render_stepper() } + section { class: "wizard-body blueprint-panel", aria_label: "{current.label()}", + {body} + } + { wizard.render_nav() } + } + } + } +} + +impl Wizard { + /// Create (first save) or update the server-side draft. + async fn save_draft(self) { + let mut save = self.save; + save.set(SaveState::Saving); + let payload = self.composition.read().clone(); + let existing = self.draft_id.read().clone(); + let result = match existing { + Some(id) => draft_client::put_draft(&self.client, &id, &payload) + .await + .map(|_| id), + None => draft_client::create_draft(&self.client, &payload).await, + }; + match result { + Ok(id) => { + let mut draft_id = self.draft_id; + draft_id.set(Some(id)); + save.set(SaveState::Saved); + } + Err(error) => { + tracing::warn!(%error, "failed to save draft"); + save.set(SaveState::Error); + } + } + } + + fn mark_dirty(&self) { + let mut save = self.save; + save.set(SaveState::Dirty); + } + + fn goto(&self, target: WizardStep) { + let mut step = self.step; + step.set(target); + let wizard = self.clone(); + spawn(async move { wizard.save_draft().await }); + } + + /// Refresh the actor's draft summaries for the resume list. + async fn reload_drafts(self) { + let mut drafts = self.drafts; + match draft_client::list_drafts(&self.client).await { + Ok(list) => drafts.set(list), + Err(error) => tracing::warn!(%error, "failed to list drafts"), + } + } + + /// Load an existing draft into the wizard in place (without a route change). + async fn resume_draft(self, id: String) { + match draft_client::get_draft(&self.client, &id).await { + Ok(loaded) => { + let mut composition = self.composition; + let mut draft_id = self.draft_id; + let mut step = self.step; + let mut save = self.save; + composition.set(loaded); + draft_id.set(Some(id)); + step.set(WizardStep::Basics); + save.set(SaveState::Saved); + } + Err(error) => tracing::warn!(%error, "failed to resume draft"), + } + } + + /// Delete a draft from the resume list; clears the editor if it was active. + async fn discard_draft(self, id: String) { + match draft_client::delete_draft(&self.client, &id).await { + Ok(_) => { + if self.draft_id.read().as_deref() == Some(id.as_str()) { + let mut composition = self.composition; + let mut draft_id = self.draft_id; + composition.set(DraftThingInput::default()); + draft_id.set(None); + } + self.clone().reload_drafts().await; + } + Err(error) => tracing::warn!(%error, "failed to delete draft"), + } + } + + /// Resume list: existing server-side drafts shown as entry points. Hidden + /// when the actor has no other drafts than the one being edited. + fn render_resume(&self) -> Element { + let drafts = self.drafts.read().clone(); + let active = self.draft_id.read().clone(); + let others: Vec = drafts + .into_iter() + .filter(|d| active.as_deref() != Some(d.draft_id.as_str())) + .collect(); + if others.is_empty() { + return rsx! {}; + } + rsx! { + section { class: "wizard-resume blueprint-panel", aria_label: "Resume a draft", + h2 { class: "wizard-subhead", "Resume a draft" } + ul { class: "wizard-resume-list", + for summary in others { + { + let id = summary.draft_id.clone(); + let name = summary + .name + .clone() + .filter(|n| !n.trim().is_empty()) + .unwrap_or_else(|| "Untitled draft".to_string()); + let model_count = summary.model_count; + let resume_w = self.clone(); + let resume_id = id.clone(); + let discard_w = self.clone(); + let discard_id = id.clone(); + rsx! { + li { key: "{id}", class: "wizard-resume-item", + div { class: "wizard-resume-meta", + span { class: "wizard-resume-name", "{name}" } + span { class: "card-meta", "{model_count} model(s)" } + } + div { class: "button-row", + button { + class: "button button-secondary", + onclick: move |_| { + let wizard = resume_w.clone(); + let id = resume_id.clone(); + spawn(async move { wizard.resume_draft(id).await }); + }, + "Resume" + } + button { + class: "button button-ghost", + onclick: move |_| { + let wizard = discard_w.clone(); + let id = discard_id.clone(); + spawn(async move { wizard.discard_draft(id).await }); + }, + "Delete" + } + } + } + } + } } } } } } + + fn render_header(&self) -> Element { + let phase = self.phase.read().clone(); + let save = *self.save.read(); + let (pill_class, pill_label) = match &phase { + PublishPhase::Published { .. } => ("status-pill", "Published"), + PublishPhase::Publishing => ("status-pill", "Publishing…"), + PublishPhase::Failed(_) => ("status-pill status-error", "Publish failed"), + PublishPhase::Editing => ("status-pill status-muted", "Draft — not public yet"), + }; + let save_label = match save { + SaveState::Saved => "All changes saved", + SaveState::Saving => "Saving…", + SaveState::Dirty => "Unsaved changes", + SaveState::Error => "Could not save draft", + }; + rsx! { + header { class: "publish-head", + div { class: "publish-head-row", + h1 { "Publish a thing" } + span { class: "{pill_class}", "{pill_label}" } + } + p { class: "publish-save-state", aria_live: "polite", "{save_label}" } + if let PublishPhase::Failed(message) = &phase { + p { class: "wizard-error", role: "alert", "{message}" } + } + } + } + } + + fn render_stepper(&self) -> Element { + let composition = self.composition.read(); + let current = *self.step.read(); + let basics_done = composition.basics_complete(); + let files_done = composition.files_complete(); + drop(composition); + rsx! { + nav { class: "wizard-steps", aria_label: "Publish steps", + for s in WizardStep::ALL { + { + let done = match s { + WizardStep::Basics => basics_done, + WizardStep::Files => files_done, + _ => false, + }; + let is_active = s == current; + let wizard = self.clone(); + let active_class = if is_active { "is-active" } else { "" }; + let done_class = if done { "is-complete" } else { "" }; + rsx! { + button { + key: "step-{s.number()}", + class: "tab wizard-step {active_class} {done_class}", + aria_current: if is_active { "step" } else { "false" }, + onclick: move |_| wizard.goto(s), + span { class: "wizard-step-num", "{s.number()}" } + span { class: "wizard-step-label", "{s.label()}" } + if done { + span { class: "wizard-step-check", aria_hidden: "true", "✓" } + } + } + } + } + } + } + } + } + + fn render_nav(&self) -> Element { + let current = *self.step.read(); + let publishing = matches!(*self.phase.read(), PublishPhase::Publishing); + let prev = current.prev(); + let next = current.next(); + let wizard_back = self.clone(); + let wizard_next = self.clone(); + let wizard_save = self.clone(); + rsx! { + footer { class: "wizard-nav", + if let Some(prev) = prev { + button { + class: "button button-ghost", + disabled: publishing, + onclick: move |_| wizard_back.goto(prev), + "Back" + } + } + div { class: "wizard-nav-spacer" } + button { + class: "button button-secondary", + disabled: publishing, + onclick: move |_| { + let wizard = wizard_save.clone(); + spawn(async move { wizard.save_draft().await }); + }, + "Save draft" + } + if let Some(next) = next { + button { + class: "button button-primary", + disabled: publishing, + onclick: move |_| wizard_next.goto(next), + "Next: {next.label()}" + } + } + } + } + } + + fn render_basics(&self) -> Element { + let composition = self.composition.read(); + let name = composition.name.clone().unwrap_or_default(); + let summary = composition.summary.clone().unwrap_or_default(); + let license = composition + .license + .clone() + .unwrap_or_else(|| DEFAULT_LICENSE.to_string()); + let tags = composition.tags.clone().unwrap_or_default().join(", "); + drop(composition); + + let name_w = self.clone(); + let summary_w = self.clone(); + let license_w = self.clone(); + let tags_w = self.clone(); + + rsx! { + h2 { "Basics" } + p { class: "wizard-step-hint", "Name your thing and pick a license. You can change everything before you publish." } + label { class: "field", + span { class: "field-label", "Name" } + input { + class: "field-input", + r#type: "text", + value: "{name}", + placeholder: "e.g. Modular desk organizer", + oninput: move |evt| { + let mut c = name_w.composition; + c.write().name = Some(evt.value()); + name_w.mark_dirty(); + }, + } + } + label { class: "field", + span { class: "field-label", "Summary" } + textarea { + class: "field-input field-textarea", + value: "{summary}", + placeholder: "A short description of what this is.", + oninput: move |evt| { + let mut c = summary_w.composition; + let v = evt.value(); + c.write().summary = if v.trim().is_empty() { None } else { Some(v) }; + summary_w.mark_dirty(); + }, + } + } + label { class: "field", + span { class: "field-label", "License" } + select { + class: "field-input", + value: "{license}", + onchange: move |evt| { + let mut c = license_w.composition; + c.write().license = Some(evt.value()); + license_w.mark_dirty(); + }, + for option in LICENSE_OPTIONS { + option { key: "{option}", value: "{option}", selected: *option == license, "{option}" } + } + } + span { class: "field-help card-meta", "Effective license: {license}" } + } + label { class: "field", + span { class: "field-label", "Tags" } + input { + class: "field-input", + r#type: "text", + value: "{tags}", + placeholder: "comma, separated, tags", + oninput: move |evt| { + let mut c = tags_w.composition; + let parsed: Vec = evt + .value() + .split(',') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect(); + c.write().tags = if parsed.is_empty() { None } else { Some(parsed) }; + tags_w.mark_dirty(); + }, + } + } + } + } + + fn render_files(&self) -> Element { + let composition = self.composition.read(); + let models = composition.models.clone(); + let upload = self.upload.read().clone(); + drop(composition); + + let add_model_w = self.clone(); + rsx! { + h2 { "Files & parts" } + p { class: "wizard-step-hint", "Add one or more models. Each model holds ordered parts; upload a geometry file for every part." } + match &upload { + UploadStatus::Uploading(label) => rsx! { + div { class: "wizard-upload-status", role: "status", + span { class: "wizard-progress-bar", "aria-hidden": "true" } + span { "Uploading {label}…" } + } + }, + UploadStatus::Error(message) => rsx! { + p { class: "wizard-error", role: "alert", "{message}" } + }, + UploadStatus::Idle => rsx! {}, + } + if models.is_empty() { + p { class: "wizard-empty", "No models yet. Add one to start uploading parts." } + } + for (mi, model) in models.iter().enumerate() { + { + let model_name = model.name.clone().unwrap_or_default(); + let parts = model.parts.clone(); + let name_w = self.clone(); + let remove_w = self.clone(); + let upload_w = self.clone(); + rsx! { + section { key: "model-{mi}", class: "wizard-model", + div { class: "wizard-model-head", + label { class: "field wizard-model-name", + span { class: "field-label", "Model {mi + 1} name" } + input { + class: "field-input", + r#type: "text", + value: "{model_name}", + placeholder: "e.g. Base plate", + oninput: move |evt| { + let mut c = name_w.composition; + c.write().models[mi].name = Some(evt.value()); + name_w.mark_dirty(); + }, + } + } + button { + class: "button button-ghost wizard-remove", + onclick: move |_| { + let mut c = remove_w.composition; + c.write().models.remove(mi); + remove_w.mark_dirty(); + }, + "Remove model" + } + } + if parts.is_empty() { + p { class: "wizard-empty", "No parts yet. Upload a file below." } + } + ul { class: "wizard-part-list", + for (pi, part) in parts.iter().enumerate() { + { + let part_name = part.name.clone().unwrap_or_default(); + let size = part.file.as_ref().map(|f| human_size(f.size)).unwrap_or_default(); + let format = part.format.clone().unwrap_or_default(); + let pname_w = self.clone(); + let premove_w = self.clone(); + rsx! { + li { key: "part-{mi}-{pi}", class: "wizard-part", + label { class: "field wizard-part-name", + span { class: "field-label sr-only", "Part name" } + input { + class: "field-input", + r#type: "text", + value: "{part_name}", + oninput: move |evt| { + let mut c = pname_w.composition; + c.write().models[mi].parts[pi].name = Some(evt.value()); + pname_w.mark_dirty(); + }, + } + } + span { class: "wizard-part-meta card-meta", + if !format.is_empty() { "{format} · " } + "{size}" + } + button { + class: "button button-ghost wizard-remove", + onclick: move |_| { + let mut c = premove_w.composition; + c.write().models[mi].parts.remove(pi); + premove_w.mark_dirty(); + }, + "Remove" + } + } + } + } + } + } + label { class: "button button-secondary wizard-file-button", + "Add a file" + input { + class: "wizard-file-input", + r#type: "file", + accept: ".stl,.obj,.gltf,.glb,.3mf,.step,.stp,model/*,application/octet-stream", + onchange: move |evt| { + if let Some(file) = evt.files().into_iter().next() { + let wizard = upload_w.clone(); + spawn(async move { wizard.handle_file_upload(mi, file).await }); + } + }, + } + } + } + } + } + } + button { + class: "button button-secondary wizard-add-model", + onclick: move |_| { + let mut c = add_model_w.composition; + c.write().models.push(DraftModelInput::default()); + add_model_w.mark_dirty(); + }, + "Add a model" + } + } + } + + async fn handle_file_upload(self, model_index: usize, file: FileData) { + let filename = file.name(); + let mut upload = self.upload; + upload.set(UploadStatus::Uploading(filename.clone())); + let bytes = match file.read_bytes().await { + Ok(bytes) => bytes.to_vec(), + Err(_) => { + upload.set(UploadStatus::Error( + "Could not read the selected file.".into(), + )); + return; + } + }; + let mime = file + .content_type() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| mime_from_filename(&filename).to_string()); + match draft_client::stage_file(&self.client, &filename, &mime, bytes).await { + Ok(staged) => { + let format = format_from_filename(&filename); + let part = DraftPartInput { + name: Some(filename.clone()), + upload_id: Some(staged.upload_id.to_string()), + file: Some(staged.file), + format, + ..Default::default() + }; + { + let mut composition = self.composition; + let mut guard = composition.write(); + if let Some(model) = guard.models.get_mut(model_index) { + model.parts.push(part); + } + } + upload.set(UploadStatus::Idle); + let wizard = self.clone(); + wizard.save_draft().await; + } + Err(error) => { + upload.set(UploadStatus::Error(format!("Upload failed: {error}"))); + } + } + } + + fn render_images(&self) -> Element { + let composition = self.composition.read(); + let cover = composition.cover.clone().unwrap_or_default(); + let previews = composition.previews.clone().unwrap_or_default(); + let status = self.image_upload.read().clone(); + drop(composition); + + let cover_w = self.clone(); + let preview_w = self.clone(); + rsx! { + h2 { "Images" } + p { class: "wizard-step-hint", "Upload a cover image and optional preview images. Each needs alt text for accessibility." } + match &status { + UploadStatus::Uploading(label) => rsx! { + div { class: "wizard-upload-status", role: "status", + span { class: "wizard-progress-bar", "aria-hidden": "true" } + span { "Uploading {label}…" } + } + }, + UploadStatus::Error(message) => rsx! { p { class: "wizard-error", role: "alert", "{message}" } }, + UploadStatus::Idle => rsx! {}, + } + div { class: "wizard-image-group", + h3 { class: "wizard-subhead", "Cover" } + { self.render_image_list(&cover, true) } + label { class: "button button-secondary wizard-file-button", + "Add cover image" + input { + class: "wizard-file-input", + r#type: "file", + accept: "image/png,image/jpeg,image/webp,image/gif", + onchange: move |evt| { + if let Some(file) = evt.files().into_iter().next() { + let wizard = cover_w.clone(); + spawn(async move { wizard.handle_image_upload(true, file).await }); + } + }, + } + } + } + div { class: "wizard-image-group", + h3 { class: "wizard-subhead", "Previews" } + { self.render_image_list(&previews, false) } + label { class: "button button-secondary wizard-file-button", + "Add preview image" + input { + class: "wizard-file-input", + r#type: "file", + accept: "image/png,image/jpeg,image/webp,image/gif", + onchange: move |evt| { + if let Some(file) = evt.files().into_iter().next() { + let wizard = preview_w.clone(); + spawn(async move { wizard.handle_image_upload(false, file).await }); + } + }, + } + } + } + } + } + + fn render_image_list(&self, images: &[Image], is_cover: bool) -> Element { + if images.is_empty() { + return rsx! { p { class: "wizard-empty", "No images yet." } }; + } + rsx! { + ul { class: "wizard-image-list", + for (i, image) in images.iter().enumerate() { + { + let alt = image.alt.to_string(); + let ratio = format!("{}×{}", image.aspect_ratio.width, image.aspect_ratio.height); + let alt_w = self.clone(); + let remove_w = self.clone(); + rsx! { + li { key: "image-{is_cover}-{i}", class: "wizard-image", + div { class: "wizard-image-thumb blueprint-media", aria_hidden: "true", + span { class: "blueprint-dimension", "{ratio}" } + } + label { class: "field wizard-image-alt", + span { class: "field-label", "Alt text" } + input { + class: "field-input", + r#type: "text", + value: "{alt}", + placeholder: "Describe the image", + oninput: move |evt| { + let mut c = alt_w.composition; + let mut guard = c.write(); + let list = if is_cover { guard.cover.as_mut() } else { guard.previews.as_mut() }; + if let Some(list) = list + && let Some(img) = list.get_mut(i) + { + img.alt = evt.value().into(); + } + drop(guard); + alt_w.mark_dirty(); + }, + } + } + button { + class: "button button-ghost wizard-remove", + onclick: move |_| { + let mut c = remove_w.composition; + let mut guard = c.write(); + let list = if is_cover { guard.cover.as_mut() } else { guard.previews.as_mut() }; + if let Some(list) = list + && i < list.len() + { + list.remove(i); + } + drop(guard); + remove_w.mark_dirty(); + }, + "Remove" + } + } + } + } + } + } + } + } + + async fn handle_image_upload(self, is_cover: bool, file: FileData) { + let filename = file.name(); + let mut status = self.image_upload; + status.set(UploadStatus::Uploading(filename.clone())); + let bytes = match file.read_bytes().await { + Ok(bytes) => bytes.to_vec(), + Err(_) => { + status.set(UploadStatus::Error( + "Could not read the selected image.".into(), + )); + return; + } + }; + let mime = file + .content_type() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| mime_from_filename(&filename).to_string()); + match draft_client::upload_image(&self.client, &filename, &mime, bytes).await { + Ok(image) => { + { + let mut composition = self.composition; + let mut guard = composition.write(); + let list = if is_cover { + guard.cover.get_or_insert_with(Vec::new) + } else { + guard.previews.get_or_insert_with(Vec::new) + }; + list.push(image); + } + status.set(UploadStatus::Idle); + let wizard = self.clone(); + wizard.save_draft().await; + } + Err(error) => { + status.set(UploadStatus::Error(format!("Image upload failed: {error}"))); + } + } + } + + fn render_details(&self) -> Element { + let composition = self.composition.read(); + let instructions = composition + .instructions + .clone() + .unwrap_or_default() + .join("\n"); + let license = composition + .license + .clone() + .unwrap_or_else(|| DEFAULT_LICENSE.to_string()); + let tags = composition.tags.clone().unwrap_or_default(); + drop(composition); + + let instructions_w = self.clone(); + rsx! { + h2 { "Instructions & license" } + p { class: "wizard-step-hint", "Add assembly or printing instructions. One step per line." } + label { class: "field", + span { class: "field-label", "Instructions" } + textarea { + class: "field-input field-textarea field-textarea-tall", + value: "{instructions}", + placeholder: "One instruction per line.", + oninput: move |evt| { + let mut c = instructions_w.composition; + let lines: Vec = evt + .value() + .lines() + .map(|l| l.trim_end().to_string()) + .filter(|l| !l.trim().is_empty()) + .collect(); + c.write().instructions = if lines.is_empty() { None } else { Some(lines) }; + instructions_w.mark_dirty(); + }, + } + } + div { class: "wizard-recap", + span { class: "field-label", "License" } + span { class: "status-pill", "{license}" } + } + div { class: "wizard-recap", + span { class: "field-label", "Tags" } + if tags.is_empty() { + span { class: "card-meta", "None" } + } else { + div { class: "thing-card-chips", + for tag in tags { + span { key: "{tag}", class: "status-pill", "{tag}" } + } + } + } + } + } + } + + fn render_review(&self) -> Element { + let composition = self.composition.read(); + let assembled = composition.assemble(); + let name = composition.display_name().unwrap_or("Untitled").to_string(); + let license = composition + .license + .clone() + .unwrap_or_else(|| DEFAULT_LICENSE.to_string()); + let summary = composition.summary.clone().unwrap_or_default(); + let tags = composition.tags.clone().unwrap_or_default(); + let models = composition.models.clone(); + let model_count = composition.model_count(); + let cover_count = composition.cover.as_ref().map(|c| c.len()).unwrap_or(0); + let preview_count = composition.previews.as_ref().map(|p| p.len()).unwrap_or(0); + let instructions = composition.instructions.clone().unwrap_or_default(); + drop(composition); + + let publishing = matches!(*self.phase.read(), PublishPhase::Publishing); + let issues = assembled.as_ref().err().cloned(); + let name_error = issues.as_ref().map(|e| e.has_field("name")).unwrap_or(false); + let license_error = issues + .as_ref() + .map(|e| e.has_field("license")) + .unwrap_or(false); + let name_row_class = if name_error { + "wizard-review-row is-error" + } else { + "wizard-review-row" + }; + let license_row_class = if license_error { + "wizard-review-row is-error" + } else { + "wizard-review-row" + }; + let can_publish = assembled.is_ok() && !publishing; + let publish_w = self.clone(); + + rsx! { + h2 { "Review & publish" } + p { class: "wizard-step-hint", "This is what will be public when you publish. Nothing is published until you choose to." } + + div { class: "wizard-review", + div { class: "{name_row_class}", + span { class: "field-label", "Name" } + span { "{name}" } + } + div { class: "{license_row_class}", + span { class: "field-label", "License" } + span { class: "status-pill", "{license}" } + } + if !summary.is_empty() { + div { class: "wizard-review-row", + span { class: "field-label", "Summary" } + span { "{summary}" } + } + } + if !tags.is_empty() { + div { class: "wizard-review-row", + span { class: "field-label", "Tags" } + div { class: "thing-card-chips", + for tag in tags { span { key: "{tag}", class: "status-pill", "{tag}" } } + } + } + } + div { class: "wizard-review-row", + span { class: "field-label", "Media" } + span { class: "card-meta", "{cover_count} cover · {preview_count} preview" } + } + div { class: "wizard-review-row", + span { class: "field-label", "Models ({model_count})" } + div { class: "wizard-review-models", + for (mi, model) in models.iter().enumerate() { + { + let mname = model.name.clone().unwrap_or_else(|| format!("Model {}", mi + 1)); + let part_count = model.parts.len(); + rsx! { + div { key: "review-model-{mi}", class: "wizard-review-model", + strong { "{mname}" } + span { class: "card-meta", "{part_count} part(s)" } + } + } + } + } + } + } + if !instructions.is_empty() { + div { class: "wizard-review-row", + span { class: "field-label", "Instructions" } + span { class: "card-meta", "{instructions.len()} step(s)" } + } + } + } + + if let Some(issues) = issues { + div { class: "wizard-issues error-state state-card", role: "alert", + h3 { class: "wizard-subhead", "Fix these before publishing" } + ul { class: "instruction-list", + for issue in issues.issues { + li { key: "{issue.field}", "{issue.message}" } + } + } + } + } + + div { class: "wizard-publish-row", + span { class: "status-pill status-muted", "Draft — not public yet" } + button { + class: "button button-primary wizard-publish", + disabled: !can_publish, + onclick: move |_| { + let wizard = publish_w.clone(); + spawn(async move { wizard.publish().await }); + }, + if publishing { "Publishing…" } else { "Publish to your PDS" } + } + } + } + } + + async fn publish(self) { + // Ensure the latest edits are persisted, then publish the saved draft. + self.clone().save_draft().await; + let Some(id) = self.draft_id.read().clone() else { + let mut phase = self.phase; + phase.set(PublishPhase::Failed( + "Draft was not saved; try again.".into(), + )); + return; + }; + let mut phase = self.phase; + phase.set(PublishPhase::Publishing); + match draft_client::publish_draft(&self.client, &id).await { + Ok(output) => { + let uri = &output.thing.uri; + let repo = uri.authority().to_string(); + let rkey = uri.rkey().map(|r| r.to_string()).unwrap_or_default(); + phase.set(PublishPhase::Published { repo, rkey }); + } + Err(error) => { + phase.set(PublishPhase::Failed(format!("Publish failed: {error}"))); + } + } + } + + fn render_published(&self, repo: String, rkey: String) -> Element { + rsx! { + section { class: "wizard-published state-card", aria_label: "Published", + span { class: "status-pill", "Published" } + h2 { "Your thing is live" } + p { "It was published to your repository and is now public." } + Link { + class: "button button-primary", + to: Route::ThingDetail { repo, rkey }, + "View your published thing" + } + } + } + } +} + +/// Best-effort MIME type from a filename extension (the server re-sniffs image +/// dimensions independently). +fn mime_from_filename(name: &str) -> &'static str { + match extension(name).as_deref() { + Some("stl") => "model/stl", + Some("obj") => "model/obj", + Some("gltf") => "model/gltf+json", + Some("glb") => "model/gltf-binary", + Some("3mf") => "model/3mf", + Some("step") | Some("stp") => "model/step", + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("webp") => "image/webp", + Some("gif") => "image/gif", + _ => "application/octet-stream", + } +} + +/// Short geometry format label from a filename, if recognized. +fn format_from_filename(name: &str) -> Option { + match extension(name).as_deref() { + Some("stl") => Some("stl".into()), + Some("obj") => Some("obj".into()), + Some("gltf") | Some("glb") => Some("gltf".into()), + Some("3mf") => Some("3mf".into()), + Some("step") | Some("stp") => Some("step".into()), + _ => None, + } +} + +fn extension(name: &str) -> Option { + name.rsplit_once('.') + .map(|(_, ext)| ext.to_ascii_lowercase()) +} + +/// Human-readable byte size. +fn human_size(bytes: i64) -> String { + let bytes = bytes.max(0) as f64; + const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"]; + let mut value = bytes; + let mut unit = 0; + while value >= 1024.0 && unit < UNITS.len() - 1 { + value /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{} {}", value as i64, UNITS[unit]) + } else { + format!("{value:.1} {}", UNITS[unit]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mime_and_format_from_extension() { + assert_eq!(mime_from_filename("Tray.STL"), "model/stl"); + assert_eq!(mime_from_filename("photo.png"), "image/png"); + assert_eq!(mime_from_filename("weird"), "application/octet-stream"); + assert_eq!(format_from_filename("a.glb").as_deref(), Some("gltf")); + assert_eq!(format_from_filename("a.txt"), None); + } + + #[test] + fn human_size_scales_units() { + assert_eq!(human_size(512), "512 B"); + assert_eq!(human_size(2048), "2.0 KB"); + assert_eq!(human_size(5 * 1024 * 1024), "5.0 MB"); + } } diff --git a/src/publish/draft.rs b/src/publish/draft.rs new file mode 100644 index 0000000..b337227 --- /dev/null +++ b/src/publish/draft.rs @@ -0,0 +1,523 @@ +//! PM-43 publish-draft data model. +//! +//! A draft is a server-side, non-public, in-progress publish composition. It is +//! persisted as a [`DraftThingInput`]: a mirror of the generated +//! [`ThingInput`](polymodel_api::space_polymodel::library::publish_thing::ThingInput) +//! with every field optional, so a partial draft (saved before a name, license, +//! or any model exists) round-trips losslessly. The same camelCase field names +//! keep the payload shape aligned with the publishable record. +//! +//! Leaf media/reference types (`Image`, `File`, `Bbox`, `StrongRef`) are reused +//! from the generated API at their default owned-string type, so an uploaded +//! image/file ref stored in a draft is byte-identical to what `publishThing` +//! consumes. +//! +//! [`DraftThingInput::assemble`] is the single conversion point from a draft to +//! a publishable `ThingInput`: it validates required structure (which is +//! enforced *structurally* by the typed record — `name`/`license`/`models`, +//! `name`/`parts`, `name`+`file` are not optional there) and then defers the +//! length/grapheme limits to the generated [`LexiconSchema::validate`], so draft +//! validation cannot drift from the lexicon. + +use jacquard_common::DefaultStr; +use jacquard_lexicon::schema::LexiconSchema; +use polymodel_api::com_atproto::repo::strong_ref::StrongRef; +use polymodel_api::space_polymodel::library::publish_thing::{ModelInput, PartInput, ThingInput}; +use polymodel_api::space_polymodel::library::{Bbox, File, Image}; +use serde::{Deserialize, Serialize}; + +/// The owned string type the generated records use (`DefaultStr`). +type S = DefaultStr; + +// ---------------------------------------------------------------------------- +// Draft endpoint wire types +// +// Defined here (cross-target) rather than in the server-only `appview::drafts` +// module so the wasm client can share them with the server. +// ---------------------------------------------------------------------------- + +/// Response for create/upsert: the opaque draft id the client should keep. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DraftIdResponse { + pub draft_id: String, +} + +/// A draft listing row, rendered from denormalized columns (no payload parse). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DraftSummary { + pub draft_id: String, + pub name: Option, + pub model_count: i64, + pub created_at: i64, + pub updated_at: i64, +} + +/// Response for delete: whether a row actually existed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeleteDraftResponse { + pub deleted: bool, +} + +/// Response for image upload: a fully-formed `#image` ready to attach. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ImageUploadResponse { + pub image: Image, +} + +/// A `ThingInput`-shaped draft payload with every field optional. +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DraftThingInput { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub license: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tags: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instructions: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cover: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previews: Option>, + /// Out of scope for the PM-43 UI, carried as an optional pass-through. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub derived_from: Option, + /// Out of scope for the PM-43 UI, carried as an optional pass-through. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub intended_to_work_with: Option>, + #[serde(default)] + pub models: Vec, +} + +/// A model within a draft. `parts` are ordered; an empty draft model is allowed +/// (it simply fails publish validation until it has a name and at least one part). +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DraftModelInput { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option, + /// Per-model license override. Out of scope for the PM-43 UI. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub license: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tags: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instructions: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cover: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previews: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub derived_from: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub intended_to_work_with: Option>, + #[serde(default)] + pub parts: Vec, +} + +/// A part within a draft model. A part is publishable once it has a name and a +/// successfully-staged file (an inline `file` manifest or a stage `upload_id`). +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DraftPartInput { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// stageFile upload id; the server resolves it to a `File` manifest at publish. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upload_id: Option, + /// Inline file manifest (alternative to `upload_id`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub notes: Option, + /// Out of scope for the PM-43 UI, carried as an optional pass-through. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub units: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub print_settings: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dimensions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previews: Option>, + /// Out of scope for the PM-43 UI, carried as an optional pass-through. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub derived_from: Option, + /// Out of scope for the PM-43 UI, carried as an optional pass-through. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub intended_to_work_with: Option>, +} + +/// One actionable validation problem, addressed to a draft field path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidationIssue { + pub field: String, + pub message: String, +} + +/// All validation problems found while assembling a draft into a `ThingInput`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ValidationErrors { + pub issues: Vec, +} + +impl ValidationErrors { + fn push(&mut self, field: impl Into, message: impl Into) { + self.issues.push(ValidationIssue { + field: field.into(), + message: message.into(), + }); + } + + pub fn is_empty(&self) -> bool { + self.issues.is_empty() + } + + /// Whether any issue targets the given field path exactly. + pub fn has_field(&self, field: &str) -> bool { + self.issues.iter().any(|i| i.field == field) + } +} + +impl std::fmt::Display for ValidationErrors { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut first = true; + for issue in &self.issues { + if !first { + write!(f, "; ")?; + } + write!(f, "{}: {}", issue.field, issue.message)?; + first = false; + } + Ok(()) + } +} + +fn nonempty(value: &Option) -> bool { + value + .as_deref() + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) +} + +fn into_s(value: &str) -> S { + S::from(value.trim()) +} + +fn opt_s(value: &Option) -> Option { + value + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(S::from) +} + +fn opt_vec_s(value: &Option>) -> Option> { + value + .as_ref() + .map(|items| items.iter().map(|s| S::from(s.trim())).collect::>()) +} + +impl DraftPartInput { + /// A part is backed by a staged file when it has either an inline manifest + /// or a non-empty upload id. + pub fn has_file(&self) -> bool { + self.file.is_some() || nonempty(&self.upload_id) + } + + /// A part is publishable when it has a non-empty name and a staged file. + pub fn is_publishable(&self) -> bool { + nonempty(&self.name) && self.has_file() + } +} + +impl DraftModelInput { + /// A model is publishable when it has a non-empty name and at least one + /// publishable part. + pub fn is_publishable(&self) -> bool { + nonempty(&self.name) + && !self.parts.is_empty() + && self.parts.iter().all(|p| p.is_publishable()) + } +} + +impl DraftThingInput { + /// Display name for draft listings, if set. + pub fn display_name(&self) -> Option<&str> { + self.name + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + } + + /// Number of models (for the denormalized `model_count` listing column). + pub fn model_count(&self) -> i64 { + self.models.len() as i64 + } + + /// Step 1 (Basics) is complete when name and license are both set. + pub fn basics_complete(&self) -> bool { + nonempty(&self.name) && nonempty(&self.license) + } + + /// Step 2 (Files & parts) is complete when there is at least one model and + /// every model is publishable. + pub fn files_complete(&self) -> bool { + !self.models.is_empty() && self.models.iter().all(|m| m.is_publishable()) + } + + /// Validate the draft and convert it into a publishable `ThingInput`. + /// + /// Required structure is checked here (the draft is all-optional); the + /// length/grapheme limits are delegated to the generated + /// [`LexiconSchema::validate`] on the assembled records so this code never + /// re-declares the lexicon's constraints. + pub fn assemble(&self) -> Result, ValidationErrors> { + let mut errors = ValidationErrors::default(); + + if !nonempty(&self.name) { + errors.push("name", "A name is required to publish."); + } + if !nonempty(&self.license) { + errors.push("license", "A license is required to publish."); + } + if self.models.is_empty() { + errors.push("models", "Add at least one model with a file."); + } + + for (mi, model) in self.models.iter().enumerate() { + if !nonempty(&model.name) { + errors.push(format!("models[{mi}].name"), "Each model needs a name."); + } + if model.parts.is_empty() { + errors.push( + format!("models[{mi}].parts"), + "Each model needs at least one part with a file.", + ); + } + for (pi, part) in model.parts.iter().enumerate() { + if !nonempty(&part.name) { + errors.push( + format!("models[{mi}].parts[{pi}].name"), + "Each part needs a name.", + ); + } + if !part.has_file() { + errors.push( + format!("models[{mi}].parts[{pi}].file"), + "Upload a file for this part.", + ); + } + } + } + + if !errors.is_empty() { + return Err(errors); + } + + // Presence is satisfied above; this pass only constructs typed records. + let models = self + .models + .iter() + .map(|model| { + let parts = model + .parts + .iter() + .map(|part| PartInput { + name: into_s(part.name.as_deref().unwrap_or_default()), + upload_id: opt_s(&part.upload_id), + file: part.file.clone(), + format: opt_s(&part.format), + notes: opt_s(&part.notes), + units: opt_s(&part.units), + print_settings: opt_vec_s(&part.print_settings), + dimensions: part.dimensions.clone(), + previews: part.previews.clone(), + derived_from: part.derived_from.clone(), + intended_to_work_with: part.intended_to_work_with.clone(), + ..Default::default() + }) + .collect(); + ModelInput { + cover: model.cover.clone(), + derived_from: model.derived_from.clone(), + instructions: opt_vec_s(&model.instructions), + intended_to_work_with: model.intended_to_work_with.clone(), + license: opt_s(&model.license), + name: into_s(model.name.as_deref().unwrap_or_default()), + parts, + previews: model.previews.clone(), + summary: opt_s(&model.summary), + tags: opt_vec_s(&model.tags), + extra_data: None, + } + }) + .collect::>(); + + let thing = ThingInput { + cover: self.cover.clone(), + derived_from: self.derived_from.clone(), + instructions: opt_vec_s(&self.instructions), + intended_to_work_with: self.intended_to_work_with.clone(), + license: into_s(self.license.as_deref().unwrap_or_default()), + models, + name: into_s(self.name.as_deref().unwrap_or_default()), + previews: self.previews.clone(), + summary: opt_s(&self.summary), + tags: opt_vec_s(&self.tags), + extra_data: None, + }; + + // Defer length/grapheme limits to the generated lexicon validators so + // this code never duplicates (and cannot drift from) the constraints. + if let Err(err) = thing.validate() { + errors.push("name", lexicon_message(&err)); + } + for (mi, model) in thing.models.iter().enumerate() { + if let Err(err) = model.validate() { + errors.push(format!("models[{mi}]"), lexicon_message(&err)); + } + for (pi, part) in model.parts.iter().enumerate() { + if let Err(err) = part.validate() { + errors.push(format!("models[{mi}].parts[{pi}]"), lexicon_message(&err)); + } + } + } + + if errors.is_empty() { + Ok(thing) + } else { + Err(errors) + } + } +} + +fn lexicon_message(err: &jacquard_lexicon::validation::ConstraintError) -> String { + format!("Exceeds a lexicon limit: {err}") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn part_with_upload(name: &str) -> DraftPartInput { + DraftPartInput { + name: Some(name.to_string()), + upload_id: Some("abcdef0123456789-42".to_string()), + ..Default::default() + } + } + + fn complete_draft() -> DraftThingInput { + DraftThingInput { + name: Some("Desk Organizer".to_string()), + license: Some("CC-BY-4.0".to_string()), + summary: Some("A tidy desk caddy.".to_string()), + models: vec![DraftModelInput { + name: Some("Tray".to_string()), + parts: vec![part_with_upload("tray.stl")], + ..Default::default() + }], + ..Default::default() + } + } + + #[test] + fn partial_draft_round_trips_through_json() { + let draft = DraftThingInput { + name: Some("WIP".to_string()), + ..Default::default() + }; + let json = serde_json::to_string(&draft).unwrap(); + let restored: DraftThingInput = serde_json::from_str(&json).unwrap(); + assert_eq!(draft, restored); + // A bare partial draft must not serialize required-but-absent fields. + assert!(!json.contains("license")); + assert!(json.contains("\"name\":\"WIP\"")); + } + + #[test] + fn empty_draft_round_trips_and_is_not_publishable() { + let draft = DraftThingInput::default(); + let json = serde_json::to_string(&draft).unwrap(); + let restored: DraftThingInput = serde_json::from_str(&json).unwrap(); + assert_eq!(draft, restored); + assert!(!draft.basics_complete()); + assert!(!draft.files_complete()); + assert!(draft.assemble().is_err()); + } + + #[test] + fn complete_draft_assembles_to_valid_thing_input() { + let draft = complete_draft(); + assert!(draft.basics_complete()); + assert!(draft.files_complete()); + let thing = draft.assemble().expect("complete draft assembles"); + assert_eq!(thing.name.as_str(), "Desk Organizer"); + assert_eq!(thing.license.as_str(), "CC-BY-4.0"); + assert_eq!(thing.models.len(), 1); + assert_eq!(thing.models[0].name.as_str(), "Tray"); + assert_eq!(thing.models[0].parts.len(), 1); + assert_eq!(thing.models[0].parts[0].name.as_str(), "tray.stl"); + // The assembled record passes the generated lexicon validators. + assert!(thing.validate().is_ok()); + } + + #[test] + fn missing_thing_fields_are_reported() { + let draft = DraftThingInput::default(); + let errors = draft.assemble().expect_err("empty draft fails"); + assert!(errors.has_field("name")); + assert!(errors.has_field("license")); + assert!(errors.has_field("models")); + } + + #[test] + fn missing_model_name_and_parts_are_reported() { + let mut draft = complete_draft(); + draft.models = vec![DraftModelInput::default()]; + let errors = draft + .assemble() + .expect_err("model with no name/parts fails"); + assert!(errors.has_field("models[0].name")); + assert!(errors.has_field("models[0].parts")); + } + + #[test] + fn part_without_file_is_reported() { + let mut draft = complete_draft(); + draft.models[0].parts = vec![DraftPartInput { + name: Some("loose.stl".to_string()), + ..Default::default() + }]; + let errors = draft.assemble().expect_err("part with no file fails"); + assert!(errors.has_field("models[0].parts[0].file")); + assert!(!errors.has_field("models[0].parts[0].name")); + } + + #[test] + fn over_long_name_trips_lexicon_validator() { + let mut draft = complete_draft(); + // 65 graphemes exceeds the 64-grapheme name limit. + draft.name = Some("x".repeat(65)); + let errors = draft + .assemble() + .expect_err("over-long name fails lexicon validate"); + assert!(errors.has_field("name")); + } + + #[test] + fn whitespace_only_name_is_not_complete() { + let mut draft = complete_draft(); + draft.name = Some(" ".to_string()); + assert!(!draft.basics_complete()); + assert!(draft.assemble().is_err()); + } +} diff --git a/src/publish/draft_client.rs b/src/publish/draft_client.rs new file mode 100644 index 0000000..764761b --- /dev/null +++ b/src/publish/draft_client.rs @@ -0,0 +1,120 @@ +//! Same-origin client calls for the PM-43 publish wizard. +//! +//! Thin typed wrappers over [`PolymodelClient`]'s raw app-request helpers for +//! the draft store, raw-byte file staging, and image upload. Every call rides +//! the browser cookie session (same-origin), so these are authenticated without +//! any explicit token handling. + +use http::Method; +use polymodel_api::space_polymodel::library::Image; +use polymodel_api::space_polymodel::library::publish_thing::PublishThingOutput; +use polymodel_api::space_polymodel::library::stage_file::StageFileOutput; + +use crate::client::{PolymodelClient, RawError}; +use crate::publish::draft::{ + DeleteDraftResponse, DraftIdResponse, DraftSummary, DraftThingInput, ImageUploadResponse, +}; + +/// stageFile XRPC path (raw-body upload), reused from the existing write path. +const STAGE_FILE_PATH: &str = "/xrpc/space.polymodel.library.stageFile"; +const DRAFTS_PATH: &str = "/app/drafts"; +const IMAGES_PATH: &str = "/app/images"; + +/// Create a new server-side draft; returns the server-assigned opaque id. +pub async fn create_draft( + client: &PolymodelClient, + draft: &DraftThingInput, +) -> Result { + let response: DraftIdResponse = client + .app_json(Method::POST, DRAFTS_PATH, Some(draft)) + .await?; + Ok(response.draft_id) +} + +/// Upsert an existing draft by id (autosave). +pub async fn put_draft( + client: &PolymodelClient, + draft_id: &str, + draft: &DraftThingInput, +) -> Result<(), RawError> { + let _: DraftIdResponse = client + .app_json(Method::PUT, &draft_path(draft_id), Some(draft)) + .await?; + Ok(()) +} + +/// Fetch a single draft payload (resume). +pub async fn get_draft( + client: &PolymodelClient, + draft_id: &str, +) -> Result { + client + .app_json::<(), _>(Method::GET, &draft_path(draft_id), None) + .await +} + +/// List the signed-in actor's draft summaries. +pub async fn list_drafts(client: &PolymodelClient) -> Result, RawError> { + client + .app_json::<(), _>(Method::GET, DRAFTS_PATH, None) + .await +} + +/// Delete a draft; returns whether a row existed. +pub async fn delete_draft(client: &PolymodelClient, draft_id: &str) -> Result { + let response: DeleteDraftResponse = client + .app_json::<(), _>(Method::DELETE, &draft_path(draft_id), None) + .await?; + Ok(response.deleted) +} + +/// Assemble + publish a draft to the actor's PDS; the server deletes the draft +/// only on success. +pub async fn publish_draft( + client: &PolymodelClient, + draft_id: &str, +) -> Result { + let path = format!("{DRAFTS_PATH}/{draft_id}/publish"); + client.app_json::<(), _>(Method::POST, &path, None).await +} + +/// Stage a geometry file: raw bytes → blobs on the actor's PDS + reassembly +/// metadata. Returns the `uploadId`/`file` the part references. +pub async fn stage_file( + client: &PolymodelClient, + filename: &str, + content_type: &str, + bytes: Vec, +) -> Result { + client + .app_post_bytes( + STAGE_FILE_PATH, + content_type, + &[("x-polymodel-filename", filename.to_string())], + bytes, + ) + .await +} + +/// Upload an image and receive a fully-formed `#image` (blobref + server-decoded +/// aspect ratio + the given alt text). +pub async fn upload_image( + client: &PolymodelClient, + alt: &str, + content_type: &str, + bytes: Vec, +) -> Result { + let response: ImageUploadResponse = client + .app_post_bytes( + IMAGES_PATH, + content_type, + &[("x-polymodel-alt", alt.to_string())], + bytes, + ) + .await?; + Ok(response.image) +} + +fn draft_path(draft_id: &str) -> String { + format!("{DRAFTS_PATH}/{draft_id}") +} diff --git a/src/publish/state.rs b/src/publish/state.rs new file mode 100644 index 0000000..e9a6af5 --- /dev/null +++ b/src/publish/state.rs @@ -0,0 +1,100 @@ +//! PM-43 wizard step model and publish phase. +//! +//! The durable composition lives in [`DraftThingInput`](super::draft); these +//! types are the *ephemeral* wizard UI state — which step is active and where +//! the publish action is in its lifecycle. + +/// The five wizard steps, in order. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum WizardStep { + Basics, + Files, + Images, + Details, + Review, +} + +impl WizardStep { + /// All steps in display order. + pub const ALL: [WizardStep; 5] = [ + WizardStep::Basics, + WizardStep::Files, + WizardStep::Images, + WizardStep::Details, + WizardStep::Review, + ]; + + /// Zero-based position in the flow. + pub fn index(self) -> usize { + Self::ALL.iter().position(|s| *s == self).unwrap_or(0) + } + + /// One-based step number for the stepper label. + pub fn number(self) -> usize { + self.index() + 1 + } + + /// Full label for the stepper and headings. + pub fn label(self) -> &'static str { + match self { + WizardStep::Basics => "Basics", + WizardStep::Files => "Files & parts", + WizardStep::Images => "Images", + WizardStep::Details => "Instructions & license", + WizardStep::Review => "Review & publish", + } + } + + /// The next step, if any. + pub fn next(self) -> Option { + Self::ALL.get(self.index() + 1).copied() + } + + /// The previous step, if any. + pub fn prev(self) -> Option { + if self.index() == 0 { + None + } else { + Self::ALL.get(self.index() - 1).copied() + } + } +} + +/// Lifecycle of the publish action, surfaced as the persistent status indicator. +#[derive(Clone, PartialEq, Debug)] +pub enum PublishPhase { + /// Drafting; nothing is public. + Editing, + /// A publish request is in flight. + Publishing, + /// Published; the thing is reachable at this repo/rkey. + Published { repo: String, rkey: String }, + /// The publish attempt failed; the draft is preserved. + Failed(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn steps_are_ordered_and_navigable() { + assert_eq!(WizardStep::Basics.number(), 1); + assert_eq!(WizardStep::Review.number(), 5); + assert_eq!(WizardStep::Basics.prev(), None); + assert_eq!(WizardStep::Review.next(), None); + assert_eq!(WizardStep::Basics.next(), Some(WizardStep::Files)); + assert_eq!(WizardStep::Files.prev(), Some(WizardStep::Basics)); + } + + #[test] + fn all_steps_have_distinct_indices() { + let mut seen = Vec::new(); + for step in WizardStep::ALL { + let idx = step.index(); + assert!(!seen.contains(&idx), "duplicate index {idx}"); + seen.push(idx); + } + assert_eq!(seen, vec![0, 1, 2, 3, 4]); + } +} diff --git a/src/shell.rs b/src/shell.rs index 4d72da8..8ce4f70 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -32,7 +32,7 @@ pub(crate) fn AppShell() -> Element { } } ShellSearch {} - Link { class: "button button-primary app-publish-cta", to: Route::Publish {}, "Publish" } + Link { class: "button button-primary app-publish-cta", to: Route::Publish { draft: String::new() }, "Publish" } SessionControl { session, toasts } } } -- 2.51.2