Something went wrong. Try again.
atproto Thingiverse but good
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758use 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::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 crate::Route;use crate::client::PolymodelClient;use crate::profile::profile_href;use crate::session::SessionIdentity;use crate::viewer::{AssetViewer, ViewerFallbackImage, demo_models};
#[derive(Clone, Debug, PartialEq, Eq)]enum DetailState { Loading, InvalidRoute(String), Error(String), Populated(Box<ThingDetailData>),}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]struct ThingDetailData { thing: library::ThingView, thing_record: library::thing::Thing, models: Vec<ModelDetailSummary>, selected_model: Option<SelectedModelDetail>,}
#[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<PartDetail>,}
#[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<ThingDetailData, String>>) -> 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<String> { 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()))}
#[cfg_attr(not(feature = "server"), allow(dead_code))]fn part_download_href(uri: &AtUri) -> Option<String> { let path = uri.path()?; if path.collection.as_str() != "space.polymodel.library.part" { return None; } let rkey = path.rkey?; Some(format!( "/app/parts/{}/{}/download", uri.authority().as_str(), rkey.as_ref() ))}
#[cfg_attr(not(feature = "server"), allow(dead_code))]fn thing_uri_from_route(repo: &str, rkey: &str) -> Result<AtUri, String> { let repo: AtIdentifier<String> = AtIdentifier::new_owned(repo) .map_err(|error| format!("Invalid route: unsupported repository identifier: {error}"))?; let rkey: Rkey<String> = 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<library::thing::Thing, String> { 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<library::model::Model, String> { 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<library::part::Part, String> { 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<String>,) -> Result<ThingDetailData, ServerFnError> { 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::<Result<Vec<_>, _>>() .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::<Result<Vec<_>, _>>() .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") }}
#[allow(clippy::useless_format)]#[component]pub(crate) fn ThingDetail(repo: String, rkey: String) -> Element { let selected_model_uri = use_signal(|| None::<AtUri>);
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<Option<AtUri>>,) -> Element { let client = use_context::<PolymodelClient>(); let session = use_context::<Signal<SessionIdentity>>(); 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::<Vec<_>>(); 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::<String>); 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| part_download_href(&part.view.uri)); let instructions = thing_record.instructions;
rsx! { header { class: "thing-detail-header", div { class: "thing-detail-title-row", div { h1 { "{title}" } p { class: "thing-detail-creator", if let Some(href) = profile_href(&thing.author) { a { class: "thing-detail-creator-link", href: "{href}", "by {creator}" } } else { "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", if let Some(href) = &first_download { a { class: "button button-primary", href: "{href}", "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<String>, 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<Vec<jacquard_common::DefaultStr>>, 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::<Vec<_>>();
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<MouseEvent>,) -> 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::<Vec<_>>();
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<PartDetail>) -> 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::<Vec<_>>(); let download_href = part_download_href(&part.view.uri);
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 let Some(href) = &download_href { a { class: "button button-secondary part-download", href: "{href}", "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<DetailMedia> { first_detail_image(cover, title).or_else(|| first_detail_image(previews, title))}
fn first_detail_image(images: Option<&[library::ImageView]>, title: &str) -> Option<DetailMedia> { 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}")}