diff --git a/assets/styling/primitives.css b/assets/styling/primitives.css index 68d65a3..bc8a6ef 100644 --- a/assets/styling/primitives.css +++ b/assets/styling/primitives.css @@ -122,6 +122,36 @@ min-height: 10rem; } +.thing-card-media-link { + position: relative; + display: block; + width: 100%; + height: 100%; + color: inherit; + text-decoration: none; +} + +.thing-card-media-link::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: transparent; + outline: 0 solid transparent; + outline-offset: -1px; +} + +.thing-card-media-link:hover::after { + background: color-mix(in srgb, var(--color-primary) 8%, transparent); + outline-width: 1px; + outline-color: var(--color-primary); +} + +.thing-card-media-link:focus-visible { + outline: 2px solid color-mix(in srgb, var(--color-primary) 35%, transparent); + outline-offset: -3px; +} + .thing-card-media img { display: block; width: 100%; @@ -167,6 +197,14 @@ font-size: 0.88rem; } +.author-byline-handle { + font-family: var(--font-mono); +} + +.author-byline-handle-secondary { + color: var(--color-muted); +} + .thing-card-chips { display: flex; flex-wrap: wrap; @@ -274,13 +312,11 @@ /* Creator links on cards and thing detail */ -.thing-card-creator-link, -.thing-detail-creator-link { +.author-byline-link { color: var(--color-secondary); } -.thing-card-creator-link:hover, -.thing-detail-creator-link:hover { +.author-byline-link:hover { color: var(--color-link); } diff --git a/crates/polymodel-api/lexicons/space_polymodel_library_defs.json b/crates/polymodel-api/lexicons/space_polymodel_library_defs.json index f1a554b..6b4b03c 100644 --- a/crates/polymodel-api/lexicons/space_polymodel_library_defs.json +++ b/crates/polymodel-api/lexicons/space_polymodel_library_defs.json @@ -351,7 +351,8 @@ "ref": "#actor" }, "cid": { - "type": "string" + "type": "string", + "format": "cid" }, "cover": { "type": "array", @@ -411,7 +412,8 @@ "ref": "#actor" }, "cid": { - "type": "string" + "type": "string", + "format": "cid" }, "file": { "type": "ref", @@ -469,7 +471,8 @@ "ref": "#actor" }, "cid": { - "type": "string" + "type": "string", + "format": "cid" }, "cover": { "type": "array", @@ -562,7 +565,8 @@ "ref": "#actor" }, "cid": { - "type": "string" + "type": "string", + "format": "cid" }, "cover": { "type": "array", diff --git a/crates/polymodel-api/src/space_polymodel/library.rs b/crates/polymodel-api/src/space_polymodel/library.rs index 0ae0207..cef6fe4 100644 --- a/crates/polymodel-api/src/space_polymodel/library.rs +++ b/crates/polymodel-api/src/space_polymodel/library.rs @@ -32,7 +32,7 @@ use jacquard_common::deps::bytes::Bytes; use jacquard_common::deps::codegen::unicode_segmentation::UnicodeSegmentation; use jacquard_common::deps::smol_str::SmolStr; use jacquard_common::types::blob::BlobRef; -use jacquard_common::types::string::{Did, Handle, AtUri, Datetime, UriValue}; +use jacquard_common::types::string::{Did, Handle, AtUri, Cid, Datetime, UriValue}; use jacquard_common::types::value::Data; use jacquard_derive::IntoStatic; use jacquard_lexicon::lexicon::LexiconDoc; @@ -238,7 +238,7 @@ pub struct ImageView { #[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] pub struct ModelView { pub author: library::Actor, - pub cid: S, + pub cid: Cid, #[serde(skip_serializing_if = "Option::is_none")] pub cover: Option>>, pub indexed_at: Datetime, @@ -260,7 +260,7 @@ pub struct ModelView { #[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] pub struct PartView { pub author: library::Actor, - pub cid: S, + pub cid: Cid, pub file: library::File, #[serde(skip_serializing_if = "Option::is_none")] pub format: Option, @@ -280,7 +280,7 @@ pub struct PartView { #[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] pub struct ThingView { pub author: library::Actor, - pub cid: S, + pub cid: Cid, #[serde(skip_serializing_if = "Option::is_none")] pub cover: Option>>, #[serde(skip_serializing_if = "Option::is_none")] @@ -314,7 +314,7 @@ pub struct ThingView { #[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] pub struct ThingViewBasic { pub author: library::Actor, - pub cid: S, + pub cid: Cid, #[serde(skip_serializing_if = "Option::is_none")] pub cover: Option>>, pub indexed_at: Datetime, @@ -1665,7 +1665,10 @@ fn lexicon_doc_space_polymodel_library_defs() -> LexiconDoc<'static> { ); map.insert( SmolStr::new_static("cid"), - LexObjectProperty::String(LexString { ..Default::default() }), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::Cid), + ..Default::default() + }), ); map.insert( SmolStr::new_static("cover"), @@ -1757,7 +1760,10 @@ fn lexicon_doc_space_polymodel_library_defs() -> LexiconDoc<'static> { ); map.insert( SmolStr::new_static("cid"), - LexObjectProperty::String(LexString { ..Default::default() }), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::Cid), + ..Default::default() + }), ); map.insert( SmolStr::new_static("file"), @@ -1844,7 +1850,10 @@ fn lexicon_doc_space_polymodel_library_defs() -> LexiconDoc<'static> { ); map.insert( SmolStr::new_static("cid"), - LexObjectProperty::String(LexString { ..Default::default() }), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::Cid), + ..Default::default() + }), ); map.insert( SmolStr::new_static("cover"), @@ -1992,7 +2001,10 @@ fn lexicon_doc_space_polymodel_library_defs() -> LexiconDoc<'static> { ); map.insert( SmolStr::new_static("cid"), - LexObjectProperty::String(LexString { ..Default::default() }), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::Cid), + ..Default::default() + }), ); map.insert( SmolStr::new_static("cover"), @@ -3986,7 +3998,7 @@ pub struct ModelViewBuilder _state: PhantomData St>, _fields: ( Option>, - Option, + Option>, Option>>, Option, Option, @@ -4063,7 +4075,7 @@ where /// Set the `cid` field (required) pub fn cid( mut self, - value: impl Into, + value: impl Into>, ) -> ModelViewBuilder, S> { self._fields.1 = Option::Some(value.into()); ModelViewBuilder { @@ -4447,7 +4459,7 @@ pub struct PartViewBuilder { _state: PhantomData St>, _fields: ( Option>, - Option, + Option>, Option>, Option, Option, @@ -4523,7 +4535,7 @@ where /// Set the `cid` field (required) pub fn cid( mut self, - value: impl Into, + value: impl Into>, ) -> PartViewBuilder, S> { self._fields.1 = Option::Some(value.into()); PartViewBuilder { @@ -5002,7 +5014,7 @@ pub struct ThingViewBuilder _state: PhantomData St>, _fields: ( Option>, - Option, + Option>, Option>>, Option>, Option, @@ -5124,7 +5136,7 @@ where /// Set the `cid` field (required) pub fn cid( mut self, - value: impl Into, + value: impl Into>, ) -> ThingViewBuilder, S> { self._fields.1 = Option::Some(value.into()); ThingViewBuilder { @@ -5704,7 +5716,7 @@ pub struct ThingViewBasicBuilder< _state: PhantomData St>, _fields: ( Option>, - Option, + Option>, Option>>, Option, Option, @@ -5811,7 +5823,7 @@ where /// Set the `cid` field (required) pub fn cid( mut self, - value: impl Into, + value: impl Into>, ) -> ThingViewBasicBuilder, S> { self._fields.1 = Option::Some(value.into()); ThingViewBasicBuilder { diff --git a/lexicons/library/defs.json b/lexicons/library/defs.json index ef9db53..fcb1626 100644 --- a/lexicons/library/defs.json +++ b/lexicons/library/defs.json @@ -210,7 +210,8 @@ "format": "at-uri" }, "cid": { - "type": "string" + "type": "string", + "format": "cid" }, "author": { "type": "ref", @@ -303,7 +304,8 @@ "format": "at-uri" }, "cid": { - "type": "string" + "type": "string", + "format": "cid" }, "name": { "type": "string" @@ -365,7 +367,8 @@ "format": "at-uri" }, "cid": { - "type": "string" + "type": "string", + "format": "cid" }, "author": { "type": "ref", @@ -416,7 +419,8 @@ "format": "at-uri" }, "cid": { - "type": "string" + "type": "string", + "format": "cid" }, "author": { "type": "ref", diff --git a/src/appview/views.rs b/src/appview/views.rs index 60a2663..ce3b171 100644 --- a/src/appview/views.rs +++ b/src/appview/views.rs @@ -24,6 +24,7 @@ use jacquard::identity::PublicResolver; use jacquard::identity::resolver::IdentityResolver; use jacquard::oauth::client::OAuthSession; use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::cid::Cid; use jacquard_common::types::string::{AtUri, Datetime, Did, Handle, UriValue}; use jacquard_common::types::{blob::BlobRef, value::Data}; use polymodel_api::space_polymodel::actor::ProfileListView; @@ -51,6 +52,10 @@ fn at_uri(uri: &str) -> AppResult { AtUri::new_owned(uri).map_err(|e| invalid_request(format!("invalid at-uri {uri:?}: {e}"))) } +fn cid_of(cid: &str) -> AppResult { + Cid::new_owned(cid.as_bytes()).map_err(|e| internal(format!("invalid cid {cid:?}: {e}"))) +} + fn did_of(did: &str) -> AppResult { Did::new_owned(did).map_err(|e| internal(format!("invalid did {did:?}: {e}"))) } @@ -423,7 +428,7 @@ async fn thing_view( let record = thing_record(row.record_json.as_deref())?; Ok(ThingView { uri: at_uri(&row.uri)?, - cid: s(row.cid), + cid: cid_of(&row.cid)?, author, record: record_data(row.record_json.as_deref())?, indexed_at: datetime_nanos(row.indexed_at), @@ -452,7 +457,7 @@ async fn thing_basic_view( let record = thing_record(row.record_json.as_deref())?; Ok(ThingViewBasic { uri: at_uri(&row.uri)?, - cid: s(row.cid), + cid: cid_of(&row.cid)?, name: s(row.name), author: actor_for_did(state, &row.did).await?, indexed_at: datetime_nanos(row.indexed_at), @@ -478,7 +483,7 @@ async fn model_view( let record = model_record(row.record_json.as_deref())?; Ok(ModelView { uri: at_uri(&row.uri)?, - cid: s(row.cid), + cid: cid_of(&row.cid)?, author, record: record_data(row.record_json.as_deref())?, indexed_at: datetime_nanos(row.indexed_at), @@ -502,7 +507,7 @@ async fn part_view( let record = part_record(row.record_json.as_deref())?; Ok(PartView { uri: at_uri(&row.uri)?, - cid: s(row.cid), + cid: cid_of(&row.cid)?, author, record: record_data(row.record_json.as_deref())?, indexed_at: datetime_nanos(row.indexed_at), diff --git a/src/author_byline.rs b/src/author_byline.rs new file mode 100644 index 0000000..0690d48 --- /dev/null +++ b/src/author_byline.rs @@ -0,0 +1,64 @@ +use dioxus::prelude::*; +use polymodel_api::space_polymodel::library; + +#[component] +pub(crate) fn AuthorByline(actor: library::Actor, class: String) -> Element { + let href = profile_href(&actor); + let display_name = clean_text(actor.display_name.as_ref().map(AsRef::as_ref)); + let handle = actor.handle.as_ref(); + + rsx! { + p { class, + a { class: "author-byline-link", href, + if let Some(display_name) = display_name { + span { class: "author-byline-name", "{display_name}" } + span { class: "author-byline-handle author-byline-handle-secondary", " @{handle}" } + } else { + span { class: "author-byline-handle", "@{handle}" } + } + } + } + } +} + +pub(crate) fn profile_href(actor: &library::Actor) -> String { + let handle = actor.handle.as_ref(); + if !handle.is_empty() { + format!("/profile/{handle}") + } else { + format!("/profile/{}", actor.did.as_ref()) + } +} + +fn clean_text(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + +#[cfg(test)] +mod tests { + use jacquard_common::types::string::{Did, Handle}; + + use super::*; + + fn fixture_actor(did: &str, handle: &str) -> library::Actor { + library::Actor { + avatar: None, + description: None, + did: Did::new_owned(did).expect("fixture DID is valid"), + display_name: Some("Maker Name".into()), + follower_count: None, + following_count: None, + handle: Handle::new_owned(handle).expect("fixture handle is valid"), + pronouns: None, + thing_count: None, + viewer: None, + extra_data: None, + } + } + + #[test] + fn profile_href_prefers_handle() { + let actor = fixture_actor("did:plc:alice", "alice.test"); + assert_eq!(profile_href(&actor), "/profile/alice.test"); + } +} diff --git a/src/browse.rs b/src/browse.rs index 5b0110e..87519ca 100644 --- a/src/browse.rs +++ b/src/browse.rs @@ -4,7 +4,6 @@ use polymodel_api::space_polymodel::library; use crate::Route; use crate::examples::{HERO_SAMPLE_HANDLES, first_hero_sample_handle}; -use crate::profile::profile_href; use crate::session::SessionIdentity; use crate::thing_card::ThingCard; use crate::thing_detail::thing_detail_href; @@ -211,8 +210,7 @@ pub(crate) fn Browse() -> Element { #[component] fn BrowseThingCard(thing: library::ThingViewBasic) -> Element { let detail_href = thing_detail_href(&thing.uri); - let creator_href = profile_href(&thing.author); - rsx! { ThingCard { thing, detail_href, creator_href } } + rsx! { ThingCard { thing, detail_href } } } #[component] diff --git a/src/main.rs b/src/main.rs index 4a86540..f560912 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ use polymodel_api::space_polymodel::actor::bootstrap_profile::BootstrapProfile; use polymodel_api::space_polymodel::actor::get_session::{GetSession, GetSessionOutput}; mod about; +mod author_byline; mod auth; mod browse; mod client; diff --git a/src/profile.rs b/src/profile.rs index b812311..e772614 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -175,17 +175,6 @@ fn profile_lists( } } -/// Canonical profile route for an actor. Prefers the handle (always present -/// and validated on `library::Actor`); falls back to the DID defensively. -pub(crate) fn profile_href(actor: &library::Actor) -> Option { - let handle = actor.handle.as_ref(); - if !handle.is_empty() { - Some(format!("/profile/{handle}")) - } else { - Some(format!("/profile/{}", actor.did.as_ref())) - } -} - fn avatar_initial(header: &ProfileHeader) -> char { let name = header .display_name @@ -687,8 +676,7 @@ fn ProfileThingsGrid(things: Vec) -> Element { #[component] fn ProfileThingCard(thing: library::ThingViewBasic) -> Element { let detail_href = thing_detail_href(&thing.uri); - let creator_href = profile_href(&thing.author); - rsx! { ThingCard { thing, detail_href, creator_href } } + rsx! { ThingCard { thing, detail_href } } } fn list_item_count_label(count: i64) -> String { @@ -934,12 +922,6 @@ mod tests { } } - #[test] - fn profile_href_prefers_handle_then_did() { - let actor = fixture_actor("did:plc:alice", "alice.test"); - assert_eq!(profile_href(&actor).as_deref(), Some("/profile/alice.test")); - } - #[test] fn header_extracts_polymodel_fields() { let header = profile_header(&ProfileViewUnion::ProfileView(Box::new(polymodel_view()))); diff --git a/src/search.rs b/src/search.rs index 36b3a34..7007b9f 100644 --- a/src/search.rs +++ b/src/search.rs @@ -2,7 +2,6 @@ use dioxus::prelude::*; use polymodel_api::space_polymodel::library; use crate::Route; -use crate::profile::profile_href; use crate::thing_card::ThingCard; use crate::thing_detail::thing_detail_href; @@ -221,8 +220,7 @@ async fn search_seed( #[component] fn SearchThingCard(thing: library::ThingViewBasic) -> Element { let detail_href = thing_detail_href(&thing.uri); - let creator_href = profile_href(&thing.author); - rsx! { ThingCard { thing, detail_href, creator_href } } + rsx! { ThingCard { thing, detail_href } } } fn search_pagination( diff --git a/src/thing_card.rs b/src/thing_card.rs index 6233270..37a699f 100644 --- a/src/thing_card.rs +++ b/src/thing_card.rs @@ -1,5 +1,4 @@ use dioxus::prelude::*; -use jacquard_common::types::string::Cid; #[cfg(test)] use jacquard_common::types::{ string::{AtUri, Datetime, Did, Handle}, @@ -13,6 +12,7 @@ use polymodel_api::space_polymodel::graph::{ }; use polymodel_api::space_polymodel::library; +use crate::author_byline::AuthorByline; use crate::client::PolymodelClient; use crate::session::SessionIdentity; @@ -129,12 +129,10 @@ pub fn thing_card_fixtures() -> Vec { pub fn ThingCard( thing: library::ThingViewBasic, detail_href: Option, - #[props(default)] creator_href: Option, ) -> Element { let client = use_context::(); let session = use_context::>(); 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); let stats = stats(&thing); let mut like_uri = use_signal(|| thing.viewer.like.clone()); @@ -156,7 +154,7 @@ pub fn ThingCard( 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"), + cid: thing.cid.clone(), extra_data: None, }; let like_client = client.clone(); @@ -167,13 +165,27 @@ pub fn ThingCard( rsx! { article { class: "thing-card card-surface", aria_label: "Thing card for {title}", div { class: "thing-card-media card-media", - if let Some(media) = &media { - img { src: "{media.url}", alt: "{media.alt}" } + 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", "preview pending" } + } + } + } } 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", "preview pending" } + 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", "preview pending" } + } } } } @@ -186,13 +198,7 @@ pub fn ThingCard( "{title}" } } - p { class: "thing-card-creator", - if let Some(href) = &creator_href { - a { class: "thing-card-creator-link", href: "{href}", "by {creator}" } - } else { - "by {creator}" - } - } + AuthorByline { actor: thing.author.clone(), class: "thing-card-creator".to_string() } } div { class: "thing-card-details", if let Some(tags) = hydrated_tags(&thing) { @@ -310,11 +316,6 @@ fn clean_text(value: Option<&str>) -> Option<&str> { value.map(str::trim).filter(|value| !value.is_empty()) } -fn creator_label(actor: &library::Actor) -> &str { - clean_text(actor.display_name.as_ref().map(AsRef::as_ref)) - .unwrap_or_else(|| actor.handle.as_ref()) -} - fn hydrated_tags(thing: &library::ThingViewBasic) -> Option<&[jacquard_common::bos::DefaultStr]> { thing.tags.as_deref().filter(|tags| { tags.iter() @@ -479,7 +480,8 @@ mod tests { thing_title(&view), ) .unwrap(); - assert_eq!(creator_label(&view.author), "Maker Name"); + 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"); } @@ -494,7 +496,8 @@ mod tests { }); assert_eq!(thing_title(&view), "Untitled thing"); - assert_eq!(creator_label(&view.author), "maker.tools"); + assert_eq!(view.author.display_name, None); + assert_eq!(view.author.handle.as_ref(), "maker.tools"); assert!( select_card_media( view.cover.as_deref(), diff --git a/src/thing_detail.rs b/src/thing_detail.rs index 27b878b..351dffb 100644 --- a/src/thing_detail.rs +++ b/src/thing_detail.rs @@ -14,8 +14,8 @@ use polymodel_api::space_polymodel::library; use polymodel_renderer_protocol::MeshFormat; use crate::Route; +use crate::author_byline::AuthorByline; use crate::client::PolymodelClient; -use crate::profile::profile_href; use crate::session::SessionIdentity; use crate::viewer::{AssetViewer, ViewerFallbackImage, ViewerMeshSource, ViewerSourceFormat}; @@ -334,7 +334,6 @@ fn DetailPopulated( } else { "thing-detail-title" }; - 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); @@ -393,13 +392,7 @@ fn DetailPopulated( header { class: "thing-detail-header", h1 { class: title_class, "{title}" } div { class: "thing-detail-title-row", - 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}" - } - } + 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 } @@ -808,11 +801,6 @@ fn usable_image_url(url: &str) -> bool { || 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()) }