atproto Thingiverse but good
10

Configure Feed

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

PM-41: thing detail route

Orual (Jun 29, 2026, 7:06 PM EDT) 23d89f01 5ba4dc88

+1874 -518
-18
Cargo.lock
··· 4155 4155 [[package]] 4156 4156 name = "jacquard" 4157 4157 version = "0.12.0" 4158 - source = "registry+https://github.com/rust-lang/crates.io-index" 4159 - checksum = "2e30d6ba826a563cb2e86f6c4e2268ef5a216b50ea0ca98a9cd3b6b4cbc7c8cc" 4160 4158 dependencies = [ 4161 4159 "bytes", 4162 4160 "getrandom 0.2.17", ··· 4199 4197 [[package]] 4200 4198 name = "jacquard-api" 4201 4199 version = "0.12.0" 4202 - source = "registry+https://github.com/rust-lang/crates.io-index" 4203 - checksum = "e9d7b4d41f44a3add61878f6022c48ef949f5d1759a88ec778017334d2847e95" 4204 4200 dependencies = [ 4205 4201 "jacquard-common 0.12.0", 4206 4202 "jacquard-derive 0.12.0", ··· 4213 4209 [[package]] 4214 4210 name = "jacquard-axum" 4215 4211 version = "0.12.0" 4216 - source = "registry+https://github.com/rust-lang/crates.io-index" 4217 - checksum = "fd1c24e629b32b51477d0217a976e9a16f0397a37bdc8a174225d4d77e3822a0" 4218 4212 dependencies = [ 4219 4213 "axum", 4220 4214 "axum-extra", ··· 4295 4289 [[package]] 4296 4290 name = "jacquard-common" 4297 4291 version = "0.12.0" 4298 - source = "registry+https://github.com/rust-lang/crates.io-index" 4299 - checksum = "82e9239b4bf08cf6b3e9d9d420be8b17bd09ee34228b52b884089cbcf67d23bf" 4300 4292 dependencies = [ 4301 4293 "base64", 4302 4294 "bon", ··· 4360 4352 [[package]] 4361 4353 name = "jacquard-derive" 4362 4354 version = "0.12.0" 4363 - source = "registry+https://github.com/rust-lang/crates.io-index" 4364 - checksum = "7bea6f33da422d272cb09a2571fb65f88aed13612774f2eb635716ce71d1e742" 4365 4355 dependencies = [ 4366 4356 "heck 0.5.0", 4367 4357 "jacquard-lexicon 0.12.0", ··· 4397 4387 [[package]] 4398 4388 name = "jacquard-identity" 4399 4389 version = "0.12.0" 4400 - source = "registry+https://github.com/rust-lang/crates.io-index" 4401 - checksum = "757c2d9d9ecca3b66e000e912f2fcf9e9e63c5dc50d20983264e3a6db56f4a8e" 4402 4390 dependencies = [ 4403 4391 "bon", 4404 4392 "bytes", ··· 4448 4436 [[package]] 4449 4437 name = "jacquard-lexicon" 4450 4438 version = "0.12.0" 4451 - source = "registry+https://github.com/rust-lang/crates.io-index" 4452 - checksum = "3bf8be274eb0af89ece99b56cbbc837748f62d44af8ab8280acafdfcafe84db7" 4453 4439 dependencies = [ 4454 4440 "cid", 4455 4441 "dashmap", ··· 4476 4462 [[package]] 4477 4463 name = "jacquard-oauth" 4478 4464 version = "0.12.0" 4479 - source = "registry+https://github.com/rust-lang/crates.io-index" 4480 - checksum = "108570f206eea7fd3272e7bfe355b589d66c163e4e4a06ee612bf84877ef2049" 4481 4465 dependencies = [ 4482 4466 "base64", 4483 4467 "bytes", ··· 5287 5271 [[package]] 5288 5272 name = "mini-moka-wasm" 5289 5273 version = "0.10.99" 5290 - source = "registry+https://github.com/rust-lang/crates.io-index" 5291 - checksum = "0102b9a2ad50fa47ca89eead2316c8222285ecfbd3f69ce99564fbe4253866e8" 5292 5274 dependencies = [ 5293 5275 "crossbeam-channel", 5294 5276 "crossbeam-utils",
+26 -5
Cargo.toml
··· 3 3 resolver = "3" 4 4 5 5 [workspace.dependencies] 6 - jacquard = { version = "0.12", default-features = false, features = ["derive", "api_bluesky"] } 7 - jacquard-axum = { version = "0.12" } 8 - jacquard-common = { version = "0.12" } 9 - jacquard-derive = { version = "0.12" } 10 - jacquard-lexicon = { version = "0.12", default-features = false } 6 + jacquard = { path = "../jacquard/crates/jacquard", default-features = false, features = ["derive", "api_bluesky"] } 7 + jacquard-axum = { path = "../jacquard/crates/jacquard-axum" } 8 + jacquard-common = { path = "../jacquard/crates/jacquard-common" } 9 + jacquard-derive = { path = "../jacquard/crates/jacquard-derive" } 10 + jacquard-lexicon = { path = "../jacquard/crates/jacquard-lexicon", default-features = false } 11 11 serde = { version = "1.0", features = ["derive"] } 12 12 thiserror = "2.0" 13 13 miette = "7.6" ··· 116 116 web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Event", "EventTarget", "HtmlCanvasElement", "WebGl2RenderingContext", "Performance", "MouseEvent", "WheelEvent", "PointerEvent", "DomRect", "BroadcastChannel", "console", "Response"] } 117 117 118 118 [dev-dependencies] 119 + anyhow = "1" 120 + axum = "0.8" 121 + chrono = "0.4" 122 + futures = "0.3" 123 + hydrant = { git = "https://tangled.org/ptr.pet/hydrant", rev = "1145fa1bb194372ceea81ab48b5d68d304da34ad", default-features = false, features = ["indexer", "indexer_stream"] } 124 + jacquard-axum = { workspace = true } 125 + libsqlite3-sys = { version = "0.37", features = ["bundled"] } 126 + rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs", "std"] } 127 + sqlx = { version = "0.9", default-features = false, features = [ 128 + "runtime-tokio", 129 + "sqlite", 130 + "macros", 131 + "migrate", 132 + ] } 119 133 tokio = { version = "1", features = ["rt", "macros"] } 134 + tower = "0.5" 135 + tower-http = { version = "0.6", features = ["trace"] } 136 + tracing-subscriber = { version = "0.3", default-features = false, features = [ 137 + "std", 138 + "fmt", 139 + "env-filter", 140 + ] } 120 141 121 142 [build-dependencies] 122 143 dotenvy = "0.15"
+308 -2
assets/styling/browse.css
··· 193 193 border-color: color-mix(in srgb, var(--color-error) 55%, var(--color-border)); 194 194 } 195 195 196 + .thing-detail-page { 197 + min-height: 100vh; 198 + max-width: 1180px; 199 + margin: 0 auto; 200 + padding: clamp(0.75rem, 2vw, 1.75rem) clamp(1rem, 3vw, 2.5rem); 201 + } 202 + 203 + .detail-nav { 204 + display: flex; 205 + } 206 + 207 + .detail-nav a { 208 + color: var(--color-subtle); 209 + font-weight: 600; 210 + } 211 + 212 + .thing-detail-header, 213 + .thing-detail-summary, 214 + .thing-detail-copy, 215 + .detail-panel, 216 + .selected-model-panel, 217 + .parts-list, 218 + .part-row-body { 219 + display: flex; 220 + flex-direction: column; 221 + } 222 + 223 + .thing-detail-header { 224 + gap: 0.5rem; 225 + } 226 + 227 + .thing-detail-title-row { 228 + display: flex; 229 + justify-content: space-between; 230 + align-items: flex-end; 231 + gap: 1rem; 232 + } 233 + 234 + .thing-detail-title-row h1, 235 + .detail-panel h2, 236 + .selected-model-panel h2, 237 + .part-row h3 { 238 + margin: 0; 239 + color: var(--color-emphasis); 240 + letter-spacing: -0.03em; 241 + } 242 + 243 + .thing-detail-title-row h1 { 244 + font-size: clamp(2.1rem, 5vw, 4rem); 245 + line-height: 0.95; 246 + } 247 + 248 + .thing-detail-creator, 249 + .detail-muted, 250 + .model-row-summary, 251 + .part-notes, 252 + .viewer-unavailable p { 253 + margin: 0; 254 + color: var(--color-subtle); 255 + } 256 + 257 + .thing-detail-stats, 258 + .part-row-meta { 259 + display: flex; 260 + flex-wrap: wrap; 261 + gap: 0.4rem 0.8rem; 262 + margin: 0; 263 + color: var(--color-muted); 264 + font-family: var(--font-mono); 265 + font-size: 0.76rem; 266 + } 267 + 268 + .thing-detail-stats div, 269 + .part-row-meta div { 270 + display: contents; 271 + } 272 + 273 + .thing-detail-stats dt, 274 + .part-row-meta dt { 275 + position: absolute; 276 + width: 1px; 277 + height: 1px; 278 + padding: 0; 279 + overflow: hidden; 280 + clip: rect(0, 0, 0, 0); 281 + white-space: nowrap; 282 + border: 0; 283 + } 284 + 285 + .thing-detail-stats dd, 286 + .part-row-meta dd { 287 + margin: 0; 288 + } 289 + 290 + .thing-detail-shell { 291 + display: grid; 292 + grid-template-columns: minmax(0, 1.05fr) minmax(18rem, 0.95fr); 293 + align-items: start; 294 + gap: clamp(1rem, 3vw, 1.5rem); 295 + padding: clamp(1rem, 3vw, 1.5rem); 296 + } 297 + 298 + .thing-detail-viewer { 299 + aspect-ratio: 4 / 3; 300 + min-height: 20rem; 301 + max-height: 34rem; 302 + border: 1px solid var(--color-border); 303 + border-radius: var(--radius-panel); 304 + overflow: hidden; 305 + } 306 + 307 + .detail-asset-viewer, 308 + .detail-asset-viewer .model-viewer-boundary, 309 + .detail-asset-viewer .model-viewer-stage { 310 + height: 100%; 311 + min-height: 0; 312 + } 313 + 314 + .detail-asset-viewer .model-viewer-boundary { 315 + border: 0; 316 + background: transparent; 317 + } 318 + 319 + .detail-asset-viewer .model-viewer-stage { 320 + grid-template-rows: 1fr; 321 + } 322 + 323 + .model-viewer-preview-image { 324 + grid-row: 1; 325 + grid-column: 1; 326 + width: 100%; 327 + height: 100%; 328 + object-fit: cover; 329 + } 330 + 331 + .thing-detail-summary { 332 + justify-content: space-between; 333 + gap: 1rem; 334 + } 335 + 336 + .thing-detail-copy { 337 + gap: 0.85rem; 338 + } 339 + 340 + .thing-detail-chips, 341 + .thing-detail-actions { 342 + display: flex; 343 + flex-wrap: wrap; 344 + gap: 0.5rem; 345 + } 346 + 347 + .thing-detail-actions { 348 + align-items: center; 349 + } 350 + 351 + .thing-detail-actions .button-primary { 352 + min-width: 9.5rem; 353 + } 354 + 355 + .button:disabled, 356 + .button[disabled], 357 + .model-row:disabled, 358 + .model-row[disabled] { 359 + cursor: not-allowed; 360 + opacity: 0.52; 361 + filter: saturate(0.7); 362 + } 363 + 364 + .button:disabled:hover, 365 + .button[disabled]:hover { 366 + border-color: var(--color-border); 367 + color: var(--color-text); 368 + filter: saturate(0.7); 369 + } 370 + 371 + .thing-detail-grid { 372 + display: grid; 373 + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); 374 + gap: 1rem; 375 + align-items: start; 376 + } 377 + 378 + .detail-panel { 379 + gap: 0.85rem; 380 + padding: 1rem; 381 + } 382 + 383 + .thing-detail-grid > .detail-panel:nth-child(-n + 2) { 384 + align-self: stretch; 385 + height: 100%; 386 + } 387 + 388 + .detail-panel-heading { 389 + display: flex; 390 + align-items: baseline; 391 + } 392 + 393 + .detail-panel-heading h2, 394 + .selected-model-heading h2 { 395 + font-size: clamp(1.25rem, 2vw, 1.6rem); 396 + line-height: 1.1; 397 + } 398 + 399 + .selected-model-heading { 400 + display: flex; 401 + flex-direction: column; 402 + gap: 0.3rem; 403 + } 404 + 405 + .instruction-list, 406 + .print-settings { 407 + margin: 0; 408 + padding-left: 1.25rem; 409 + color: var(--color-text); 410 + } 411 + 412 + .instruction-list li + li, 413 + .print-settings li + li { 414 + margin-top: 0.35rem; 415 + } 416 + 417 + .model-list { 418 + display: flex; 419 + flex-direction: column; 420 + gap: 0.55rem; 421 + } 422 + 423 + .model-row { 424 + display: flex; 425 + flex-direction: column; 426 + gap: 0.25rem; 427 + width: 100%; 428 + padding: 0.75rem; 429 + border: 1px solid var(--color-border); 430 + border-radius: var(--radius-panel); 431 + background: var(--color-base); 432 + color: var(--color-text); 433 + text-align: left; 434 + } 435 + 436 + .model-row:hover, 437 + .model-row.is-selected { 438 + border-color: var(--color-primary); 439 + } 440 + 441 + .model-row.is-selected { 442 + background: color-mix(in srgb, var(--color-primary) 8%, transparent); 443 + } 444 + 445 + .model-row-title { 446 + font-weight: 700; 447 + } 448 + 449 + .model-row-meta { 450 + color: var(--color-muted); 451 + font-family: var(--font-mono); 452 + font-size: 0.75rem; 453 + } 454 + 455 + .selected-model-panel, 456 + .part-row-body { 457 + gap: 0.65rem; 458 + } 459 + 460 + .parts-list { 461 + gap: 0.75rem; 462 + } 463 + 464 + .part-row { 465 + display: flex; 466 + gap: 0.75rem; 467 + padding: 0.75rem; 468 + border: 1px solid var(--color-border); 469 + border-radius: var(--radius-panel); 470 + background: var(--color-base); 471 + } 472 + 473 + .part-row-index { 474 + display: inline-flex; 475 + align-items: center; 476 + justify-content: center; 477 + flex: 0 0 2rem; 478 + width: 2rem; 479 + height: 2rem; 480 + border: 1px solid var(--color-primary); 481 + border-radius: 999px; 482 + color: var(--color-primary); 483 + font-family: var(--font-mono); 484 + font-size: 0.78rem; 485 + } 486 + 487 + .viewer-unavailable { 488 + min-height: 12rem; 489 + padding: 1rem; 490 + border: 1px solid var(--color-border); 491 + border-radius: var(--radius-panel); 492 + display: flex; 493 + flex-direction: column; 494 + justify-content: space-between; 495 + gap: 1rem; 496 + } 497 + 498 + 196 499 @media (max-width: 820px) { 197 500 .foundation-hero, 198 - .browse-controls { 501 + .browse-controls, 502 + .thing-detail-shell, 503 + .thing-detail-grid { 199 504 grid-template-columns: 1fr; 200 505 } 201 506 202 - .product-header-row { 507 + .product-header-row, 508 + .thing-detail-title-row { 203 509 display: grid; 204 510 align-items: start; 205 511 }
-42
assets/styling/polymodel.css
··· 120 120 .model-preview-line { fill: none; stroke: color-mix(in srgb, var(--color-primary) 70%, var(--color-text)); stroke-width: 3; } 121 121 .model-preview-label { fill: var(--color-muted); font-family: var(--font-mono); font-size: 18px; } 122 122 123 - .model-viewer-overlay { 124 - position: relative; 125 - margin: 1rem; 126 - max-width: none; 127 - padding: 1rem; 128 - border: 1px solid var(--color-border); 129 - background: color-mix(in srgb, var(--color-surface) 92%, transparent); 130 - } 131 - 132 - .model-viewer-overlay h2, 133 - .model-viewer-overlay p, 134 - .viewer-mesh-summary { 135 - margin: 0; 136 - } 137 - 138 - .model-viewer-overlay p + .viewer-mesh-summary, 139 - .viewer-mesh-summary + .viewer-lifecycle-note { 140 - margin-top: 0.75rem; 141 - } 142 - 143 - .viewer-mesh-summary { 144 - display: grid; 145 - grid-template-columns: repeat(2, minmax(0, 1fr)); 146 - gap: 0.5rem 1rem; 147 - } 148 - 149 - .viewer-mesh-summary div { 150 - display: grid; 151 - gap: 0.15rem; 152 - } 153 - 154 - .viewer-mesh-summary dt { 155 - color: var(--color-subtle); 156 - font-family: var(--font-mono); 157 - font-size: 0.7rem; 158 - text-transform: uppercase; 159 - } 160 - 161 - .viewer-mesh-summary dd { 162 - margin: 0; 163 - } 164 - 165 123 .viewer-lifecycle-note { 166 124 color: var(--color-subtle); 167 125 font-size: 0.85rem;
+2 -2
src/appview/error.rs
··· 12 12 13 13 /// Appview handler error. Maps onto the XRPC error response shape. 14 14 #[derive(Debug)] 15 - pub(super) enum AppError { 15 + pub(crate) enum AppError { 16 16 /// 400 `InvalidRequest`: bad parameters (bad at-uri, unknown algorithm, 17 17 /// unparseable cursor, unresolvable handle). 18 18 InvalidRequest(String), ··· 23 23 Internal(String), 24 24 } 25 25 26 - pub(super) type AppResult<T> = Result<T, AppError>; 26 + pub(crate) type AppResult<T> = Result<T, AppError>; 27 27 28 28 pub(super) fn invalid_request(msg: impl Into<String>) -> AppError { 29 29 AppError::InvalidRequest(msg.into())
+4 -4
src/appview/views.rs
··· 444 444 .await) 445 445 } 446 446 447 - pub(super) async fn get_thing( 447 + pub(crate) async fn get_thing( 448 448 state: &AppState, 449 449 uri: &str, 450 450 viewer_did: Option<Did<&str>>, ··· 483 483 Ok(views) 484 484 } 485 485 486 - pub(super) struct ModelDetail { 486 + pub(crate) struct ModelDetail { 487 487 pub model: ModelView, 488 488 pub parts: Vec<PartView>, 489 489 pub thing: Option<ThingViewBasic>, 490 490 } 491 491 492 - pub(super) async fn get_model( 492 + pub(crate) async fn get_model( 493 493 state: &AppState, 494 494 uri: &str, 495 495 viewer_did: Option<Did<&str>>, ··· 614 614 rkey_paged_feed(state, rows, limit, viewer_did).await 615 615 } 616 616 617 - pub(super) async fn feed_recent( 617 + pub(crate) async fn feed_recent( 618 618 state: &AppState, 619 619 limit: i64, 620 620 cursor: Option<&str>,
+193
src/browse.rs
··· 1 + use dioxus::prelude::*; 2 + use jacquard_common::xrpc::XrpcClient; 3 + use polymodel_api::space_polymodel::library; 4 + 5 + use crate::auth::ToastState; 6 + use crate::client::PolymodelClient; 7 + use crate::session::SessionIdentity; 8 + use crate::shell::SessionControl; 9 + use crate::thing_card::ThingCard; 10 + use crate::thing_detail::thing_detail_href; 11 + use crate::viewer::demo_models; 12 + use crate::Route; 13 + 14 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] 15 + enum FeedAlgorithm { 16 + Recent, 17 + Hot, 18 + } 19 + 20 + impl FeedAlgorithm { 21 + fn as_str(self) -> &'static str { 22 + match self { 23 + Self::Recent => "recent", 24 + Self::Hot => "hot", 25 + } 26 + } 27 + 28 + fn label(self) -> &'static str { 29 + match self { 30 + Self::Recent => "Recent", 31 + Self::Hot => "Hot", 32 + } 33 + } 34 + } 35 + 36 + #[derive(Clone, Debug, PartialEq, Eq)] 37 + enum BrowseFeedState { 38 + Loading, 39 + Error(String), 40 + Empty, 41 + Populated(Vec<library::ThingViewBasic>), 42 + } 43 + 44 + fn browse_feed_state( 45 + pending: bool, 46 + result: Option<&Result<Vec<library::ThingViewBasic>, String>>, 47 + ) -> BrowseFeedState { 48 + if pending && result.is_none() { 49 + return BrowseFeedState::Loading; 50 + } 51 + 52 + match result { 53 + Some(Ok(things)) if things.is_empty() => BrowseFeedState::Empty, 54 + Some(Ok(things)) => BrowseFeedState::Populated(things.clone()), 55 + Some(Err(error)) => BrowseFeedState::Error(error.clone()), 56 + None => BrowseFeedState::Loading, 57 + } 58 + } 59 + 60 + #[allow(clippy::useless_format)] 61 + #[component] 62 + pub(crate) fn Browse() -> Element { 63 + let viewer_model = demo_models()[0].id.to_string(); 64 + let client = use_context::<PolymodelClient>(); 65 + let session = use_context::<Signal<SessionIdentity>>(); 66 + let toasts = use_context::<Signal<ToastState>>(); 67 + let selected_algorithm = use_signal(|| FeedAlgorithm::Recent); 68 + 69 + let mut feed = use_resource(move || { 70 + let client = client.clone(); 71 + let algorithm = *selected_algorithm.read(); 72 + async move { 73 + let request = library::get_feed::GetFeed::new() 74 + .algorithm(Some(algorithm.as_str().into())) 75 + .limit(24) 76 + .build(); 77 + let response = client 78 + .send(request) 79 + .await 80 + .map_err(|error| format!("Feed request failed: {error}"))?; 81 + let output = response 82 + .into_output() 83 + .map_err(|error| format!("Feed decode failed: {error}"))?; 84 + Ok(output 85 + .value 86 + .items 87 + .into_iter() 88 + .map(|item| item.thing) 89 + .collect::<Vec<_>>()) 90 + } 91 + }); 92 + 93 + let feed_result = feed.read(); 94 + let feed_state = browse_feed_state(feed.pending(), feed_result.as_ref()); 95 + 96 + rsx! { 97 + main { class: "browse-page product-shell", 98 + header { class: "product-header", 99 + div { class: "product-kicker", "ATProto model sharing" } 100 + div { class: "product-header-row", 101 + div { h1 { "Polymodel" } } 102 + nav { class: "product-nav", aria_label: "Primary", 103 + a { href: "#browse-shell", "Browse" } 104 + Link { to: Route::Foundation {}, "Foundation" } 105 + Link { class: "button button-secondary", to: Route::Viewer { model_id: viewer_model }, "Open viewer" } 106 + SessionControl { session, toasts } 107 + } 108 + } 109 + } 110 + 111 + section { id: "browse-shell", class: "browse-controls blueprint-panel", aria_label: "Browse shell", 112 + label { class: "search-field", 113 + span { "Search model projects" } 114 + input { r#type: "search", placeholder: "Search by project, tag, or maker", aria_label: "Search model projects" } 115 + } 116 + div { class: "tab-row", role: "tablist", aria_label: "Feeds", 117 + FeedTab { algorithm: FeedAlgorithm::Recent, selected_algorithm } 118 + FeedTab { algorithm: FeedAlgorithm::Hot, selected_algorithm } 119 + button { class: "tab", role: "tab", aria_selected: "false", disabled: true, "Following" } 120 + } 121 + } 122 + 123 + section { class: "browse-results-grid", aria_label: "Browse results", 124 + match feed_state { 125 + BrowseFeedState::Loading => rsx! { 126 + for index in 0..6 { 127 + div { key: "skeleton-{index}", class: "state-card loading-state", 128 + span { class: "status-pill", "Loading" } 129 + div { class: "skeleton-line skeleton-wide" } 130 + div { class: "skeleton-line" } 131 + div { class: "skeleton-box" } 132 + } 133 + } 134 + }, 135 + BrowseFeedState::Error(error) => rsx! { 136 + div { class: "state-card error-state browse-state-card", 137 + p { "{error}" } 138 + button { class: "button button-secondary", onclick: move |_| feed.restart(), "Retry" } 139 + } 140 + }, 141 + BrowseFeedState::Empty => rsx! { 142 + div { class: "state-card empty-state browse-state-card", 143 + span { class: "status-pill status-muted", "Empty" } 144 + h2 { "No projects yet" } 145 + p { "Polymodel reached the appview feed, but this local index has no projects for the selected feed yet." } 146 + } 147 + }, 148 + BrowseFeedState::Populated(things) => rsx! { 149 + for thing in things { 150 + BrowseThingCard { key: "{thing.uri}", thing } 151 + } 152 + }, 153 + } 154 + } 155 + } 156 + } 157 + } 158 + 159 + #[component] 160 + fn BrowseThingCard(thing: library::ThingViewBasic) -> Element { 161 + let detail_href = thing_detail_href(&thing.uri); 162 + rsx! { ThingCard { thing, detail_href } } 163 + } 164 + 165 + #[component] 166 + fn FeedTab(algorithm: FeedAlgorithm, selected_algorithm: Signal<FeedAlgorithm>) -> Element { 167 + let is_active = *selected_algorithm.read() == algorithm; 168 + let class = if is_active { "tab is-active" } else { "tab" }; 169 + 170 + rsx! { 171 + button { class, role: "tab", aria_selected: "{is_active}", onclick: move |_| selected_algorithm.set(algorithm), "{algorithm.label()}" } 172 + } 173 + } 174 + 175 + #[cfg(test)] 176 + mod tests { 177 + use super::*; 178 + use crate::thing_card::thing_card_fixtures; 179 + 180 + #[test] 181 + fn browse_state_distinguishes_loading_empty_error_and_populated() { 182 + assert_eq!(browse_feed_state(true, None), BrowseFeedState::Loading); 183 + assert_eq!(browse_feed_state(false, Some(&Ok(vec![]))), BrowseFeedState::Empty); 184 + assert_eq!( 185 + browse_feed_state(false, Some(&Err("boom".to_string()))), 186 + BrowseFeedState::Error("boom".to_string()) 187 + ); 188 + match browse_feed_state(false, Some(&Ok(thing_card_fixtures()))) { 189 + BrowseFeedState::Populated(things) => assert_eq!(things.len(), 3), 190 + state => panic!("expected populated state, got {state:?}"), 191 + } 192 + } 193 + }
+98
src/foundation.rs
··· 1 + use dioxus::prelude::*; 2 + 3 + use crate::Route; 4 + 5 + #[component] 6 + pub(crate) fn Foundation() -> Element { 7 + rsx! { 8 + main { class: "browse-page product-shell", 9 + header { class: "product-header", 10 + div { class: "product-kicker", "PM-38 foundation" } 11 + div { class: "product-header-row", 12 + div { 13 + h1 { "Foundation" } 14 + p { class: "product-lede", "Self-hosted typography, blueprint tokens, and reusable front-end states for upcoming product slices." } 15 + } 16 + nav { class: "product-nav", aria_label: "Foundation navigation", 17 + Link { to: Route::Browse {}, "Back to home" } 18 + a { href: "#ui-states", "UI states" } 19 + } 20 + } 21 + } 22 + 23 + section { id: "browse-foundation", class: "foundation-hero blueprint-panel", 24 + div { class: "foundation-hero-copy", 25 + p { class: "eyebrow", "Token and asset check" } 26 + h2 { "Blueprint styling foundation" } 27 + p { "This route keeps PM-38 primitives visible without making implementation scaffolding the product home." } 28 + div { class: "button-row", 29 + button { class: "button button-primary", "Primary action" } 30 + button { class: "button button-ghost", "Ghost action" } 31 + } 32 + } 33 + div { class: "blueprint-preview", aria_label: "Blueprint placeholder preview", 34 + div { class: "blueprint-preview-grid" } 35 + span { class: "blueprint-axis blueprint-axis-x", "X" } 36 + span { class: "blueprint-axis blueprint-axis-y", "Y" } 37 + span { class: "blueprint-dimension", "120 × 86 × 42 mm" } 38 + } 39 + } 40 + 41 + section { class: "foundation-grid", aria_label: "Foundation primitives", 42 + for item in foundation_items() { FoundationCard { item } } 43 + } 44 + 45 + section { id: "ui-states", class: "state-grid", aria_label: "Reusable states", 46 + div { class: "state-card loading-state", span { class: "status-pill", "Loading" } div { class: "skeleton-line skeleton-wide" } div { class: "skeleton-line" } div { class: "skeleton-box" } } 47 + div { class: "state-card empty-state", span { class: "status-pill status-muted", "Empty" } h2 { "No projects yet" } p { "Empty browse states can use this panel before live feeds are connected." } } 48 + div { class: "state-card error-state", span { class: "status-pill status-error", "Error" } h2 { "Feed unavailable" } p { "Error states keep the browse surface useful without inventing data plumbing in this slice." } } 49 + } 50 + } 51 + } 52 + } 53 + 54 + #[derive(Clone, Copy, PartialEq)] 55 + struct FoundationItem { 56 + label: &'static str, 57 + title: &'static str, 58 + body: &'static str, 59 + meta: &'static str, 60 + } 61 + 62 + fn foundation_items() -> [FoundationItem; 3] { 63 + [ 64 + FoundationItem { label: "Panel", title: "Project card surface", body: "A crisp bordered card for future model summaries, previews, and maker metadata.", meta: "primitive/card" }, 65 + FoundationItem { label: "Input", title: "Search and filter shell", body: "Reusable input styling for browse, profile, and publish flows without binding to a data source yet.", meta: "primitive/input" }, 66 + FoundationItem { label: "Status", title: "Metadata and chips", body: "Compact mono labels for license, dimensions, file state, and feed status.", meta: "primitive/status" }, 67 + ] 68 + } 69 + 70 + #[component] 71 + fn FoundationCard(item: FoundationItem) -> Element { 72 + rsx! { 73 + article { class: "foundation-card card-surface", 74 + div { class: "card-media blueprint-media", span { class: "status-pill", "{item.label}" } } 75 + div { class: "card-body", h2 { class: "card-title", "{item.title}" } p { "{item.body}" } span { class: "card-meta", "{item.meta}" } } 76 + } 77 + } 78 + } 79 + 80 + #[cfg(test)] 81 + mod tests { 82 + use super::*; 83 + 84 + #[test] 85 + fn foundation_items_cover_base_primitives() { 86 + let items = foundation_items(); 87 + assert!(items.iter().any(|item| item.label == "Panel")); 88 + assert!(items.iter().any(|item| item.label == "Input")); 89 + assert!(items.iter().any(|item| item.label == "Status")); 90 + } 91 + 92 + #[test] 93 + fn foundation_copy_avoids_workflow_marketing() { 94 + let combined = foundation_items().iter().map(|item| item.body).collect::<Vec<_>>().join(" "); 95 + assert!(!combined.contains("Jira")); 96 + assert!(!combined.contains("Polytoken")); 97 + } 98 + }
+61
src/indexing/config.rs
··· 24 24 /// defaults the OAuth client origin to `https://polymodel.space`, a functional 25 25 /// hosted client; set `POLYMODEL_BASE_URL` for local or alternate origins. 26 26 pub base_url: Option<String>, 27 + /// Local/demo sample-data mode. `auto` seeds conservative local SQLite DBs 28 + /// only when empty; `force` seeds any SQLite DB when empty; `off` disables it. 29 + pub sample_data: SampleDataMode, 30 + } 31 + 32 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 33 + pub enum SampleDataMode { 34 + Auto, 35 + Off, 36 + Force, 37 + } 38 + 39 + impl SampleDataMode { 40 + fn from_env(value: Option<String>) -> Self { 41 + match value.as_deref().map(str::trim).map(str::to_ascii_lowercase) { 42 + Some(value) if value == "off" => Self::Off, 43 + Some(value) if value == "force" => Self::Force, 44 + Some(value) if value == "auto" => Self::Auto, 45 + Some(value) => { 46 + tracing::warn!(%value, "unknown POLYMODEL_SAMPLE_DATA value; falling back to off"); 47 + Self::Off 48 + } 49 + None => Self::Auto, 50 + } 51 + } 52 + 53 + pub fn should_seed(self, database_url: &str) -> bool { 54 + match self { 55 + Self::Off => false, 56 + Self::Force => database_url.starts_with("sqlite:"), 57 + Self::Auto => is_local_sqlite(database_url), 58 + } 59 + } 60 + } 61 + 62 + fn is_local_sqlite(database_url: &str) -> bool { 63 + database_url == "sqlite::memory:" 64 + || database_url.starts_with("sqlite:./") 65 + || database_url.starts_with("sqlite:../") 66 + || database_url.starts_with("sqlite:/") 27 67 } 28 68 29 69 impl ServerConfig { ··· 38 78 .ok() 39 79 .map(|s| s.trim().to_owned()) 40 80 .filter(|s| !s.is_empty()); 81 + let sample_data = SampleDataMode::from_env(env::var("POLYMODEL_SAMPLE_DATA").ok()); 41 82 Self { 42 83 database_url, 43 84 base_url, 85 + sample_data, 44 86 } 45 87 } 46 88 } 89 + 90 + #[cfg(test)] 91 + mod tests { 92 + use super::*; 93 + 94 + #[test] 95 + fn sample_data_auto_is_limited_to_local_sqlite_urls() { 96 + assert!(SampleDataMode::Auto.should_seed("sqlite:./data/polymodel.db")); 97 + assert!(SampleDataMode::Auto.should_seed("sqlite:/tmp/polymodel.db")); 98 + assert!(!SampleDataMode::Auto.should_seed("postgres://example")); 99 + } 100 + 101 + #[test] 102 + fn sample_data_env_parses_known_values() { 103 + assert_eq!(SampleDataMode::from_env(Some("off".into())), SampleDataMode::Off); 104 + assert_eq!(SampleDataMode::from_env(Some("force".into())), SampleDataMode::Force); 105 + assert_eq!(SampleDataMode::from_env(Some("auto".into())), SampleDataMode::Auto); 106 + } 107 + }
+3
src/indexing/mod.rs
··· 9 9 //! 1. Hydrant's fjall store (`HYDRANT_DATABASE_PATH`) for raw ATProto records. 10 10 //! 2. The SQLite projection database (`DATABASE_URL`) for app-specific views. 11 11 12 + #![cfg_attr(test, allow(dead_code))] 13 + 12 14 pub mod config; 13 15 pub mod db; 14 16 pub mod projection; 17 + pub mod sample_data; 15 18 pub mod setup; 16 19 17 20 use std::time::Duration;
+390
src/indexing/sample_data.rs
··· 1 + //! Deterministic local/demo sample data for appview read paths. 2 + //! 3 + //! This module writes projection-shaped SQLite rows only when the local/demo 4 + //! database has no Polymodel things. It lets ordinary local `just serve` and e2e 5 + //! exercise the same `getFeed`, `getThing`, and `getModel` appview contracts as 6 + //! production reads without UI-only fixtures. 7 + 8 + use jacquard_common::types::blob::BlobRef; 9 + use polymodel_api::space_polymodel::{ 10 + actor::profile::Profile, 11 + library::{File, model::Model, part::Part, thing::Thing}, 12 + }; 13 + use serde::{Serialize, de::DeserializeOwned}; 14 + use serde_json::{Value, json}; 15 + use sqlx::{SqliteConnection, SqlitePool}; 16 + 17 + pub const SAMPLE_DID: &str = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"; 18 + pub const SAMPLE_HANDLE: &str = "ari.model.tools"; 19 + pub const SAMPLE_THING_RKEY: &str = "parametric-enclosure"; 20 + pub const SAMPLE_THING_URI: &str = 21 + "at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/space.polymodel.library.thing/parametric-enclosure"; 22 + 23 + const SAMPLE_TIME: &str = "2026-06-21T12:00:00.000Z"; 24 + const SAMPLE_TIME_MILLIS: i64 = 1_750_507_200_000; 25 + 26 + pub async fn seed_if_empty(pool: &SqlitePool) -> anyhow::Result<()> { 27 + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM things") 28 + .fetch_one(pool) 29 + .await?; 30 + if count > 0 { 31 + tracing::debug!(count, "sample data skipped because things table is populated"); 32 + return Ok(()); 33 + } 34 + 35 + seed(pool).await?; 36 + tracing::info!(thing_uri = SAMPLE_THING_URI, "seeded local/demo Polymodel sample data"); 37 + Ok(()) 38 + } 39 + 40 + async fn seed(pool: &SqlitePool) -> anyhow::Result<()> { 41 + let mut tx = pool.begin().await?; 42 + 43 + insert_identity(&mut tx, SAMPLE_DID, SAMPLE_HANDLE).await?; 44 + insert_profile(&mut tx).await?; 45 + 46 + let model_uris = vec![ 47 + model_uri("enclosure-v1"), 48 + model_uri("panel-variant"), 49 + model_uri("ruggedized-lid"), 50 + ]; 51 + let parts = sample_parts(); 52 + for part in &parts { 53 + insert_part(&mut tx, part).await?; 54 + } 55 + 56 + insert_model( 57 + &mut tx, 58 + "enclosure-v1", 59 + "Snap-fit electronics enclosure", 60 + "Main printable enclosure with lid, body, buttons, and mounting hardware.", 61 + &parts[0..6], 62 + ) 63 + .await?; 64 + insert_model( 65 + &mut tx, 66 + "panel-variant", 67 + "Panel-mount variant", 68 + "Alternate front panel for flush mounting sensors and displays.", 69 + &parts[6..12], 70 + ) 71 + .await?; 72 + insert_model( 73 + &mut tx, 74 + "ruggedized-lid", 75 + "Ruggedized outdoor lid", 76 + "Weather-resistant lid and gasket parts for workshop deployments.", 77 + &parts[12..18], 78 + ) 79 + .await?; 80 + 81 + let instructions = vec![ 82 + "Print the body and lid in PETG or PLA+ with 0.2 mm layers.".to_string(), 83 + "Use the model selector to choose the standard, panel-mount, or ruggedized variant.".to_string(), 84 + "Press brass inserts into the corner bosses before final assembly.".to_string(), 85 + ]; 86 + let tags = vec!["enclosure", "parametric", "print-in-place"]; 87 + let thing_record = json!({ 88 + "name": "Parametric enclosure kit", 89 + "summary": "A configurable electronics enclosure kit with three printable model variants and detailed part files.", 90 + "instructions": instructions, 91 + "license": "CC-BY-4.0", 92 + "tags": tags, 93 + "cover": [image("bafkreiathingcover", "Rendered preview of a parametric electronics enclosure")], 94 + "previews": [image("bafkreiathingpreview", "Exploded blueprint preview of the enclosure kit")], 95 + "models": model_uris.iter().map(|uri| strong_ref(uri)).collect::<Vec<_>>(), 96 + "createdAt": SAMPLE_TIME 97 + }); 98 + 99 + let thing_record_json = typed_json::<Thing>(thing_record.clone())?; 100 + 101 + sqlx::query( 102 + r#"INSERT INTO things (did, rkey, uri, cid, name, summary, license, tags_json, 103 + tags_text, instructions_text, cover_json, derived_from_uri, created_at, indexed_at, record_json) 104 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)"#, 105 + ) 106 + .bind(SAMPLE_DID) 107 + .bind(SAMPLE_THING_RKEY) 108 + .bind(SAMPLE_THING_URI) 109 + .bind("bafyreipolymodelsamplething") 110 + .bind("Parametric enclosure kit") 111 + .bind("A configurable electronics enclosure kit with three printable model variants and detailed part files.") 112 + .bind("CC-BY-4.0") 113 + .bind(serde_json::to_string(&tags)?) 114 + .bind(tags.join(" ")) 115 + .bind(instructions.join("\n")) 116 + .bind(serde_json::to_string(&thing_record["cover"])? ) 117 + .bind(SAMPLE_TIME_MILLIS) 118 + .bind(SAMPLE_TIME_MILLIS) 119 + .bind(thing_record_json) 120 + .execute(&mut *tx) 121 + .await?; 122 + 123 + for (position, model_uri) in model_uris.iter().enumerate() { 124 + sqlx::query( 125 + "INSERT INTO thing_models (thing_uri, model_uri, position) VALUES (?, ?, ?)", 126 + ) 127 + .bind(SAMPLE_THING_URI) 128 + .bind(model_uri) 129 + .bind(position as i64) 130 + .execute(&mut *tx) 131 + .await?; 132 + } 133 + 134 + sqlx::query( 135 + "INSERT INTO content_stats (uri, like_count, save_count, tag_count) VALUES (?, 42, 12, 3)", 136 + ) 137 + .bind(SAMPLE_THING_URI) 138 + .execute(&mut *tx) 139 + .await?; 140 + 141 + tx.commit().await?; 142 + Ok(()) 143 + } 144 + 145 + async fn insert_identity(conn: &mut SqliteConnection, did: &str, handle: &str) -> anyhow::Result<()> { 146 + sqlx::query("INSERT INTO identities (did, handle, updated_at) VALUES (?, ?, ?)") 147 + .bind(did) 148 + .bind(handle) 149 + .bind(SAMPLE_TIME_MILLIS) 150 + .execute(&mut *conn) 151 + .await?; 152 + Ok(()) 153 + } 154 + 155 + async fn insert_profile(conn: &mut SqliteConnection) -> anyhow::Result<()> { 156 + let avatar = typed_json::<BlobRef>(blob("bafkreiariavatar", "image/jpeg", 2048))?; 157 + let profile_record = typed_json::<Profile>(json!({ 158 + "displayName": "Ari Chen", 159 + "description": "Maker of practical parametric fixtures and electronics housings.", 160 + "avatar": serde_json::from_str::<Value>(&avatar)?, 161 + "pronouns": "she/her", 162 + "defaultLicense": "CC-BY-4.0" 163 + }))?; 164 + sqlx::query( 165 + r#"INSERT INTO profiles (did, display_name, description, avatar_json, default_license, 166 + pronouns, printers_json, links_json, record_json, indexed_at) 167 + VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)"#, 168 + ) 169 + .bind(SAMPLE_DID) 170 + .bind("Ari Chen") 171 + .bind("Maker of practical parametric fixtures and electronics housings.") 172 + .bind(&avatar) 173 + .bind("CC-BY-4.0") 174 + .bind("she/her") 175 + .bind(profile_record) 176 + .bind(SAMPLE_TIME_MILLIS) 177 + .execute(&mut *conn) 178 + .await?; 179 + Ok(()) 180 + } 181 + 182 + async fn insert_model( 183 + conn: &mut SqliteConnection, 184 + rkey: &str, 185 + name: &str, 186 + summary: &str, 187 + parts: &[SamplePart], 188 + ) -> anyhow::Result<()> { 189 + let uri = model_uri(rkey); 190 + let record = typed_json::<Model>(json!({ 191 + "name": name, 192 + "summary": summary, 193 + "instructions": ["Review the ordered part list before printing.", "Print fit-critical clips slowly for best tolerances."], 194 + "license": "CC-BY-4.0", 195 + "tags": ["variant", "enclosure"], 196 + "cover": [image("bafkreimodelcover", &format!("Preview of {name}"))], 197 + "previews": [image("bafkreimodelpreview", &format!("Blueprint preview of {name}"))], 198 + "parts": parts.iter().map(|part| strong_ref(&part_uri(part.rkey))).collect::<Vec<_>>(), 199 + "createdAt": SAMPLE_TIME 200 + }))?; 201 + 202 + sqlx::query( 203 + r#"INSERT INTO models (did, rkey, uri, cid, name, summary, created_at, indexed_at, record_json) 204 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"#, 205 + ) 206 + .bind(SAMPLE_DID) 207 + .bind(rkey) 208 + .bind(&uri) 209 + .bind(format!("bafyreimodel{rkey}")) 210 + .bind(name) 211 + .bind(summary) 212 + .bind(SAMPLE_TIME_MILLIS) 213 + .bind(SAMPLE_TIME_MILLIS) 214 + .bind(record) 215 + .execute(&mut *conn) 216 + .await?; 217 + 218 + for (position, part) in parts.iter().enumerate() { 219 + sqlx::query("INSERT INTO model_parts (model_uri, part_uri, position) VALUES (?, ?, ?)") 220 + .bind(&uri) 221 + .bind(part_uri(part.rkey)) 222 + .bind(position as i64) 223 + .execute(&mut *conn) 224 + .await?; 225 + } 226 + Ok(()) 227 + } 228 + 229 + async fn insert_part(conn: &mut SqliteConnection, part: &SamplePart) -> anyhow::Result<()> { 230 + let file = file_manifest(part.file_size); 231 + let file_json = typed_json::<File>(file.clone())?; 232 + let record = typed_json::<Part>(json!({ 233 + "name": part.name, 234 + "file": file, 235 + "format": "STL", 236 + "dimensions": { "x": part.dimensions.0, "y": part.dimensions.1, "z": part.dimensions.2, "unit": "mm" }, 237 + "units": "mm", 238 + "notes": part.notes, 239 + "printSettings": ["0.2 mm layer height", "15% gyroid infill", "No supports required unless noted"], 240 + "previews": [image("bafkreipartpreview", &format!("Preview of {}", part.name))], 241 + "createdAt": SAMPLE_TIME 242 + }))?; 243 + 244 + sqlx::query( 245 + r#"INSERT INTO parts (did, rkey, uri, cid, name, format, file_json, created_at, indexed_at, record_json) 246 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, 247 + ) 248 + .bind(SAMPLE_DID) 249 + .bind(part.rkey) 250 + .bind(part_uri(part.rkey)) 251 + .bind(format!("bafyreipart{}", part.rkey)) 252 + .bind(part.name) 253 + .bind("STL") 254 + .bind(file_json) 255 + .bind(SAMPLE_TIME_MILLIS) 256 + .bind(SAMPLE_TIME_MILLIS) 257 + .bind(record) 258 + .execute(&mut *conn) 259 + .await?; 260 + Ok(()) 261 + } 262 + 263 + #[derive(Clone, Copy)] 264 + struct SamplePart { 265 + rkey: &'static str, 266 + name: &'static str, 267 + file_size: i64, 268 + dimensions: (&'static str, &'static str, &'static str), 269 + notes: &'static str, 270 + } 271 + 272 + fn sample_parts() -> Vec<SamplePart> { 273 + [ 274 + ("part-01", "Main enclosure body"), 275 + ("part-02", "Snap-fit lid"), 276 + ("part-03", "Button cap set"), 277 + ("part-04", "USB-C bezel"), 278 + ("part-05", "PCB mounting tray"), 279 + ("part-06", "Corner screw boss"), 280 + ("part-07", "Front sensor panel"), 281 + ("part-08", "Display window frame"), 282 + ("part-09", "Panel latch pair"), 283 + ("part-10", "Flush wall bracket"), 284 + ("part-11", "Cable strain relief"), 285 + ("part-12", "Label plate"), 286 + ("part-13", "Ruggedized lid shell"), 287 + ("part-14", "TPU gasket guide"), 288 + ("part-15", "Outdoor cable gland"), 289 + ("part-16", "Hinged dust cover"), 290 + ("part-17", "Drain slot insert"), 291 + ("part-18", "Mounting foot set"), 292 + ] 293 + .into_iter() 294 + .enumerate() 295 + .map(|(index, (rkey, name))| SamplePart { 296 + rkey, 297 + name, 298 + file_size: 48_000 + ((index + 1) as i64 * 2_048), 299 + dimensions: ("86.0", "54.0", "18.5"), 300 + notes: "Orient the visible face upward and verify first-layer adhesion around clips.", 301 + }) 302 + .collect() 303 + } 304 + 305 + fn model_uri(rkey: &str) -> String { 306 + format!("at://{SAMPLE_DID}/space.polymodel.library.model/{rkey}") 307 + } 308 + 309 + fn part_uri(rkey: &str) -> String { 310 + format!("at://{SAMPLE_DID}/space.polymodel.library.part/{rkey}") 311 + } 312 + 313 + fn strong_ref(uri: &str) -> Value { 314 + json!({ "uri": uri, "cid": "bafyreicid" }) 315 + } 316 + 317 + fn image(cid: &str, alt: &str) -> Value { 318 + json!({ 319 + "alt": alt, 320 + "aspectRatio": { "width": 4, "height": 3 }, 321 + "image": blob(cid, "image/jpeg", 1234) 322 + }) 323 + } 324 + 325 + fn file_manifest(size: i64) -> Value { 326 + json!({ 327 + "mimeType": "model/stl", 328 + "size": size, 329 + "digest": "sha256:sample-polymodel-fixture", 330 + "chunks": [{ "blob": blob("bafkreistlchunk", "model/stl", size), "offset": 0, "size": size }] 331 + }) 332 + } 333 + 334 + fn blob(cid: &str, mime_type: &str, size: i64) -> Value { 335 + json!({ "$type": "blob", "ref": { "$link": cid }, "mimeType": mime_type, "size": size }) 336 + } 337 + 338 + fn typed_json<T>(value: Value) -> anyhow::Result<String> 339 + where 340 + T: DeserializeOwned + Serialize, 341 + { 342 + let typed: T = serde_json::from_value(value)?; 343 + Ok(serde_json::to_string(&typed)?) 344 + } 345 + 346 + #[cfg(test)] 347 + mod tests { 348 + use std::str::FromStr; 349 + use std::sync::Arc; 350 + 351 + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; 352 + 353 + use super::*; 354 + use crate::appview::{state::AppState, views}; 355 + 356 + async fn test_pool() -> SqlitePool { 357 + let options = SqliteConnectOptions::from_str("sqlite::memory:").unwrap(); 358 + let pool = SqlitePoolOptions::new() 359 + .max_connections(1) 360 + .connect_with(options) 361 + .await 362 + .unwrap(); 363 + sqlx::migrate!("./migrations").run(&pool).await.unwrap(); 364 + pool 365 + } 366 + 367 + #[tokio::test] 368 + async fn seed_populates_feed_thing_and_model_read_paths() { 369 + let pool = test_pool().await; 370 + seed_if_empty(&pool).await.unwrap(); 371 + seed_if_empty(&pool).await.unwrap(); 372 + let bootstrap = crate::oauth::bootstrap_oauth(pool.clone(), Some("http://localhost")) 373 + .expect("ephemeral OAuth bootstrap for tests"); 374 + let state = Arc::new(AppState::new(pool, bootstrap)); 375 + 376 + let feed = views::feed_recent(&state, 10, None, None).await.unwrap(); 377 + assert_eq!(feed.items.len(), 1); 378 + assert_eq!(feed.items[0].thing.name.as_str(), "Parametric enclosure kit"); 379 + assert_eq!(feed.items[0].thing.model_count, 3); 380 + assert_eq!(feed.items[0].thing.part_count, 18); 381 + 382 + let (thing, models) = views::get_thing(&state, SAMPLE_THING_URI, None).await.unwrap(); 383 + assert_eq!(thing.author.display_name.as_ref().unwrap().as_str(), "Ari Chen"); 384 + assert_eq!(models.len(), 3); 385 + 386 + let detail = views::get_model(&state, models[0].uri.as_ref(), None).await.unwrap(); 387 + assert_eq!(detail.parts.len(), 6); 388 + assert_eq!(detail.parts[0].name.as_str(), "Main enclosure body"); 389 + } 390 + }
+24 -425
src/main.rs
··· 1 1 use dioxus::prelude::*; 2 2 use jacquard_common::xrpc::XrpcClient; 3 - use polymodel_api::space_polymodel::{actor::get_session::GetSession, library}; 3 + use polymodel_api::space_polymodel::actor::get_session::GetSession; 4 4 5 5 mod auth; 6 + mod browse; 6 7 mod client; 7 8 mod fonts; 9 + mod foundation; 8 10 mod mesh; 9 11 mod session; 12 + mod shell; 10 13 mod thing_card; 14 + mod thing_detail; 11 15 mod viewer; 16 + mod viewer_route; 12 17 13 18 // Frontend compile-time configuration, generated by build.rs from POLYMODEL_*. 14 19 mod env; 15 20 // Server-only indexing infrastructure (Hydrant firehose + SQLite projection). 16 - #[cfg(feature = "server")] 21 + #[cfg(any(feature = "server", test))] 17 22 mod indexing; 18 23 // Server-only XRPC read endpoints (appview query layer, PM-26). 19 - #[cfg(feature = "server")] 24 + #[cfg(any(feature = "server", test))] 20 25 mod appview; 26 + #[cfg(any(feature = "server", test))] 27 + mod oauth; 21 28 22 - use auth::{ToastState, broadcast_sign_out, logout_url}; 29 + use browse::Browse; 23 30 use client::PolymodelClient; 31 + use foundation::Foundation; 24 32 use session::SessionIdentity; 25 - use thing_card::ThingCard; 26 - 27 - #[cfg(feature = "server")] 28 - mod oauth; 29 - 30 - use viewer::{ViewerPage, demo_model_for_route, demo_models}; 33 + use shell::ToastShelf; 34 + use thing_detail::ThingDetail; 35 + use viewer_route::Viewer; 31 36 32 37 const FAVICON: Asset = asset!("/assets/favicon.jpg"); 33 38 const THEME_CSS: Asset = asset!("/assets/styling/theme.css"); ··· 52 57 let db = indexing::db::init_db(&cfg) 53 58 .await 54 59 .expect("failed to initialize sqlite projection database"); 60 + if cfg.sample_data.should_seed(&cfg.database_url) { 61 + indexing::sample_data::seed_if_empty(&db) 62 + .await 63 + .expect("failed to seed local/demo sample data"); 64 + } 55 65 indexing::start_indexing(db.clone()) 56 66 .await 57 67 .expect("failed to start indexing pipeline"); ··· 83 93 } 84 94 85 95 #[derive(Clone, Debug, PartialEq, Routable)] 86 - enum Route { 96 + pub(crate) enum Route { 87 97 #[route("/")] 88 98 Browse {}, 89 99 #[route("/foundation")] 90 100 Foundation {}, 91 101 #[route("/viewer/:model_id")] 92 102 Viewer { model_id: String }, 103 + #[route("/:repo/thing/:rkey")] 104 + ThingDetail { repo: String, rkey: String }, 93 105 } 94 106 95 107 #[component] 96 108 fn App() -> Element { 97 109 let client = use_context_provider(PolymodelClient::default); 98 110 let session = use_context_provider(|| Signal::new(SessionIdentity::Anonymous)); 99 - let toasts = use_context_provider(|| Signal::new(ToastState::default())); 111 + let toasts = use_context_provider(|| Signal::new(auth::ToastState::default())); 100 112 101 113 let _session_seed = use_resource(move || { 102 114 let client = client.clone(); ··· 126 138 ToastShelf { toasts } 127 139 } 128 140 } 129 - 130 - #[derive(Clone, Copy, Debug, PartialEq, Eq)] 131 - enum FeedAlgorithm { 132 - Recent, 133 - Hot, 134 - } 135 - 136 - impl FeedAlgorithm { 137 - fn as_str(self) -> &'static str { 138 - match self { 139 - Self::Recent => "recent", 140 - Self::Hot => "hot", 141 - } 142 - } 143 - 144 - fn label(self) -> &'static str { 145 - match self { 146 - Self::Recent => "Recent", 147 - Self::Hot => "Hot", 148 - } 149 - } 150 - } 151 - 152 - #[derive(Clone, Debug, PartialEq, Eq)] 153 - enum BrowseFeedState { 154 - Loading, 155 - Error(String), 156 - Empty, 157 - Populated(Vec<library::ThingViewBasic>), 158 - } 159 - 160 - fn browse_feed_state( 161 - pending: bool, 162 - result: Option<&Result<Vec<library::ThingViewBasic>, String>>, 163 - ) -> BrowseFeedState { 164 - if pending && result.is_none() { 165 - return BrowseFeedState::Loading; 166 - } 167 - 168 - match result { 169 - Some(Ok(things)) if things.is_empty() => BrowseFeedState::Empty, 170 - Some(Ok(things)) => BrowseFeedState::Populated(things.clone()), 171 - Some(Err(error)) => BrowseFeedState::Error(error.clone()), 172 - None => BrowseFeedState::Loading, 173 - } 174 - } 175 - 176 - #[allow(clippy::useless_format)] 177 - #[component] 178 - fn Browse() -> Element { 179 - let viewer_model = demo_models()[0].id.to_string(); 180 - let client = use_context::<PolymodelClient>(); 181 - let session = use_context::<Signal<SessionIdentity>>(); 182 - let toasts = use_context::<Signal<ToastState>>(); 183 - let selected_algorithm = use_signal(|| FeedAlgorithm::Recent); 184 - 185 - let mut feed = use_resource(move || { 186 - let client = client.clone(); 187 - let algorithm = *selected_algorithm.read(); 188 - async move { 189 - let request = library::get_feed::GetFeed::new() 190 - .algorithm(Some(algorithm.as_str().into())) 191 - .limit(24) 192 - .build(); 193 - let response = client 194 - .send(request) 195 - .await 196 - .map_err(|error| format!("Feed request failed: {error}"))?; 197 - let output = response 198 - .into_output() 199 - .map_err(|error| format!("Feed decode failed: {error}"))?; 200 - Ok(output 201 - .value 202 - .items 203 - .into_iter() 204 - .map(|item| item.thing) 205 - .collect::<Vec<_>>()) 206 - } 207 - }); 208 - 209 - let feed_result = feed.read(); 210 - let feed_state = browse_feed_state(feed.pending(), feed_result.as_ref()); 211 - 212 - rsx! { 213 - main { class: "browse-page product-shell", 214 - header { class: "product-header", 215 - div { class: "product-kicker", "ATProto model sharing" } 216 - div { class: "product-header-row", 217 - div { 218 - h1 { "Polymodel" } 219 - } 220 - nav { class: "product-nav", aria_label: "Primary", 221 - a { href: "#browse-shell", "Browse" } 222 - Link { to: Route::Foundation {}, "Foundation" } 223 - Link { 224 - class: "button button-secondary", 225 - to: Route::Viewer { model_id: viewer_model }, 226 - "Open viewer" 227 - } 228 - SessionControl { session, toasts } 229 - } 230 - } 231 - } 232 - 233 - section { id: "browse-shell", class: "browse-controls blueprint-panel", aria_label: "Browse shell", 234 - label { class: "search-field", 235 - span { "Search model projects" } 236 - input { 237 - r#type: "search", 238 - placeholder: "Search by project, tag, or maker", 239 - aria_label: "Search model projects" 240 - } 241 - } 242 - div { class: "tab-row", role: "tablist", aria_label: "Feeds", 243 - FeedTab { algorithm: FeedAlgorithm::Recent, selected_algorithm } 244 - FeedTab { algorithm: FeedAlgorithm::Hot, selected_algorithm } 245 - button { class: "tab", role: "tab", aria_selected: "false", disabled: true, "Following" } 246 - } 247 - } 248 - 249 - section { class: "browse-results-grid", aria_label: "Browse results", 250 - match feed_state { 251 - BrowseFeedState::Loading => rsx! { 252 - for index in 0..6 { 253 - div { key: "skeleton-{index}", class: "state-card loading-state", 254 - span { class: "status-pill", "Loading" } 255 - div { class: "skeleton-line skeleton-wide" } 256 - div { class: "skeleton-line" } 257 - div { class: "skeleton-box" } 258 - } 259 - } 260 - }, 261 - BrowseFeedState::Error(error) => { 262 - let error_message = error.clone(); 263 - rsx! { 264 - div { class: "state-card error-state browse-state-card", 265 - p { "{error_message}" } 266 - button { 267 - class: "button button-secondary", 268 - onclick: move |_| feed.restart(), 269 - "Retry" 270 - } 271 - } 272 - } }, 273 - BrowseFeedState::Empty => rsx! { 274 - div { class: "state-card empty-state browse-state-card", 275 - span { class: "status-pill status-muted", "Empty" } 276 - h2 { "No projects yet" } 277 - p { "Polymodel reached the appview feed, but this local index has no projects for the selected feed yet." } 278 - } 279 - }, 280 - BrowseFeedState::Populated(things) => rsx! { 281 - for thing in things { 282 - ThingCard { key: "{thing.uri}", thing } 283 - } 284 - }, 285 - } 286 - } 287 - } 288 - } 289 - } 290 - 291 - #[component] 292 - fn FeedTab(algorithm: FeedAlgorithm, selected_algorithm: Signal<FeedAlgorithm>) -> Element { 293 - let is_active = *selected_algorithm.read() == algorithm; 294 - let class = if is_active { "tab is-active" } else { "tab" }; 295 - 296 - rsx! { 297 - button { 298 - class, 299 - role: "tab", 300 - aria_selected: "{is_active}", 301 - onclick: move |_| selected_algorithm.set(algorithm), 302 - "{algorithm.label()}" 303 - } 304 - } 305 - } 306 - 307 - #[component] 308 - fn SessionControl(session: Signal<SessionIdentity>, toasts: Signal<ToastState>) -> Element { 309 - let identity = session.read().clone(); 310 - 311 - match identity { 312 - SessionIdentity::Anonymous => rsx! { 313 - form { 314 - class: "session-control", 315 - action: "/oauth/start", 316 - method: "get", 317 - input { 318 - r#type: "text", 319 - name: "identifier", 320 - placeholder: "handle.test", 321 - aria_label: "ATProto handle", 322 - } 323 - button { class: "button button-primary", r#type: "submit", "Sign in" } 324 - } 325 - }, 326 - SessionIdentity::Authenticated(identity) => { 327 - let label = SessionIdentity::Authenticated(identity).label(); 328 - rsx! { 329 - div { class: "session-control session-control-authenticated", 330 - span { class: "status-pill", "{label}" } 331 - form { 332 - method: "post", 333 - action: "{logout_url()}", 334 - button { 335 - class: "button button-secondary", 336 - r#type: "submit", 337 - onclick: move |_| { 338 - broadcast_sign_out(); 339 - session.set(SessionIdentity::Anonymous); 340 - toasts.write().push("Signing out…"); 341 - }, 342 - "Sign out" 343 - } 344 - } 345 - } 346 - } 347 - } 348 - } 349 - } 350 - 351 - #[component] 352 - fn ToastShelf(toasts: Signal<ToastState>) -> Element { 353 - let items = toasts.read().items().to_vec(); 354 - 355 - rsx! { 356 - div { class: "toast-shelf", aria_live: "polite", 357 - for toast in items { 358 - div { key: "toast-{toast.id}", class: "toast blueprint-panel", 359 - span { "{toast.message}" } 360 - button { 361 - class: "toast-dismiss", 362 - aria_label: "Dismiss notification", 363 - onclick: move |_| toasts.write().dismiss(toast.id), 364 - "×" 365 - } 366 - } 367 - } 368 - } 369 - } 370 - } 371 - 372 - #[component] 373 - fn Foundation() -> Element { 374 - rsx! { 375 - main { class: "browse-page product-shell", 376 - header { class: "product-header", 377 - div { class: "product-kicker", "PM-38 foundation" } 378 - div { class: "product-header-row", 379 - div { 380 - h1 { "Foundation" } 381 - p { class: "product-lede", 382 - "Self-hosted typography, blueprint tokens, and reusable front-end states for upcoming product slices." 383 - } 384 - } 385 - nav { class: "product-nav", aria_label: "Foundation navigation", 386 - Link { to: Route::Browse {}, "Back to home" } 387 - a { href: "#ui-states", "UI states" } 388 - } 389 - } 390 - } 391 - 392 - section { id: "browse-foundation", class: "foundation-hero blueprint-panel", 393 - div { class: "foundation-hero-copy", 394 - p { class: "eyebrow", "Token and asset check" } 395 - h2 { "Blueprint styling foundation" } 396 - p { 397 - "This route keeps PM-38 primitives visible without making implementation scaffolding the product home." 398 - } 399 - div { class: "button-row", 400 - button { class: "button button-primary", "Primary action" } 401 - button { class: "button button-ghost", "Ghost action" } 402 - } 403 - } 404 - div { class: "blueprint-preview", aria_label: "Blueprint placeholder preview", 405 - div { class: "blueprint-preview-grid" } 406 - span { class: "blueprint-axis blueprint-axis-x", "X" } 407 - span { class: "blueprint-axis blueprint-axis-y", "Y" } 408 - span { class: "blueprint-dimension", "120 × 86 × 42 mm" } 409 - } 410 - } 411 - 412 - section { class: "foundation-grid", aria_label: "Foundation primitives", 413 - for item in foundation_items() { 414 - FoundationCard { item } 415 - } 416 - } 417 - 418 - section { id: "ui-states", class: "state-grid", aria_label: "Reusable states", 419 - div { class: "state-card loading-state", 420 - span { class: "status-pill", "Loading" } 421 - div { class: "skeleton-line skeleton-wide" } 422 - div { class: "skeleton-line" } 423 - div { class: "skeleton-box" } 424 - } 425 - div { class: "state-card empty-state", 426 - span { class: "status-pill status-muted", "Empty" } 427 - h2 { "No projects yet" } 428 - p { "Empty browse states can use this panel before live feeds are connected." } 429 - } 430 - div { class: "state-card error-state", 431 - span { class: "status-pill status-error", "Error" } 432 - h2 { "Feed unavailable" } 433 - p { "Error states keep the browse surface useful without inventing data plumbing in this slice." } 434 - } 435 - } 436 - } 437 - } 438 - } 439 - 440 - #[component] 441 - fn Viewer(model_id: String) -> Element { 442 - let model = demo_model_for_route(&model_id); 443 - 444 - rsx! { 445 - main { class: "viewer-route-page", 446 - nav { class: "viewer-route-nav", aria_label: "Viewer navigation", 447 - Link { to: Route::Browse {}, "← Back to browse" } 448 - } 449 - ViewerPage { key: "{model.id}", initial_model: model } 450 - } 451 - } 452 - } 453 - 454 - #[derive(Clone, Copy, PartialEq)] 455 - struct FoundationItem { 456 - label: &'static str, 457 - title: &'static str, 458 - body: &'static str, 459 - meta: &'static str, 460 - } 461 - 462 - fn foundation_items() -> [FoundationItem; 3] { 463 - [ 464 - FoundationItem { 465 - label: "Panel", 466 - title: "Project card surface", 467 - body: "A crisp bordered card for future model summaries, previews, and maker metadata.", 468 - meta: "primitive/card", 469 - }, 470 - FoundationItem { 471 - label: "Input", 472 - title: "Search and filter shell", 473 - body: "Reusable input styling for browse, profile, and publish flows without binding to a data source yet.", 474 - meta: "primitive/input", 475 - }, 476 - FoundationItem { 477 - label: "Status", 478 - title: "Metadata and chips", 479 - body: "Compact mono labels for license, dimensions, file state, and feed status.", 480 - meta: "primitive/status", 481 - }, 482 - ] 483 - } 484 - 485 - #[component] 486 - fn FoundationCard(item: FoundationItem) -> Element { 487 - rsx! { 488 - article { class: "foundation-card card-surface", 489 - div { class: "card-media blueprint-media", 490 - span { class: "status-pill", "{item.label}" } 491 - } 492 - div { class: "card-body", 493 - h2 { class: "card-title", "{item.title}" } 494 - p { "{item.body}" } 495 - span { class: "card-meta", "{item.meta}" } 496 - } 497 - } 498 - } 499 - } 500 - 501 - #[cfg(test)] 502 - mod tests { 503 - use super::*; 504 - use thing_card::thing_card_fixtures; 505 - 506 - #[test] 507 - fn foundation_items_cover_base_primitives() { 508 - let items = foundation_items(); 509 - assert!(items.iter().any(|item| item.label == "Panel")); 510 - assert!(items.iter().any(|item| item.label == "Input")); 511 - assert!(items.iter().any(|item| item.label == "Status")); 512 - } 513 - 514 - #[test] 515 - fn foundation_copy_avoids_workflow_marketing() { 516 - let combined = foundation_items() 517 - .iter() 518 - .map(|item| item.body) 519 - .collect::<Vec<_>>() 520 - .join(" "); 521 - assert!(!combined.contains("Jira")); 522 - assert!(!combined.contains("Polytoken")); 523 - } 524 - 525 - #[test] 526 - fn browse_state_distinguishes_loading_empty_error_and_populated() { 527 - assert_eq!(browse_feed_state(true, None), BrowseFeedState::Loading); 528 - assert_eq!( 529 - browse_feed_state(false, Some(&Ok(vec![]))), 530 - BrowseFeedState::Empty 531 - ); 532 - assert_eq!( 533 - browse_feed_state(false, Some(&Err("boom".to_string()))), 534 - BrowseFeedState::Error("boom".to_string()) 535 - ); 536 - match browse_feed_state(false, Some(&Ok(thing_card_fixtures()))) { 537 - BrowseFeedState::Populated(things) => assert_eq!(things.len(), 3), 538 - state => panic!("expected populated state, got {state:?}"), 539 - } 540 - } 541 - }
+69
src/shell.rs
··· 1 + use dioxus::prelude::*; 2 + 3 + use crate::auth::{ToastState, broadcast_sign_out, logout_url}; 4 + use crate::session::SessionIdentity; 5 + 6 + #[component] 7 + pub(crate) fn SessionControl(session: Signal<SessionIdentity>, toasts: Signal<ToastState>) -> Element { 8 + let identity = session.read().clone(); 9 + 10 + match identity { 11 + SessionIdentity::Anonymous => rsx! { 12 + form { 13 + class: "session-control", 14 + action: "/oauth/start", 15 + method: "get", 16 + input { 17 + r#type: "text", 18 + name: "identifier", 19 + placeholder: "handle.test", 20 + aria_label: "ATProto handle", 21 + } 22 + button { class: "button button-primary", r#type: "submit", "Sign in" } 23 + } 24 + }, 25 + SessionIdentity::Authenticated(identity) => { 26 + let label = SessionIdentity::Authenticated(identity).label(); 27 + rsx! { 28 + div { class: "session-control session-control-authenticated", 29 + span { class: "status-pill", "{label}" } 30 + form { 31 + method: "post", 32 + action: "{logout_url()}", 33 + button { 34 + class: "button button-secondary", 35 + r#type: "submit", 36 + onclick: move |_| { 37 + broadcast_sign_out(); 38 + session.set(SessionIdentity::Anonymous); 39 + toasts.write().push("Signing out…"); 40 + }, 41 + "Sign out" 42 + } 43 + } 44 + } 45 + } 46 + } 47 + } 48 + } 49 + 50 + #[component] 51 + pub(crate) fn ToastShelf(toasts: Signal<ToastState>) -> Element { 52 + let items = toasts.read().items().to_vec(); 53 + 54 + rsx! { 55 + div { class: "toast-shelf", aria_live: "polite", 56 + for toast in items { 57 + div { key: "toast-{toast.id}", class: "toast blueprint-panel", 58 + span { "{toast.message}" } 59 + button { 60 + class: "toast-dismiss", 61 + aria_label: "Dismiss notification", 62 + onclick: move |_| toasts.write().dismiss(toast.id), 63 + "×" 64 + } 65 + } 66 + } 67 + } 68 + } 69 + }
+8 -2
src/thing_card.rs
··· 116 116 117 117 #[allow(clippy::useless_format)] 118 118 #[component] 119 - pub fn ThingCard(thing: library::ThingViewBasic) -> Element { 119 + pub fn ThingCard(thing: library::ThingViewBasic, detail_href: Option<String>) -> Element { 120 120 let title = thing_title(&thing); 121 121 let creator = creator_label(&thing.author); 122 122 let media = select_card_media(thing.cover.as_deref(), thing.previews.as_deref(), title); ··· 148 148 } 149 149 div { class: "thing-card-body card-body", 150 150 div { class: "thing-card-heading", 151 - h2 { class: "card-title thing-card-title", "{title}" } 151 + h2 { class: "card-title thing-card-title", 152 + if let Some(href) = &detail_href { 153 + a { class: "thing-card-link", href: "{href}", "{title}" } 154 + } else { 155 + "{title}" 156 + } 157 + } 152 158 p { class: "thing-card-creator", "by {creator}" } 153 159 } 154 160 if let Some(tags) = hydrated_tags(&thing) {
+599
src/thing_detail.rs
··· 1 + use dioxus::prelude::*; 2 + use jacquard::common::types::ident::AtIdentifier; 3 + use jacquard::common::types::recordkey::Rkey; 4 + use jacquard::common::types::string::AtUri; 5 + use jacquard::common::types::value::from_data; 6 + use jacquard_common::xrpc::XrpcClient; 7 + use polymodel_api::space_polymodel::library; 8 + 9 + use crate::Route; 10 + use crate::client::PolymodelClient; 11 + use crate::viewer::{AssetViewer, ViewerFallbackImage, demo_models}; 12 + 13 + #[derive(Clone, Debug, PartialEq, Eq)] 14 + enum DetailState { 15 + Loading, 16 + InvalidRoute(String), 17 + Error(String), 18 + Populated(Box<ThingDetailData>), 19 + } 20 + 21 + #[derive(Clone, Debug, PartialEq, Eq)] 22 + struct ThingDetailData { 23 + thing: library::ThingView, 24 + thing_record: library::thing::Thing, 25 + models: Vec<ModelDetailSummary>, 26 + selected_model: Option<SelectedModelDetail>, 27 + } 28 + 29 + #[derive(Clone, Debug, PartialEq, Eq)] 30 + struct ModelDetailSummary { 31 + view: library::ModelView, 32 + record: library::model::Model, 33 + } 34 + 35 + #[derive(Clone, Debug, PartialEq, Eq)] 36 + struct SelectedModelDetail { 37 + model: library::ModelView, 38 + record: library::model::Model, 39 + parts: Vec<PartDetail>, 40 + } 41 + 42 + #[derive(Clone, Debug, PartialEq, Eq)] 43 + struct PartDetail { 44 + view: library::PartView, 45 + record: library::part::Part, 46 + } 47 + 48 + fn detail_state( 49 + pending: bool, 50 + result: Option<&Result<ThingDetailData, String>>, 51 + ) -> DetailState { 52 + if pending && result.is_none() { 53 + return DetailState::Loading; 54 + } 55 + 56 + match result { 57 + Some(Ok(data)) => DetailState::Populated(Box::new(data.clone())), 58 + Some(Err(error)) if error.starts_with("Invalid route:") => { 59 + DetailState::InvalidRoute(error.clone()) 60 + } 61 + Some(Err(error)) => DetailState::Error(error.clone()), 62 + None => DetailState::Loading, 63 + } 64 + } 65 + 66 + pub(crate) fn thing_detail_href(uri: &AtUri) -> Option<String> { 67 + let path = uri.path()?; 68 + if path.collection.as_str() != "space.polymodel.library.thing" { 69 + return None; 70 + } 71 + let repo = uri.authority(); 72 + let rkey = path.rkey?; 73 + Some(format!("/{}/thing/{}", repo.as_str(), rkey.as_ref())) 74 + } 75 + 76 + fn thing_uri_from_route(repo: &str, rkey: &str) -> Result<AtUri, String> { 77 + let repo: AtIdentifier<String> = AtIdentifier::new_owned(repo) 78 + .map_err(|error| format!("Invalid route: unsupported repository identifier: {error}"))?; 79 + let rkey: Rkey<String> = Rkey::new_owned(rkey) 80 + .map_err(|error| format!("Invalid route: unsupported record key: {error}"))?; 81 + AtUri::new_owned(format!( 82 + "at://{}/space.polymodel.library.thing/{}", 83 + repo.as_str(), 84 + rkey.as_str() 85 + )) 86 + .map_err(|error| format!("Invalid route: could not build thing URI: {error}")) 87 + } 88 + 89 + fn thing_record(view: &library::ThingView) -> Result<library::thing::Thing, String> { 90 + from_data(&view.record).map_err(|error| format!("Thing record decode failed: {error}")) 91 + } 92 + 93 + fn model_record(view: &library::ModelView) -> Result<library::model::Model, String> { 94 + from_data(&view.record).map_err(|error| format!("Model record decode failed: {error}")) 95 + } 96 + 97 + fn part_record(view: &library::PartView) -> Result<library::part::Part, String> { 98 + from_data(&view.record).map_err(|error| format!("Part record decode failed: {error}")) 99 + } 100 + 101 + async fn load_thing_detail( 102 + client: PolymodelClient, 103 + route_uri: Result<AtUri, String>, 104 + selected_model_uri: Option<AtUri>, 105 + ) -> Result<ThingDetailData, String> { 106 + let uri = route_uri?; 107 + let thing_response = client 108 + .send(library::get_thing::GetThing::new().uri(uri).build()) 109 + .await 110 + .map_err(|error| format!("Thing request failed: {error}"))?; 111 + let output = thing_response 112 + .into_output() 113 + .map_err(|error| format!("Thing decode failed: {error}"))?; 114 + let thing_record = thing_record(&output.thing)?; 115 + let models = output 116 + .models 117 + .unwrap_or_default() 118 + .into_iter() 119 + .map(|view| model_record(&view).map(|record| ModelDetailSummary { view, record })) 120 + .collect::<Result<Vec<_>, _>>()?; 121 + let selected_uri = selected_model_uri.or_else(|| models.first().map(|model| model.view.uri.clone())); 122 + let selected_model = if let Some(uri) = selected_uri { 123 + let response = client 124 + .send(library::get_model::GetModel::new().uri(uri).build()) 125 + .await 126 + .map_err(|error| format!("Model request failed: {error}"))?; 127 + let output = response 128 + .into_output() 129 + .map_err(|error| format!("Model decode failed: {error}"))?; 130 + let record = model_record(&output.model)?; 131 + let parts = output 132 + .parts 133 + .unwrap_or_default() 134 + .into_iter() 135 + .map(|view| part_record(&view).map(|record| PartDetail { view, record })) 136 + .collect::<Result<Vec<_>, _>>()?; 137 + Some(SelectedModelDetail { 138 + model: output.model, 139 + record, 140 + parts, 141 + }) 142 + } else { 143 + None 144 + }; 145 + 146 + Ok(ThingDetailData { 147 + thing: output.thing, 148 + thing_record, 149 + models, 150 + selected_model, 151 + }) 152 + } 153 + 154 + fn file_size_label(size: i64) -> String { 155 + if size >= 1_000_000 { 156 + format!("{:.1} MB", size as f64 / 1_000_000.0) 157 + } else if size >= 1_000 { 158 + format!("{:.0} KB", size as f64 / 1_000.0) 159 + } else { 160 + format!("{size} B") 161 + } 162 + } 163 + 164 + #[component] 165 + pub(crate) fn ThingDetail(repo: String, rkey: String) -> Element { 166 + let client = use_context::<PolymodelClient>(); 167 + let selected_model_uri = use_signal(|| None::<AtUri>); 168 + let route_uri = thing_uri_from_route(&repo, &rkey); 169 + 170 + let detail = use_resource(move || { 171 + let client = client.clone(); 172 + let route_uri = route_uri.clone(); 173 + let selected_model_uri = selected_model_uri.read().clone(); 174 + async move { load_thing_detail(client, route_uri, selected_model_uri).await } 175 + }); 176 + 177 + let detail_result = detail.read(); 178 + let state = detail_state(detail.pending(), detail_result.as_ref()); 179 + 180 + rsx! { 181 + main { class: "thing-detail-page product-shell", 182 + nav { class: "detail-nav", aria_label: "Thing detail navigation", 183 + Link { to: Route::Browse {}, "← Back to browse" } 184 + } 185 + match state { 186 + DetailState::Loading => rsx! { DetailLoading {} }, 187 + DetailState::InvalidRoute(message) => rsx! { DetailError { title: "Thing route unavailable".to_string(), message } }, 188 + DetailState::Error(message) => rsx! { DetailError { title: "Thing unavailable".to_string(), message } }, 189 + DetailState::Populated(data) => rsx! { DetailPopulated { data: *data, selected_model_uri } }, 190 + } 191 + } 192 + } 193 + } 194 + 195 + #[component] 196 + fn DetailLoading() -> Element { 197 + rsx! { 198 + section { class: "thing-detail-shell blueprint-panel", aria_label: "Thing detail loading", 199 + div { class: "state-card loading-state", 200 + span { class: "status-pill", "Loading" } 201 + div { class: "skeleton-line skeleton-wide" } 202 + div { class: "skeleton-line" } 203 + div { class: "skeleton-box" } 204 + } 205 + } 206 + } 207 + } 208 + 209 + #[component] 210 + fn DetailError(title: String, message: String) -> Element { 211 + rsx! { 212 + section { class: "thing-detail-shell blueprint-panel", aria_label: "Thing detail error", 213 + div { class: "state-card error-state", 214 + span { class: "status-pill status-error", "Error" } 215 + h1 { "{title}" } 216 + p { "{message}" } 217 + Link { class: "button button-secondary", to: Route::Browse {}, "Back to browse" } 218 + } 219 + } 220 + } 221 + } 222 + 223 + #[allow(clippy::useless_format)] 224 + #[component] 225 + fn DetailPopulated(data: ThingDetailData, mut selected_model_uri: Signal<Option<AtUri>>) -> Element { 226 + let ThingDetailData { 227 + thing, 228 + thing_record, 229 + models, 230 + selected_model, 231 + } = data; 232 + let title = clean_text(Some(thing.name.as_ref())) 233 + .unwrap_or("Untitled thing") 234 + .to_string(); 235 + let creator = actor_label(&thing.author).to_string(); 236 + let summary = clean_text(thing.summary.as_ref().map(AsRef::as_ref)) 237 + .or_else(|| clean_text(thing_record.summary.as_ref().map(AsRef::as_ref))) 238 + .map(ToString::to_string); 239 + let media = select_detail_media(thing.cover.as_deref(), thing.previews.as_deref(), &title); 240 + let selected_uri = selected_model 241 + .as_ref() 242 + .map(|selected| selected.model.uri.clone()); 243 + let tags = thing 244 + .tags 245 + .as_deref() 246 + .or(thing_record.tags.as_deref()) 247 + .unwrap_or_default() 248 + .iter() 249 + .filter_map(|tag| clean_text(Some(tag.as_ref())).map(ToString::to_string)) 250 + .collect::<Vec<_>>(); 251 + let license = thing.license.to_string(); 252 + let model_count = count_label(thing.model_count, "model"); 253 + let part_count = count_label(thing.part_count, "part"); 254 + let like_count = count_label(thing.like_count, "like"); 255 + let save_count = count_label(thing.save_count, "save"); 256 + let instructions = thing_record.instructions; 257 + 258 + rsx! { 259 + header { class: "thing-detail-header", 260 + div { class: "product-kicker", "Thing detail" } 261 + div { class: "thing-detail-title-row", 262 + div { 263 + h1 { "{title}" } 264 + p { class: "thing-detail-creator", "by {creator}" } 265 + } 266 + dl { class: "thing-detail-stats", aria_label: "Thing stats", 267 + StatItem { label: "Models".to_string(), value: model_count } 268 + StatItem { label: "Parts".to_string(), value: part_count } 269 + StatItem { label: "Likes".to_string(), value: like_count } 270 + StatItem { label: "Saves".to_string(), value: save_count } 271 + } 272 + } 273 + } 274 + 275 + section { class: "thing-detail-shell blueprint-panel", aria_label: "Thing overview", 276 + div { class: "thing-detail-viewer", 277 + AssetViewer { initial_model: demo_models()[0], fallback: media.map(|media| ViewerFallbackImage { url: media.url, alt: media.alt }) } 278 + } 279 + 280 + div { class: "thing-detail-summary", 281 + div { class: "thing-detail-copy", 282 + if let Some(summary) = summary { 283 + p { class: "product-lede", "{summary}" } 284 + } else { 285 + p { class: "product-lede", "No summary is published for this thing yet." } 286 + } 287 + div { class: "thing-detail-chips", aria_label: "Thing tags and license", 288 + span { class: "status-pill", "{license}" } 289 + for tag in tags { 290 + span { class: "status-pill status-muted", "#{tag}" } 291 + } 292 + } 293 + } 294 + 295 + div { class: "thing-detail-actions", aria_label: "Thing actions", 296 + button { class: "button button-primary", "Download files" } 297 + button { class: "button button-secondary", disabled: true, aria_label: "Save disabled until sign in is available", "Save" } 298 + } 299 + } 300 + } 301 + 302 + section { class: "thing-detail-grid", aria_label: "Thing detail sections", 303 + DetailPanel { title: "Overview".to_string(), 304 + InstructionList { instructions, empty: "No thing-level instructions are published yet.".to_string() } 305 + } 306 + 307 + DetailPanel { title: "Models".to_string(), 308 + if models.is_empty() { 309 + p { class: "detail-muted", "This thing does not publish any model variants yet." } 310 + } else { 311 + div { class: "model-list", role: "list", aria_label: "Model variants", 312 + for model in models { 313 + { 314 + let row_selected = selected_uri.as_ref() == Some(&model.view.uri); 315 + let uri_for_select = model.view.uri.clone(); 316 + rsx! { 317 + ModelRow { 318 + key: "{model.view.uri}", 319 + model, 320 + selected: row_selected, 321 + onselect: move |_| selected_model_uri.set(Some(uri_for_select.clone())) 322 + } 323 + } 324 + } 325 + } 326 + } 327 + } 328 + } 329 + 330 + if let Some(selected) = selected_model { 331 + { 332 + let SelectedModelDetail { model, record, parts } = selected; 333 + rsx! { 334 + article { class: "detail-panel model-detail-panel card-surface", 335 + SelectedModelPanel { model, record } 336 + } 337 + DetailPanel { title: "Parts & files".to_string(), 338 + PartsPanel { parts } 339 + } 340 + } 341 + } 342 + } else { 343 + DetailPanel { title: "Parts & files".to_string(), 344 + p { class: "detail-muted", "Select a model to load its ordered parts and files." } 345 + } 346 + } 347 + } 348 + } 349 + } 350 + 351 + #[component] 352 + fn DetailPanel(title: String, children: Element) -> Element { 353 + rsx! { 354 + article { class: "detail-panel card-surface", 355 + div { class: "detail-panel-heading", 356 + h2 { "{title}" } 357 + } 358 + {children} 359 + } 360 + } 361 + } 362 + 363 + #[component] 364 + fn StatItem(label: String, value: String) -> Element { 365 + rsx! { 366 + div { 367 + dt { "{label}" } 368 + dd { "{value}" } 369 + } 370 + } 371 + } 372 + 373 + #[component] 374 + fn InstructionList(instructions: Option<Vec<jacquard_common::DefaultStr>>, empty: String) -> Element { 375 + let instructions = instructions.unwrap_or_default(); 376 + let items = instructions 377 + .iter() 378 + .filter_map(|item| clean_text(Some(item.as_ref())).map(ToString::to_string)) 379 + .collect::<Vec<_>>(); 380 + 381 + rsx! { 382 + if items.is_empty() { 383 + p { class: "detail-muted", "{empty}" } 384 + } else { 385 + ol { class: "instruction-list", 386 + for (index, item) in items.iter().enumerate() { 387 + li { key: "instruction-{index}", "{item}" } 388 + } 389 + } 390 + } 391 + } 392 + } 393 + 394 + #[component] 395 + fn ModelRow(model: ModelDetailSummary, selected: bool, onselect: EventHandler<MouseEvent>) -> Element { 396 + let name = clean_text(Some(model.view.name.as_ref())) 397 + .unwrap_or("Untitled model") 398 + .to_string(); 399 + let summary = clean_text(model.view.summary.as_ref().map(AsRef::as_ref)) 400 + .or_else(|| clean_text(model.record.summary.as_ref().map(AsRef::as_ref))) 401 + .map(ToString::to_string); 402 + let class = if selected { "model-row is-selected" } else { "model-row" }; 403 + let license = model 404 + .record 405 + .license 406 + .as_ref() 407 + .map(AsRef::as_ref) 408 + .and_then(|value| clean_text(Some(value))) 409 + .unwrap_or("inherits thing license") 410 + .to_string(); 411 + 412 + rsx! { 413 + button { class, role: "listitem", onclick: move |event| onselect.call(event), 414 + span { class: "model-row-title", "{name}" } 415 + span { class: "model-row-meta", "{model.view.part_count} parts · {license}" } 416 + if let Some(summary) = summary { 417 + span { class: "model-row-summary", "{summary}" } 418 + } 419 + } 420 + } 421 + } 422 + 423 + #[component] 424 + fn SelectedModelPanel(model: library::ModelView, record: library::model::Model) -> Element { 425 + let name = clean_text(Some(model.name.as_ref())) 426 + .unwrap_or("Untitled model") 427 + .to_string(); 428 + let summary = clean_text(model.summary.as_ref().map(AsRef::as_ref)) 429 + .or_else(|| clean_text(record.summary.as_ref().map(AsRef::as_ref))) 430 + .map(ToString::to_string); 431 + let instructions = record.instructions; 432 + let tags = record 433 + .tags 434 + .as_deref() 435 + .unwrap_or_default() 436 + .iter() 437 + .filter_map(|tag| clean_text(Some(tag.as_ref())).map(ToString::to_string)) 438 + .collect::<Vec<_>>(); 439 + 440 + rsx! { 441 + div { class: "selected-model-panel", 442 + div { class: "selected-model-heading", 443 + span { class: "product-kicker", "Current model" } 444 + h2 { "{name}" } 445 + } 446 + if let Some(summary) = summary { 447 + p { "{summary}" } 448 + } 449 + InstructionList { instructions, empty: "No model-specific instructions are published yet.".to_string() } 450 + if !tags.is_empty() { 451 + div { class: "thing-detail-chips", aria_label: "Selected model tags", 452 + for tag in tags { 453 + span { class: "status-pill status-muted", "#{tag}" } 454 + } 455 + } 456 + } 457 + } 458 + } 459 + } 460 + 461 + #[component] 462 + fn PartsPanel(parts: Vec<PartDetail>) -> Element { 463 + rsx! { 464 + if parts.is_empty() { 465 + p { class: "detail-muted", "This model does not publish any ordered part files yet." } 466 + } else { 467 + div { class: "parts-list", role: "list", aria_label: "Ordered part files", 468 + for (index, part) in parts.iter().enumerate() { 469 + PartRow { key: "{part.view.uri}", position: index + 1, part: part.clone() } 470 + } 471 + } 472 + } 473 + } 474 + } 475 + 476 + #[component] 477 + fn PartRow(position: usize, part: PartDetail) -> Element { 478 + let name = clean_text(Some(part.view.name.as_ref())) 479 + .unwrap_or("Untitled part") 480 + .to_string(); 481 + let format = part 482 + .view 483 + .format 484 + .as_ref() 485 + .or(part.record.format.as_ref()) 486 + .map(AsRef::as_ref) 487 + .and_then(|value| clean_text(Some(value))) 488 + .unwrap_or("file") 489 + .to_string(); 490 + let size = file_size_label(part.view.file.size); 491 + let dimensions = part.record.dimensions.as_ref().map(dimensions_label); 492 + let notes = clean_text(part.record.notes.as_ref().map(AsRef::as_ref)).map(ToString::to_string); 493 + let print_settings = part 494 + .record 495 + .print_settings 496 + .as_deref() 497 + .unwrap_or_default() 498 + .iter() 499 + .filter_map(|setting| clean_text(Some(setting.as_ref())).map(ToString::to_string)) 500 + .collect::<Vec<_>>(); 501 + 502 + rsx! { 503 + article { class: "part-row", role: "listitem", 504 + div { class: "part-row-index", aria_label: "Part position", "{position}" } 505 + div { class: "part-row-body", 506 + h3 { "{name}" } 507 + dl { class: "part-row-meta", aria_label: "Part file metadata", 508 + StatItem { label: "Format".to_string(), value: format } 509 + StatItem { label: "Size".to_string(), value: size } 510 + if let Some(dimensions) = dimensions { 511 + StatItem { label: "Dimensions".to_string(), value: dimensions } 512 + } 513 + } 514 + if let Some(notes) = notes { 515 + p { class: "part-notes", "{notes}" } 516 + } 517 + if !print_settings.is_empty() { 518 + ul { class: "print-settings", aria_label: "Print settings", 519 + for setting in print_settings { 520 + li { "{setting}" } 521 + } 522 + } 523 + } 524 + } 525 + } 526 + } 527 + } 528 + 529 + #[component] 530 + fn BlueprintPlaceholder(label: String, detail: String) -> Element { 531 + rsx! { 532 + div { class: "thing-card-placeholder blueprint-media", role: "img", aria_label: "Blueprint placeholder for missing preview media", 533 + span { class: "status-pill status-muted", "{label}" } 534 + span { class: "blueprint-axis blueprint-axis-x", "X" } 535 + span { class: "blueprint-axis blueprint-axis-y", "Y" } 536 + span { class: "blueprint-dimension", "{detail}" } 537 + } 538 + } 539 + } 540 + 541 + #[derive(Clone, Debug, PartialEq, Eq)] 542 + struct DetailMedia { 543 + url: String, 544 + alt: String, 545 + } 546 + 547 + fn select_detail_media( 548 + cover: Option<&[library::ImageView]>, 549 + previews: Option<&[library::ImageView]>, 550 + title: &str, 551 + ) -> Option<DetailMedia> { 552 + first_detail_image(cover, title).or_else(|| first_detail_image(previews, title)) 553 + } 554 + 555 + fn first_detail_image(images: Option<&[library::ImageView]>, title: &str) -> Option<DetailMedia> { 556 + images?.iter().find_map(|image| { 557 + let url = image.fullsize.as_ref().to_string(); 558 + if !usable_image_url(&url) { 559 + return None; 560 + } 561 + let alt = clean_text(image.alt.as_ref().map(AsRef::as_ref)) 562 + .map(ToString::to_string) 563 + .unwrap_or_else(|| format!("Preview image for {title}")); 564 + Some(DetailMedia { url, alt }) 565 + }) 566 + } 567 + 568 + fn usable_image_url(url: &str) -> bool { 569 + url.starts_with("http://") 570 + || url.starts_with("https://") 571 + || url.starts_with('/') 572 + || url.starts_with("data:image/") 573 + } 574 + 575 + fn actor_label(actor: &library::Actor) -> &str { 576 + clean_text(actor.display_name.as_ref().map(AsRef::as_ref)).unwrap_or_else(|| actor.handle.as_ref()) 577 + } 578 + 579 + fn clean_text(value: Option<&str>) -> Option<&str> { 580 + value.map(str::trim).filter(|value| !value.is_empty()) 581 + } 582 + 583 + fn count_label(value: i64, noun: &str) -> String { 584 + let suffix = if value == 1 { "" } else { "s" }; 585 + format!("{value} {noun}{suffix}") 586 + } 587 + 588 + fn dimensions_label(dimensions: &library::Bbox) -> String { 589 + let unit = dimensions 590 + .unit 591 + .as_ref() 592 + .map(AsRef::as_ref) 593 + .and_then(|value| clean_text(Some(value))) 594 + .unwrap_or("mm"); 595 + let x: &str = dimensions.x.as_ref(); 596 + let y: &str = dimensions.y.as_ref(); 597 + let z: &str = dimensions.z.as_ref(); 598 + format!("{x} × {y} × {z} {unit}") 599 + }
+71 -18
src/viewer.rs
··· 40 40 pub triangles: usize, 41 41 } 42 42 43 + #[derive(Clone, Debug, PartialEq, Eq)] 44 + pub struct ViewerFallbackImage { 45 + pub url: String, 46 + pub alt: String, 47 + } 48 + 43 49 const DEMO_MODELS: [DemoModel; 2] = [ 44 50 DemoModel { 45 51 id: "body-f-chest-v4", ··· 381 387 let hash = mount.mesh.content_hash; 382 388 match self.active.as_mut() { 383 389 None => { 384 - self.active = Some(R::mount(canvas, mount)?); 390 + let mut renderer = R::mount(canvas, mount)?; 391 + renderer.resize()?; 392 + self.active = Some(renderer); 385 393 self.active_hash = Some(hash); 386 394 self.mounts += 1; 387 395 } ··· 498 506 resize_count, 499 507 context_acquisitions, 500 508 renderer_failed, 509 + fallback: None, 510 + } 511 + } 512 + } 513 + } 514 + 515 + #[component] 516 + pub fn AssetViewer(initial_model: DemoModel, fallback: Option<ViewerFallbackImage>) -> Element { 517 + let canvas_mounted = use_signal(|| false); 518 + let resize_count = use_signal(|| 0_u64); 519 + let context_acquisitions = use_signal(|| 0_u64); 520 + let renderer_failed = use_signal(|| false); 521 + let loaded: Signal<Option<LoadedMesh>> = use_signal(|| None); 522 + let status: Signal<ViewerStatus> = use_signal(|| { 523 + ViewerStatus::Loading(SessionKey::new(initial_model.id, initial_model.generation)) 524 + }); 525 + 526 + let _mesh_load = use_resource(move || { 527 + let mut loaded = loaded; 528 + let mut status = status; 529 + async move { 530 + status.set(ViewerStatus::Loading(SessionKey::new( 531 + initial_model.id, 532 + initial_model.generation, 533 + ))); 534 + match load_demo_mesh(initial_model).await { 535 + Ok(result) => { 536 + status.set(ViewerStatus::Ready(SessionKey::new( 537 + initial_model.id, 538 + initial_model.generation, 539 + ))); 540 + loaded.set(Some(result)); 541 + } 542 + Err(issue) => status.set(ViewerStatus::MeshError(issue)), 543 + } 544 + } 545 + }); 546 + 547 + rsx! { 548 + div { class: "detail-asset-viewer", aria_label: "Asset-backed 3D model viewer", 549 + ModelViewer { 550 + loaded, 551 + status, 552 + canvas_mounted, 553 + resize_count, 554 + context_acquisitions, 555 + renderer_failed, 556 + fallback, 501 557 } 502 558 } 503 559 } ··· 511 567 resize_count: Signal<u64>, 512 568 context_acquisitions: Signal<u64>, 513 569 renderer_failed: Signal<bool>, 570 + fallback: Option<ViewerFallbackImage>, 514 571 } 515 572 516 573 #[component] ··· 519 576 let resize_count = props.resize_count; 520 577 let context_acquisitions = props.context_acquisitions; 521 578 let renderer_failed = props.renderer_failed; 579 + let fallback = props.fallback; 522 580 let loaded = props.loaded; 523 581 let status = props.status; 524 582 ··· 574 632 onpointerdown: move |event| event.stop_propagation(), 575 633 } 576 634 if !renderer_active { 577 - if let Some(summary) = summary { 635 + if let Some(fallback) = fallback { 636 + img { 637 + id: "polymodel-viewer-preview", 638 + class: "model-viewer-preview-image", 639 + src: "{fallback.url}", 640 + alt: "{fallback.alt}" 641 + } 642 + } else if let Some(summary) = summary { 578 643 AssetPreview { summary } 579 - } 580 - } 581 - div { class: "model-viewer-overlay", aria_live: "polite", 582 - h2 { "{status_val.label()}" } 583 - p { "{status_val.message()}" } 584 - if let Some(summary) = summary { 585 - dl { class: "viewer-mesh-summary", 586 - div { dt { "Model" } dd { "{summary.display_name}" } } 587 - div { dt { "Asset" } dd { "{summary.asset_path}" } } 588 - div { dt { "Vertices" } dd { "{summary.vertices}" } } 589 - div { dt { "Triangles" } dd { "{summary.triangles}" } } 590 - } 591 644 } 592 645 } 593 646 } ··· 1395 1448 .expect("mount should succeed"); 1396 1449 assert!(lifecycle.has_active_session()); 1397 1450 assert!(lifecycle.active_mut().is_some()); 1398 - assert_eq!(events.get(), 1); 1451 + assert_eq!(events.get(), 2, "initial mount must also draw the first frame"); 1399 1452 assert_eq!(lifecycle.mounts(), 1); 1400 1453 1401 1454 // Resize forwards to the active renderer. 1402 1455 lifecycle.resize().expect("resize should forward"); 1403 - assert_eq!(events.get(), 2); 1456 + assert_eq!(events.get(), 3); 1404 1457 1405 1458 // Same-identity re-render is a no-op (no replace). 1406 1459 lifecycle 1407 1460 .reconcile(Some(canvas.clone()), &ready_chest, Some(chest.clone())) 1408 1461 .expect("same-identity reconcile should succeed"); 1409 - assert_eq!(events.get(), 2); 1462 + assert_eq!(events.get(), 3); 1410 1463 1411 1464 // Switch to a different model: replace once, still a single mount. 1412 1465 let ready_forearm = ViewerStatus::Ready(forearm.key.clone()); 1413 1466 lifecycle 1414 1467 .reconcile(Some(canvas.clone()), &ready_forearm, Some(forearm.clone())) 1415 1468 .expect("replace should succeed"); 1416 - assert_eq!(events.get(), 3); 1469 + assert_eq!(events.get(), 4); 1417 1470 assert_eq!(drops.get(), 0); 1418 1471 assert_eq!(lifecycle.mounts(), 1); 1419 1472
+18
src/viewer_route.rs
··· 1 + use dioxus::prelude::*; 2 + 3 + use crate::Route; 4 + use crate::viewer::{ViewerPage, demo_model_for_route}; 5 + 6 + #[component] 7 + pub(crate) fn Viewer(model_id: String) -> Element { 8 + let model = demo_model_for_route(&model_id); 9 + 10 + rsx! { 11 + main { class: "viewer-route-page", 12 + nav { class: "viewer-route-nav", aria_label: "Viewer navigation", 13 + Link { to: Route::Browse {}, "← Back to browse" } 14 + } 15 + ViewerPage { key: "{model.id}", initial_model: model } 16 + } 17 + } 18 + }