better author list

Orual 75f19169 215f4513

+461 -320
+105
crates/weaver-app/src/components/author_list/author.css
··· 1 + /* AuthorList Component Styles */ 2 + 3 + .author-list { 4 + display: flex; 5 + flex-wrap: wrap; 6 + gap: 0.5rem; 7 + position: relative; 8 + } 9 + 10 + .author-list-full { 11 + flex-direction: row; 12 + align-items: center; 13 + gap: 1rem; 14 + } 15 + 16 + .author-list-compact, 17 + .author-list-collapsed { 18 + flex-direction: row; 19 + align-items: center; 20 + cursor: pointer; 21 + } 22 + 23 + .author-list-compact:hover, 24 + .author-list-collapsed:hover { 25 + text-decoration: underline; 26 + text-decoration-color: var(--color-muted); 27 + } 28 + 29 + /* Author block (full display with avatar) */ 30 + .author-block { 31 + display: flex; 32 + align-items: center; 33 + gap: 0.5rem; 34 + text-decoration: none; 35 + color: inherit; 36 + } 37 + 38 + .author-block:hover .embed-author-name { 39 + color: var(--color-secondary); 40 + } 41 + 42 + /* Inline author name (compact mode) */ 43 + .author-inline { 44 + color: var(--color-text); 45 + text-decoration: none; 46 + } 47 + 48 + .author-inline:hover { 49 + color: var(--color-secondary); 50 + } 51 + 52 + /* Separator between authors */ 53 + .author-separator { 54 + color: var(--color-muted); 55 + } 56 + 57 + /* Et al. text */ 58 + .author-et-al { 59 + color: var(--color-muted); 60 + font-style: italic; 61 + cursor: pointer; 62 + } 63 + 64 + .author-et-al:hover { 65 + color: var(--color-secondary); 66 + } 67 + 68 + /* Dropdown overlay */ 69 + .author-list-dropdown-overlay { 70 + position: fixed; 71 + inset: 0; 72 + z-index: 100; 73 + background: rgba(0, 0, 0, 0.1); 74 + display: flex; 75 + align-items: flex-start; 76 + justify-content: center; 77 + padding-top: 10vh; 78 + } 79 + 80 + .author-list-dropdown-content { 81 + background: var(--color-surface); 82 + border: 1px solid var(--color-border); 83 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 84 + border-radius: 0.5rem; 85 + padding: 1rem; 86 + max-height: 60vh; 87 + overflow-y: auto; 88 + min-width: 200px; 89 + max-width: 400px; 90 + } 91 + 92 + .author-list-dropdown { 93 + display: flex; 94 + flex-direction: column; 95 + gap: 1rem; 96 + } 97 + 98 + @media (prefers-color-scheme: dark) { 99 + .author-list-dropdown-overlay { 100 + background: rgba(0, 0, 0, 0.4); 101 + } 102 + .author-list-dropdown-content { 103 + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); 104 + } 105 + }
+279
crates/weaver-app/src/components/author_list/component.rs
··· 1 + //! AuthorList component for displaying multiple authors with progressive disclosure. 2 + 3 + use crate::Route; 4 + use dioxus::prelude::*; 5 + use jacquard::IntoStatic; 6 + use jacquard::types::ident::AtIdentifier; 7 + use jacquard::types::string::{Did, Handle, Uri}; 8 + use weaver_api::sh_weaver::actor::ProfileDataViewInner; 9 + use weaver_api::sh_weaver::notebook::AuthorListView; 10 + 11 + const AUTHOR_CSS: Asset = asset!("./author.css"); 12 + 13 + /// Normalized author data extracted from ProfileDataViewInner variants. 14 + #[derive(Clone, PartialEq)] 15 + pub 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 + 22 + impl 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. 34 + pub 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)] 59 + enum DisplayMode { 60 + Hidden, 61 + Full, 62 + Compact, 63 + Collapsed, 64 + } 65 + 66 + fn 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)] 90 + pub 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] 120 + pub 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] 205 + fn 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 + Link { 214 + to: Route::RepositoryIndex { 215 + ident: AtIdentifier::Handle(info.handle.clone()) 216 + }, 217 + class: "embed-author author-block", 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] 237 + fn 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 + Link { 245 + to: Route::RepositoryIndex { 246 + ident: AtIdentifier::Handle(info.handle.clone()) 247 + }, 248 + class: "author-inline", 249 + "{display}" 250 + } 251 + } 252 + } 253 + 254 + /// Dropdown overlay showing all authors in full block display. 255 + #[component] 256 + fn 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 + }
+5
crates/weaver-app/src/components/author_list/mod.rs
··· 1 + //! Author list component for displaying multiple authors with progressive disclosure. 2 + 3 + mod component; 4 + 5 + pub use component::{AuthorInfo, AuthorList, AuthorListProps, extract_author_info};
+22 -190
crates/weaver-app/src/components/entry.rs
··· 3 3 use crate::Route; 4 4 #[cfg(feature = "server")] 5 5 use crate::blobcache::BlobCache; 6 + use crate::components::AuthorList; 6 7 use crate::{components::EntryActions, data::use_handle}; 7 8 use dioxus::prelude::*; 8 9 use jacquard::types::aturi::AtUri; ··· 416 417 417 418 let entry_uri = entry_view.uri.clone().into_static(); 418 419 419 - // Only show author if notebook has multiple authors 420 + // Show author list if notebook has multiple authors 420 421 let show_author = author_count > 1; 421 - let first_author = if show_author { 422 - entry_view.authors.first() 423 - } else { 424 - None 425 - }; 426 422 427 423 // Render preview from truncated entry content 428 424 let preview_html = parsed_entry.as_ref().map(|entry| { ··· 460 456 } 461 457 } 462 458 } 463 - if let Some(author) = first_author { 464 - { 465 - use weaver_api::sh_weaver::actor::ProfileDataViewInner; 466 - 467 - match &author.record.inner { 468 - ProfileDataViewInner::ProfileView(profile) => { 469 - let display_name = profile.display_name.as_ref().map(|n| n.as_ref()).unwrap_or("Unknown"); 470 - let handle = profile.handle.clone(); 471 - rsx! { 472 - span { class: "embed-author entry-card-author", 473 - if let Some(ref avatar_url) = profile.avatar { 474 - img { class: "embed-avatar", src: avatar_url.as_ref(), alt: "", width: "42", height: "42" } 475 - } 476 - span { class: "embed-author-info", 477 - span { class: "embed-author-name", "{display_name}" } 478 - span { class: "embed-author-handle", "@{handle}" } 479 - } 480 - } 481 - } 482 - } 483 - ProfileDataViewInner::ProfileViewDetailed(profile) => { 484 - let display_name = profile.display_name.as_ref().map(|n| n.as_ref()).unwrap_or("Unknown"); 485 - let handle = profile.handle.clone(); 486 - rsx! { 487 - span { class: "embed-author entry-card-author", 488 - if let Some(ref avatar_url) = profile.avatar { 489 - img { class: "embed-avatar", src: avatar_url.as_ref(), alt: "", width: "42", height: "42" } 490 - } 491 - span { class: "embed-author-info", 492 - span { class: "embed-author-name", "{display_name}" } 493 - span { class: "embed-author-handle", "@{handle}" } 494 - } 495 - } 496 - } 497 - } 498 - ProfileDataViewInner::TangledProfileView(profile) => { 499 - rsx! { 500 - span { class: "embed-author entry-card-author", 501 - span { class: "embed-author-info", 502 - span { class: "embed-author-handle", "@{profile.handle.as_ref()}" } 503 - } 504 - } 505 - } 506 - } 507 - _ => { 508 - rsx! { 509 - span { class: "embed-author entry-card-author", 510 - span { class: "embed-author-info", 511 - span { class: "embed-author-name", "Unknown" } 512 - } 513 - } 514 - } 515 - } 516 - } 459 + if show_author && !entry_view.authors.is_empty() { 460 + AuthorList { 461 + authors: entry_view.authors.clone(), 462 + owner_ident: Some(ident.clone()), 463 + class: Some("entry-card-author".to_string()), 517 464 } 518 465 } 519 466 } ··· 543 490 #[props(default = false)] show_actions: bool, 544 491 #[props(default = false)] is_pinned: bool, 545 492 #[props(default = true)] show_author: bool, 493 + /// Profile identity for context-aware author visibility (hides single author on their own profile) 494 + #[props(default)] profile_ident: Option<AtIdentifier<'static>>, 546 495 #[props(default)] on_pinned_changed: Option<EventHandler<bool>>, 547 496 ) -> Element { 548 497 use crate::Route; 549 498 use crate::auth::AuthState; 550 - use jacquard::from_data; 551 - use weaver_api::sh_weaver::actor::ProfileDataViewInner; 552 499 553 500 let title = entry_view 554 501 .title ··· 573 520 // Format date from record's created_at 574 521 let formatted_date = entry.created_at.as_ref().format("%B %d, %Y").to_string(); 575 522 576 - // Get first author if we're showing it 577 - let first_author = if show_author { 578 - entry_view.authors.first() 579 - } else { 580 - None 581 - }; 523 + // Whether to show authors 524 + let has_authors = show_author && !entry_view.authors.is_empty(); 582 525 583 526 // Check edit access via permissions 584 527 let auth_state = use_context::<Signal<AuthState>>(); ··· 621 564 h3 { class: "entry-card-title", "{title}" } 622 565 } 623 566 // Date inline with title when no author shown 624 - if first_author.is_none() { 567 + if !has_authors { 625 568 div { class: "entry-card-date", 626 569 time { datetime: "{entry.created_at.as_str()}", "{formatted_date}" } 627 570 } ··· 639 582 } 640 583 } 641 584 642 - // Byline: author + date (only when author shown) 643 - if let Some(author) = first_author { 585 + // Byline: author + date (only when authors shown) 586 + if has_authors { 644 587 div { class: "entry-card-byline", 645 - { 646 - match &author.record.inner { 647 - ProfileDataViewInner::ProfileView(profile) => { 648 - let display_name = profile.display_name.as_ref().map(|n| n.as_ref()).unwrap_or("Unknown"); 649 - let handle = profile.handle.clone(); 650 - rsx! { 651 - Link { 652 - to: Route::RepositoryIndex { ident: AtIdentifier::Handle(handle.clone()) }, 653 - class: "embed-author entry-card-author", 654 - if let Some(ref avatar_url) = profile.avatar { 655 - img { class: "embed-avatar", src: avatar_url.as_ref(), alt: "", width: "42", height: "42" } 656 - } 657 - span { class: "embed-author-info", 658 - span { class: "embed-author-name", "{display_name}" } 659 - span { class: "embed-author-handle", "@{handle}" } 660 - } 661 - } 662 - } 663 - } 664 - ProfileDataViewInner::ProfileViewDetailed(profile) => { 665 - let display_name = profile.display_name.as_ref().map(|n| n.as_ref()).unwrap_or("Unknown"); 666 - let handle = profile.handle.clone(); 667 - rsx! { 668 - Link { 669 - to: Route::RepositoryIndex { ident: AtIdentifier::Handle(handle.clone()) }, 670 - class: "embed-author entry-card-author", 671 - if let Some(ref avatar_url) = profile.avatar { 672 - img { class: "embed-avatar", src: avatar_url.as_ref(), alt: "", width: "42", height: "42" } 673 - } 674 - span { class: "embed-author-info", 675 - span { class: "embed-author-name", "{display_name}" } 676 - span { class: "embed-author-handle", "@{handle}" } 677 - } 678 - } 679 - } 680 - } 681 - ProfileDataViewInner::TangledProfileView(profile) => { 682 - let handle = profile.handle.clone(); 683 - rsx! { 684 - Link { 685 - to: Route::RepositoryIndex { ident: AtIdentifier::Handle(handle.clone()) }, 686 - class: "embed-author entry-card-author", 687 - span { class: "embed-author-info", 688 - span { class: "embed-author-handle", "@{handle}" } 689 - } 690 - } 691 - } 692 - } 693 - _ => { 694 - rsx! { 695 - span { class: "embed-author entry-card-author", 696 - span { class: "embed-author-info", 697 - span { class: "embed-author-name", "Unknown" } 698 - } 699 - } 700 - } 701 - } 702 - } 588 + AuthorList { 589 + authors: entry_view.authors.clone(), 590 + profile_ident: profile_ident.clone(), 591 + owner_ident: Some(ident.clone()), 592 + class: Some("entry-card-author".to_string()), 703 593 } 704 594 div { class: "entry-card-date", 705 595 time { datetime: "{entry.created_at.as_str()}", "{formatted_date}" } ··· 771 661 // Authors 772 662 if !entry_view.authors.is_empty() { 773 663 div { class: "entry-authors", 774 - for (i, author) in entry_view.authors.iter().enumerate() { 775 - if i > 0 { span { " " } } 776 - { 777 - use weaver_api::sh_weaver::actor::ProfileDataViewInner; 778 - 779 - match &author.record.inner { 780 - ProfileDataViewInner::ProfileView(profile) => { 781 - let display_name = profile.display_name.as_ref().map(|n| n.as_ref()).unwrap_or("Unknown"); 782 - let handle = profile.handle.clone(); 783 - 784 - rsx! { 785 - Link { 786 - to: Route::RepositoryIndex { ident: AtIdentifier::Handle(handle.clone()) }, 787 - class: "embed-author", 788 - if let Some(ref avatar_url) = profile.avatar { 789 - img { class: "embed-avatar", src: avatar_url.as_ref(), alt: "", width: "42", height: "42" } 790 - } 791 - span { class: "embed-author-info", 792 - span { class: "embed-author-name", "{display_name}" } 793 - span { class: "embed-author-handle", "@{handle}" } 794 - } 795 - } 796 - } 797 - } 798 - ProfileDataViewInner::ProfileViewDetailed(profile) => { 799 - let display_name = profile.display_name.as_ref().map(|n| n.as_ref()).unwrap_or("Unknown"); 800 - let handle = profile.handle.clone(); 801 - rsx! { 802 - Link { 803 - to: Route::RepositoryIndex { ident: AtIdentifier::Handle(handle.clone()) }, 804 - class: "embed-author", 805 - if let Some(ref avatar_url) = profile.avatar { 806 - img { class: "embed-avatar", src: avatar_url.as_ref(), alt: "", width: "42", height: "42" } 807 - } 808 - span { class: "embed-author-info", 809 - span { class: "embed-author-name", "{display_name}" } 810 - span { class: "embed-author-handle", "@{handle}" } 811 - } 812 - } 813 - } 814 - } 815 - ProfileDataViewInner::TangledProfileView(profile) => { 816 - rsx! { 817 - span { class: "embed-author", 818 - span { class: "embed-author-info", 819 - span { class: "embed-author-handle", "@{profile.handle.as_ref()}" } 820 - } 821 - } 822 - } 823 - } 824 - _ => { 825 - rsx! { 826 - span { class: "embed-author", 827 - span { class: "embed-author-info", 828 - span { class: "embed-author-name", "Unknown" } 829 - } 830 - } 831 - } 832 - } 833 - } 834 - } 664 + AuthorList { 665 + authors: entry_view.authors.clone(), 666 + owner_ident: Some(ident.clone()), 835 667 } 836 668 } 837 669 }
+14 -38
crates/weaver-app/src/components/identity.rs
··· 1 1 use crate::auth::AuthState; 2 2 use crate::components::css::DefaultNotebookCss; 3 - use crate::components::{FeedEntryCard, ProfileActions, ProfileActionsMenubar}; 3 + use crate::components::{AuthorList, FeedEntryCard, ProfileActions, ProfileActionsMenubar}; 4 4 use crate::{Route, data, fetch}; 5 5 use dioxus::prelude::*; 6 6 use jacquard::{smol_str::SmolStr, types::ident::AtIdentifier}; ··· 358 358 NotebookCard { 359 359 notebook: notebook.clone(), 360 360 entry_refs: entries.clone(), 361 - is_pinned: true 361 + is_pinned: true, 362 + profile_ident: Some(ident()), 362 363 } 363 364 } 364 365 } ··· 373 374 entry: entry.clone(), 374 375 show_actions: true, 375 376 is_pinned: true, 376 - show_author: false 377 + profile_ident: Some(ident()), 377 378 } 378 379 } 379 380 } ··· 405 406 NotebookCard { 406 407 notebook: notebook.clone(), 407 408 entry_refs: entries.clone(), 408 - is_pinned: false 409 + is_pinned: false, 410 + profile_ident: Some(ident()), 409 411 } 410 412 } 411 413 } ··· 420 422 entry: entry.clone(), 421 423 show_actions: true, 422 424 is_pinned: false, 423 - show_author: false 425 + profile_ident: Some(ident()), 424 426 } 425 427 } 426 428 } ··· 446 448 entry_refs: Vec<StrongRef<'static>>, 447 449 #[props(default = false)] is_pinned: bool, 448 450 #[props(default)] show_author: Option<bool>, 451 + /// Profile identity for context-aware author visibility (hides single author on their own profile) 452 + #[props(default)] profile_ident: Option<AtIdentifier<'static>>, 449 453 #[props(default)] on_pinned_changed: Option<EventHandler<bool>>, 450 454 #[props(default)] on_deleted: Option<EventHandler<()>>, 451 455 ) -> Element { ··· 542 546 } 543 547 } 544 548 545 - // Show authors only if multiple 549 + // Show authors 546 550 if show_authors { 547 551 div { class: "notebook-card-authors", 548 - for (i, author) in notebook.authors.iter().enumerate() { 549 - if i > 0 { span { class: "author-separator", ", " } } 550 - { 551 - use weaver_api::sh_weaver::actor::ProfileDataViewInner; 552 - 553 - match &author.record.inner { 554 - ProfileDataViewInner::ProfileView(profile) => { 555 - let display_name = profile.display_name.as_ref() 556 - .map(|n| n.as_ref()) 557 - .unwrap_or("Unknown"); 558 - rsx! { 559 - span { class: "author-name", "{display_name}" } 560 - } 561 - } 562 - ProfileDataViewInner::ProfileViewDetailed(profile) => { 563 - let display_name = profile.display_name.as_ref() 564 - .map(|n| n.as_ref()) 565 - .unwrap_or("Unknown"); 566 - rsx! { 567 - span { class: "author-name", "{display_name}" } 568 - } 569 - } 570 - ProfileDataViewInner::TangledProfileView(profile) => { 571 - rsx! { 572 - span { class: "author-name", "@{profile.handle.as_ref()}" } 573 - } 574 - } 575 - _ => rsx! { 576 - span { class: "author-name", "Unknown" } 577 - } 578 - } 579 - } 552 + AuthorList { 553 + authors: notebook.authors.clone(), 554 + profile_ident: profile_ident.clone(), 555 + owner_ident: Some(ident.clone()), 580 556 } 581 557 } 582 558 }
+3
crates/weaver-app/src/components/mod.rs
··· 31 31 pub mod collab; 32 32 pub use collab::{CollaboratorAvatars, CollaboratorsPanel, InviteDialog, InvitesList}; 33 33 34 + pub mod author_list; 35 + pub use author_list::AuthorList; 36 + 34 37 use dioxus::prelude::*; 35 38 36 39 #[derive(PartialEq, Props, Clone)]
+13 -79
crates/weaver-app/src/components/notebook_cover.rs
··· 1 1 #![allow(non_snake_case)] 2 2 3 3 use crate::Route; 4 + use crate::components::AuthorList; 4 5 use crate::components::button::{Button, ButtonVariant}; 5 6 use dioxus::prelude::*; 7 + use jacquard::IntoStatic; 6 8 use jacquard::smol_str::SmolStr; 7 9 use jacquard::types::ident::AtIdentifier; 8 10 use weaver_api::sh_weaver::notebook::NotebookView; ··· 41 43 42 44 // Authors section 43 45 if !notebook.authors.is_empty() { 44 - div { class: "notebook-cover-authors", 45 - NotebookAuthors { authors: notebook.authors.clone() } 46 + { 47 + let owner = notebook.uri.authority().clone().into_static(); 48 + rsx! { 49 + div { class: "notebook-cover-authors", 50 + AuthorList { 51 + authors: notebook.authors.clone(), 52 + owner_ident: Some(owner), 53 + avatar_size: 48, 54 + } 55 + } 56 + } 46 57 } 47 58 } 48 59 ··· 99 110 } 100 111 } 101 112 } 102 - 103 - #[component] 104 - fn NotebookAuthors( 105 - authors: Vec<weaver_api::sh_weaver::notebook::AuthorListView<'static>>, 106 - ) -> Element { 107 - rsx! { 108 - div { class: "notebook-authors-list", 109 - for (i, author) in authors.iter().enumerate() { 110 - if i > 0 { span { class: "author-separator", ", " } } 111 - NotebookAuthor { author: author.clone() } 112 - } 113 - } 114 - } 115 - } 116 - 117 - #[component] 118 - fn NotebookAuthor(author: weaver_api::sh_weaver::notebook::AuthorListView<'static>) -> Element { 119 - use weaver_api::sh_weaver::actor::ProfileDataViewInner; 120 - 121 - // Author already has profile data hydrated 122 - match &author.record.inner { 123 - ProfileDataViewInner::ProfileView(p) => { 124 - let display_name = p 125 - .display_name 126 - .as_ref() 127 - .map(|n| n.as_ref()) 128 - .unwrap_or("Unknown"); 129 - 130 - rsx! { 131 - span { class: "embed-author notebook-author", 132 - if let Some(ref avatar) = p.avatar { 133 - img { class: "embed-avatar", src: avatar.as_ref(), alt: "", width: "48", height: "48" } 134 - } 135 - span { class: "embed-author-info", 136 - span { class: "embed-author-name", "{display_name}" } 137 - span { class: "embed-author-handle", "@{p.handle}" } 138 - } 139 - } 140 - } 141 - } 142 - ProfileDataViewInner::ProfileViewDetailed(p) => { 143 - let display_name = p 144 - .display_name 145 - .as_ref() 146 - .map(|n| n.as_ref()) 147 - .unwrap_or("Unknown"); 148 - 149 - rsx! { 150 - span { class: "embed-author notebook-author", 151 - if let Some(ref avatar) = p.avatar { 152 - img { class: "embed-avatar", src: avatar.as_ref(), alt: "", width: "48", height: "48" } 153 - } 154 - span { class: "embed-author-info", 155 - span { class: "embed-author-name", "{display_name}" } 156 - span { class: "embed-author-handle", "@{p.handle}" } 157 - } 158 - } 159 - } 160 - } 161 - ProfileDataViewInner::TangledProfileView(p) => { 162 - rsx! { 163 - span { class: "embed-author notebook-author", 164 - span { class: "embed-author-info", 165 - span { class: "embed-author-handle", "@{p.handle.as_ref()}" } 166 - } 167 - } 168 - } 169 - } 170 - _ => rsx! { 171 - span { class: "embed-author notebook-author", 172 - span { class: "embed-author-info", 173 - span { class: "embed-author-name", "Unknown" } 174 - } 175 - } 176 - }, 177 - } 178 - }
+4 -1
crates/weaver-app/src/fetch.rs
··· 727 727 } 728 728 notebooks.push(result); 729 729 } 730 - Err(_) => continue, // Skip notebooks that fail to load 730 + Err(e) => { 731 + tracing::warn!("fetch_notebooks_for_did: view_notebook failed for {}: {}", record.uri, e); 732 + continue; 733 + } 731 734 } 732 735 } 733 736 }
+3 -2
crates/weaver-app/src/main.rs
··· 148 148 ); 149 149 150 150 // Filter out noisy crates 151 - let filter = 152 - EnvFilter::new("debug,loro_internal=warn,jacquard_identity=info,jacquard_common=info"); 151 + let filter = EnvFilter::new( 152 + "debug,loro_internal=warn,jacquard_identity=info,jacquard_common=info,iroh=info", 153 + ); 153 154 154 155 let reg = Registry::default() 155 156 .with(filter)
+13 -10
crates/weaver-common/src/agent.rs
··· 1601 1601 } 1602 1602 }; 1603 1603 1604 - // Fetch the record to get createdAt 1604 + // Fetch the record to get createdAt (use untyped fetch to handle any collection) 1605 1605 let record = self 1606 - .get_record::<weaver_api::sh_weaver::notebook::entry::Entry>(resource_uri) 1606 + .fetch_record_slingshot(resource_uri) 1607 1607 .await 1608 - .map_err(|e| WeaverError::from(AgentError::from(e)))? 1609 - .into_output() 1610 - .map_err(|e| { 1611 - WeaverError::from(AgentError::from(ClientError::invalid_request(format!( 1612 - "Failed to parse record: {}", 1613 - e 1614 - )))) 1608 + .map_err(|e| WeaverError::from(AgentError::from(e)))?; 1609 + let authority_granted_at = record 1610 + .value 1611 + .query("createdAt") 1612 + .first() 1613 + .and_then(|v| v.as_str()) 1614 + .and_then(|s| s.parse::<jacquard::types::string::Datetime>().ok()) 1615 + .ok_or_else(|| { 1616 + WeaverError::from(AgentError::from(ClientError::invalid_request( 1617 + "Record missing createdAt", 1618 + ))) 1615 1619 })?; 1616 - let authority_granted_at = record.value.created_at; 1617 1620 1618 1621 editors.push( 1619 1622 PermissionGrant::new()