at main 791 lines 33 kB view raw
1use crate::auth::AuthState; 2use crate::components::css::DefaultNotebookCss; 3use crate::components::{AuthorList, FeedEntryCard, ProfileActions, ProfileActionsMenubar}; 4use crate::{Route, data, fetch}; 5use dioxus::prelude::*; 6use jacquard::{smol_str::SmolStr, types::ident::AtIdentifier}; 7use std::collections::HashSet; 8use weaver_api::com_atproto::repo::strong_ref::StrongRef; 9use weaver_api::sh_weaver::notebook::{ 10 BookEntryRef, BookEntryView, EntryView, NotebookView, entry::Entry, 11}; 12 13/// Constructs BookEntryViews from notebook entry refs and all available entries. 14/// 15/// Matches StrongRefs by URI to find the corresponding EntryView, 16/// then builds BookEntryView with index and prev/next navigation refs. 17fn build_book_entry_views( 18 entry_refs: &[StrongRef<'static>], 19 all_entries: &[(EntryView<'static>, Entry<'static>)], 20) -> Vec<BookEntryView<'static>> { 21 use jacquard::IntoStatic; 22 23 // Build a lookup map for faster matching 24 let entry_map: std::collections::HashMap<&str, &EntryView<'static>> = all_entries 25 .iter() 26 .map(|(view, _)| (view.uri.as_ref(), view)) 27 .collect(); 28 29 let mut views = Vec::with_capacity(entry_refs.len()); 30 31 for (idx, strong_ref) in entry_refs.iter().enumerate() { 32 let Some(entry_view) = entry_map.get(strong_ref.uri.as_ref()).copied() else { 33 continue; 34 }; 35 36 // Build prev ref (if not first) 37 let prev = if idx > 0 { 38 entry_refs 39 .get(idx - 1) 40 .and_then(|prev_ref| entry_map.get(prev_ref.uri.as_ref()).copied()) 41 .map(|prev_view| { 42 BookEntryRef::new() 43 .entry(prev_view.clone()) 44 .build() 45 .into_static() 46 }) 47 } else { 48 None 49 }; 50 51 // Build next ref (if not last) 52 let next = if idx + 1 < entry_refs.len() { 53 entry_refs 54 .get(idx + 1) 55 .and_then(|next_ref| entry_map.get(next_ref.uri.as_ref()).copied()) 56 .map(|next_view| { 57 BookEntryRef::new() 58 .entry(next_view.clone()) 59 .build() 60 .into_static() 61 }) 62 } else { 63 None 64 }; 65 66 views.push( 67 BookEntryView::new() 68 .entry(entry_view.clone()) 69 .index(idx as i64) 70 .maybe_prev(prev) 71 .maybe_next(next) 72 .build() 73 .into_static(), 74 ); 75 } 76 77 views 78} 79 80/// A single item in the profile timeline (either notebook or standalone entry) 81#[derive(Clone, PartialEq)] 82pub enum ProfileTimelineItem { 83 Notebook { 84 notebook: NotebookView<'static>, 85 entries: Vec<BookEntryView<'static>>, 86 /// Most recent entry's created_at for sorting 87 sort_date: jacquard::types::string::Datetime, 88 }, 89 StandaloneEntry { 90 entry_view: EntryView<'static>, 91 entry: Entry<'static>, 92 }, 93} 94 95impl ProfileTimelineItem { 96 pub fn sort_date(&self) -> &jacquard::types::string::Datetime { 97 match self { 98 Self::Notebook { sort_date, .. } => sort_date, 99 Self::StandaloneEntry { entry, .. } => &entry.created_at, 100 } 101 } 102} 103 104/// OpenGraph and Twitter Card meta tags for profile/repository pages 105#[component] 106pub fn ProfileOgMeta( 107 display_name: String, 108 handle: String, 109 bio: String, 110 image_url: String, 111 canonical_url: String, 112 notebook_count: usize, 113) -> Element { 114 let page_title = format!("{} (@{}) | Weaver", display_name, handle); 115 let full_description = if notebook_count > 0 { 116 format!("{} notebooks · {}", notebook_count, bio) 117 } else if bio.is_empty() { 118 format!("@{} on Weaver", handle) 119 } else { 120 bio.clone() 121 }; 122 123 rsx! { 124 document::Title { "{page_title}" } 125 document::Meta { property: "og:title", content: "{display_name}" } 126 document::Meta { property: "og:description", content: "{full_description}" } 127 document::Meta { property: "og:image", content: "{image_url}" } 128 document::Meta { property: "og:type", content: "profile" } 129 document::Meta { property: "og:url", content: "{canonical_url}" } 130 document::Meta { property: "og:site_name", content: "Weaver" } 131 document::Meta { property: "profile:username", content: "{handle}" } 132 document::Meta { name: "twitter:card", content: "summary_large_image" } 133 document::Meta { name: "twitter:title", content: "{display_name}" } 134 document::Meta { name: "twitter:description", content: "{full_description}" } 135 document::Meta { name: "twitter:image", content: "{image_url}" } 136 document::Meta { name: "twitter:creator", content: "@{handle}" } 137 } 138} 139 140// Card styles (entry-card, notebook-card) loaded at navbar level 141const ENTRY_CSS: Asset = asset!("/assets/styling/entry.css"); 142const LAYOUTS_CSS: Asset = asset!("/assets/styling/layouts.css"); 143 144#[component] 145pub fn Repository(ident: ReadSignal<AtIdentifier<'static>>) -> Element { 146 rsx! { 147 DefaultNotebookCss { } 148 document::Link { rel: "stylesheet", href: LAYOUTS_CSS } 149 document::Link { rel: "stylesheet", href: ENTRY_CSS } 150 div { 151 Outlet::<Route> {} 152 } 153 } 154} 155 156#[component] 157pub fn RepositoryIndex(ident: ReadSignal<AtIdentifier<'static>>) -> Element { 158 use crate::components::ProfileDisplay; 159 use jacquard::from_data; 160 use weaver_api::sh_weaver::notebook::book::Book; 161 162 let auth_state = use_context::<Signal<AuthState>>(); 163 164 // Use client-only versions to avoid SSR issues with concurrent server futures 165 let (_profile_res, profile) = data::use_profile_data(ident); 166 let (_notebooks_res, notebooks) = data::use_notebooks_for_did(ident); 167 let (_entries_res, all_entries) = data::use_entries_for_did(ident); 168 169 #[cfg(feature = "fullstack-server")] 170 { 171 _profile_res?; 172 _notebooks_res?; 173 _entries_res?; 174 } 175 176 // Check if viewing own profile 177 let is_own_profile = use_memo(move || { 178 let current_did = auth_state.read().did.clone(); 179 match (&current_did, ident()) { 180 (Some(did), AtIdentifier::Did(profile_did)) => *did == profile_did, 181 _ => false, 182 } 183 }); 184 185 // Extract pinned URIs from profile (only Weaver ProfileView has pinned) 186 // Returns (Vec for ordering, HashSet for O(1) lookups) 187 let pinned_uris = use_memo(move || { 188 use jacquard::IntoStatic; 189 use weaver_api::sh_weaver::actor::ProfileDataViewInner; 190 191 let Some(prof) = profile.read().as_ref().cloned() else { 192 return (Vec::<String>::new(), HashSet::<String>::new()); 193 }; 194 195 match &prof.inner { 196 ProfileDataViewInner::ProfileView(p) => { 197 let uris: Vec<String> = p 198 .pinned 199 .as_ref() 200 .map(|pins| pins.iter().map(|r| r.uri.as_ref().to_string()).collect()) 201 .unwrap_or_default(); 202 let set: HashSet<String> = uris.iter().cloned().collect(); 203 (uris, set) 204 } 205 _ => (Vec::new(), HashSet::new()), 206 } 207 }); 208 209 // Compute standalone entries (entries not in any notebook) 210 let standalone_entries = use_memo(move || { 211 let nbs = notebooks.read(); 212 let ents = all_entries.read(); 213 214 let (Some(nbs), Some(ents)) = (nbs.as_ref(), ents.as_ref()) else { 215 return Vec::new(); 216 }; 217 218 // Collect all entry URIs from all notebook entry_lists 219 let notebook_entry_uris: HashSet<&str> = nbs 220 .iter() 221 .flat_map(|(_, refs)| refs.iter().map(|r| r.uri.as_ref())) 222 .collect(); 223 224 // Filter entries not in any notebook 225 ents.iter() 226 .filter(|(view, _)| !notebook_entry_uris.contains(view.uri.as_ref())) 227 .cloned() 228 .collect::<Vec<_>>() 229 }); 230 231 // Helper to check if a URI is pinned 232 fn is_pinned(uri: &str, pinned_set: &HashSet<String>) -> bool { 233 pinned_set.contains(uri) 234 } 235 236 // Build pinned items (matching notebooks/entries against pinned URIs) 237 let pinned_items = use_memo(move || { 238 let nbs = notebooks.read(); 239 let standalone = standalone_entries.read(); 240 let ents = all_entries.read(); 241 let (pinned_vec, pinned_set) = &*pinned_uris.read(); 242 243 let mut items: Vec<ProfileTimelineItem> = Vec::new(); 244 245 // Check notebooks 246 if let Some(nbs) = nbs.as_ref() { 247 if let Some(all_ents) = ents.as_ref() { 248 for (notebook, entry_refs) in nbs { 249 if is_pinned(notebook.uri.as_ref(), pinned_set) { 250 let book_entries = build_book_entry_views(entry_refs, all_ents); 251 let sort_date = book_entries 252 .iter() 253 .filter_map(|bev| { 254 all_ents 255 .iter() 256 .find(|(v, _)| v.uri.as_ref() == bev.entry.uri.as_ref()) 257 }) 258 .map(|(_, entry)| entry.created_at.clone()) 259 .max() 260 .unwrap_or_else(|| notebook.indexed_at.clone()); 261 262 items.push(ProfileTimelineItem::Notebook { 263 notebook: notebook.clone(), 264 entries: book_entries, 265 sort_date, 266 }); 267 } 268 } 269 } 270 } 271 272 // Check standalone entries 273 for (view, entry) in standalone.iter() { 274 if is_pinned(view.uri.as_ref(), pinned_set) { 275 items.push(ProfileTimelineItem::StandaloneEntry { 276 entry_view: view.clone(), 277 entry: entry.clone(), 278 }); 279 } 280 } 281 282 // Sort pinned by their order in the pinned list 283 items.sort_by_key(|item| { 284 let uri = match item { 285 ProfileTimelineItem::Notebook { notebook, .. } => notebook.uri.as_ref(), 286 ProfileTimelineItem::StandaloneEntry { entry_view, .. } => entry_view.uri.as_ref(), 287 }; 288 pinned_vec 289 .iter() 290 .position(|p| p == uri) 291 .unwrap_or(usize::MAX) 292 }); 293 294 items 295 }); 296 297 // Build merged timeline sorted by date (newest first), excluding pinned items 298 let timeline = use_memo(move || { 299 let nbs = notebooks.read(); 300 let standalone = standalone_entries.read(); 301 let ents = all_entries.read(); 302 let (_pinned_vec, pinned_set) = &*pinned_uris.read(); 303 304 let mut items: Vec<ProfileTimelineItem> = Vec::new(); 305 306 // Add notebooks (excluding pinned) 307 if let Some(nbs) = nbs.as_ref() { 308 if let Some(all_ents) = ents.as_ref() { 309 for (notebook, entry_refs) in nbs { 310 if !is_pinned(notebook.uri.as_ref(), pinned_set) { 311 let book_entries = build_book_entry_views(entry_refs, all_ents); 312 let sort_date = book_entries 313 .iter() 314 .filter_map(|bev| { 315 all_ents 316 .iter() 317 .find(|(v, _)| v.uri.as_ref() == bev.entry.uri.as_ref()) 318 }) 319 .map(|(_, entry)| entry.created_at.clone()) 320 .max() 321 .unwrap_or_else(|| notebook.indexed_at.clone()); 322 323 items.push(ProfileTimelineItem::Notebook { 324 notebook: notebook.clone(), 325 entries: book_entries, 326 sort_date, 327 }); 328 } 329 } 330 } 331 } 332 333 // Add standalone entries (excluding pinned) 334 for (view, entry) in standalone.iter() { 335 if !is_pinned(view.uri.as_ref(), pinned_set) { 336 items.push(ProfileTimelineItem::StandaloneEntry { 337 entry_view: view.clone(), 338 entry: entry.clone(), 339 }); 340 } 341 } 342 343 // Sort by date descending (newest first) 344 items.sort_by(|a, b| b.sort_date().cmp(&a.sort_date())); 345 346 items 347 }); 348 349 // Count standalone entries for stats 350 let entry_count = use_memo(move || all_entries.read().as_ref().map(|e| e.len()).unwrap_or(0)); 351 352 // Build OG metadata when profile is available 353 let og_meta = match &*profile.read() { 354 Some(profile_view) => { 355 use weaver_api::sh_weaver::actor::ProfileDataViewInner; 356 357 let (display_name, handle, bio) = match &profile_view.inner { 358 ProfileDataViewInner::ProfileView(p) => ( 359 p.display_name 360 .as_ref() 361 .map(|n| n.as_ref().to_string()) 362 .unwrap_or_default(), 363 p.handle.as_ref().to_string(), 364 p.description 365 .as_ref() 366 .map(|d| d.as_ref().to_string()) 367 .unwrap_or_default(), 368 ), 369 ProfileDataViewInner::ProfileViewDetailed(p) => ( 370 p.display_name 371 .as_ref() 372 .map(|n| n.as_ref().to_string()) 373 .unwrap_or_default(), 374 p.handle.as_ref().to_string(), 375 p.description 376 .as_ref() 377 .map(|d| d.as_ref().to_string()) 378 .unwrap_or_default(), 379 ), 380 ProfileDataViewInner::TangledProfileView(p) => { 381 (String::new(), p.handle.as_ref().to_string(), String::new()) 382 } 383 _ => (String::new(), "unknown".to_string(), String::new()), 384 }; 385 386 let notebook_count = notebooks.read().as_ref().map(|n| n.len()).unwrap_or(0); 387 388 let base = if crate::env::WEAVER_APP_ENV == "dev" { 389 format!("http://127.0.0.1:{}", crate::env::WEAVER_PORT) 390 } else { 391 crate::env::WEAVER_APP_HOST.to_string() 392 }; 393 let og_image_url = format!("{}/og/profile/{}.png", base, ident()); 394 let canonical_url = format!("{}/{}", base, ident()); 395 396 Some(rsx! { 397 ProfileOgMeta { 398 display_name: if display_name.is_empty() { handle.clone() } else { display_name }, 399 handle, 400 bio, 401 image_url: og_image_url, 402 canonical_url, 403 notebook_count, 404 } 405 }) 406 } 407 None => None, 408 }; 409 410 rsx! { 411 {og_meta} 412 413 div { class: "repository-layout", 414 // Profile sidebar (desktop) / header (mobile) 415 aside { class: "repository-sidebar", 416 ProfileDisplay { profile, notebooks, entry_count: *entry_count.read(), is_own_profile: is_own_profile() } 417 } 418 419 // Main content area 420 main { class: "repository-main", 421 // Mobile menubar (hidden on desktop) 422 ProfileActionsMenubar { ident } 423 424 div { class: "profile-timeline", 425 // Pinned items section 426 { 427 let pinned = pinned_items.read(); 428 if !pinned.is_empty() { 429 rsx! { 430 div { class: "pinned-section", 431 h3 { class: "pinned-header", "Pinned" } 432 for (idx, item) in pinned.iter().enumerate() { 433 { 434 match item { 435 ProfileTimelineItem::Notebook { notebook, entries, .. } => { 436 rsx! { 437 div { 438 key: "pinned-notebook-{notebook.cid}", 439 class: "pinned-item", 440 NotebookCard { 441 notebook: notebook.clone(), 442 entries: entries.clone(), 443 is_pinned: true, 444 profile_ident: Some(ident()), 445 } 446 } 447 } 448 } 449 ProfileTimelineItem::StandaloneEntry { entry_view, entry } => { 450 rsx! { 451 div { 452 key: "pinned-entry-{idx}", 453 class: "pinned-item standalone-entry-item", 454 FeedEntryCard { 455 entry_view: entry_view.clone(), 456 entry: entry.clone(), 457 show_actions: true, 458 is_pinned: true, 459 profile_ident: Some(ident()), 460 } 461 } 462 } 463 } 464 } 465 } 466 } 467 } 468 } 469 } else { 470 rsx! {} 471 } 472 } 473 474 // Chronological timeline 475 { 476 let timeline_items = timeline.read(); 477 if timeline_items.is_empty() && pinned_items.read().is_empty() { 478 rsx! { div { class: "timeline-empty", "No content yet" } } 479 } else { 480 rsx! { 481 for (idx, item) in timeline_items.iter().enumerate() { 482 { 483 match item { 484 ProfileTimelineItem::Notebook { notebook, entries, .. } => { 485 rsx! { 486 div { 487 key: "notebook-{notebook.cid}", 488 NotebookCard { 489 notebook: notebook.clone(), 490 entries: entries.clone(), 491 is_pinned: false, 492 profile_ident: Some(ident()), 493 } 494 } 495 } 496 } 497 ProfileTimelineItem::StandaloneEntry { entry_view, entry } => { 498 rsx! { 499 div { 500 key: "entry-{idx}", 501 class: "standalone-entry-item", 502 FeedEntryCard { 503 entry_view: entry_view.clone(), 504 entry: entry.clone(), 505 show_actions: true, 506 is_pinned: false, 507 profile_ident: Some(ident()), 508 } 509 } 510 } 511 } 512 } 513 } 514 } 515 } 516 } 517 } 518 } 519 } 520 521 // Actions sidebar (desktop only) 522 ProfileActions { ident } 523 } 524 } 525} 526 527#[component] 528fn NotebookEntryPreview( 529 book_entry_view: weaver_api::sh_weaver::notebook::BookEntryView<'static>, 530 ident: AtIdentifier<'static>, 531 book_title: SmolStr, 532 #[props(default)] extra_class: Option<&'static str>, 533) -> Element { 534 use jacquard::{IntoStatic, from_data}; 535 use weaver_api::sh_weaver::notebook::entry::Entry; 536 537 let entry_view = &book_entry_view.entry; 538 539 let entry_title = entry_view 540 .title 541 .as_ref() 542 .map(|t| t.as_ref()) 543 .unwrap_or("Untitled"); 544 545 let entry_path = entry_view 546 .path 547 .as_ref() 548 .map(|p| p.as_ref().to_string()) 549 .unwrap_or_else(|| entry_title.to_string()); 550 551 let parsed_entry = from_data::<Entry>(&entry_view.record).ok(); 552 553 let preview_html = parsed_entry.as_ref().map(|entry| { 554 let parser = markdown_weaver::Parser::new(&entry.content); 555 let mut html_buf = String::new(); 556 markdown_weaver::html::push_html(&mut html_buf, parser); 557 html_buf 558 }); 559 560 let created_at = parsed_entry 561 .as_ref() 562 .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string()); 563 564 let entry_uri = entry_view.uri.clone().into_static(); 565 566 let class_name = if let Some(extra) = extra_class { 567 format!("notebook-entry-preview {}", extra) 568 } else { 569 "notebook-entry-preview".to_string() 570 }; 571 572 rsx! { 573 div { class: "{class_name}", 574 div { class: "entry-preview-header", 575 Link { 576 to: Route::EntryPage { 577 ident: ident.clone(), 578 book_title: book_title.clone(), 579 title: entry_path.clone().into() 580 }, 581 class: "entry-preview-title-link", 582 div { class: "entry-preview-title", "{entry_title}" } 583 } 584 if let Some(ref date) = created_at { 585 div { class: "entry-preview-date", "{date}" } 586 } 587 crate::components::EntryActions { 588 entry_uri, 589 entry_cid: entry_view.cid.clone().into_static(), 590 entry_title: entry_title.to_string(), 591 in_notebook: true, 592 notebook_title: Some(book_title.clone()), 593 permissions: entry_view.permissions.clone() 594 } 595 } 596 if let Some(ref html) = preview_html { 597 Link { 598 to: Route::EntryPage { 599 ident: ident.clone(), 600 book_title: book_title.clone(), 601 title: entry_path.clone().into() 602 }, 603 class: "entry-preview-content-link", 604 div { class: "entry-preview-content", dangerous_inner_html: "{html}" } 605 } 606 } 607 } 608 } 609} 610 611#[component] 612pub fn NotebookCard( 613 notebook: NotebookView<'static>, 614 entries: Vec<BookEntryView<'static>>, 615 #[props(default = false)] is_pinned: bool, 616 #[props(default)] show_author: Option<bool>, 617 /// Profile identity for context-aware author visibility (hides single author on their own profile) 618 #[props(default)] 619 profile_ident: Option<AtIdentifier<'static>>, 620 #[props(default)] on_pinned_changed: Option<EventHandler<bool>>, 621 #[props(default)] on_deleted: Option<EventHandler<()>>, 622) -> Element { 623 use jacquard::{IntoStatic, from_data}; 624 use weaver_api::sh_weaver::notebook::book::Book; 625 626 let fetcher = use_context::<fetch::Fetcher>(); 627 let auth_state = use_context::<Signal<AuthState>>(); 628 629 // Parse Book from the NotebookView's record field for settings. 630 let book: Option<Book<'static>> = from_data(&notebook.record) 631 .ok() 632 .map(|b: Book<'_>| b.into_static()); 633 634 let title = notebook 635 .title 636 .as_ref() 637 .map(|t| t.as_ref()) 638 .unwrap_or("Untitled Notebook"); 639 640 // Get notebook path for URLs, fallback to title 641 let notebook_path = notebook 642 .path 643 .as_ref() 644 .map(|p| p.as_ref().to_string()) 645 .unwrap_or_else(|| title.to_string()); 646 647 // Check ownership for "Add Entry" link 648 let notebook_ident = notebook.uri.authority().clone().into_static(); 649 let is_owner = { 650 let current_did = auth_state.read().did.clone(); 651 match (&current_did, &notebook_ident) { 652 (Some(did), AtIdentifier::Did(nb_did)) => *did == *nb_did, 653 _ => false, 654 } 655 }; 656 657 // Format date 658 let formatted_date = notebook.indexed_at.as_ref().format("%B %d, %Y").to_string(); 659 660 // Show authors: explicit prop overrides, otherwise show only if multiple 661 let show_authors = show_author.unwrap_or(notebook.authors.len() > 1); 662 663 let ident = notebook.uri.authority().clone().into_static(); 664 let book_title: SmolStr = notebook_path.clone().into(); 665 666 rsx! { 667 div { class: "notebook-card", 668 div { class: "notebook-card-container", 669 670 div { class: "notebook-card-header", 671 div { class: "notebook-card-header-top", 672 Link { 673 to: Route::EntryPage { 674 ident: ident.clone(), 675 book_title: notebook_path.clone().into(), 676 title: "".into() // Will redirect to first entry 677 }, 678 class: "notebook-card-header-link", 679 h2 { class: "notebook-card-title", "{title}" } 680 } 681 if is_owner { 682 div { class: "notebook-header-actions", 683 Link { 684 to: Route::NewDraft { ident: notebook_ident.clone(), notebook: Some(book_title.clone()) }, 685 class: "notebook-action-link", 686 crate::components::button::Button { 687 variant: crate::components::button::ButtonVariant::Ghost, 688 "Add" 689 } 690 } 691 if let Some(ref book) = book { 692 crate::components::NotebookActions { 693 notebook_uri: notebook.uri.clone().into_static(), 694 notebook_cid: notebook.cid.clone().into_static(), 695 notebook_title: title.to_string(), 696 notebook: book.clone(), 697 is_pinned, 698 on_pinned_changed, 699 on_deleted 700 } 701 } 702 } 703 } 704 } 705 706 div { class: "notebook-card-date", 707 time { datetime: "{notebook.indexed_at.as_str()}", "{formatted_date}" } 708 } 709 } 710 711 // Show authors 712 if show_authors { 713 div { class: "notebook-card-authors", 714 AuthorList { 715 authors: notebook.authors.clone(), 716 profile_ident: profile_ident.clone(), 717 owner_ident: Some(ident.clone()), 718 } 719 } 720 } 721 722 // Entry previews section 723 div { class: "notebook-card-previews", 724 { 725 use jacquard::from_data; 726 use weaver_api::sh_weaver::notebook::entry::Entry; 727 tracing::info!("rendering entries: {:?}", entries.iter().map(|e| 728 e.entry.uri.as_ref()).collect::<Vec<_>>()); 729 730 if entries.len() <= 5 { 731 // Show all entries if 5 or fewer 732 rsx! { 733 for entry_view in entries.iter() { 734 NotebookEntryPreview { 735 book_entry_view: entry_view.clone(), 736 ident: ident.clone(), 737 book_title: book_title.clone(), 738 } 739 } 740 } 741 } else { 742 // Show first, interstitial, and last 743 rsx! { 744 if let Some(first_entry) = entries.first() { 745 NotebookEntryPreview { 746 book_entry_view: first_entry.clone(), 747 ident: ident.clone(), 748 book_title: book_title.clone(), 749 extra_class: "notebook-entry-preview-first", 750 } 751 } 752 753 // Interstitial showing count 754 { 755 let middle_count = entries.len().saturating_sub(2); 756 rsx! { 757 div { class: "notebook-entry-interstitial", 758 "... {middle_count} more " 759 if middle_count == 1 { "entry" } else { "entries" } 760 " ..." 761 } 762 } 763 } 764 765 if let Some(last_entry) = entries.last() { 766 NotebookEntryPreview { 767 book_entry_view: last_entry.clone(), 768 ident: ident.clone(), 769 book_title: book_title.clone(), 770 extra_class: "notebook-entry-preview-last", 771 } 772 } 773 } 774 } 775 } 776 } 777 778 779 if let Some(ref tags) = notebook.tags { 780 if !tags.is_empty() { 781 div { class: "notebook-card-tags", 782 for tag in tags.iter() { 783 span { class: "notebook-card-tag", "{tag}" } 784 } 785 } 786 } 787 } 788 } 789 } 790 } 791}