Something went wrong. Try again.
atproto Thingiverse but good
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575use dioxus::prelude::*;#[cfg(test)]#[cfg(test)]use jacquard_common::types::{ cid::Cid, string::{AtUri, Datetime, Did, Handle}, uri::UriValue,};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::author_byline::AuthorByline;use crate::client::PolymodelClient;use crate::session::SessionIdentity;use crate::thing_routes::thing_detail_href;
#[cfg(test)]const COMPLETE_PREVIEW_SVG: &str = "data:image/svg+xml,%3Csvg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 640 480\"%3E%3Crect width=\"640\" height=\"480\" fill=\"%23f4efe4\"/%3E%3Cg stroke=\"%232a6f97\" stroke-width=\"2\" fill=\"none\"%3E%3Cpath d=\"M64 96h512v288H64zM160 144h320v192H160zM256 192h128v96H256z\"/%3E%3Cpath d=\"M64 384 160 336M576 384 480 336M160 144 64 96M480 144 576 96\"/%3E%3C/g%3E%3Cg fill=\"%2366767f\" font-family=\"monospace\" font-size=\"24\"%3E%3Ctext x=\"72\" y=\"438\"%3Eparametric enclosure preview%3C/text%3E%3C/g%3E%3C/svg%3E";
#[derive(Clone, Debug, PartialEq)]pub struct ThingCardMedia { pub url: String, pub alt: String,}
#[cfg(test)]pub fn thing_card_fixtures() -> Vec<library::ThingViewBasic> { vec![ library::ThingViewBasic { author: actor( "did:plc:ari", "ari.model.tools", Some("Ari Chen"), Some("she/her"), ), cid: Cid::new_owned(b"bafyreicardcomplete").expect("fixture cid is valid"), cover: Some(vec![library::ImageView { alt: Some("Rendered preview of a parametric electronics enclosure".into()), aspect_ratio: None, fullsize: UriValue::new_owned(COMPLETE_PREVIEW_SVG).expect("fixture URI is valid"), thumb: UriValue::new_owned(COMPLETE_PREVIEW_SVG).expect("fixture URI is valid"), extra_data: None, }]), indexed_at: Datetime::try_from("2026-06-21T12:00:00Z".to_string()) .expect("fixture datetime is valid"), like_count: 42, model_count: 3, name: "Parametric enclosure kit".into(), part_count: 18, previews: None, save_count: 12, tags: Some(vec![ "enclosure".into(), "parametric".into(), "print-in-place".into(), ]), uri: AtUri::new_owned("at://did:plc:ari/space.polymodel.library.thing/complete") .expect("fixture AT URI is valid"), viewer: library::ViewerState { like: Some( AtUri::new_owned("at://did:plc:viewer/space.polymodel.graph.like/one") .expect("fixture AT URI is valid"), ), save: Some( AtUri::new_owned("at://did:plc:viewer/space.polymodel.graph.save/one") .expect("fixture AT URI is valid"), ), extra_data: None, }, extra_data: None, }, library::ThingViewBasic { author: actor("did:plc:mira", "mira.tools", None, None), cid: Cid::new_owned(b"bafyreicardsparse").expect("fixture cid is valid"), cover: None, indexed_at: Datetime::try_from("2026-06-21T12:05:00Z".to_string()) .expect("fixture datetime is valid"), like_count: 0, model_count: 0, name: "Untitled thing".into(), part_count: 0, previews: None, save_count: 0, tags: None, uri: AtUri::new_owned("at://did:plc:mira/space.polymodel.library.thing/sparse") .expect("fixture AT URI is valid"), viewer: library::ViewerState::default(), extra_data: None, }, library::ThingViewBasic { author: actor( "did:plc:foundry", "foundrylab.tools", Some("Foundry Lab"), Some("they/them"), ), cid: Cid::new_owned(b"bafyreicardmissingmedia").expect("fixture cid is valid"), cover: None, indexed_at: Datetime::try_from("2026-06-21T12:10:00Z".to_string()) .expect("fixture datetime is valid"), like_count: 8, model_count: 1, name: "Modular calibration tower".into(), part_count: 6, previews: Some(vec![]), save_count: 5, tags: Some(vec!["calibration".into(), "fixture".into()]), uri: AtUri::new_owned( "at://did:plc:foundry/space.polymodel.library.thing/missing-media", ) .expect("fixture AT URI is valid"), viewer: library::ViewerState { like: None, save: Some( AtUri::new_owned("at://did:plc:viewer/space.polymodel.graph.save/two") .expect("fixture AT URI is valid"), ), extra_data: None, }, extra_data: None, }, ]}
#[allow(clippy::useless_format)]#[component]pub fn ThingCard(thing: library::ThingViewBasic) -> Element { let client = use_context::<PolymodelClient>(); let session = use_context::<Signal<SessionIdentity>>(); let title = thing_title(&thing); let detail_href = thing_detail_href(&thing.uri); let media = select_card_media(thing.cover.as_deref(), thing.previews.as_deref(), title); let stats = stats(&thing); let mut like_uri = use_signal(|| thing.viewer.like.clone()); let mut save_uri = use_signal(|| thing.viewer.save.clone()); let mut like_count = use_signal(|| thing.like_count); let mut save_count = use_signal(|| thing.save_count); let mut action_pending = use_signal(|| false); let mut action_error = use_signal(|| None::<String>); let like_label = if like_uri.read().is_some() { "Liked" } else { "Like" }; let save_label = if save_uri.read().is_some() { "Saved" } else { "Save" }; let can_write = matches!(&*session.read(), SessionIdentity::Authenticated(_)); let subject = StrongRef { uri: thing.uri.clone(), cid: thing.cid.clone(), extra_data: None, }; let like_client = client.clone(); let like_subject = subject.clone(); let save_client = client.clone(); let save_subject = subject.clone();
rsx! { article { class: "thing-card card-surface", aria_label: "Thing card for {title}", div { class: "thing-card-media card-media", if let Some(href) = &detail_href { a { class: "thing-card-media-link", href: "{href}", aria_label: "Open {title}", if let Some(media) = &media { img { src: "{media.url}", alt: "{media.alt}" } } else { div { class: "thing-card-placeholder blueprint-media", role: "img", aria_label: "Blueprint placeholder for missing preview media", span { class: "blueprint-axis blueprint-axis-x", "X" } span { class: "blueprint-axis blueprint-axis-y", "Y" } span { class: "blueprint-dimension", "no preview image" } } } } } else { if let Some(media) = &media { img { src: "{media.url}", alt: "{media.alt}" } } else { div { class: "thing-card-placeholder blueprint-media", role: "img", aria_label: "Blueprint placeholder for missing preview media", span { class: "blueprint-axis blueprint-axis-x", "X" } span { class: "blueprint-axis blueprint-axis-y", "Y" } span { class: "blueprint-dimension", "no preview image" } } } } } div { class: "thing-card-body card-body", div { class: "thing-card-heading", h2 { class: "card-title thing-card-title", if let Some(href) = &detail_href { a { class: "thing-card-link", href: "{href}", "{title}" } } else { "{title}" } } AuthorByline { actor: thing.author.clone(), class: "thing-card-creator".to_string() } } div { class: "thing-card-details", if let Some(tags) = hydrated_tags(&thing) { div { class: "thing-card-chips", aria_label: "Hydrated display tags", for tag in tags.iter().take(3) { span { class: "status-pill status-muted", "#{tag}" } } } } dl { class: "thing-card-stats", aria_label: "Thing stats", for (label, value) in stats_with_social_counts(&stats, *like_count.read(), *save_count.read()) { div { dt { "{label}" } dd { "{value}" } } } } div { class: "thing-card-actions", aria_label: "Thing card saved and liked state", if can_write { button { class: "thing-card-action", disabled: *action_pending.read(), aria_pressed: "{like_uri.read().is_some()}", aria_label: "{like_label} {title}", onclick: move |_| { let client = like_client.clone(); let subject = like_subject.clone(); let current = like_uri.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) => { let was_liked = like_uri.read().is_some(); like_uri.set(next); let current_count = *like_count.read(); let is_liked = like_uri.read().is_some(); like_count.set(adjust_count(current_count, was_liked, is_liked)); } Err(error) => action_error.set(Some(format!("Like failed: {error}"))), } action_pending.set(false); }); }, "{like_label}" } button { class: "thing-card-action", disabled: *action_pending.read(), aria_pressed: "{save_uri.read().is_some()}", aria_label: "{save_label} {title}", onclick: move |_| { let client = save_client.clone(); let subject = save_subject.clone(); let current = save_uri.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) => { let was_saved = save_uri.read().is_some(); save_uri.set(next); let current_count = *save_count.read(); let is_saved = save_uri.read().is_some(); save_count.set(adjust_count(current_count, was_saved, is_saved)); } Err(error) => action_error.set(Some(format!("Save failed: {error}"))), } action_pending.set(false); }); }, "{save_label}" } } else { a { class: "thing-card-action", href: "/oauth/start", aria_label: "Sign in to like {title}", "Like" } a { class: "thing-card-action", href: "/oauth/start", aria_label: "Sign in to save {title}", "Save" } } if let Some(error) = action_error.read().as_ref() { span { class: "thing-card-action-error", role: "status", "{error}" } } } } } } }}
fn thing_title(thing: &library::ThingViewBasic) -> &str { clean_text(Some(thing.name.as_ref())).unwrap_or("Untitled thing")}
fn clean_text(value: Option<&str>) -> Option<&str> { value.map(str::trim).filter(|value| !value.is_empty())}
fn hydrated_tags(thing: &library::ThingViewBasic) -> Option<&[jacquard_common::bos::DefaultStr]> { thing.tags.as_deref().filter(|tags| { tags.iter() .any(|tag| clean_text(Some(tag.as_ref())).is_some()) })}
fn stats(thing: &library::ThingViewBasic) -> [(&'static str, String); 4] { [ ("Models", count_label(thing.model_count, "model")), ("Parts", count_label(thing.part_count, "part")), ("Likes", count_label(thing.like_count, "like")), ("Saves", count_label(thing.save_count, "save")), ]}
fn stats_with_social_counts( stats: &[(&'static str, String); 4], like_count: i64, save_count: i64,) -> [(&'static str, String); 4] { [ stats[0].clone(), stats[1].clone(), ("Likes", count_label(like_count, "like")), ("Saves", count_label(save_count, "save")), ]}
fn adjust_count(current: i64, was_active: bool, is_active: bool) -> i64 { match (was_active, is_active) { (false, true) => current + 1, (true, false) => current.saturating_sub(1), _ => current, }}
fn count_label(value: i64, noun: &str) -> String { let suffix = if value == 1 { "" } else { "s" }; format!("{value} {noun}{suffix}")}
fn select_card_media( cover: Option<&[library::ImageView]>, previews: Option<&[library::ImageView]>, title: &str,) -> Option<ThingCardMedia> { first_usable_image(cover, title).or_else(|| first_usable_image(previews, title))}
fn first_usable_image( images: Option<&[library::ImageView]>, title: &str,) -> Option<ThingCardMedia> { images?.iter().find_map(|image| { let url = image.thumb.as_ref().to_string(); if !url.starts_with("http://") && !url.starts_with("https://") && !url.starts_with('/') && !url.starts_with("data:image/") { 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(ThingCardMedia { url, alt }) })}
#[cfg(test)]fn actor( did: &str, handle: &str, display_name: Option<&str>, pronouns: Option<&str>,) -> library::Actor { library::Actor { avatar: None, description: None, did: Did::new_owned(did).expect("fixture DID is valid"), display_name: display_name.map(Into::into), follower_count: None, following_count: None, handle: Handle::new_owned(handle).expect("fixture handle is valid"), pronouns: pronouns.map(Into::into), thing_count: None, viewer: None, extra_data: None, }}
#[cfg(test)]mod tests { use super::*;
fn basic_view(overrides: impl FnOnce(&mut library::ThingViewBasic)) -> library::ThingViewBasic { let mut view = library::ThingViewBasic { author: actor( "did:plc:maker", "maker.tools", Some("Maker Name"), Some("he/him"), ), cid: Cid::new_owned(b"bafyreitest").expect("fixture cid is valid"), cover: Some(vec![library::ImageView { alt: Some("Cover alt".into()), aspect_ratio: None, fullsize: UriValue::new_owned(COMPLETE_PREVIEW_SVG).expect("fixture URI is valid"), thumb: UriValue::new_owned(COMPLETE_PREVIEW_SVG).expect("fixture URI is valid"), extra_data: None, }]), indexed_at: Datetime::try_from("2026-06-21T12:00:00Z".to_string()) .expect("fixture datetime is valid"), like_count: 2, model_count: 1, name: "Named thing".into(), part_count: 4, previews: Some(vec![library::ImageView { alt: Some("Preview alt".into()), aspect_ratio: None, fullsize: UriValue::new_owned("/assets/preview.jpg").expect("fixture URI is valid"), thumb: UriValue::new_owned("/assets/preview.jpg").expect("fixture URI is valid"), extra_data: None, }]), save_count: 3, tags: Some(vec!["hydrated".into()]), uri: AtUri::new_owned("at://did:plc:maker/space.polymodel.library.thing/abc") .expect("fixture AT URI is valid"), viewer: library::ViewerState { like: Some( AtUri::new_owned("at://did:plc:viewer/space.polymodel.graph.like/one") .expect("fixture AT URI is valid"), ), save: None, extra_data: None, }, extra_data: None, }; overrides(&mut view); view }
#[test] fn basic_view_contract_has_required_author_handle_and_counts() { let view = basic_view(|_| {}); assert_eq!(view.author.handle.as_ref(), "maker.tools"); assert_eq!(view.name.as_str(), "Named thing"); assert_eq!(view.model_count, 1); assert_eq!(view.part_count, 4); assert_eq!(view.like_count, 2); assert_eq!(view.save_count, 3); assert!(view.viewer.like.is_some()); }
#[test] fn presentation_prefers_display_name_and_cover_media() { let view = basic_view(|_| {}); let media = select_card_media( view.cover.as_deref(), view.previews.as_deref(), thing_title(&view), ) .unwrap(); assert_eq!( view.author.display_name.as_ref().map(AsRef::as_ref), Some("Maker Name") ); assert_eq!(view.author.handle.as_ref(), "maker.tools"); assert_eq!(media.url, COMPLETE_PREVIEW_SVG); assert_eq!(media.alt, "Cover alt"); }
#[test] fn presentation_uses_required_handle_when_display_name_missing() { let view = basic_view(|view| { view.author.display_name = None; view.name = " ".into(); view.cover = None; view.previews = None; });
assert_eq!(thing_title(&view), "Untitled thing"); assert_eq!(view.author.display_name, None); assert_eq!(view.author.handle.as_ref(), "maker.tools"); assert!( select_card_media( view.cover.as_deref(), view.previews.as_deref(), thing_title(&view) ) .is_none() ); }
#[test] fn media_selection_skips_unusable_urls_and_falls_back_alt() { let view = basic_view(|view| { view.cover = Some(vec![library::ImageView { alt: Some("bad".into()), aspect_ratio: None, fullsize: UriValue::new_owned("notaurl").expect("fixture URI is accepted as Any"), thumb: UriValue::new_owned("notaurl").expect("fixture URI is accepted as Any"), extra_data: None, }]); view.previews = Some(vec![library::ImageView { alt: None, aspect_ratio: None, fullsize: UriValue::new_owned("https://cdn.example/preview.jpg") .expect("fixture URI is valid"), thumb: UriValue::new_owned("https://cdn.example/preview.jpg") .expect("fixture URI is valid"), extra_data: None, }]); view.name = "Fallback media".into(); });
let media = select_card_media( view.cover.as_deref(), view.previews.as_deref(), thing_title(&view), ) .unwrap(); assert_eq!(media.url, "https://cdn.example/preview.jpg"); assert_eq!(media.alt, "Preview image for Fallback media"); }
#[test] fn stats_are_subordinate_display_labels() { let view = basic_view(|view| { view.model_count = 1; view.part_count = 2; view.like_count = 0; view.save_count = 1; });
assert_eq!( stats(&view), [ ("Models", "1 model".to_string()), ("Parts", "2 parts".to_string()), ("Likes", "0 likes".to_string()), ("Saves", "1 save".to_string()) ] ); }
#[test] fn hydrated_tags_are_view_metadata() { let view = basic_view(|view| view.tags = Some(vec!["external".into(), "community".into()])); let tags = hydrated_tags(&view).unwrap(); assert_eq!(tags[0].as_str(), "external"); assert_eq!(tags[1].as_str(), "community"); }}