From 23d89f01afee1bdc7846f23cef22b9af3cf02b32 Mon Sep 17 00:00:00 2001 From: Orual Date: Mon, 22 Jun 2026 14:25:22 -0400 Subject: [PATCH] PM-41: thing detail route --- Cargo.lock | 18 -- Cargo.toml | 31 +- assets/styling/browse.css | 310 +++++++++++++++++- assets/styling/polymodel.css | 42 --- src/appview/error.rs | 4 +- src/appview/views.rs | 8 +- src/browse.rs | 193 +++++++++++ src/foundation.rs | 98 ++++++ src/indexing/config.rs | 61 ++++ src/indexing/mod.rs | 3 + src/indexing/sample_data.rs | 390 +++++++++++++++++++++++ src/main.rs | 449 ++------------------------ src/shell.rs | 69 ++++ src/thing_card.rs | 10 +- src/thing_detail.rs | 599 +++++++++++++++++++++++++++++++++++ src/viewer.rs | 89 ++++-- src/viewer_route.rs | 18 ++ 17 files changed, 1874 insertions(+), 518 deletions(-) create mode 100644 src/browse.rs create mode 100644 src/foundation.rs create mode 100644 src/indexing/sample_data.rs create mode 100644 src/shell.rs create mode 100644 src/thing_detail.rs create mode 100644 src/viewer_route.rs diff --git a/Cargo.lock b/Cargo.lock index 8e5400f..23b608a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4155,8 +4155,6 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jacquard" version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e30d6ba826a563cb2e86f6c4e2268ef5a216b50ea0ca98a9cd3b6b4cbc7c8cc" dependencies = [ "bytes", "getrandom 0.2.17", @@ -4199,8 +4197,6 @@ dependencies = [ [[package]] name = "jacquard-api" version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9d7b4d41f44a3add61878f6022c48ef949f5d1759a88ec778017334d2847e95" dependencies = [ "jacquard-common 0.12.0", "jacquard-derive 0.12.0", @@ -4213,8 +4209,6 @@ dependencies = [ [[package]] name = "jacquard-axum" version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd1c24e629b32b51477d0217a976e9a16f0397a37bdc8a174225d4d77e3822a0" dependencies = [ "axum", "axum-extra", @@ -4295,8 +4289,6 @@ dependencies = [ [[package]] name = "jacquard-common" version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82e9239b4bf08cf6b3e9d9d420be8b17bd09ee34228b52b884089cbcf67d23bf" dependencies = [ "base64", "bon", @@ -4360,8 +4352,6 @@ dependencies = [ [[package]] name = "jacquard-derive" version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bea6f33da422d272cb09a2571fb65f88aed13612774f2eb635716ce71d1e742" dependencies = [ "heck 0.5.0", "jacquard-lexicon 0.12.0", @@ -4397,8 +4387,6 @@ dependencies = [ [[package]] name = "jacquard-identity" version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "757c2d9d9ecca3b66e000e912f2fcf9e9e63c5dc50d20983264e3a6db56f4a8e" dependencies = [ "bon", "bytes", @@ -4448,8 +4436,6 @@ dependencies = [ [[package]] name = "jacquard-lexicon" version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bf8be274eb0af89ece99b56cbbc837748f62d44af8ab8280acafdfcafe84db7" dependencies = [ "cid", "dashmap", @@ -4476,8 +4462,6 @@ dependencies = [ [[package]] name = "jacquard-oauth" version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "108570f206eea7fd3272e7bfe355b589d66c163e4e4a06ee612bf84877ef2049" dependencies = [ "base64", "bytes", @@ -5287,8 +5271,6 @@ dependencies = [ [[package]] name = "mini-moka-wasm" version = "0.10.99" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0102b9a2ad50fa47ca89eead2316c8222285ecfbd3f69ce99564fbe4253866e8" dependencies = [ "crossbeam-channel", "crossbeam-utils", diff --git a/Cargo.toml b/Cargo.toml index ad0bf77..e87c094 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,11 +3,11 @@ members = [".", "crates/*"] resolver = "3" [workspace.dependencies] -jacquard = { version = "0.12", default-features = false, features = ["derive", "api_bluesky"] } -jacquard-axum = { version = "0.12" } -jacquard-common = { version = "0.12" } -jacquard-derive = { version = "0.12" } -jacquard-lexicon = { version = "0.12", default-features = false } +jacquard = { path = "../jacquard/crates/jacquard", default-features = false, features = ["derive", "api_bluesky"] } +jacquard-axum = { path = "../jacquard/crates/jacquard-axum" } +jacquard-common = { path = "../jacquard/crates/jacquard-common" } +jacquard-derive = { path = "../jacquard/crates/jacquard-derive" } +jacquard-lexicon = { path = "../jacquard/crates/jacquard-lexicon", default-features = false } serde = { version = "1.0", features = ["derive"] } thiserror = "2.0" miette = "7.6" @@ -116,7 +116,28 @@ 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"] } [dev-dependencies] +anyhow = "1" +axum = "0.8" +chrono = "0.4" +futures = "0.3" +hydrant = { git = "https://tangled.org/ptr.pet/hydrant", rev = "1145fa1bb194372ceea81ab48b5d68d304da34ad", default-features = false, features = ["indexer", "indexer_stream"] } +jacquard-axum = { workspace = true } +libsqlite3-sys = { version = "0.37", features = ["bundled"] } +rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs", "std"] } +sqlx = { version = "0.9", default-features = false, features = [ + "runtime-tokio", + "sqlite", + "macros", + "migrate", +] } tokio = { version = "1", features = ["rt", "macros"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["trace"] } +tracing-subscriber = { version = "0.3", default-features = false, features = [ + "std", + "fmt", + "env-filter", +] } [build-dependencies] dotenvy = "0.15" diff --git a/assets/styling/browse.css b/assets/styling/browse.css index a7a1c18..278d308 100644 --- a/assets/styling/browse.css +++ b/assets/styling/browse.css @@ -193,13 +193,319 @@ border-color: color-mix(in srgb, var(--color-error) 55%, var(--color-border)); } +.thing-detail-page { + min-height: 100vh; + max-width: 1180px; + margin: 0 auto; + padding: clamp(0.75rem, 2vw, 1.75rem) clamp(1rem, 3vw, 2.5rem); +} + +.detail-nav { + display: flex; +} + +.detail-nav a { + color: var(--color-subtle); + font-weight: 600; +} + +.thing-detail-header, +.thing-detail-summary, +.thing-detail-copy, +.detail-panel, +.selected-model-panel, +.parts-list, +.part-row-body { + display: flex; + flex-direction: column; +} + +.thing-detail-header { + gap: 0.5rem; +} + +.thing-detail-title-row { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 1rem; +} + +.thing-detail-title-row h1, +.detail-panel h2, +.selected-model-panel h2, +.part-row h3 { + margin: 0; + color: var(--color-emphasis); + letter-spacing: -0.03em; +} + +.thing-detail-title-row h1 { + font-size: clamp(2.1rem, 5vw, 4rem); + line-height: 0.95; +} + +.thing-detail-creator, +.detail-muted, +.model-row-summary, +.part-notes, +.viewer-unavailable p { + margin: 0; + color: var(--color-subtle); +} + +.thing-detail-stats, +.part-row-meta { + display: flex; + flex-wrap: wrap; + gap: 0.4rem 0.8rem; + margin: 0; + color: var(--color-muted); + font-family: var(--font-mono); + font-size: 0.76rem; +} + +.thing-detail-stats div, +.part-row-meta div { + display: contents; +} + +.thing-detail-stats dt, +.part-row-meta dt { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.thing-detail-stats dd, +.part-row-meta dd { + margin: 0; +} + +.thing-detail-shell { + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(18rem, 0.95fr); + align-items: start; + gap: clamp(1rem, 3vw, 1.5rem); + padding: clamp(1rem, 3vw, 1.5rem); +} + +.thing-detail-viewer { + aspect-ratio: 4 / 3; + min-height: 20rem; + max-height: 34rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-panel); + overflow: hidden; +} + +.detail-asset-viewer, +.detail-asset-viewer .model-viewer-boundary, +.detail-asset-viewer .model-viewer-stage { + height: 100%; + min-height: 0; +} + +.detail-asset-viewer .model-viewer-boundary { + border: 0; + background: transparent; +} + +.detail-asset-viewer .model-viewer-stage { + grid-template-rows: 1fr; +} + +.model-viewer-preview-image { + grid-row: 1; + grid-column: 1; + width: 100%; + height: 100%; + object-fit: cover; +} + +.thing-detail-summary { + justify-content: space-between; + gap: 1rem; +} + +.thing-detail-copy { + gap: 0.85rem; +} + +.thing-detail-chips, +.thing-detail-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.thing-detail-actions { + align-items: center; +} + +.thing-detail-actions .button-primary { + min-width: 9.5rem; +} + +.button:disabled, +.button[disabled], +.model-row:disabled, +.model-row[disabled] { + cursor: not-allowed; + opacity: 0.52; + filter: saturate(0.7); +} + +.button:disabled:hover, +.button[disabled]:hover { + border-color: var(--color-border); + color: var(--color-text); + filter: saturate(0.7); +} + +.thing-detail-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 1rem; + align-items: start; +} + +.detail-panel { + gap: 0.85rem; + padding: 1rem; +} + +.thing-detail-grid > .detail-panel:nth-child(-n + 2) { + align-self: stretch; + height: 100%; +} + +.detail-panel-heading { + display: flex; + align-items: baseline; +} + +.detail-panel-heading h2, +.selected-model-heading h2 { + font-size: clamp(1.25rem, 2vw, 1.6rem); + line-height: 1.1; +} + +.selected-model-heading { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.instruction-list, +.print-settings { + margin: 0; + padding-left: 1.25rem; + color: var(--color-text); +} + +.instruction-list li + li, +.print-settings li + li { + margin-top: 0.35rem; +} + +.model-list { + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.model-row { + display: flex; + flex-direction: column; + gap: 0.25rem; + width: 100%; + padding: 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-panel); + background: var(--color-base); + color: var(--color-text); + text-align: left; +} + +.model-row:hover, +.model-row.is-selected { + border-color: var(--color-primary); +} + +.model-row.is-selected { + background: color-mix(in srgb, var(--color-primary) 8%, transparent); +} + +.model-row-title { + font-weight: 700; +} + +.model-row-meta { + color: var(--color-muted); + font-family: var(--font-mono); + font-size: 0.75rem; +} + +.selected-model-panel, +.part-row-body { + gap: 0.65rem; +} + +.parts-list { + gap: 0.75rem; +} + +.part-row { + display: flex; + gap: 0.75rem; + padding: 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-panel); + background: var(--color-base); +} + +.part-row-index { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 2rem; + width: 2rem; + height: 2rem; + border: 1px solid var(--color-primary); + border-radius: 999px; + color: var(--color-primary); + font-family: var(--font-mono); + font-size: 0.78rem; +} + +.viewer-unavailable { + min-height: 12rem; + padding: 1rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-panel); + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 1rem; +} + + @media (max-width: 820px) { .foundation-hero, - .browse-controls { + .browse-controls, + .thing-detail-shell, + .thing-detail-grid { grid-template-columns: 1fr; } - .product-header-row { + .product-header-row, + .thing-detail-title-row { display: grid; align-items: start; } diff --git a/assets/styling/polymodel.css b/assets/styling/polymodel.css index f1842fe..3b240fc 100644 --- a/assets/styling/polymodel.css +++ b/assets/styling/polymodel.css @@ -120,48 +120,6 @@ .model-preview-line { fill: none; stroke: color-mix(in srgb, var(--color-primary) 70%, var(--color-text)); stroke-width: 3; } .model-preview-label { fill: var(--color-muted); font-family: var(--font-mono); font-size: 18px; } -.model-viewer-overlay { - position: relative; - margin: 1rem; - max-width: none; - padding: 1rem; - border: 1px solid var(--color-border); - background: color-mix(in srgb, var(--color-surface) 92%, transparent); -} - -.model-viewer-overlay h2, -.model-viewer-overlay p, -.viewer-mesh-summary { - margin: 0; -} - -.model-viewer-overlay p + .viewer-mesh-summary, -.viewer-mesh-summary + .viewer-lifecycle-note { - margin-top: 0.75rem; -} - -.viewer-mesh-summary { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.5rem 1rem; -} - -.viewer-mesh-summary div { - display: grid; - gap: 0.15rem; -} - -.viewer-mesh-summary dt { - color: var(--color-subtle); - font-family: var(--font-mono); - font-size: 0.7rem; - text-transform: uppercase; -} - -.viewer-mesh-summary dd { - margin: 0; -} - .viewer-lifecycle-note { color: var(--color-subtle); font-size: 0.85rem; diff --git a/src/appview/error.rs b/src/appview/error.rs index e3a3104..648af04 100644 --- a/src/appview/error.rs +++ b/src/appview/error.rs @@ -12,7 +12,7 @@ use jacquard_axum::GenericXrpcErrorResponse; /// Appview handler error. Maps onto the XRPC error response shape. #[derive(Debug)] -pub(super) enum AppError { +pub(crate) enum AppError { /// 400 `InvalidRequest`: bad parameters (bad at-uri, unknown algorithm, /// unparseable cursor, unresolvable handle). InvalidRequest(String), @@ -23,7 +23,7 @@ pub(super) enum AppError { Internal(String), } -pub(super) type AppResult = Result; +pub(crate) type AppResult = Result; pub(super) fn invalid_request(msg: impl Into) -> AppError { AppError::InvalidRequest(msg.into()) diff --git a/src/appview/views.rs b/src/appview/views.rs index bd498f1..42488ce 100644 --- a/src/appview/views.rs +++ b/src/appview/views.rs @@ -444,7 +444,7 @@ async fn fetch_thing_by_uri(state: &AppState, uri: &str) -> AppResult>, @@ -483,13 +483,13 @@ async fn models_for_thing( Ok(views) } -pub(super) struct ModelDetail { +pub(crate) struct ModelDetail { pub model: ModelView, pub parts: Vec, pub thing: Option, } -pub(super) async fn get_model( +pub(crate) async fn get_model( state: &AppState, uri: &str, viewer_did: Option>, @@ -614,7 +614,7 @@ pub(super) async fn author_things( rkey_paged_feed(state, rows, limit, viewer_did).await } -pub(super) async fn feed_recent( +pub(crate) async fn feed_recent( state: &AppState, limit: i64, cursor: Option<&str>, diff --git a/src/browse.rs b/src/browse.rs new file mode 100644 index 0000000..c8188c9 --- /dev/null +++ b/src/browse.rs @@ -0,0 +1,193 @@ +use dioxus::prelude::*; +use jacquard_common::xrpc::XrpcClient; +use polymodel_api::space_polymodel::library; + +use crate::auth::ToastState; +use crate::client::PolymodelClient; +use crate::session::SessionIdentity; +use crate::shell::SessionControl; +use crate::thing_card::ThingCard; +use crate::thing_detail::thing_detail_href; +use crate::viewer::demo_models; +use crate::Route; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FeedAlgorithm { + Recent, + Hot, +} + +impl FeedAlgorithm { + fn as_str(self) -> &'static str { + match self { + Self::Recent => "recent", + Self::Hot => "hot", + } + } + + fn label(self) -> &'static str { + match self { + Self::Recent => "Recent", + Self::Hot => "Hot", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum BrowseFeedState { + Loading, + Error(String), + Empty, + Populated(Vec), +} + +fn browse_feed_state( + pending: bool, + result: Option<&Result, String>>, +) -> BrowseFeedState { + if pending && result.is_none() { + return BrowseFeedState::Loading; + } + + match result { + Some(Ok(things)) if things.is_empty() => BrowseFeedState::Empty, + Some(Ok(things)) => BrowseFeedState::Populated(things.clone()), + Some(Err(error)) => BrowseFeedState::Error(error.clone()), + None => BrowseFeedState::Loading, + } +} + +#[allow(clippy::useless_format)] +#[component] +pub(crate) fn Browse() -> Element { + let viewer_model = demo_models()[0].id.to_string(); + let client = use_context::(); + let session = use_context::>(); + let toasts = use_context::>(); + let selected_algorithm = use_signal(|| FeedAlgorithm::Recent); + + let mut feed = use_resource(move || { + let client = client.clone(); + let algorithm = *selected_algorithm.read(); + async move { + let request = library::get_feed::GetFeed::new() + .algorithm(Some(algorithm.as_str().into())) + .limit(24) + .build(); + let response = client + .send(request) + .await + .map_err(|error| format!("Feed request failed: {error}"))?; + let output = response + .into_output() + .map_err(|error| format!("Feed decode failed: {error}"))?; + Ok(output + .value + .items + .into_iter() + .map(|item| item.thing) + .collect::>()) + } + }); + + let feed_result = feed.read(); + let feed_state = browse_feed_state(feed.pending(), feed_result.as_ref()); + + rsx! { + main { class: "browse-page product-shell", + header { class: "product-header", + div { class: "product-kicker", "ATProto model sharing" } + div { class: "product-header-row", + div { h1 { "Polymodel" } } + nav { class: "product-nav", aria_label: "Primary", + a { href: "#browse-shell", "Browse" } + Link { to: Route::Foundation {}, "Foundation" } + Link { class: "button button-secondary", to: Route::Viewer { model_id: viewer_model }, "Open viewer" } + SessionControl { session, toasts } + } + } + } + + section { id: "browse-shell", class: "browse-controls blueprint-panel", aria_label: "Browse shell", + label { class: "search-field", + span { "Search model projects" } + input { r#type: "search", placeholder: "Search by project, tag, or maker", aria_label: "Search model projects" } + } + div { class: "tab-row", role: "tablist", aria_label: "Feeds", + FeedTab { algorithm: FeedAlgorithm::Recent, selected_algorithm } + FeedTab { algorithm: FeedAlgorithm::Hot, selected_algorithm } + button { class: "tab", role: "tab", aria_selected: "false", disabled: true, "Following" } + } + } + + section { class: "browse-results-grid", aria_label: "Browse results", + match feed_state { + BrowseFeedState::Loading => rsx! { + for index in 0..6 { + div { key: "skeleton-{index}", class: "state-card loading-state", + span { class: "status-pill", "Loading" } + div { class: "skeleton-line skeleton-wide" } + div { class: "skeleton-line" } + div { class: "skeleton-box" } + } + } + }, + BrowseFeedState::Error(error) => rsx! { + div { class: "state-card error-state browse-state-card", + p { "{error}" } + button { class: "button button-secondary", onclick: move |_| feed.restart(), "Retry" } + } + }, + BrowseFeedState::Empty => rsx! { + div { class: "state-card empty-state browse-state-card", + span { class: "status-pill status-muted", "Empty" } + h2 { "No projects yet" } + p { "Polymodel reached the appview feed, but this local index has no projects for the selected feed yet." } + } + }, + BrowseFeedState::Populated(things) => rsx! { + for thing in things { + BrowseThingCard { key: "{thing.uri}", thing } + } + }, + } + } + } + } +} + +#[component] +fn BrowseThingCard(thing: library::ThingViewBasic) -> Element { + let detail_href = thing_detail_href(&thing.uri); + rsx! { ThingCard { thing, detail_href } } +} + +#[component] +fn FeedTab(algorithm: FeedAlgorithm, selected_algorithm: Signal) -> Element { + let is_active = *selected_algorithm.read() == algorithm; + let class = if is_active { "tab is-active" } else { "tab" }; + + rsx! { + button { class, role: "tab", aria_selected: "{is_active}", onclick: move |_| selected_algorithm.set(algorithm), "{algorithm.label()}" } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::thing_card::thing_card_fixtures; + + #[test] + fn browse_state_distinguishes_loading_empty_error_and_populated() { + assert_eq!(browse_feed_state(true, None), BrowseFeedState::Loading); + assert_eq!(browse_feed_state(false, Some(&Ok(vec![]))), BrowseFeedState::Empty); + assert_eq!( + browse_feed_state(false, Some(&Err("boom".to_string()))), + BrowseFeedState::Error("boom".to_string()) + ); + match browse_feed_state(false, Some(&Ok(thing_card_fixtures()))) { + BrowseFeedState::Populated(things) => assert_eq!(things.len(), 3), + state => panic!("expected populated state, got {state:?}"), + } + } +} diff --git a/src/foundation.rs b/src/foundation.rs new file mode 100644 index 0000000..00e601a --- /dev/null +++ b/src/foundation.rs @@ -0,0 +1,98 @@ +use dioxus::prelude::*; + +use crate::Route; + +#[component] +pub(crate) fn Foundation() -> Element { + rsx! { + main { class: "browse-page product-shell", + header { class: "product-header", + div { class: "product-kicker", "PM-38 foundation" } + div { class: "product-header-row", + div { + h1 { "Foundation" } + p { class: "product-lede", "Self-hosted typography, blueprint tokens, and reusable front-end states for upcoming product slices." } + } + nav { class: "product-nav", aria_label: "Foundation navigation", + Link { to: Route::Browse {}, "Back to home" } + a { href: "#ui-states", "UI states" } + } + } + } + + section { id: "browse-foundation", class: "foundation-hero blueprint-panel", + div { class: "foundation-hero-copy", + p { class: "eyebrow", "Token and asset check" } + h2 { "Blueprint styling foundation" } + p { "This route keeps PM-38 primitives visible without making implementation scaffolding the product home." } + div { class: "button-row", + button { class: "button button-primary", "Primary action" } + button { class: "button button-ghost", "Ghost action" } + } + } + div { class: "blueprint-preview", aria_label: "Blueprint placeholder preview", + div { class: "blueprint-preview-grid" } + span { class: "blueprint-axis blueprint-axis-x", "X" } + span { class: "blueprint-axis blueprint-axis-y", "Y" } + span { class: "blueprint-dimension", "120 × 86 × 42 mm" } + } + } + + section { class: "foundation-grid", aria_label: "Foundation primitives", + for item in foundation_items() { FoundationCard { item } } + } + + section { id: "ui-states", class: "state-grid", aria_label: "Reusable states", + 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" } } + 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." } } + 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." } } + } + } + } +} + +#[derive(Clone, Copy, PartialEq)] +struct FoundationItem { + label: &'static str, + title: &'static str, + body: &'static str, + meta: &'static str, +} + +fn foundation_items() -> [FoundationItem; 3] { + [ + FoundationItem { label: "Panel", title: "Project card surface", body: "A crisp bordered card for future model summaries, previews, and maker metadata.", meta: "primitive/card" }, + 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" }, + FoundationItem { label: "Status", title: "Metadata and chips", body: "Compact mono labels for license, dimensions, file state, and feed status.", meta: "primitive/status" }, + ] +} + +#[component] +fn FoundationCard(item: FoundationItem) -> Element { + rsx! { + article { class: "foundation-card card-surface", + div { class: "card-media blueprint-media", span { class: "status-pill", "{item.label}" } } + div { class: "card-body", h2 { class: "card-title", "{item.title}" } p { "{item.body}" } span { class: "card-meta", "{item.meta}" } } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn foundation_items_cover_base_primitives() { + let items = foundation_items(); + assert!(items.iter().any(|item| item.label == "Panel")); + assert!(items.iter().any(|item| item.label == "Input")); + assert!(items.iter().any(|item| item.label == "Status")); + } + + #[test] + fn foundation_copy_avoids_workflow_marketing() { + let combined = foundation_items().iter().map(|item| item.body).collect::>().join(" "); + assert!(!combined.contains("Jira")); + assert!(!combined.contains("Polytoken")); + } +} diff --git a/src/indexing/config.rs b/src/indexing/config.rs index 5e4520c..d7e9cfa 100644 --- a/src/indexing/config.rs +++ b/src/indexing/config.rs @@ -24,6 +24,46 @@ pub struct ServerConfig { /// defaults the OAuth client origin to `https://polymodel.space`, a functional /// hosted client; set `POLYMODEL_BASE_URL` for local or alternate origins. pub base_url: Option, + /// Local/demo sample-data mode. `auto` seeds conservative local SQLite DBs + /// only when empty; `force` seeds any SQLite DB when empty; `off` disables it. + pub sample_data: SampleDataMode, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SampleDataMode { + Auto, + Off, + Force, +} + +impl SampleDataMode { + fn from_env(value: Option) -> Self { + match value.as_deref().map(str::trim).map(str::to_ascii_lowercase) { + Some(value) if value == "off" => Self::Off, + Some(value) if value == "force" => Self::Force, + Some(value) if value == "auto" => Self::Auto, + Some(value) => { + tracing::warn!(%value, "unknown POLYMODEL_SAMPLE_DATA value; falling back to off"); + Self::Off + } + None => Self::Auto, + } + } + + pub fn should_seed(self, database_url: &str) -> bool { + match self { + Self::Off => false, + Self::Force => database_url.starts_with("sqlite:"), + Self::Auto => is_local_sqlite(database_url), + } + } +} + +fn is_local_sqlite(database_url: &str) -> bool { + database_url == "sqlite::memory:" + || database_url.starts_with("sqlite:./") + || database_url.starts_with("sqlite:../") + || database_url.starts_with("sqlite:/") } impl ServerConfig { @@ -38,9 +78,30 @@ impl ServerConfig { .ok() .map(|s| s.trim().to_owned()) .filter(|s| !s.is_empty()); + let sample_data = SampleDataMode::from_env(env::var("POLYMODEL_SAMPLE_DATA").ok()); Self { database_url, base_url, + sample_data, } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sample_data_auto_is_limited_to_local_sqlite_urls() { + assert!(SampleDataMode::Auto.should_seed("sqlite:./data/polymodel.db")); + assert!(SampleDataMode::Auto.should_seed("sqlite:/tmp/polymodel.db")); + assert!(!SampleDataMode::Auto.should_seed("postgres://example")); + } + + #[test] + fn sample_data_env_parses_known_values() { + assert_eq!(SampleDataMode::from_env(Some("off".into())), SampleDataMode::Off); + assert_eq!(SampleDataMode::from_env(Some("force".into())), SampleDataMode::Force); + assert_eq!(SampleDataMode::from_env(Some("auto".into())), SampleDataMode::Auto); + } +} diff --git a/src/indexing/mod.rs b/src/indexing/mod.rs index fa050ff..a1a4f99 100644 --- a/src/indexing/mod.rs +++ b/src/indexing/mod.rs @@ -9,9 +9,12 @@ //! 1. Hydrant's fjall store (`HYDRANT_DATABASE_PATH`) for raw ATProto records. //! 2. The SQLite projection database (`DATABASE_URL`) for app-specific views. +#![cfg_attr(test, allow(dead_code))] + pub mod config; pub mod db; pub mod projection; +pub mod sample_data; pub mod setup; use std::time::Duration; diff --git a/src/indexing/sample_data.rs b/src/indexing/sample_data.rs new file mode 100644 index 0000000..91a0d09 --- /dev/null +++ b/src/indexing/sample_data.rs @@ -0,0 +1,390 @@ +//! Deterministic local/demo sample data for appview read paths. +//! +//! This module writes projection-shaped SQLite rows only when the local/demo +//! database has no Polymodel things. It lets ordinary local `just serve` and e2e +//! exercise the same `getFeed`, `getThing`, and `getModel` appview contracts as +//! production reads without UI-only fixtures. + +use jacquard_common::types::blob::BlobRef; +use polymodel_api::space_polymodel::{ + actor::profile::Profile, + library::{File, model::Model, part::Part, thing::Thing}, +}; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::{Value, json}; +use sqlx::{SqliteConnection, SqlitePool}; + +pub const SAMPLE_DID: &str = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"; +pub const SAMPLE_HANDLE: &str = "ari.model.tools"; +pub const SAMPLE_THING_RKEY: &str = "parametric-enclosure"; +pub const SAMPLE_THING_URI: &str = + "at://did:plc:aaaaaaaaaaaaaaaaaaaaaaaa/space.polymodel.library.thing/parametric-enclosure"; + +const SAMPLE_TIME: &str = "2026-06-21T12:00:00.000Z"; +const SAMPLE_TIME_MILLIS: i64 = 1_750_507_200_000; + +pub async fn seed_if_empty(pool: &SqlitePool) -> anyhow::Result<()> { + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM things") + .fetch_one(pool) + .await?; + if count > 0 { + tracing::debug!(count, "sample data skipped because things table is populated"); + return Ok(()); + } + + seed(pool).await?; + tracing::info!(thing_uri = SAMPLE_THING_URI, "seeded local/demo Polymodel sample data"); + Ok(()) +} + +async fn seed(pool: &SqlitePool) -> anyhow::Result<()> { + let mut tx = pool.begin().await?; + + insert_identity(&mut tx, SAMPLE_DID, SAMPLE_HANDLE).await?; + insert_profile(&mut tx).await?; + + let model_uris = vec![ + model_uri("enclosure-v1"), + model_uri("panel-variant"), + model_uri("ruggedized-lid"), + ]; + let parts = sample_parts(); + for part in &parts { + insert_part(&mut tx, part).await?; + } + + insert_model( + &mut tx, + "enclosure-v1", + "Snap-fit electronics enclosure", + "Main printable enclosure with lid, body, buttons, and mounting hardware.", + &parts[0..6], + ) + .await?; + insert_model( + &mut tx, + "panel-variant", + "Panel-mount variant", + "Alternate front panel for flush mounting sensors and displays.", + &parts[6..12], + ) + .await?; + insert_model( + &mut tx, + "ruggedized-lid", + "Ruggedized outdoor lid", + "Weather-resistant lid and gasket parts for workshop deployments.", + &parts[12..18], + ) + .await?; + + let instructions = vec![ + "Print the body and lid in PETG or PLA+ with 0.2 mm layers.".to_string(), + "Use the model selector to choose the standard, panel-mount, or ruggedized variant.".to_string(), + "Press brass inserts into the corner bosses before final assembly.".to_string(), + ]; + let tags = vec!["enclosure", "parametric", "print-in-place"]; + let thing_record = json!({ + "name": "Parametric enclosure kit", + "summary": "A configurable electronics enclosure kit with three printable model variants and detailed part files.", + "instructions": instructions, + "license": "CC-BY-4.0", + "tags": tags, + "cover": [image("bafkreiathingcover", "Rendered preview of a parametric electronics enclosure")], + "previews": [image("bafkreiathingpreview", "Exploded blueprint preview of the enclosure kit")], + "models": model_uris.iter().map(|uri| strong_ref(uri)).collect::>(), + "createdAt": SAMPLE_TIME + }); + + let thing_record_json = typed_json::(thing_record.clone())?; + + sqlx::query( + r#"INSERT INTO things (did, rkey, uri, cid, name, summary, license, tags_json, + tags_text, instructions_text, cover_json, derived_from_uri, created_at, indexed_at, record_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)"#, + ) + .bind(SAMPLE_DID) + .bind(SAMPLE_THING_RKEY) + .bind(SAMPLE_THING_URI) + .bind("bafyreipolymodelsamplething") + .bind("Parametric enclosure kit") + .bind("A configurable electronics enclosure kit with three printable model variants and detailed part files.") + .bind("CC-BY-4.0") + .bind(serde_json::to_string(&tags)?) + .bind(tags.join(" ")) + .bind(instructions.join("\n")) + .bind(serde_json::to_string(&thing_record["cover"])? ) + .bind(SAMPLE_TIME_MILLIS) + .bind(SAMPLE_TIME_MILLIS) + .bind(thing_record_json) + .execute(&mut *tx) + .await?; + + for (position, model_uri) in model_uris.iter().enumerate() { + sqlx::query( + "INSERT INTO thing_models (thing_uri, model_uri, position) VALUES (?, ?, ?)", + ) + .bind(SAMPLE_THING_URI) + .bind(model_uri) + .bind(position as i64) + .execute(&mut *tx) + .await?; + } + + sqlx::query( + "INSERT INTO content_stats (uri, like_count, save_count, tag_count) VALUES (?, 42, 12, 3)", + ) + .bind(SAMPLE_THING_URI) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(()) +} + +async fn insert_identity(conn: &mut SqliteConnection, did: &str, handle: &str) -> anyhow::Result<()> { + sqlx::query("INSERT INTO identities (did, handle, updated_at) VALUES (?, ?, ?)") + .bind(did) + .bind(handle) + .bind(SAMPLE_TIME_MILLIS) + .execute(&mut *conn) + .await?; + Ok(()) +} + +async fn insert_profile(conn: &mut SqliteConnection) -> anyhow::Result<()> { + let avatar = typed_json::(blob("bafkreiariavatar", "image/jpeg", 2048))?; + let profile_record = typed_json::(json!({ + "displayName": "Ari Chen", + "description": "Maker of practical parametric fixtures and electronics housings.", + "avatar": serde_json::from_str::(&avatar)?, + "pronouns": "she/her", + "defaultLicense": "CC-BY-4.0" + }))?; + sqlx::query( + r#"INSERT INTO profiles (did, display_name, description, avatar_json, default_license, + pronouns, printers_json, links_json, record_json, indexed_at) + VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)"#, + ) + .bind(SAMPLE_DID) + .bind("Ari Chen") + .bind("Maker of practical parametric fixtures and electronics housings.") + .bind(&avatar) + .bind("CC-BY-4.0") + .bind("she/her") + .bind(profile_record) + .bind(SAMPLE_TIME_MILLIS) + .execute(&mut *conn) + .await?; + Ok(()) +} + +async fn insert_model( + conn: &mut SqliteConnection, + rkey: &str, + name: &str, + summary: &str, + parts: &[SamplePart], +) -> anyhow::Result<()> { + let uri = model_uri(rkey); + let record = typed_json::(json!({ + "name": name, + "summary": summary, + "instructions": ["Review the ordered part list before printing.", "Print fit-critical clips slowly for best tolerances."], + "license": "CC-BY-4.0", + "tags": ["variant", "enclosure"], + "cover": [image("bafkreimodelcover", &format!("Preview of {name}"))], + "previews": [image("bafkreimodelpreview", &format!("Blueprint preview of {name}"))], + "parts": parts.iter().map(|part| strong_ref(&part_uri(part.rkey))).collect::>(), + "createdAt": SAMPLE_TIME + }))?; + + sqlx::query( + r#"INSERT INTO models (did, rkey, uri, cid, name, summary, created_at, indexed_at, record_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"#, + ) + .bind(SAMPLE_DID) + .bind(rkey) + .bind(&uri) + .bind(format!("bafyreimodel{rkey}")) + .bind(name) + .bind(summary) + .bind(SAMPLE_TIME_MILLIS) + .bind(SAMPLE_TIME_MILLIS) + .bind(record) + .execute(&mut *conn) + .await?; + + for (position, part) in parts.iter().enumerate() { + sqlx::query("INSERT INTO model_parts (model_uri, part_uri, position) VALUES (?, ?, ?)") + .bind(&uri) + .bind(part_uri(part.rkey)) + .bind(position as i64) + .execute(&mut *conn) + .await?; + } + Ok(()) +} + +async fn insert_part(conn: &mut SqliteConnection, part: &SamplePart) -> anyhow::Result<()> { + let file = file_manifest(part.file_size); + let file_json = typed_json::(file.clone())?; + let record = typed_json::(json!({ + "name": part.name, + "file": file, + "format": "STL", + "dimensions": { "x": part.dimensions.0, "y": part.dimensions.1, "z": part.dimensions.2, "unit": "mm" }, + "units": "mm", + "notes": part.notes, + "printSettings": ["0.2 mm layer height", "15% gyroid infill", "No supports required unless noted"], + "previews": [image("bafkreipartpreview", &format!("Preview of {}", part.name))], + "createdAt": SAMPLE_TIME + }))?; + + sqlx::query( + r#"INSERT INTO parts (did, rkey, uri, cid, name, format, file_json, created_at, indexed_at, record_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#, + ) + .bind(SAMPLE_DID) + .bind(part.rkey) + .bind(part_uri(part.rkey)) + .bind(format!("bafyreipart{}", part.rkey)) + .bind(part.name) + .bind("STL") + .bind(file_json) + .bind(SAMPLE_TIME_MILLIS) + .bind(SAMPLE_TIME_MILLIS) + .bind(record) + .execute(&mut *conn) + .await?; + Ok(()) +} + +#[derive(Clone, Copy)] +struct SamplePart { + rkey: &'static str, + name: &'static str, + file_size: i64, + dimensions: (&'static str, &'static str, &'static str), + notes: &'static str, +} + +fn sample_parts() -> Vec { + [ + ("part-01", "Main enclosure body"), + ("part-02", "Snap-fit lid"), + ("part-03", "Button cap set"), + ("part-04", "USB-C bezel"), + ("part-05", "PCB mounting tray"), + ("part-06", "Corner screw boss"), + ("part-07", "Front sensor panel"), + ("part-08", "Display window frame"), + ("part-09", "Panel latch pair"), + ("part-10", "Flush wall bracket"), + ("part-11", "Cable strain relief"), + ("part-12", "Label plate"), + ("part-13", "Ruggedized lid shell"), + ("part-14", "TPU gasket guide"), + ("part-15", "Outdoor cable gland"), + ("part-16", "Hinged dust cover"), + ("part-17", "Drain slot insert"), + ("part-18", "Mounting foot set"), + ] + .into_iter() + .enumerate() + .map(|(index, (rkey, name))| SamplePart { + rkey, + name, + file_size: 48_000 + ((index + 1) as i64 * 2_048), + dimensions: ("86.0", "54.0", "18.5"), + notes: "Orient the visible face upward and verify first-layer adhesion around clips.", + }) + .collect() +} + +fn model_uri(rkey: &str) -> String { + format!("at://{SAMPLE_DID}/space.polymodel.library.model/{rkey}") +} + +fn part_uri(rkey: &str) -> String { + format!("at://{SAMPLE_DID}/space.polymodel.library.part/{rkey}") +} + +fn strong_ref(uri: &str) -> Value { + json!({ "uri": uri, "cid": "bafyreicid" }) +} + +fn image(cid: &str, alt: &str) -> Value { + json!({ + "alt": alt, + "aspectRatio": { "width": 4, "height": 3 }, + "image": blob(cid, "image/jpeg", 1234) + }) +} + +fn file_manifest(size: i64) -> Value { + json!({ + "mimeType": "model/stl", + "size": size, + "digest": "sha256:sample-polymodel-fixture", + "chunks": [{ "blob": blob("bafkreistlchunk", "model/stl", size), "offset": 0, "size": size }] + }) +} + +fn blob(cid: &str, mime_type: &str, size: i64) -> Value { + json!({ "$type": "blob", "ref": { "$link": cid }, "mimeType": mime_type, "size": size }) +} + +fn typed_json(value: Value) -> anyhow::Result +where + T: DeserializeOwned + Serialize, +{ + let typed: T = serde_json::from_value(value)?; + Ok(serde_json::to_string(&typed)?) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + use std::sync::Arc; + + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + + use super::*; + use crate::appview::{state::AppState, views}; + + async fn test_pool() -> SqlitePool { + 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(); + pool + } + + #[tokio::test] + async fn seed_populates_feed_thing_and_model_read_paths() { + let pool = test_pool().await; + seed_if_empty(&pool).await.unwrap(); + seed_if_empty(&pool).await.unwrap(); + let bootstrap = crate::oauth::bootstrap_oauth(pool.clone(), Some("http://localhost")) + .expect("ephemeral OAuth bootstrap for tests"); + let state = Arc::new(AppState::new(pool, bootstrap)); + + let feed = views::feed_recent(&state, 10, None, None).await.unwrap(); + assert_eq!(feed.items.len(), 1); + assert_eq!(feed.items[0].thing.name.as_str(), "Parametric enclosure kit"); + assert_eq!(feed.items[0].thing.model_count, 3); + assert_eq!(feed.items[0].thing.part_count, 18); + + let (thing, models) = views::get_thing(&state, SAMPLE_THING_URI, None).await.unwrap(); + assert_eq!(thing.author.display_name.as_ref().unwrap().as_str(), "Ari Chen"); + assert_eq!(models.len(), 3); + + let detail = views::get_model(&state, models[0].uri.as_ref(), None).await.unwrap(); + assert_eq!(detail.parts.len(), 6); + assert_eq!(detail.parts[0].name.as_str(), "Main enclosure body"); + } +} diff --git a/src/main.rs b/src/main.rs index 29dab14..e3ef616 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,33 +1,38 @@ use dioxus::prelude::*; use jacquard_common::xrpc::XrpcClient; -use polymodel_api::space_polymodel::{actor::get_session::GetSession, library}; +use polymodel_api::space_polymodel::actor::get_session::GetSession; mod auth; +mod browse; mod client; mod fonts; +mod foundation; mod mesh; mod session; +mod shell; mod thing_card; +mod thing_detail; mod viewer; +mod viewer_route; // Frontend compile-time configuration, generated by build.rs from POLYMODEL_*. mod env; // Server-only indexing infrastructure (Hydrant firehose + SQLite projection). -#[cfg(feature = "server")] +#[cfg(any(feature = "server", test))] mod indexing; // Server-only XRPC read endpoints (appview query layer, PM-26). -#[cfg(feature = "server")] +#[cfg(any(feature = "server", test))] mod appview; +#[cfg(any(feature = "server", test))] +mod oauth; -use auth::{ToastState, broadcast_sign_out, logout_url}; +use browse::Browse; use client::PolymodelClient; +use foundation::Foundation; use session::SessionIdentity; -use thing_card::ThingCard; - -#[cfg(feature = "server")] -mod oauth; - -use viewer::{ViewerPage, demo_model_for_route, demo_models}; +use shell::ToastShelf; +use thing_detail::ThingDetail; +use viewer_route::Viewer; const FAVICON: Asset = asset!("/assets/favicon.jpg"); const THEME_CSS: Asset = asset!("/assets/styling/theme.css"); @@ -52,6 +57,11 @@ fn main() { let db = indexing::db::init_db(&cfg) .await .expect("failed to initialize sqlite projection database"); + if cfg.sample_data.should_seed(&cfg.database_url) { + indexing::sample_data::seed_if_empty(&db) + .await + .expect("failed to seed local/demo sample data"); + } indexing::start_indexing(db.clone()) .await .expect("failed to start indexing pipeline"); @@ -83,20 +93,22 @@ fn main() { } #[derive(Clone, Debug, PartialEq, Routable)] -enum Route { +pub(crate) enum Route { #[route("/")] Browse {}, #[route("/foundation")] Foundation {}, #[route("/viewer/:model_id")] Viewer { model_id: String }, + #[route("/:repo/thing/:rkey")] + ThingDetail { repo: String, rkey: String }, } #[component] fn App() -> Element { let client = use_context_provider(PolymodelClient::default); let session = use_context_provider(|| Signal::new(SessionIdentity::Anonymous)); - let toasts = use_context_provider(|| Signal::new(ToastState::default())); + let toasts = use_context_provider(|| Signal::new(auth::ToastState::default())); let _session_seed = use_resource(move || { let client = client.clone(); @@ -126,416 +138,3 @@ fn App() -> Element { ToastShelf { toasts } } } - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum FeedAlgorithm { - Recent, - Hot, -} - -impl FeedAlgorithm { - fn as_str(self) -> &'static str { - match self { - Self::Recent => "recent", - Self::Hot => "hot", - } - } - - fn label(self) -> &'static str { - match self { - Self::Recent => "Recent", - Self::Hot => "Hot", - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -enum BrowseFeedState { - Loading, - Error(String), - Empty, - Populated(Vec), -} - -fn browse_feed_state( - pending: bool, - result: Option<&Result, String>>, -) -> BrowseFeedState { - if pending && result.is_none() { - return BrowseFeedState::Loading; - } - - match result { - Some(Ok(things)) if things.is_empty() => BrowseFeedState::Empty, - Some(Ok(things)) => BrowseFeedState::Populated(things.clone()), - Some(Err(error)) => BrowseFeedState::Error(error.clone()), - None => BrowseFeedState::Loading, - } -} - -#[allow(clippy::useless_format)] -#[component] -fn Browse() -> Element { - let viewer_model = demo_models()[0].id.to_string(); - let client = use_context::(); - let session = use_context::>(); - let toasts = use_context::>(); - let selected_algorithm = use_signal(|| FeedAlgorithm::Recent); - - let mut feed = use_resource(move || { - let client = client.clone(); - let algorithm = *selected_algorithm.read(); - async move { - let request = library::get_feed::GetFeed::new() - .algorithm(Some(algorithm.as_str().into())) - .limit(24) - .build(); - let response = client - .send(request) - .await - .map_err(|error| format!("Feed request failed: {error}"))?; - let output = response - .into_output() - .map_err(|error| format!("Feed decode failed: {error}"))?; - Ok(output - .value - .items - .into_iter() - .map(|item| item.thing) - .collect::>()) - } - }); - - let feed_result = feed.read(); - let feed_state = browse_feed_state(feed.pending(), feed_result.as_ref()); - - rsx! { - main { class: "browse-page product-shell", - header { class: "product-header", - div { class: "product-kicker", "ATProto model sharing" } - div { class: "product-header-row", - div { - h1 { "Polymodel" } - } - nav { class: "product-nav", aria_label: "Primary", - a { href: "#browse-shell", "Browse" } - Link { to: Route::Foundation {}, "Foundation" } - Link { - class: "button button-secondary", - to: Route::Viewer { model_id: viewer_model }, - "Open viewer" - } - SessionControl { session, toasts } - } - } - } - - section { id: "browse-shell", class: "browse-controls blueprint-panel", aria_label: "Browse shell", - label { class: "search-field", - span { "Search model projects" } - input { - r#type: "search", - placeholder: "Search by project, tag, or maker", - aria_label: "Search model projects" - } - } - div { class: "tab-row", role: "tablist", aria_label: "Feeds", - FeedTab { algorithm: FeedAlgorithm::Recent, selected_algorithm } - FeedTab { algorithm: FeedAlgorithm::Hot, selected_algorithm } - button { class: "tab", role: "tab", aria_selected: "false", disabled: true, "Following" } - } - } - - section { class: "browse-results-grid", aria_label: "Browse results", - match feed_state { - BrowseFeedState::Loading => rsx! { - for index in 0..6 { - div { key: "skeleton-{index}", class: "state-card loading-state", - span { class: "status-pill", "Loading" } - div { class: "skeleton-line skeleton-wide" } - div { class: "skeleton-line" } - div { class: "skeleton-box" } - } - } - }, - BrowseFeedState::Error(error) => { - let error_message = error.clone(); - rsx! { - div { class: "state-card error-state browse-state-card", - p { "{error_message}" } - button { - class: "button button-secondary", - onclick: move |_| feed.restart(), - "Retry" - } - } - } }, - BrowseFeedState::Empty => rsx! { - div { class: "state-card empty-state browse-state-card", - span { class: "status-pill status-muted", "Empty" } - h2 { "No projects yet" } - p { "Polymodel reached the appview feed, but this local index has no projects for the selected feed yet." } - } - }, - BrowseFeedState::Populated(things) => rsx! { - for thing in things { - ThingCard { key: "{thing.uri}", thing } - } - }, - } - } - } - } -} - -#[component] -fn FeedTab(algorithm: FeedAlgorithm, selected_algorithm: Signal) -> Element { - let is_active = *selected_algorithm.read() == algorithm; - let class = if is_active { "tab is-active" } else { "tab" }; - - rsx! { - button { - class, - role: "tab", - aria_selected: "{is_active}", - onclick: move |_| selected_algorithm.set(algorithm), - "{algorithm.label()}" - } - } -} - -#[component] -fn SessionControl(session: Signal, toasts: Signal) -> Element { - let identity = session.read().clone(); - - match identity { - SessionIdentity::Anonymous => rsx! { - form { - class: "session-control", - action: "/oauth/start", - method: "get", - input { - r#type: "text", - name: "identifier", - placeholder: "handle.test", - aria_label: "ATProto handle", - } - button { class: "button button-primary", r#type: "submit", "Sign in" } - } - }, - SessionIdentity::Authenticated(identity) => { - let label = SessionIdentity::Authenticated(identity).label(); - rsx! { - div { class: "session-control session-control-authenticated", - span { class: "status-pill", "{label}" } - form { - method: "post", - action: "{logout_url()}", - button { - class: "button button-secondary", - r#type: "submit", - onclick: move |_| { - broadcast_sign_out(); - session.set(SessionIdentity::Anonymous); - toasts.write().push("Signing out…"); - }, - "Sign out" - } - } - } - } - } - } -} - -#[component] -fn ToastShelf(toasts: Signal) -> Element { - let items = toasts.read().items().to_vec(); - - rsx! { - div { class: "toast-shelf", aria_live: "polite", - for toast in items { - div { key: "toast-{toast.id}", class: "toast blueprint-panel", - span { "{toast.message}" } - button { - class: "toast-dismiss", - aria_label: "Dismiss notification", - onclick: move |_| toasts.write().dismiss(toast.id), - "×" - } - } - } - } - } -} - -#[component] -fn Foundation() -> Element { - rsx! { - main { class: "browse-page product-shell", - header { class: "product-header", - div { class: "product-kicker", "PM-38 foundation" } - div { class: "product-header-row", - div { - h1 { "Foundation" } - p { class: "product-lede", - "Self-hosted typography, blueprint tokens, and reusable front-end states for upcoming product slices." - } - } - nav { class: "product-nav", aria_label: "Foundation navigation", - Link { to: Route::Browse {}, "Back to home" } - a { href: "#ui-states", "UI states" } - } - } - } - - section { id: "browse-foundation", class: "foundation-hero blueprint-panel", - div { class: "foundation-hero-copy", - p { class: "eyebrow", "Token and asset check" } - h2 { "Blueprint styling foundation" } - p { - "This route keeps PM-38 primitives visible without making implementation scaffolding the product home." - } - div { class: "button-row", - button { class: "button button-primary", "Primary action" } - button { class: "button button-ghost", "Ghost action" } - } - } - div { class: "blueprint-preview", aria_label: "Blueprint placeholder preview", - div { class: "blueprint-preview-grid" } - span { class: "blueprint-axis blueprint-axis-x", "X" } - span { class: "blueprint-axis blueprint-axis-y", "Y" } - span { class: "blueprint-dimension", "120 × 86 × 42 mm" } - } - } - - section { class: "foundation-grid", aria_label: "Foundation primitives", - for item in foundation_items() { - FoundationCard { item } - } - } - - section { id: "ui-states", class: "state-grid", aria_label: "Reusable states", - 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" } - } - 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." } - } - 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." } - } - } - } - } -} - -#[component] -fn Viewer(model_id: String) -> Element { - let model = demo_model_for_route(&model_id); - - rsx! { - main { class: "viewer-route-page", - nav { class: "viewer-route-nav", aria_label: "Viewer navigation", - Link { to: Route::Browse {}, "← Back to browse" } - } - ViewerPage { key: "{model.id}", initial_model: model } - } - } -} - -#[derive(Clone, Copy, PartialEq)] -struct FoundationItem { - label: &'static str, - title: &'static str, - body: &'static str, - meta: &'static str, -} - -fn foundation_items() -> [FoundationItem; 3] { - [ - FoundationItem { - label: "Panel", - title: "Project card surface", - body: "A crisp bordered card for future model summaries, previews, and maker metadata.", - meta: "primitive/card", - }, - 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", - }, - FoundationItem { - label: "Status", - title: "Metadata and chips", - body: "Compact mono labels for license, dimensions, file state, and feed status.", - meta: "primitive/status", - }, - ] -} - -#[component] -fn FoundationCard(item: FoundationItem) -> Element { - rsx! { - article { class: "foundation-card card-surface", - div { class: "card-media blueprint-media", - span { class: "status-pill", "{item.label}" } - } - div { class: "card-body", - h2 { class: "card-title", "{item.title}" } - p { "{item.body}" } - span { class: "card-meta", "{item.meta}" } - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use thing_card::thing_card_fixtures; - - #[test] - fn foundation_items_cover_base_primitives() { - let items = foundation_items(); - assert!(items.iter().any(|item| item.label == "Panel")); - assert!(items.iter().any(|item| item.label == "Input")); - assert!(items.iter().any(|item| item.label == "Status")); - } - - #[test] - fn foundation_copy_avoids_workflow_marketing() { - let combined = foundation_items() - .iter() - .map(|item| item.body) - .collect::>() - .join(" "); - assert!(!combined.contains("Jira")); - assert!(!combined.contains("Polytoken")); - } - - #[test] - fn browse_state_distinguishes_loading_empty_error_and_populated() { - assert_eq!(browse_feed_state(true, None), BrowseFeedState::Loading); - assert_eq!( - browse_feed_state(false, Some(&Ok(vec![]))), - BrowseFeedState::Empty - ); - assert_eq!( - browse_feed_state(false, Some(&Err("boom".to_string()))), - BrowseFeedState::Error("boom".to_string()) - ); - match browse_feed_state(false, Some(&Ok(thing_card_fixtures()))) { - BrowseFeedState::Populated(things) => assert_eq!(things.len(), 3), - state => panic!("expected populated state, got {state:?}"), - } - } -} diff --git a/src/shell.rs b/src/shell.rs new file mode 100644 index 0000000..1f6d2a5 --- /dev/null +++ b/src/shell.rs @@ -0,0 +1,69 @@ +use dioxus::prelude::*; + +use crate::auth::{ToastState, broadcast_sign_out, logout_url}; +use crate::session::SessionIdentity; + +#[component] +pub(crate) fn SessionControl(session: Signal, toasts: Signal) -> Element { + let identity = session.read().clone(); + + match identity { + SessionIdentity::Anonymous => rsx! { + form { + class: "session-control", + action: "/oauth/start", + method: "get", + input { + r#type: "text", + name: "identifier", + placeholder: "handle.test", + aria_label: "ATProto handle", + } + button { class: "button button-primary", r#type: "submit", "Sign in" } + } + }, + SessionIdentity::Authenticated(identity) => { + let label = SessionIdentity::Authenticated(identity).label(); + rsx! { + div { class: "session-control session-control-authenticated", + span { class: "status-pill", "{label}" } + form { + method: "post", + action: "{logout_url()}", + button { + class: "button button-secondary", + r#type: "submit", + onclick: move |_| { + broadcast_sign_out(); + session.set(SessionIdentity::Anonymous); + toasts.write().push("Signing out…"); + }, + "Sign out" + } + } + } + } + } + } +} + +#[component] +pub(crate) fn ToastShelf(toasts: Signal) -> Element { + let items = toasts.read().items().to_vec(); + + rsx! { + div { class: "toast-shelf", aria_live: "polite", + for toast in items { + div { key: "toast-{toast.id}", class: "toast blueprint-panel", + span { "{toast.message}" } + button { + class: "toast-dismiss", + aria_label: "Dismiss notification", + onclick: move |_| toasts.write().dismiss(toast.id), + "×" + } + } + } + } + } +} diff --git a/src/thing_card.rs b/src/thing_card.rs index 1ea3f52..562baca 100644 --- a/src/thing_card.rs +++ b/src/thing_card.rs @@ -116,7 +116,7 @@ pub fn thing_card_fixtures() -> Vec { #[allow(clippy::useless_format)] #[component] -pub fn ThingCard(thing: library::ThingViewBasic) -> Element { +pub fn ThingCard(thing: library::ThingViewBasic, detail_href: Option) -> Element { let title = thing_title(&thing); let creator = creator_label(&thing.author); let media = select_card_media(thing.cover.as_deref(), thing.previews.as_deref(), title); @@ -148,7 +148,13 @@ pub fn ThingCard(thing: library::ThingViewBasic) -> Element { } div { class: "thing-card-body card-body", div { class: "thing-card-heading", - h2 { class: "card-title thing-card-title", "{title}" } + h2 { class: "card-title thing-card-title", + if let Some(href) = &detail_href { + a { class: "thing-card-link", href: "{href}", "{title}" } + } else { + "{title}" + } + } p { class: "thing-card-creator", "by {creator}" } } if let Some(tags) = hydrated_tags(&thing) { diff --git a/src/thing_detail.rs b/src/thing_detail.rs new file mode 100644 index 0000000..68e7129 --- /dev/null +++ b/src/thing_detail.rs @@ -0,0 +1,599 @@ +use dioxus::prelude::*; +use jacquard::common::types::ident::AtIdentifier; +use jacquard::common::types::recordkey::Rkey; +use jacquard::common::types::string::AtUri; +use jacquard::common::types::value::from_data; +use jacquard_common::xrpc::XrpcClient; +use polymodel_api::space_polymodel::library; + +use crate::Route; +use crate::client::PolymodelClient; +use crate::viewer::{AssetViewer, ViewerFallbackImage, demo_models}; + +#[derive(Clone, Debug, PartialEq, Eq)] +enum DetailState { + Loading, + InvalidRoute(String), + Error(String), + Populated(Box), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ThingDetailData { + thing: library::ThingView, + thing_record: library::thing::Thing, + models: Vec, + selected_model: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ModelDetailSummary { + view: library::ModelView, + record: library::model::Model, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct SelectedModelDetail { + model: library::ModelView, + record: library::model::Model, + parts: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct PartDetail { + view: library::PartView, + record: library::part::Part, +} + +fn detail_state( + pending: bool, + result: Option<&Result>, +) -> DetailState { + if pending && result.is_none() { + return DetailState::Loading; + } + + match result { + Some(Ok(data)) => DetailState::Populated(Box::new(data.clone())), + Some(Err(error)) if error.starts_with("Invalid route:") => { + DetailState::InvalidRoute(error.clone()) + } + Some(Err(error)) => DetailState::Error(error.clone()), + None => DetailState::Loading, + } +} + +pub(crate) fn thing_detail_href(uri: &AtUri) -> Option { + let path = uri.path()?; + if path.collection.as_str() != "space.polymodel.library.thing" { + return None; + } + let repo = uri.authority(); + let rkey = path.rkey?; + Some(format!("/{}/thing/{}", repo.as_str(), rkey.as_ref())) +} + +fn thing_uri_from_route(repo: &str, rkey: &str) -> Result { + let repo: AtIdentifier = AtIdentifier::new_owned(repo) + .map_err(|error| format!("Invalid route: unsupported repository identifier: {error}"))?; + let rkey: Rkey = Rkey::new_owned(rkey) + .map_err(|error| format!("Invalid route: unsupported record key: {error}"))?; + AtUri::new_owned(format!( + "at://{}/space.polymodel.library.thing/{}", + repo.as_str(), + rkey.as_str() + )) + .map_err(|error| format!("Invalid route: could not build thing URI: {error}")) +} + +fn thing_record(view: &library::ThingView) -> Result { + from_data(&view.record).map_err(|error| format!("Thing record decode failed: {error}")) +} + +fn model_record(view: &library::ModelView) -> Result { + from_data(&view.record).map_err(|error| format!("Model record decode failed: {error}")) +} + +fn part_record(view: &library::PartView) -> Result { + from_data(&view.record).map_err(|error| format!("Part record decode failed: {error}")) +} + +async fn load_thing_detail( + client: PolymodelClient, + route_uri: Result, + selected_model_uri: Option, +) -> Result { + let uri = route_uri?; + let thing_response = client + .send(library::get_thing::GetThing::new().uri(uri).build()) + .await + .map_err(|error| format!("Thing request failed: {error}"))?; + let output = thing_response + .into_output() + .map_err(|error| format!("Thing decode failed: {error}"))?; + let thing_record = thing_record(&output.thing)?; + let models = output + .models + .unwrap_or_default() + .into_iter() + .map(|view| model_record(&view).map(|record| ModelDetailSummary { view, record })) + .collect::, _>>()?; + let selected_uri = selected_model_uri.or_else(|| models.first().map(|model| model.view.uri.clone())); + let selected_model = if let Some(uri) = selected_uri { + let response = client + .send(library::get_model::GetModel::new().uri(uri).build()) + .await + .map_err(|error| format!("Model request failed: {error}"))?; + let output = response + .into_output() + .map_err(|error| format!("Model decode failed: {error}"))?; + let record = model_record(&output.model)?; + let parts = output + .parts + .unwrap_or_default() + .into_iter() + .map(|view| part_record(&view).map(|record| PartDetail { view, record })) + .collect::, _>>()?; + Some(SelectedModelDetail { + model: output.model, + record, + parts, + }) + } else { + None + }; + + Ok(ThingDetailData { + thing: output.thing, + thing_record, + models, + selected_model, + }) +} + +fn file_size_label(size: i64) -> String { + if size >= 1_000_000 { + format!("{:.1} MB", size as f64 / 1_000_000.0) + } else if size >= 1_000 { + format!("{:.0} KB", size as f64 / 1_000.0) + } else { + format!("{size} B") + } +} + +#[component] +pub(crate) fn ThingDetail(repo: String, rkey: String) -> Element { + let client = use_context::(); + let selected_model_uri = use_signal(|| None::); + let route_uri = thing_uri_from_route(&repo, &rkey); + + let detail = use_resource(move || { + let client = client.clone(); + let route_uri = route_uri.clone(); + let selected_model_uri = selected_model_uri.read().clone(); + async move { load_thing_detail(client, route_uri, selected_model_uri).await } + }); + + let detail_result = detail.read(); + let state = detail_state(detail.pending(), detail_result.as_ref()); + + rsx! { + main { class: "thing-detail-page product-shell", + nav { class: "detail-nav", aria_label: "Thing detail navigation", + Link { to: Route::Browse {}, "← Back to browse" } + } + match state { + DetailState::Loading => rsx! { DetailLoading {} }, + DetailState::InvalidRoute(message) => rsx! { DetailError { title: "Thing route unavailable".to_string(), message } }, + DetailState::Error(message) => rsx! { DetailError { title: "Thing unavailable".to_string(), message } }, + DetailState::Populated(data) => rsx! { DetailPopulated { data: *data, selected_model_uri } }, + } + } + } +} + +#[component] +fn DetailLoading() -> Element { + rsx! { + section { class: "thing-detail-shell blueprint-panel", aria_label: "Thing detail loading", + 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" } + } + } + } +} + +#[component] +fn DetailError(title: String, message: String) -> Element { + rsx! { + section { class: "thing-detail-shell blueprint-panel", aria_label: "Thing detail error", + div { class: "state-card error-state", + span { class: "status-pill status-error", "Error" } + h1 { "{title}" } + p { "{message}" } + Link { class: "button button-secondary", to: Route::Browse {}, "Back to browse" } + } + } + } +} + +#[allow(clippy::useless_format)] +#[component] +fn DetailPopulated(data: ThingDetailData, mut selected_model_uri: Signal>) -> Element { + let ThingDetailData { + thing, + thing_record, + models, + selected_model, + } = data; + let title = clean_text(Some(thing.name.as_ref())) + .unwrap_or("Untitled thing") + .to_string(); + let creator = actor_label(&thing.author).to_string(); + let summary = clean_text(thing.summary.as_ref().map(AsRef::as_ref)) + .or_else(|| clean_text(thing_record.summary.as_ref().map(AsRef::as_ref))) + .map(ToString::to_string); + let media = select_detail_media(thing.cover.as_deref(), thing.previews.as_deref(), &title); + let selected_uri = selected_model + .as_ref() + .map(|selected| selected.model.uri.clone()); + let tags = thing + .tags + .as_deref() + .or(thing_record.tags.as_deref()) + .unwrap_or_default() + .iter() + .filter_map(|tag| clean_text(Some(tag.as_ref())).map(ToString::to_string)) + .collect::>(); + let license = thing.license.to_string(); + let model_count = count_label(thing.model_count, "model"); + let part_count = count_label(thing.part_count, "part"); + let like_count = count_label(thing.like_count, "like"); + let save_count = count_label(thing.save_count, "save"); + let instructions = thing_record.instructions; + + rsx! { + header { class: "thing-detail-header", + div { class: "product-kicker", "Thing detail" } + div { class: "thing-detail-title-row", + div { + h1 { "{title}" } + p { class: "thing-detail-creator", "by {creator}" } + } + dl { class: "thing-detail-stats", aria_label: "Thing stats", + StatItem { label: "Models".to_string(), value: model_count } + StatItem { label: "Parts".to_string(), value: part_count } + StatItem { label: "Likes".to_string(), value: like_count } + StatItem { label: "Saves".to_string(), value: save_count } + } + } + } + + section { class: "thing-detail-shell blueprint-panel", aria_label: "Thing overview", + div { class: "thing-detail-viewer", + AssetViewer { initial_model: demo_models()[0], fallback: media.map(|media| ViewerFallbackImage { url: media.url, alt: media.alt }) } + } + + div { class: "thing-detail-summary", + div { class: "thing-detail-copy", + if let Some(summary) = summary { + p { class: "product-lede", "{summary}" } + } else { + p { class: "product-lede", "No summary is published for this thing yet." } + } + div { class: "thing-detail-chips", aria_label: "Thing tags and license", + span { class: "status-pill", "{license}" } + for tag in tags { + span { class: "status-pill status-muted", "#{tag}" } + } + } + } + + div { class: "thing-detail-actions", aria_label: "Thing actions", + button { class: "button button-primary", "Download files" } + button { class: "button button-secondary", disabled: true, aria_label: "Save disabled until sign in is available", "Save" } + } + } + } + + section { class: "thing-detail-grid", aria_label: "Thing detail sections", + DetailPanel { title: "Overview".to_string(), + InstructionList { instructions, empty: "No thing-level instructions are published yet.".to_string() } + } + + DetailPanel { title: "Models".to_string(), + if models.is_empty() { + p { class: "detail-muted", "This thing does not publish any model variants yet." } + } else { + div { class: "model-list", role: "list", aria_label: "Model variants", + for model in models { + { + let row_selected = selected_uri.as_ref() == Some(&model.view.uri); + let uri_for_select = model.view.uri.clone(); + rsx! { + ModelRow { + key: "{model.view.uri}", + model, + selected: row_selected, + onselect: move |_| selected_model_uri.set(Some(uri_for_select.clone())) + } + } + } + } + } + } + } + + if let Some(selected) = selected_model { + { + let SelectedModelDetail { model, record, parts } = selected; + rsx! { + article { class: "detail-panel model-detail-panel card-surface", + SelectedModelPanel { model, record } + } + DetailPanel { title: "Parts & files".to_string(), + PartsPanel { parts } + } + } + } + } else { + DetailPanel { title: "Parts & files".to_string(), + p { class: "detail-muted", "Select a model to load its ordered parts and files." } + } + } + } + } +} + +#[component] +fn DetailPanel(title: String, children: Element) -> Element { + rsx! { + article { class: "detail-panel card-surface", + div { class: "detail-panel-heading", + h2 { "{title}" } + } + {children} + } + } +} + +#[component] +fn StatItem(label: String, value: String) -> Element { + rsx! { + div { + dt { "{label}" } + dd { "{value}" } + } + } +} + +#[component] +fn InstructionList(instructions: Option>, empty: String) -> Element { + let instructions = instructions.unwrap_or_default(); + let items = instructions + .iter() + .filter_map(|item| clean_text(Some(item.as_ref())).map(ToString::to_string)) + .collect::>(); + + rsx! { + if items.is_empty() { + p { class: "detail-muted", "{empty}" } + } else { + ol { class: "instruction-list", + for (index, item) in items.iter().enumerate() { + li { key: "instruction-{index}", "{item}" } + } + } + } + } +} + +#[component] +fn ModelRow(model: ModelDetailSummary, selected: bool, onselect: EventHandler) -> Element { + let name = clean_text(Some(model.view.name.as_ref())) + .unwrap_or("Untitled model") + .to_string(); + let summary = clean_text(model.view.summary.as_ref().map(AsRef::as_ref)) + .or_else(|| clean_text(model.record.summary.as_ref().map(AsRef::as_ref))) + .map(ToString::to_string); + let class = if selected { "model-row is-selected" } else { "model-row" }; + let license = model + .record + .license + .as_ref() + .map(AsRef::as_ref) + .and_then(|value| clean_text(Some(value))) + .unwrap_or("inherits thing license") + .to_string(); + + rsx! { + button { class, role: "listitem", onclick: move |event| onselect.call(event), + span { class: "model-row-title", "{name}" } + span { class: "model-row-meta", "{model.view.part_count} parts · {license}" } + if let Some(summary) = summary { + span { class: "model-row-summary", "{summary}" } + } + } + } +} + +#[component] +fn SelectedModelPanel(model: library::ModelView, record: library::model::Model) -> Element { + let name = clean_text(Some(model.name.as_ref())) + .unwrap_or("Untitled model") + .to_string(); + let summary = clean_text(model.summary.as_ref().map(AsRef::as_ref)) + .or_else(|| clean_text(record.summary.as_ref().map(AsRef::as_ref))) + .map(ToString::to_string); + let instructions = record.instructions; + let tags = record + .tags + .as_deref() + .unwrap_or_default() + .iter() + .filter_map(|tag| clean_text(Some(tag.as_ref())).map(ToString::to_string)) + .collect::>(); + + rsx! { + div { class: "selected-model-panel", + div { class: "selected-model-heading", + span { class: "product-kicker", "Current model" } + h2 { "{name}" } + } + if let Some(summary) = summary { + p { "{summary}" } + } + InstructionList { instructions, empty: "No model-specific instructions are published yet.".to_string() } + if !tags.is_empty() { + div { class: "thing-detail-chips", aria_label: "Selected model tags", + for tag in tags { + span { class: "status-pill status-muted", "#{tag}" } + } + } + } + } + } +} + +#[component] +fn PartsPanel(parts: Vec) -> Element { + rsx! { + if parts.is_empty() { + p { class: "detail-muted", "This model does not publish any ordered part files yet." } + } else { + div { class: "parts-list", role: "list", aria_label: "Ordered part files", + for (index, part) in parts.iter().enumerate() { + PartRow { key: "{part.view.uri}", position: index + 1, part: part.clone() } + } + } + } + } +} + +#[component] +fn PartRow(position: usize, part: PartDetail) -> Element { + let name = clean_text(Some(part.view.name.as_ref())) + .unwrap_or("Untitled part") + .to_string(); + let format = part + .view + .format + .as_ref() + .or(part.record.format.as_ref()) + .map(AsRef::as_ref) + .and_then(|value| clean_text(Some(value))) + .unwrap_or("file") + .to_string(); + let size = file_size_label(part.view.file.size); + let dimensions = part.record.dimensions.as_ref().map(dimensions_label); + let notes = clean_text(part.record.notes.as_ref().map(AsRef::as_ref)).map(ToString::to_string); + let print_settings = part + .record + .print_settings + .as_deref() + .unwrap_or_default() + .iter() + .filter_map(|setting| clean_text(Some(setting.as_ref())).map(ToString::to_string)) + .collect::>(); + + rsx! { + article { class: "part-row", role: "listitem", + div { class: "part-row-index", aria_label: "Part position", "{position}" } + div { class: "part-row-body", + h3 { "{name}" } + dl { class: "part-row-meta", aria_label: "Part file metadata", + StatItem { label: "Format".to_string(), value: format } + StatItem { label: "Size".to_string(), value: size } + if let Some(dimensions) = dimensions { + StatItem { label: "Dimensions".to_string(), value: dimensions } + } + } + if let Some(notes) = notes { + p { class: "part-notes", "{notes}" } + } + if !print_settings.is_empty() { + ul { class: "print-settings", aria_label: "Print settings", + for setting in print_settings { + li { "{setting}" } + } + } + } + } + } + } +} + +#[component] +fn BlueprintPlaceholder(label: String, detail: String) -> Element { + rsx! { + div { class: "thing-card-placeholder blueprint-media", role: "img", aria_label: "Blueprint placeholder for missing preview media", + span { class: "status-pill status-muted", "{label}" } + span { class: "blueprint-axis blueprint-axis-x", "X" } + span { class: "blueprint-axis blueprint-axis-y", "Y" } + span { class: "blueprint-dimension", "{detail}" } + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct DetailMedia { + url: String, + alt: String, +} + +fn select_detail_media( + cover: Option<&[library::ImageView]>, + previews: Option<&[library::ImageView]>, + title: &str, +) -> Option { + first_detail_image(cover, title).or_else(|| first_detail_image(previews, title)) +} + +fn first_detail_image(images: Option<&[library::ImageView]>, title: &str) -> Option { + images?.iter().find_map(|image| { + let url = image.fullsize.as_ref().to_string(); + if !usable_image_url(&url) { + return None; + } + let alt = clean_text(image.alt.as_ref().map(AsRef::as_ref)) + .map(ToString::to_string) + .unwrap_or_else(|| format!("Preview image for {title}")); + Some(DetailMedia { url, alt }) + }) +} + +fn usable_image_url(url: &str) -> bool { + url.starts_with("http://") + || url.starts_with("https://") + || url.starts_with('/') + || url.starts_with("data:image/") +} + +fn actor_label(actor: &library::Actor) -> &str { + clean_text(actor.display_name.as_ref().map(AsRef::as_ref)).unwrap_or_else(|| actor.handle.as_ref()) +} + +fn clean_text(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + +fn count_label(value: i64, noun: &str) -> String { + let suffix = if value == 1 { "" } else { "s" }; + format!("{value} {noun}{suffix}") +} + +fn dimensions_label(dimensions: &library::Bbox) -> String { + let unit = dimensions + .unit + .as_ref() + .map(AsRef::as_ref) + .and_then(|value| clean_text(Some(value))) + .unwrap_or("mm"); + let x: &str = dimensions.x.as_ref(); + let y: &str = dimensions.y.as_ref(); + let z: &str = dimensions.z.as_ref(); + format!("{x} × {y} × {z} {unit}") +} diff --git a/src/viewer.rs b/src/viewer.rs index 9e80c2e..47b389f 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -40,6 +40,12 @@ pub struct DemoModel { pub triangles: usize, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ViewerFallbackImage { + pub url: String, + pub alt: String, +} + const DEMO_MODELS: [DemoModel; 2] = [ DemoModel { id: "body-f-chest-v4", @@ -381,7 +387,9 @@ where let hash = mount.mesh.content_hash; match self.active.as_mut() { None => { - self.active = Some(R::mount(canvas, mount)?); + let mut renderer = R::mount(canvas, mount)?; + renderer.resize()?; + self.active = Some(renderer); self.active_hash = Some(hash); self.mounts += 1; } @@ -498,6 +506,54 @@ pub fn ViewerPage(initial_model: DemoModel) -> Element { resize_count, context_acquisitions, renderer_failed, + fallback: None, + } + } + } +} + +#[component] +pub fn AssetViewer(initial_model: DemoModel, fallback: Option) -> Element { + let canvas_mounted = use_signal(|| false); + let resize_count = use_signal(|| 0_u64); + let context_acquisitions = use_signal(|| 0_u64); + let renderer_failed = use_signal(|| false); + let loaded: Signal> = use_signal(|| None); + let status: Signal = use_signal(|| { + ViewerStatus::Loading(SessionKey::new(initial_model.id, initial_model.generation)) + }); + + let _mesh_load = use_resource(move || { + let mut loaded = loaded; + let mut status = status; + async move { + status.set(ViewerStatus::Loading(SessionKey::new( + initial_model.id, + initial_model.generation, + ))); + match load_demo_mesh(initial_model).await { + Ok(result) => { + status.set(ViewerStatus::Ready(SessionKey::new( + initial_model.id, + initial_model.generation, + ))); + loaded.set(Some(result)); + } + Err(issue) => status.set(ViewerStatus::MeshError(issue)), + } + } + }); + + rsx! { + div { class: "detail-asset-viewer", aria_label: "Asset-backed 3D model viewer", + ModelViewer { + loaded, + status, + canvas_mounted, + resize_count, + context_acquisitions, + renderer_failed, + fallback, } } } @@ -511,6 +567,7 @@ pub struct ModelViewerProps { resize_count: Signal, context_acquisitions: Signal, renderer_failed: Signal, + fallback: Option, } #[component] @@ -519,6 +576,7 @@ pub fn ModelViewer(props: ModelViewerProps) -> Element { let resize_count = props.resize_count; let context_acquisitions = props.context_acquisitions; let renderer_failed = props.renderer_failed; + let fallback = props.fallback; let loaded = props.loaded; let status = props.status; @@ -574,20 +632,15 @@ pub fn ModelViewer(props: ModelViewerProps) -> Element { onpointerdown: move |event| event.stop_propagation(), } if !renderer_active { - if let Some(summary) = summary { - AssetPreview { summary } - } - } - div { class: "model-viewer-overlay", aria_live: "polite", - h2 { "{status_val.label()}" } - p { "{status_val.message()}" } - if let Some(summary) = summary { - dl { class: "viewer-mesh-summary", - div { dt { "Model" } dd { "{summary.display_name}" } } - div { dt { "Asset" } dd { "{summary.asset_path}" } } - div { dt { "Vertices" } dd { "{summary.vertices}" } } - div { dt { "Triangles" } dd { "{summary.triangles}" } } + if let Some(fallback) = fallback { + img { + id: "polymodel-viewer-preview", + class: "model-viewer-preview-image", + src: "{fallback.url}", + alt: "{fallback.alt}" } + } else if let Some(summary) = summary { + AssetPreview { summary } } } } @@ -1395,25 +1448,25 @@ mod tests { .expect("mount should succeed"); assert!(lifecycle.has_active_session()); assert!(lifecycle.active_mut().is_some()); - assert_eq!(events.get(), 1); + assert_eq!(events.get(), 2, "initial mount must also draw the first frame"); assert_eq!(lifecycle.mounts(), 1); // Resize forwards to the active renderer. lifecycle.resize().expect("resize should forward"); - assert_eq!(events.get(), 2); + assert_eq!(events.get(), 3); // Same-identity re-render is a no-op (no replace). lifecycle .reconcile(Some(canvas.clone()), &ready_chest, Some(chest.clone())) .expect("same-identity reconcile should succeed"); - assert_eq!(events.get(), 2); + assert_eq!(events.get(), 3); // Switch to a different model: replace once, still a single mount. let ready_forearm = ViewerStatus::Ready(forearm.key.clone()); lifecycle .reconcile(Some(canvas.clone()), &ready_forearm, Some(forearm.clone())) .expect("replace should succeed"); - assert_eq!(events.get(), 3); + assert_eq!(events.get(), 4); assert_eq!(drops.get(), 0); assert_eq!(lifecycle.mounts(), 1); diff --git a/src/viewer_route.rs b/src/viewer_route.rs new file mode 100644 index 0000000..f1b13a7 --- /dev/null +++ b/src/viewer_route.rs @@ -0,0 +1,18 @@ +use dioxus::prelude::*; + +use crate::Route; +use crate::viewer::{ViewerPage, demo_model_for_route}; + +#[component] +pub(crate) fn Viewer(model_id: String) -> Element { + let model = demo_model_for_route(&model_id); + + rsx! { + main { class: "viewer-route-page", + nav { class: "viewer-route-nav", aria_label: "Viewer navigation", + Link { to: Route::Browse {}, "← Back to browse" } + } + ViewerPage { key: "{model.id}", initial_model: model } + } + } +} -- 2.51.2