at main 279 lines 9.3 kB view raw
1//! AuthorList component for displaying multiple authors with progressive disclosure. 2 3use crate::components::{AppLink, AppLinkTarget}; 4use dioxus::prelude::*; 5use jacquard::IntoStatic; 6use jacquard::types::ident::AtIdentifier; 7use jacquard::types::string::{Did, Handle, Uri}; 8use weaver_api::sh_weaver::actor::ProfileDataViewInner; 9use weaver_api::sh_weaver::notebook::AuthorListView; 10 11const AUTHOR_CSS: Asset = asset!("./author.css"); 12 13/// Normalized author data extracted from ProfileDataViewInner variants. 14#[derive(Clone, PartialEq)] 15pub struct AuthorInfo { 16 pub did: Did<'static>, 17 pub handle: Handle<'static>, 18 pub display_name: Option<String>, 19 pub avatar_url: Option<Uri<'static>>, 20} 21 22impl AuthorInfo { 23 /// Check if this author matches an AtIdentifier (comparing DID or handle as appropriate). 24 pub fn matches_ident(&self, ident: &AtIdentifier<'_>) -> bool { 25 match ident { 26 AtIdentifier::Did(did) => self.did == *did, 27 AtIdentifier::Handle(handle) => self.handle == *handle, 28 } 29 } 30} 31 32/// Extract normalized author info from ProfileDataViewInner. 33/// Returns None for unknown/unhandled variants. 34pub fn extract_author_info(inner: &ProfileDataViewInner<'_>) -> Option<AuthorInfo> { 35 match inner { 36 ProfileDataViewInner::ProfileView(p) => Some(AuthorInfo { 37 did: p.did.clone().into_static(), 38 handle: p.handle.clone().into_static(), 39 display_name: p.display_name.as_ref().map(|n| n.to_string()), 40 avatar_url: p.avatar.clone().map(|u| u.into_static()), 41 }), 42 ProfileDataViewInner::ProfileViewDetailed(p) => Some(AuthorInfo { 43 did: p.did.clone().into_static(), 44 handle: p.handle.clone().into_static(), 45 display_name: p.display_name.as_ref().map(|n| n.to_string()), 46 avatar_url: p.avatar.clone().map(|u| u.into_static()), 47 }), 48 ProfileDataViewInner::TangledProfileView(p) => Some(AuthorInfo { 49 did: p.did.clone().into_static(), 50 handle: p.handle.clone().into_static(), 51 display_name: None, 52 avatar_url: None, 53 }), 54 _ => None, 55 } 56} 57 58#[derive(Clone, Copy, PartialEq)] 59enum DisplayMode { 60 Hidden, 61 Full, 62 Compact, 63 Collapsed, 64} 65 66fn determine_display_mode( 67 author_infos: &[AuthorInfo], 68 profile_ident: &Option<AtIdentifier<'static>>, 69) -> DisplayMode { 70 let count = author_infos.len(); 71 72 // Context-aware: single author matching profile ident = hidden 73 if count == 1 { 74 if let Some(pident) = profile_ident { 75 if author_infos[0].matches_ident(pident) { 76 return DisplayMode::Hidden; 77 } 78 } 79 } 80 81 match count { 82 0 => DisplayMode::Hidden, 83 1 | 2 => DisplayMode::Full, 84 3 | 4 => DisplayMode::Compact, 85 _ => DisplayMode::Collapsed, 86 } 87} 88 89#[derive(Props, Clone, PartialEq)] 90pub struct AuthorListProps { 91 /// The authors to display. 92 pub authors: Vec<AuthorListView<'static>>, 93 94 /// Optional profile identity for context-aware visibility. 95 /// If set and there's only 1 author matching this identity, render nothing. 96 #[props(default)] 97 pub profile_ident: Option<AtIdentifier<'static>>, 98 99 /// Optional resource owner identity - this author will be sorted first. 100 #[props(default)] 101 pub owner_ident: Option<AtIdentifier<'static>>, 102 103 /// Avatar size in the full block display (default: 42). 104 #[props(default = 42)] 105 pub avatar_size: u32, 106 107 /// Additional CSS class for the container. 108 #[props(default)] 109 pub class: Option<String>, 110} 111 112/// Displays a list of authors with progressive disclosure based on count. 113/// 114/// - 1-2 authors: Full block (avatar + name + handle) 115/// - 3-4 authors: Compact (names only, comma-separated) 116/// - 5+ authors: Collapsed ("Name, Name, et al.") 117/// 118/// Compact/collapsed modes expand on click to show full dropdown. 119#[component] 120pub fn AuthorList(props: AuthorListProps) -> Element { 121 let mut expanded = use_signal(|| false); 122 123 let container_class = props.class.as_deref().unwrap_or(""); 124 125 // Pre-extract all author infos, filtering out unknown variants 126 let mut author_infos: Vec<AuthorInfo> = props 127 .authors 128 .iter() 129 .filter_map(|a| extract_author_info(&a.record.inner)) 130 .collect(); 131 132 // Sort owner first if specified 133 if let Some(ref owner) = props.owner_ident { 134 author_infos.sort_by_key(|info| if info.matches_ident(owner) { 0 } else { 1 }); 135 } 136 137 let mode = determine_display_mode(&author_infos, &props.profile_ident); 138 139 match mode { 140 DisplayMode::Hidden => rsx! {}, 141 142 DisplayMode::Full => rsx! { 143 document::Stylesheet { href: AUTHOR_CSS } 144 div { class: "author-list author-list-full {container_class}", 145 for info in author_infos.iter() { 146 AuthorBlock { info: info.clone(), avatar_size: props.avatar_size } 147 } 148 } 149 }, 150 151 DisplayMode::Compact => rsx! { 152 document::Stylesheet { href: AUTHOR_CSS } 153 div { 154 class: "author-list author-list-compact {container_class}", 155 onclick: move |_| expanded.set(true), 156 for (i, info) in author_infos.iter().enumerate() { 157 if i > 0 { 158 span { class: "author-separator", ", " } 159 } 160 AuthorInline { info: info.clone() } 161 } 162 163 if expanded() { 164 AuthorDropdown { 165 authors: author_infos.clone(), 166 avatar_size: props.avatar_size, 167 on_close: move |_| expanded.set(false), 168 } 169 } 170 } 171 }, 172 173 DisplayMode::Collapsed => { 174 let first_two: Vec<_> = author_infos.iter().take(2).cloned().collect(); 175 let remaining = author_infos.len().saturating_sub(2); 176 177 rsx! { 178 document::Stylesheet { href: AUTHOR_CSS } 179 div { 180 class: "author-list author-list-collapsed {container_class}", 181 onclick: move |_| expanded.set(true), 182 for (i, info) in first_two.iter().enumerate() { 183 if i > 0 { 184 span { class: "author-separator", ", " } 185 } 186 AuthorInline { info: info.clone() } 187 } 188 span { class: "author-et-al", " et al. ({remaining} more)" } 189 190 if expanded() { 191 AuthorDropdown { 192 authors: author_infos.clone(), 193 avatar_size: props.avatar_size, 194 on_close: move |_| expanded.set(false), 195 } 196 } 197 } 198 } 199 } 200 } 201} 202 203/// Full author display with avatar, name, and handle (as a link). 204#[component] 205fn AuthorBlock(info: AuthorInfo, avatar_size: u32) -> Element { 206 let display = info 207 .display_name 208 .as_deref() 209 .unwrap_or_else(|| info.handle.as_ref()); 210 let handle_display = info.handle.as_ref(); 211 212 rsx! { 213 AppLink { 214 to: AppLinkTarget::Profile { 215 ident: AtIdentifier::Handle(info.handle.clone()) 216 }, 217 class: Some("embed-author author-block".to_string()), 218 if let Some(ref avatar) = info.avatar_url { 219 img { 220 class: "embed-avatar", 221 src: avatar.as_ref(), 222 alt: "", 223 width: "{avatar_size}", 224 height: "{avatar_size}", 225 } 226 } 227 span { class: "embed-author-info", 228 span { class: "embed-author-name", "{display}" } 229 span { class: "embed-author-handle", "@{handle_display}" } 230 } 231 } 232 } 233} 234 235/// Inline author name only (as a link), for compact display. 236#[component] 237fn AuthorInline(info: AuthorInfo) -> Element { 238 let display = info 239 .display_name 240 .as_deref() 241 .unwrap_or_else(|| info.handle.as_ref()); 242 243 rsx! { 244 AppLink { 245 to: AppLinkTarget::Profile { 246 ident: AtIdentifier::Handle(info.handle.clone()) 247 }, 248 class: Some("author-inline".to_string()), 249 "{display}" 250 } 251 } 252} 253 254/// Dropdown overlay showing all authors in full block display. 255#[component] 256fn AuthorDropdown( 257 authors: Vec<AuthorInfo>, 258 avatar_size: u32, 259 on_close: EventHandler<()>, 260) -> Element { 261 rsx! { 262 div { 263 class: "author-list-dropdown-overlay", 264 onclick: move |e| { 265 e.stop_propagation(); 266 on_close.call(()); 267 }, 268 div { 269 class: "author-list-dropdown-content", 270 onclick: move |e| e.stop_propagation(), 271 div { class: "author-list-dropdown", 272 for info in authors.iter() { 273 AuthorBlock { info: info.clone(), avatar_size } 274 } 275 } 276 } 277 } 278 } 279}