use dioxus::prelude::*; use jacquard::common::types::ident::AtIdentifier; use jacquard::common::types::recordkey::Rkey; use jacquard::common::types::string::{AtUri, Cid}; use jacquard::common::types::value::from_data; use jacquard_common::deps::smol_str::StrExt; use jacquard_common::xrpc::XrpcClient; use polymodel_api::com_atproto::repo::strong_ref::StrongRef; use polymodel_api::space_polymodel::graph::{ create_like::CreateLike, create_save::CreateSave, delete_like::DeleteLike, delete_save::DeleteSave, }; use polymodel_api::space_polymodel::library; use polymodel_renderer_protocol::format_registry::{self, FormatId}; use crate::Route; use crate::author_byline::AuthorByline; use crate::client::PolymodelClient; use crate::session::SessionIdentity; use crate::viewer::{AssetViewer, ViewerFallbackImage, ViewerMeshSource, ViewerSourceFormat}; #[derive(Clone, Debug, PartialEq, Eq)] enum DetailState { Loading, InvalidRoute(String), Error(String), Populated(Box), } #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] struct ThingDetailData { thing: library::ThingView, thing_record: library::thing::Thing, models: Vec, selected_model: Option, } #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] struct ModelDetailSummary { view: library::ModelView, record: library::model::Model, } #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] struct SelectedModelDetail { model: library::ModelView, record: library::model::Model, parts: Vec, } #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 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, } } #[cfg_attr(not(feature = "server"), allow(dead_code))] fn part_download_href(uri: &AtUri) -> Option { let path = uri.path()?; if path.collection.as_str() != "space.polymodel.library.part" { return None; } path.rkey.as_ref()?; Some(format!( "/xrpc/space.polymodel.library.getPartFile?uri={}", urlencoding::encode(uri.as_str()) )) } fn viewer_source_for_part(part: &PartDetail) -> Option { let url = part_download_href(&part.view.uri)?; let name = clean_text(Some(part.view.name.as_ref())) .unwrap_or("Untitled part") .to_string(); let format = part_viewer_format(part, &url, &name)?; Some(ViewerMeshSource { key: part.view.uri.as_str().to_string(), name, url, format, expected_size: Some(part.view.file.size), expected_digest: part.view.file.digest.as_deref().map(|b| b.to_vec()), }) } fn part_viewer_format(part: &PartDetail, url: &str, name: &str) -> Option { let projected_mime = part.view.file.mime_type.as_str().trim(); if !projected_mime.is_empty() && !projected_mime.split(';').next().is_some_and(|mime| { mime.trim() .eq_ignore_ascii_case(format_registry::GENERIC_MIME) }) { return viewer_format_candidate(projected_mime); } [ Some(url), Some(name), part.view.format.as_ref().map(|format| format.as_str()), part.record.format.as_ref().map(|format| format.as_str()), ] .into_iter() .flatten() .find_map(viewer_format_candidate) .or(Some(ViewerSourceFormat::BoundedEvidence)) } fn viewer_format_candidate(candidate: &str) -> Option { let value = candidate.trim().to_lowercase_smolstr(); let format = format_registry::format_for_mime(value.as_str()) .or_else(|| format_registry::format_for_extension(value.as_str()))?; match format { FormatId::Step => Some(ViewerSourceFormat::Step), _ => format.renderer_format().map(ViewerSourceFormat::Renderer), } } #[cfg_attr(not(feature = "server"), allow(dead_code))] /// Build a `space.polymodel.library.thing` `at://` URI from route components. /// Shared with the publish flow's "Works with" link parser. pub(crate) 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}")) } #[cfg_attr(not(feature = "server"), allow(dead_code))] fn thing_record(view: &library::ThingView) -> Result { from_data(&view.record).map_err(|error| format!("Thing record decode failed: {error}")) } #[cfg_attr(not(feature = "server"), allow(dead_code))] fn model_record(view: &library::ModelView) -> Result { from_data(&view.record).map_err(|error| format!("Model record decode failed: {error}")) } #[cfg_attr(not(feature = "server"), allow(dead_code))] fn part_record(view: &library::PartView) -> Result { from_data(&view.record).map_err(|error| format!("Part record decode failed: {error}")) } #[server] async fn thing_detail_seed( repo: String, rkey: String, selected_model_uri: Option, ) -> Result { let uri = thing_uri_from_route(&repo, &rkey).map_err(ServerFnError::new)?; let (thing, model_views) = crate::appview::ssr::get_thing_from_fullstack_context(uri.as_str()).await?; let thing_record = thing_record(&thing).map_err(ServerFnError::new)?; let models = model_views .into_iter() .map(|view| model_record(&view).map(|record| ModelDetailSummary { view, record })) .collect::, _>>() .map_err(ServerFnError::new)?; let selected_uri = selected_model_uri.or_else(|| models.first().map(|m| m.view.uri.as_str().to_owned())); let selected_model = if let Some(uri) = selected_uri { let detail = crate::appview::ssr::get_model_from_fullstack_context(&uri).await?; let record = model_record(&detail.model).map_err(ServerFnError::new)?; let parts = detail .parts .into_iter() .map(|view| part_record(&view).map(|record| PartDetail { view, record })) .collect::, _>>() .map_err(ServerFnError::new)?; Some(SelectedModelDetail { model: detail.model, record, parts, }) } else { None }; Ok(ThingDetailData { 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") } } /// Fetch a part file from the XRPC endpoint, verify its size and digest, then /// trigger a Blob-URL download. Only creates the download on successful verification. #[cfg(target_arch = "wasm32")] async fn verified_download( url: &str, name: &str, expected_size: i64, expected_digest: Option<&[u8]>, ) -> Result<(), String> { use polymodel_renderer_protocol::verify_part_file; use wasm_bindgen::JsCast; use wasm_bindgen_futures::JsFuture; let opts = web_sys::RequestInit::new(); opts.set_method("GET"); let request = web_sys::Request::new_with_str_and_init(url, &opts) .map_err(|e| format!("request: {:?}", e))?; let global = js_sys::global(); let fetch_fn = js_sys::Reflect::get(&global, &"fetch".into()).map_err(|e| format!("no fetch: {:?}", e))?; let fetch_promise = js_sys::Function::from(fetch_fn) .call1(&global, &request) .map_err(|e| format!("fetch call: {:?}", e))? .dyn_into::() .map_err(|_| "fetch: not a Promise".to_string())?; let response = JsFuture::from(fetch_promise) .await .map_err(|e| format!("fetch: {:?}", e))? .dyn_into::() .map_err(|_| "fetch: not a Response")?; if !response.ok() { let status = response.status(); let text = JsFuture::from(response.text().map_err(|e| format!("text: {:?}", e))?) .await .map_err(|e| format!("text: {:?}", e))? .as_string() .unwrap_or_default(); return Err(format!("part file fetch failed: HTTP {status}: {text}")); } let array_buffer = JsFuture::from( response .array_buffer() .map_err(|e| format!("array_buffer: {:?}", e))?, ) .await .map_err(|e| format!("array_buffer: {:?}", e))?; let bytes = js_sys::Uint8Array::new(&array_buffer).to_vec(); verify_part_file(&bytes, expected_size, expected_digest) .map_err(|e| format!("verification failed: {e}"))?; let blob = web_sys::Blob::new_with_u8_array_sequence(&js_sys::Array::of1( &js_sys::Uint8Array::from(&bytes[..]), )) .map_err(|e| format!("blob: {:?}", e))?; let blob_url = web_sys::Url::create_object_url_with_blob(&blob) .map_err(|e| format!("object url: {:?}", e))?; let document = web_sys::window() .ok_or("no window")? .document() .ok_or("no document")?; let anchor = document .create_element("a") .map_err(|e| format!("create element: {:?}", e))? .dyn_into::() .map_err(|_| "created element is not an anchor".to_string())?; anchor.set_href(&blob_url); anchor.set_download(name); anchor.click(); web_sys::Url::revoke_object_url(&blob_url).map_err(|e| format!("revoke: {:?}", e))?; Ok(()) } #[cfg(not(target_arch = "wasm32"))] async fn verified_download( _url: &str, _name: &str, _expected_size: i64, _expected_digest: Option<&[u8]>, ) -> Result<(), String> { Err("downloads are only available in the browser".to_string()) } #[allow(clippy::useless_format)] #[component] pub(crate) fn ThingDetail(repo: String, rkey: String) -> Element { let selected_model_uri = use_signal(|| None::); let detail = use_server_future(move || { let repo = repo.clone(); let rkey = rkey.clone(); let selected = selected_model_uri .read() .as_ref() .map(|uri| uri.as_str().to_owned()); thing_detail_seed(repo, rkey, selected) })?; let detail_result = detail.read(); let mapped = detail_result.as_ref().map(|r| match r { Ok(data) => Ok(data.clone()), Err(ServerFnError::ServerError { message, .. }) => Err(message.clone()), Err(other) => Err(other.to_string()), }); let state = detail_state(detail.pending(), mapped.as_ref()); rsx! { main { class: "thing-detail-page product-shell", 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 } }, } } } } #[allow(clippy::useless_format)] #[component] fn DetailLoading() -> Element { rsx! { section { class: "thing-detail-shell blueprint-panel", aria_label: "Thing detail loading", h1 { class: "sr-only", "Loading thing" } 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" } } } } } #[allow(clippy::useless_format)] #[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 client = use_context::(); let session = use_context::>(); 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 title_len = title.chars().count(); let title_class = if title_len >= 48 { "thing-detail-title is-very-long" } else if title_len >= 28 { "thing-detail-title is-long" } else { "thing-detail-title" }; 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 mut viewer_like = use_signal(|| thing.viewer.like.clone()); let mut viewer_save = use_signal(|| thing.viewer.save.clone()); let mut action_pending = use_signal(|| false); let mut action_error = use_signal(|| None::); let like_label = if viewer_like.read().is_some() { "Liked" } else { "Like" }; let save_label = if viewer_save.read().is_some() { "Saved" } else { "Save" }; let can_write = matches!(&*session.read(), SessionIdentity::Authenticated(_)); let subject = StrongRef { uri: thing.uri.clone(), cid: Cid::new_owned(thing.cid.as_str().as_bytes()).expect("thing cid is server-validated"), extra_data: None, }; let like_client = client.clone(); let like_subject = subject.clone(); let save_client = client.clone(); let save_subject = subject.clone(); let first_download = selected_model .as_ref() .and_then(|selected| selected.parts.first()) .and_then(|part| { let href = part_download_href(&part.view.uri)?; Some(( href, part.view.file.size, part.view.file.digest.as_deref().map(|b| b.to_vec()), clean_text(Some(part.view.name.as_ref())) .unwrap_or("Untitled part") .to_string(), )) }) .clone(); let viewer_source = selected_model .as_ref() .and_then(|selected| selected.parts.first()) .and_then(viewer_source_for_part); let instructions = thing_record.instructions; rsx! { header { class: "thing-detail-header", h1 { class: title_class, "{title}" } div { class: "thing-detail-title-row", AuthorByline { actor: thing.author.clone(), class: "thing-detail-creator".to_string() } 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 { fallback: media.map(|media| ViewerFallbackImage { url: media.url, alt: media.alt }), source: viewer_source, } } 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", if first_download.is_some() { button { class: "button button-primary", onclick: { let dl = first_download.clone(); move |_| { let (url, size, digest, name) = dl.clone().unwrap_or_default(); spawn(async move { if let Err(e) = verified_download(&url, &name, size, digest.as_deref()).await { tracing::error!("part download failed: {e}"); } }); } }, "Download files" } } else { a { class: "button button-primary", href: "#parts-files", "View files" } } if can_write { button { class: "button button-secondary", disabled: *action_pending.read(), aria_pressed: "{viewer_like.read().is_some()}", onclick: move |_| { let client = like_client.clone(); let subject = like_subject.clone(); let current = viewer_like.read().clone(); action_pending.set(true); action_error.set(None); spawn(async move { let result = if let Some(uri) = current { match client.send(DeleteLike { uri: Some(uri), subject: None, extra_data: None }).await { Ok(response) => response.into_output().map(|_| None).map_err(|error| error.to_string()), Err(error) => Err(error.to_string()), } } else { match client.send(CreateLike { subject, extra_data: None }).await { Ok(response) => response.into_output().map(|output| Some(output.uri)).map_err(|error| error.to_string()), Err(error) => Err(error.to_string()), } }; match result { Ok(next) => viewer_like.set(next), Err(error) => action_error.set(Some(format!("Like failed: {error}"))), } action_pending.set(false); }); }, "{like_label}" } button { class: "button button-secondary", disabled: *action_pending.read(), aria_pressed: "{viewer_save.read().is_some()}", onclick: move |_| { let client = save_client.clone(); let subject = save_subject.clone(); let current = viewer_save.read().clone(); action_pending.set(true); action_error.set(None); spawn(async move { let result = if let Some(uri) = current { match client.send(DeleteSave { uri: Some(uri), subject: None, extra_data: None }).await { Ok(response) => response.into_output().map(|_| None).map_err(|error| error.to_string()), Err(error) => Err(error.to_string()), } } else { match client.send(CreateSave { subject, note: None, extra_data: None }).await { Ok(response) => response.into_output().map(|output| Some(output.uri)).map_err(|error| error.to_string()), Err(error) => Err(error.to_string()), } }; match result { Ok(next) => viewer_save.set(next), Err(error) => action_error.set(Some(format!("Save failed: {error}"))), } action_pending.set(false); }); }, "{save_label}" } } else { a { class: "button button-secondary", href: "/oauth/start", "Sign in to like" } a { class: "button button-secondary", href: "/oauth/start", "Sign in to save" } } if let Some(error) = action_error.read().as_ref() { p { class: "detail-action-error", role: "status", "{error}" } } } } } 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(), id: Some("parts-files".to_string()), PartsPanel { parts } } } } } else { DetailPanel { title: "Parts & files".to_string(), id: Some("parts-files".to_string()), p { class: "detail-muted", "Select a model to load its ordered parts and files." } } } } } } #[allow(clippy::useless_format)] #[component] fn DetailPanel(title: String, #[props(default)] id: Option, children: Element) -> Element { rsx! { article { class: "detail-panel card-surface", id: id.unwrap_or_default(), div { class: "detail-panel-heading", h2 { "{title}" } } {children} } } } #[allow(clippy::useless_format)] #[component] fn StatItem(label: String, value: String) -> Element { rsx! { div { dt { "{label}" } dd { "{value}" } } } } #[allow(clippy::useless_format)] #[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}" } } } } } } #[allow(clippy::useless_format)] #[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}" } } } } } #[allow(clippy::useless_format)] #[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}" } } } } } } } #[allow(clippy::useless_format)] #[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() } } } } } } #[allow(clippy::useless_format)] #[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::>(); let download_href = part_download_href(&part.view.uri); let download_data = download_href.as_ref().map(|href| { ( href.clone(), part.view.file.size, part.view.file.digest.as_deref().map(|b| b.to_vec()), name.clone(), ) }); 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}" } } } } if download_data.is_some() { button { class: "button button-secondary part-download", onclick: { let dl = download_data.clone(); move |_| { let (url, size, digest, name) = dl.clone().unwrap_or_default(); spawn(async move { if let Err(e) = verified_download(&url, &name, size, digest.as_deref()).await { tracing::error!("part download failed: {e}"); } }); } }, "Download part" } } } } } } #[allow(clippy::useless_format)] #[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 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}") } #[cfg(test)] mod tests { use super::*; use jacquard_common::types::datetime::Datetime; use polymodel_renderer_protocol::MeshFormat; fn actor() -> library::Actor { library::Actor { avatar: None, description: None, did: jacquard_common::types::string::Did::new_owned("did:plc:maker").unwrap(), display_name: Some("Maker".into()), follower_count: None, following_count: None, handle: jacquard_common::types::string::Handle::new_owned("maker.test").unwrap(), pronouns: None, thing_count: None, viewer: None, extra_data: None, } } fn cid() -> Cid { Cid::new_owned(b"bafyreilistcid").unwrap() } fn indexed_at() -> Datetime { Datetime::raw_str("2024-01-01T00:00:00.000Z") } fn thing_view_with_malformed_record() -> library::ThingView { library::ThingView { author: actor(), cid: cid(), cover: None, derived_from: None, indexed_at: indexed_at(), intended_to_work_with: None, license: "CC-BY-4.0".into(), like_count: 0, model_count: 0, name: "Fixture thing".into(), part_count: 0, previews: None, record: jacquard::Data::Null, save_count: 0, summary: None, tags: None, uri: AtUri::new_owned("at://did:plc:maker/space.polymodel.library.thing/fixture") .unwrap(), viewer: library::ViewerState::default(), extra_data: None, } } fn model_view_with_malformed_record() -> library::ModelView { library::ModelView { author: actor(), cid: cid(), cover: None, indexed_at: indexed_at(), name: "Fixture model".into(), part_count: 0, previews: None, record: jacquard::Data::Null, summary: None, uri: AtUri::new_owned("at://did:plc:maker/space.polymodel.library.model/fixture") .unwrap(), viewer: library::ViewerState::default(), extra_data: None, } } fn part_view_with_malformed_record() -> library::PartView { library::PartView { author: actor(), cid: cid(), file: library::File { chunks: vec![], digest: None, mime_type: "model/stl".into(), size: 0, extra_data: None, }, format: Some("stl".into()), indexed_at: indexed_at(), name: "Fixture part".into(), previews: None, record: jacquard::Data::Null, uri: AtUri::new_owned("at://did:plc:maker/space.polymodel.library.part/fixture") .unwrap(), viewer: library::ViewerState::default(), extra_data: None, } } fn part_detail_for_viewer( mime_type: &str, view_format: Option<&str>, record_format: Option<&str>, ) -> PartDetail { let mut view = part_view_with_malformed_record(); view.file.mime_type = mime_type.into(); view.format = view_format.map(Into::into); PartDetail { record: library::part::Part { created_at: indexed_at(), derived_from: None, dimensions: None, file: view.file.clone(), format: record_format.map(Into::into), intended_to_work_with: None, name: view.name.clone(), notes: None, previews: None, print_settings: None, units: None, extra_data: None, }, view, } } #[test] fn thing_uri_from_route_rejects_invalid_identifier() { let invalid_repo = thing_uri_from_route("not a repository", "thing-1").unwrap_err(); assert!(invalid_repo.starts_with("Invalid route: unsupported repository identifier:")); let invalid_rkey = thing_uri_from_route("did:plc:maker", "").unwrap_err(); assert!(invalid_rkey.starts_with("Invalid route: unsupported record key:")); } #[test] fn part_download_href_encodes_route_values() { let uri = AtUri::new_owned("at://did:plc:maker/space.polymodel.library.part/part-1").unwrap(); let href = part_download_href(&uri).unwrap(); assert_eq!( href, "/xrpc/space.polymodel.library.getPartFile?uri=at%3A%2F%2Fdid%3Aplc%3Amaker%2Fspace.polymodel.library.part%2Fpart-1" ); let wrong_collection = AtUri::new_owned("at://did:plc:maker/space.polymodel.library.model/model-1").unwrap(); assert_eq!(part_download_href(&wrong_collection), None); let missing_rkey = AtUri::new_owned("at://did:plc:maker/space.polymodel.library.part").unwrap(); assert_eq!(part_download_href(&missing_rkey), None); } #[test] fn viewer_format_candidate_supports_aliases_and_extensions() { assert_eq!( viewer_format_candidate(" MODEL/STL "), Some(ViewerSourceFormat::Renderer(MeshFormat::Stl)) ); assert_eq!( viewer_format_candidate("drawing.STP"), Some(ViewerSourceFormat::Step) ); assert_eq!( viewer_format_candidate("scene.GLB"), Some(ViewerSourceFormat::Renderer(MeshFormat::Gltf)) ); assert_eq!( viewer_format_candidate("archive.3mf"), Some(ViewerSourceFormat::Renderer(MeshFormat::Threemf)) ); assert_eq!(viewer_format_candidate("archive.ply"), None); } #[test] fn ordinary_file_formats_map_to_typed_renderer_formats() { assert_eq!( viewer_format_candidate("application/x-ldraw"), Some(ViewerSourceFormat::Renderer(MeshFormat::CompoundLdraw)) ); assert_eq!( viewer_format_candidate("models/main.ldr"), Some(ViewerSourceFormat::Renderer(MeshFormat::CompoundLdraw)) ); assert_eq!( viewer_format_candidate("models/main.dat"), Some(ViewerSourceFormat::Renderer(MeshFormat::CompoundLdraw)) ); assert_eq!( viewer_format_candidate("model/ldraw; charset=utf-8"), Some(ViewerSourceFormat::Renderer(MeshFormat::CompoundLdraw)) ); assert_eq!( viewer_format_candidate("model/gltf-binary"), Some(ViewerSourceFormat::Renderer(MeshFormat::Gltf)) ); assert_eq!( viewer_format_candidate("model/stl"), Some(ViewerSourceFormat::Renderer(MeshFormat::Stl)) ); assert_eq!( viewer_format_candidate("model/3mf"), Some(ViewerSourceFormat::Renderer(MeshFormat::Threemf)) ); } #[test] fn viewer_source_format_precedence_prefers_earlier_sources() { let url = "/parts/fixture.step"; let cases = [ ( part_detail_for_viewer("model/stl", Some("scene.3mf"), Some("model/obj")), "drawing.glb", ViewerSourceFormat::Renderer(MeshFormat::Stl), ), ( part_detail_for_viewer( format_registry::GENERIC_MIME, Some("scene.3mf"), Some("model/obj"), ), "drawing.glb", ViewerSourceFormat::Renderer(MeshFormat::Gltf), ), ( part_detail_for_viewer( format_registry::GENERIC_MIME, Some("scene.3mf"), Some("model/obj"), ), "drawing.step", ViewerSourceFormat::Step, ), ( part_detail_for_viewer( "application/x-unknown", Some("scene.3mf"), Some("model/obj"), ), "drawing.ply", ViewerSourceFormat::Renderer(MeshFormat::Threemf), ), ( part_detail_for_viewer( "application/x-unknown", Some("scene.ply"), Some("model/obj"), ), "drawing.ply", ViewerSourceFormat::Renderer(MeshFormat::Obj), ), ]; assert_eq!( part_viewer_format(&cases[0].0, url, cases[0].1), Some(cases[0].2.clone()) ); assert_eq!( part_viewer_format(&cases[1].0, url, "drawing.ply"), Some(ViewerSourceFormat::Step) ); assert_eq!( part_viewer_format(&cases[2].0, "parts/fixture.ply", cases[2].1), Some(ViewerSourceFormat::Step) ); assert_eq!( part_viewer_format(&cases[3].0, "parts/fixture.ply", cases[3].1), None ); assert_eq!( part_viewer_format(&cases[4].0, "parts/fixture.ply", cases[4].1), None ); let neutral = part_detail_for_viewer( format_registry::GENERIC_MIME, Some("scene.ply"), Some("unknown"), ); assert_eq!( part_viewer_format(&neutral, "parts/fixture", "drawing"), Some(ViewerSourceFormat::BoundedEvidence) ); } #[test] fn detail_state_classifies_loading_and_error() { assert_eq!(detail_state(true, None), DetailState::Loading); assert_eq!(detail_state(false, None), DetailState::Loading); let invalid_route = detail_state( false, Some(&Err("Invalid route: unsupported record key: empty".into())), ); assert_eq!( invalid_route, DetailState::InvalidRoute("Invalid route: unsupported record key: empty".into()) ); let error = detail_state(false, Some(&Err("upstream unavailable".into()))); assert_eq!(error, DetailState::Error("upstream unavailable".into())); let refreshing_error = detail_state(true, Some(&Err("refresh failed".into()))); assert_eq!( refreshing_error, DetailState::Error("refresh failed".into()) ); } #[test] fn decode_thing_record_rejects_malformed_data() { let error = thing_record(&thing_view_with_malformed_record()).unwrap_err(); assert!(error.starts_with("Thing record decode failed:")); } #[test] fn decode_model_record_rejects_malformed_data() { let error = model_record(&model_view_with_malformed_record()).unwrap_err(); assert!(error.starts_with("Model record decode failed:")); } #[test] fn decode_part_record_rejects_malformed_data() { let error = part_record(&part_view_with_malformed_record()).unwrap_err(); assert!(error.starts_with("Part record decode failed:")); } }