at main 796 lines 27 kB view raw
1#![allow(non_snake_case)] 2 3#[cfg(feature = "server")] 4use crate::blobcache::BlobCache; 5use crate::components::AuthorList; 6use crate::components::{AppLink, AppLinkTarget}; 7use crate::{components::EntryActions, data::use_handle}; 8use dioxus::prelude::*; 9use jacquard::types::aturi::AtUri; 10use jacquard::{IntoStatic, types::string::Handle}; 11 12pub const ENTRY_CSS: Asset = asset!("/assets/styling/entry.css"); 13 14#[allow(unused_imports)] 15use jacquard::smol_str::ToSmolStr; 16use jacquard::types::string::Datetime; 17#[allow(unused_imports)] 18use jacquard::{ 19 smol_str::SmolStr, 20 types::{cid::Cid, string::AtIdentifier}, 21}; 22#[allow(unused_imports)] 23use std::sync::Arc; 24use weaver_api::sh_weaver::notebook::{BookEntryView, EntryView, entry}; 25 26#[component] 27pub fn EntryPage( 28 ident: ReadSignal<AtIdentifier<'static>>, 29 book_title: ReadSignal<SmolStr>, 30 title: ReadSignal<SmolStr>, 31) -> Element { 32 // Use feature-gated hook for SSR support 33 let (entry_res, entry) = crate::data::use_entry_data(ident, book_title, title); 34 35 // Track props for change detection (works with both Route and SubdomainRoute) 36 let mut last_title = use_signal(|| title().clone()); 37 38 #[cfg(all( 39 target_family = "wasm", 40 target_os = "unknown", 41 not(feature = "fullstack-server") 42 ))] 43 let fetcher = use_context::<crate::fetch::Fetcher>(); 44 45 // Suspend SSR until entry loads 46 #[cfg(feature = "fullstack-server")] 47 let mut entry_res = entry_res?; 48 49 #[cfg(feature = "fullstack-server")] 50 use_effect(use_reactive!(|title| { 51 if title != last_title() { 52 tracing::debug!("[EntryPage] title changed, restarting resource"); 53 entry_res.restart(); 54 last_title.set(title()); 55 } 56 })); 57 58 // Debug: log route params and entry state 59 tracing::debug!( 60 "[EntryPage] route params: ident={:?}, book_title={:?}, title={:?}", 61 ident(), 62 book_title(), 63 title() 64 ); 65 tracing::debug!( 66 "[EntryPage] rendering, entry.is_some={}", 67 entry.read().is_some() 68 ); 69 70 // Handle blob caching when entry data is available 71 // Use read() instead of read_unchecked() for proper reactive tracking 72 match &*entry.read() { 73 Some((book_entry_view, entry_record)) => { 74 rsx! { EntryPageView { 75 book_entry_view: book_entry_view.clone(), 76 entry_record: entry_record.clone(), 77 ident: ident(), 78 book_title: book_title() 79 } } 80 } 81 _ => rsx! { p { "Loading..." } }, 82 } 83} 84 85/// Calculate word count and estimated reading time (in minutes) for content 86pub fn calculate_reading_stats(content: &str) -> (usize, usize) { 87 let word_count = content.split_whitespace().count(); 88 let reading_time_mins = (word_count + 199) / 200; // ~200 wpm, rounded up 89 (word_count, reading_time_mins.max(1)) 90} 91 92/// Extract a plain-text preview from markdown content (first ~160 chars) 93pub fn extract_preview(content: &str, max_len: usize) -> String { 94 // Simple extraction: skip markdown syntax, get plain text 95 let plain: String = content 96 .lines() 97 .filter(|line| { 98 let trimmed = line.trim(); 99 // Skip headings, images, links, code blocks 100 !trimmed.starts_with('#') 101 && !trimmed.starts_with('!') 102 && !trimmed.starts_with("```") 103 && !trimmed.is_empty() 104 }) 105 .take(5) 106 .collect::<Vec<_>>() 107 .join(" "); 108 109 // Clean up markdown inline syntax 110 let cleaned = plain 111 .replace("**", "") 112 .replace("__", "") 113 .replace('*', "") 114 .replace('_', "") 115 .replace('`', ""); 116 117 if cleaned.len() <= max_len { 118 cleaned 119 } else { 120 // Use char boundary-safe truncation to avoid panic on multibyte chars 121 let truncated: String = cleaned.chars().take(max_len - 3).collect(); 122 format!("{}...", truncated) 123 } 124} 125 126/// OpenGraph and Twitter Card meta tags for entries 127#[component] 128pub fn EntryOgMeta( 129 title: String, 130 description: String, 131 image_url: String, 132 canonical_url: String, 133 author_handle: String, 134 #[props(default)] book_title: Option<String>, 135) -> Element { 136 let page_title = if let Some(ref book) = book_title { 137 format!("{} | {} | Weaver", title, book) 138 } else { 139 format!("{} | Weaver", title) 140 }; 141 142 rsx! { 143 document::Title { "{page_title}" } 144 document::Meta { property: "og:title", content: "{title}" } 145 document::Meta { property: "og:description", content: "{description}" } 146 document::Meta { property: "og:image", content: "{image_url}" } 147 document::Meta { property: "og:type", content: "article" } 148 document::Meta { property: "og:url", content: "{canonical_url}" } 149 document::Meta { property: "og:site_name", content: "Weaver" } 150 document::Meta { name: "twitter:card", content: "summary_large_image" } 151 document::Meta { name: "twitter:title", content: "{title}" } 152 document::Meta { name: "twitter:description", content: "{description}" } 153 document::Meta { name: "twitter:image", content: "{image_url}" } 154 document::Meta { name: "twitter:creator", content: "@{author_handle}" } 155 } 156} 157 158/// Full entry page with metadata, content, and navigation 159#[component] 160fn EntryPageView( 161 book_entry_view: ReadSignal<BookEntryView<'static>>, 162 entry_record: ReadSignal<entry::Entry<'static>>, 163 ident: ReadSignal<AtIdentifier<'static>>, 164 book_title: ReadSignal<SmolStr>, 165) -> Element { 166 // Extract metadata 167 let entry_view = &book_entry_view().entry; 168 let title = entry_view 169 .title 170 .as_ref() 171 .map(|t| t.as_ref()) 172 .unwrap_or("Untitled"); 173 174 // Get entry path for URLs 175 let entry_path = entry_view 176 .path 177 .as_ref() 178 .map(|p| p.as_ref().to_string()) 179 .unwrap_or_else(|| title.to_string()); 180 181 // Get author handle 182 let author_handle = entry_view 183 .authors 184 .first() 185 .map(|a| { 186 use weaver_api::sh_weaver::actor::ProfileDataViewInner; 187 match &a.record.inner { 188 ProfileDataViewInner::ProfileView(p) => p.handle.as_ref().to_string(), 189 ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref().to_string(), 190 ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref().to_string(), 191 _ => "unknown".to_string(), 192 } 193 }) 194 .unwrap_or_else(|| "unknown".to_string()); 195 196 // Build OG URLs 197 let base = if crate::env::WEAVER_APP_ENV == "dev" { 198 format!("http://127.0.0.1:{}", crate::env::WEAVER_PORT) 199 } else { 200 crate::env::WEAVER_APP_HOST.to_string() 201 }; 202 let canonical_url = format!("{}/{}/{}/{}", base, ident(), book_title(), entry_path); 203 let og_image_url = format!( 204 "{}/og/{}/{}/{}.png", 205 base, 206 ident(), 207 book_title(), 208 entry_path 209 ); 210 211 // Extract description preview from content 212 let description = extract_preview(entry_record().content.as_ref(), 160); 213 214 tracing::info!("Entry: {book_title} - {title}"); 215 216 rsx! { 217 EntryOgMeta { 218 title: title.to_string(), 219 description: description, 220 image_url: og_image_url, 221 canonical_url: canonical_url, 222 author_handle: author_handle, 223 book_title: Some(book_title().to_string()), 224 } 225 document::Link { rel: "stylesheet", href: ENTRY_CSS } 226 227 div { class: "entry-page", 228 // Header: nav prev + metadata + nav next 229 header { class: "entry-header", 230 if let Some(ref prev) = book_entry_view().prev { 231 NavButton { 232 direction: "prev", 233 entry: prev.entry.clone(), 234 ident: ident(), 235 book_title: book_title() 236 } 237 } else { 238 div { class: "nav-placeholder" } 239 } 240 241 { 242 let (word_count, reading_time_mins) = calculate_reading_stats(&entry_record().content); 243 rsx! { 244 EntryMetadata { 245 entry_view: entry_view.clone(), 246 created_at: entry_record().created_at.clone(), 247 entry_uri: entry_view.uri.clone().into_static(), 248 book_title: Some(book_title()), 249 ident: ident(), 250 word_count: Some(word_count), 251 reading_time_mins: Some(reading_time_mins) 252 } 253 } 254 } 255 256 if let Some(ref next) = book_entry_view().next { 257 NavButton { 258 direction: "next", 259 entry: next.entry.clone(), 260 ident: ident(), 261 book_title: book_title() 262 } 263 } else { 264 div { class: "nav-placeholder" } 265 } 266 } 267 268 // Main content area 269 div { class: "entry-content-wrapper", 270 div { class: "entry-content-main notebook-content", 271 EntryMarkdown { 272 content: entry_record, 273 ident 274 } 275 } 276 } 277 278 // Footer navigation 279 footer { class: "entry-footer-nav", 280 if let Some(ref prev) = book_entry_view().prev { 281 NavButton { 282 direction: "prev", 283 entry: prev.entry.clone(), 284 ident: ident(), 285 book_title: book_title() 286 } 287 } 288 289 if let Some(ref next) = book_entry_view().next { 290 NavButton { 291 direction: "next", 292 entry: next.entry.clone(), 293 ident: ident(), 294 book_title: book_title() 295 } 296 } 297 } 298 } 299 } 300} 301 302#[component] 303pub fn EntryCard( 304 entry: BookEntryView<'static>, 305 book_title: SmolStr, 306 author_count: usize, 307 ident: AtIdentifier<'static>, 308) -> Element { 309 use crate::auth::AuthState; 310 use jacquard::from_data; 311 use weaver_api::sh_weaver::notebook::entry::Entry; 312 313 let mut hidden = use_signal(|| false); 314 315 // If removed from notebook, hide this card 316 if hidden() { 317 return rsx! {}; 318 } 319 320 let auth_state = use_context::<Signal<AuthState>>(); 321 322 let entry_view = &entry.entry; 323 let title = entry_view 324 .title 325 .as_ref() 326 .map(|t| t.as_ref()) 327 .unwrap_or("Untitled"); 328 329 // Get path from view for URL, fallback to title 330 let entry_path = entry_view 331 .path 332 .as_ref() 333 .map(|p| p.as_ref().to_string()) 334 .unwrap_or_else(|| title.to_string()); 335 336 // Parse entry record for content preview 337 let parsed_entry = from_data::<Entry>(&entry_view.record).ok(); 338 339 // Format date 340 let formatted_date = entry_view 341 .indexed_at 342 .as_ref() 343 .format("%B %d, %Y") 344 .to_string(); 345 346 // Check edit access via permissions 347 let can_edit = { 348 let current_did = auth_state.read().did.clone(); 349 match &current_did { 350 Some(did) => { 351 if let Some(ref perms) = entry_view.permissions { 352 perms.editors.iter().any(|grant| grant.did == *did) 353 } else { 354 // Fall back to ownership check 355 match &ident { 356 AtIdentifier::Did(ident_did) => *did == *ident_did, 357 _ => false, 358 } 359 } 360 } 361 None => false, 362 } 363 }; 364 365 let entry_uri = entry_view.uri.clone().into_static(); 366 367 // Show author list if notebook has multiple authors 368 let show_author = author_count > 1; 369 370 // Render preview from truncated entry content 371 let preview_html = parsed_entry.as_ref().map(|entry| { 372 let parser = markdown_weaver::Parser::new(&entry.content); 373 let mut html_buf = String::new(); 374 markdown_weaver::html::push_html(&mut html_buf, parser); 375 html_buf 376 }); 377 378 // Calculate reading stats 379 let reading_stats = parsed_entry 380 .as_ref() 381 .map(|entry| calculate_reading_stats(&entry.content)); 382 383 rsx! { 384 div { class: "entry-card", 385 div { class: "entry-card-meta", 386 div { class: "entry-card-header", 387 AppLink { 388 to: AppLinkTarget::Entry { 389 ident: ident.clone(), 390 book_title: book_title.clone(), 391 entry_path: entry_path.clone().into(), 392 }, 393 class: Some("entry-card-title-link".to_string()), 394 h3 { class: "entry-card-title", "{title}" } 395 } 396 div { class: "entry-card-date", 397 time { datetime: "{entry_view.indexed_at.as_str()}", "{formatted_date}" } 398 } 399 if can_edit { 400 EntryActions { 401 entry_uri, 402 entry_cid: entry_view.cid.clone().into_static(), 403 entry_title: title.to_string(), 404 in_notebook: true, 405 notebook_title: Some(book_title.clone()), 406 permissions: entry_view.permissions.clone(), 407 on_removed: Some(EventHandler::new(move |_| hidden.set(true))) 408 } 409 } 410 } 411 if show_author && !entry_view.authors.is_empty() { 412 AuthorList { 413 authors: entry_view.authors.clone(), 414 owner_ident: Some(ident.clone()), 415 class: Some("entry-card-author".to_string()), 416 } 417 } 418 } 419 420 if let Some(ref html) = preview_html { 421 div { class: "entry-card-preview", dangerous_inner_html: "{html}" } 422 } 423 if let Some(ref tags) = entry_view.tags { 424 if !tags.is_empty() { 425 div { class: "entry-card-tags", 426 for tag in tags.iter() { 427 span { class: "entry-card-tag", "{tag}" } 428 } 429 } 430 } 431 } 432 if let Some((words, mins)) = reading_stats { 433 div { class: "entry-card-stats", 434 span { class: "word-count", "{words} words" } 435 span { class: "reading-time", "{mins} min read" } 436 } 437 } 438 } 439 } 440} 441 442/// Card for entries in a feed (e.g., home page) 443/// Takes EntryView directly (not BookEntryView) 444#[component] 445pub fn FeedEntryCard( 446 entry_view: EntryView<'static>, 447 entry: entry::Entry<'static>, 448 #[props(default = false)] show_actions: bool, 449 #[props(default = false)] is_pinned: bool, 450 #[props(default = true)] show_author: bool, 451 /// Profile identity for context-aware author visibility (hides single author on their own profile) 452 #[props(default)] 453 profile_ident: Option<AtIdentifier<'static>>, 454 #[props(default)] on_pinned_changed: Option<EventHandler<bool>>, 455) -> Element { 456 use crate::auth::AuthState; 457 458 let title = entry_view 459 .title 460 .as_ref() 461 .map(|t| t.as_ref()) 462 .unwrap_or("Untitled"); 463 464 // Extract DID and rkey from the entry URI 465 let uri = &entry_view.uri; 466 let parsed_uri = jacquard::types::aturi::AtUri::new(uri.as_ref()).ok(); 467 468 let ident = parsed_uri 469 .as_ref() 470 .map(|u| u.authority().clone().into_static()) 471 .unwrap_or_else(|| AtIdentifier::Handle(Handle::new_static("invalid.handle").unwrap())); 472 473 let rkey: SmolStr = parsed_uri 474 .as_ref() 475 .and_then(|u| u.rkey().map(|r| SmolStr::new(r.0.as_str()))) 476 .unwrap_or_default(); 477 478 // Format date from record's created_at 479 let formatted_date = entry.created_at.as_ref().format("%B %d, %Y").to_string(); 480 481 // Whether to show authors 482 let has_authors = show_author && !entry_view.authors.is_empty(); 483 484 // Check edit access via permissions 485 let auth_state = use_context::<Signal<AuthState>>(); 486 let can_edit = { 487 let current_did = auth_state.read().did.clone(); 488 match &current_did { 489 Some(did) => { 490 if let Some(ref perms) = entry_view.permissions { 491 perms.editors.iter().any(|grant| grant.did == *did) 492 } else { 493 // Fall back to ownership check 494 match &ident { 495 AtIdentifier::Did(ident_did) => *did == *ident_did, 496 _ => false, 497 } 498 } 499 } 500 None => false, 501 } 502 }; 503 504 // Render preview from truncated entry content 505 let preview_html = { 506 let parser = markdown_weaver::Parser::new(&entry.content); 507 let mut html_buf = String::new(); 508 markdown_weaver::html::push_html(&mut html_buf, parser); 509 html_buf 510 }; 511 512 // Calculate reading stats 513 let (word_count, reading_time_mins) = calculate_reading_stats(&entry.content); 514 515 rsx! { 516 div { class: "entry-card feed-entry-card", 517 // Header: title (and date if no author) 518 div { class: "entry-card-header", 519 AppLink { 520 to: AppLinkTarget::StandaloneEntry { 521 ident: ident.clone(), 522 rkey: rkey.clone(), 523 }, 524 class: Some("entry-card-title-link".to_string()), 525 h3 { class: "entry-card-title", "{title}" } 526 } 527 // Date inline with title when no author shown 528 if !has_authors { 529 div { class: "entry-card-date", 530 time { datetime: "{entry.created_at.as_str()}", "{formatted_date}" } 531 } 532 } 533 if show_actions && can_edit { 534 crate::components::EntryActions { 535 entry_uri: entry_view.uri.clone().into_static(), 536 entry_cid: entry_view.cid.clone().into_static(), 537 entry_title: title.to_string(), 538 in_notebook: false, 539 is_pinned, 540 permissions: entry_view.permissions.clone(), 541 on_pinned_changed 542 } 543 } 544 } 545 546 // Byline: author + date (only when authors shown) 547 if has_authors { 548 div { class: "entry-card-byline", 549 AuthorList { 550 authors: entry_view.authors.clone(), 551 profile_ident: profile_ident.clone(), 552 owner_ident: Some(ident.clone()), 553 class: Some("entry-card-author".to_string()), 554 } 555 div { class: "entry-card-date", 556 time { datetime: "{entry.created_at.as_str()}", "{formatted_date}" } 557 } 558 } 559 } 560 561 div { class: "entry-card-preview", dangerous_inner_html: "{preview_html}" } 562 if let Some(ref tags) = entry_view.tags { 563 if !tags.is_empty() { 564 div { class: "entry-card-tags", 565 for tag in tags.iter() { 566 span { class: "entry-card-tag", "{tag}" } 567 } 568 } 569 } 570 } 571 div { class: "entry-card-stats", 572 span { class: "word-count", "{word_count} words" } 573 span { class: "reading-time", "{reading_time_mins} min read" } 574 } 575 } 576 } 577} 578 579/// Metadata header showing title, authors, date, tags, reading stats 580#[component] 581pub fn EntryMetadata( 582 entry_view: EntryView<'static>, 583 created_at: Datetime, 584 entry_uri: AtUri<'static>, 585 book_title: Option<SmolStr>, 586 ident: AtIdentifier<'static>, 587 #[props(default)] word_count: Option<usize>, 588 #[props(default)] reading_time_mins: Option<usize>, 589) -> Element { 590 use crate::components::use_app_navigate; 591 592 let navigate = use_app_navigate(); 593 594 let title = entry_view 595 .title 596 .as_ref() 597 .map(|t| t.as_ref()) 598 .unwrap_or("Untitled"); 599 600 let entry_title = title.to_string(); 601 602 // Navigate back to notebook when entry is removed 603 let nav_book_title = book_title.clone(); 604 let nav_ident = ident.clone(); 605 let on_removed = move |_| { 606 if let Some(ref title) = nav_book_title { 607 navigate(AppLinkTarget::Notebook { 608 ident: nav_ident.clone(), 609 book_title: title.clone(), 610 }); 611 } 612 }; 613 614 rsx! { 615 header { class: "entry-metadata", 616 div { class: "entry-header-row", 617 h1 { class: "entry-title", "{title}" } 618 EntryActions { 619 entry_uri: entry_uri.clone(), 620 entry_cid: entry_view.cid.clone().into_static(), 621 entry_title, 622 in_notebook: book_title.is_some(), 623 notebook_title: book_title.clone(), 624 permissions: entry_view.permissions.clone(), 625 on_removed: Some(EventHandler::new(on_removed)) 626 } 627 } 628 629 div { class: "entry-meta-info", 630 // Authors 631 div { class: "entry-authors", 632 AuthorList { 633 authors: entry_view.authors.clone(), 634 owner_ident: Some(ident.clone()), 635 } 636 } 637 638 639 // Date 640 div { class: "entry-date", 641 { 642 let formatted_date = created_at.as_ref().format("%B %d, %Y").to_string(); 643 644 rsx! { 645 time { datetime: "{entry_view.indexed_at.as_str()}", "{formatted_date}" } 646 647 } 648 } 649 } 650 651 // Tags and reading stats on their own line 652 div { class: "entry-meta-secondary", 653 if let Some(ref tags) = entry_view.tags { 654 div { class: "entry-tags", 655 span { class: "meta-label", "Tags:" } 656 for tag in tags.iter() { 657 span { class: "entry-tag", "{tag}" } 658 } 659 } 660 } 661 662 if let (Some(words), Some(mins)) = (word_count, reading_time_mins) { 663 div { class: "entry-reading-stats", 664 span { class: "word-count", "{words} words" } 665 span { class: "reading-time", "{mins} min read" } 666 } 667 } 668 } 669 } 670 } 671 } 672} 673 674/// Navigation link for prev/next entries (minimal: arrow + title) 675#[component] 676pub fn NavButton( 677 direction: &'static str, 678 entry: EntryView<'static>, 679 ident: AtIdentifier<'static>, 680 book_title: SmolStr, 681) -> Element { 682 let entry_title = entry 683 .title 684 .as_ref() 685 .map(|t| t.as_ref()) 686 .unwrap_or("Untitled"); 687 688 let entry_path = entry 689 .path 690 .as_ref() 691 .map(|p| p.as_ref().to_string()) 692 .unwrap_or_else(|| entry_title.to_string()); 693 694 let (arrow, title_first) = if direction == "prev" { 695 ("", false) 696 } else { 697 ("", true) 698 }; 699 700 rsx! { 701 AppLink { 702 to: AppLinkTarget::Entry { 703 ident: ident.clone(), 704 book_title: book_title.clone(), 705 entry_path: entry_path.into(), 706 }, 707 class: Some(format!("nav-button nav-button-{}", direction)), 708 if title_first { 709 span { class: "nav-title", "{entry_title}" } 710 span { class: "nav-arrow", "{arrow}" } 711 } else { 712 span { class: "nav-arrow", "{arrow}" } 713 span { class: "nav-title", "{entry_title}" } 714 } 715 } 716 } 717} 718 719#[derive(Props, Clone, PartialEq)] 720pub struct EntryMarkdownProps { 721 #[props(default)] 722 id: Signal<String>, 723 #[props(default = use_signal(||"entry".to_string()))] 724 class: Signal<String>, 725 content: ReadSignal<entry::Entry<'static>>, 726 ident: ReadSignal<AtIdentifier<'static>>, 727} 728 729/// Render some text as markdown. 730pub fn EntryMarkdown(props: EntryMarkdownProps) -> Element { 731 let (mut _res, processed) = crate::data::use_rendered_markdown(props.content, props.ident); 732 733 // Track entry title to detect content change and restart resource 734 let mut last_title = use_signal(|| (props.content)().title.to_string()); 735 let current_title = (props.content)().title.to_string(); 736 if current_title != last_title() { 737 #[cfg(feature = "fullstack-server")] 738 if let Ok(ref mut r) = _res { 739 r.restart(); 740 } 741 last_title.set(current_title); 742 } 743 744 #[cfg(feature = "fullstack-server")] 745 _res?; 746 747 match &*processed.read() { 748 Some(html_buf) => rsx! { 749 div { 750 id: "{&*props.id.read()}", 751 class: "{&*props.class.read()}", 752 dangerous_inner_html: "{html_buf}" 753 } 754 }, 755 _ => rsx! { 756 div { 757 id: "{&*props.id.read()}", 758 class: "{&*props.class.read()}", 759 "Loading..." 760 } 761 }, 762 } 763} 764 765/// Render entry content directly without signals 766#[component] 767fn EntryMarkdownDirect( 768 #[props(default)] id: String, 769 #[props(default = "entry".to_string())] class: String, 770 content: entry::Entry<'static>, 771 ident: AtIdentifier<'static>, 772) -> Element { 773 // Use feature-gated hook for SSR support 774 let content = use_signal(|| content); 775 let ident = use_signal(|| ident); 776 let (_res, processed) = crate::data::use_rendered_markdown(content.into(), ident.into()); 777 #[cfg(feature = "fullstack-server")] 778 _res?; 779 780 match &*processed.read() { 781 Some(html_buf) => rsx! { 782 div { 783 id: "{id}", 784 class: "{class}", 785 dangerous_inner_html: "{html_buf}" 786 } 787 }, 788 _ => rsx! { 789 div { 790 id: "{id}", 791 class: "{class}", 792 "Loading..." 793 } 794 }, 795 } 796}