more of an actual home page now

Orual 21e174ee ee72d593

+641 -47
+60 -2
crates/weaver-app/assets/styling/entry-card.css
··· 105 105 } 106 106 107 107 .entry-card-meta { 108 - gap: 1rem; 109 108 margin-bottom: 0.5rem; 110 109 font-size: 0.875rem; 111 110 color: var(--color-subtle); 112 111 } 113 112 113 + a.entry-card-author, 114 114 .entry-card-author { 115 - margin-left: auto; 116 115 display: flex; 117 116 align-items: center; 118 117 gap: 0.5rem; 118 + margin-top: 0.25rem; 119 + text-decoration: none; 119 120 } 120 121 121 122 .entry-card-author .author-name { 122 123 font-weight: 500; 123 124 color: var(--color-text); 125 + transition: color 0.2s ease; 126 + } 127 + 128 + .entry-card-author .meta-label { 129 + color: var(--color-muted); 130 + font-size: 0.8rem; 131 + transition: color 0.2s ease; 132 + } 133 + 134 + .entry-card-author:hover .author-name, 135 + .entry-card-author:hover .meta-label, 136 + .feed-entry-card .entry-card-author:hover .meta-label { 137 + color: var(--color-link); 124 138 } 125 139 126 140 .entry-card-date { ··· 128 142 font-size: 0.8rem; 129 143 } 130 144 145 + /* Feed entry card - matches existing entry-card patterns */ 146 + .feed-entry-card { 147 + background: var(--color-surface); 148 + box-shadow: 0 1px 2px color-mix(in srgb, var(--color-text) 6%, transparent); 149 + padding: 1.25rem; 150 + transition: 151 + box-shadow 0.2s ease, 152 + border-color 0.2s ease; 153 + } 154 + 155 + .feed-entry-card:hover { 156 + border-left: 2px solid var(--color-secondary); 157 + margin-left: -1px; 158 + } 159 + 160 + .feed-entry-card:hover .entry-card-title { 161 + color: var(--color-secondary); 162 + } 163 + 164 + .feed-entry-card .entry-card-title { 165 + margin-bottom: 0.5rem; 166 + } 167 + 168 + .feed-entry-card .entry-card-byline { 169 + display: flex; 170 + align-items: center; 171 + gap: 0.5rem; 172 + margin-bottom: 0.5rem; 173 + } 174 + 175 + .feed-entry-card .entry-card-byline .entry-card-date { 176 + margin-left: auto; 177 + } 178 + 131 179 .entry-card-tags { 132 180 display: flex; 133 181 gap: 0.4rem; ··· 196 244 .entry-card-link { 197 245 box-shadow: none; 198 246 border: 1px solid var(--color-border); 247 + } 248 + 249 + .feed-entry-card { 250 + box-shadow: none; 251 + border-top: 1px solid var(--color-border); 252 + border-right: 1px solid var(--color-border); 253 + border-bottom: 1px solid var(--color-border); 254 + 255 + border-left: 1px solid var(--color-border); 256 + /* Keep border-left as accent */ 199 257 } 200 258 }
+51
crates/weaver-app/assets/styling/home.css
··· 1 + .home-container { 2 + max-width: 1000px; 3 + margin: 0 auto; 4 + padding: 1rem; 5 + } 6 + 7 + .section-header { 8 + font-size: 1.25rem; 9 + font-weight: 600; 10 + color: var(--color-text-muted); 11 + margin-bottom: 1rem; 12 + padding-bottom: 0.5rem; 13 + } 14 + 15 + .pinned-section { 16 + margin-bottom: 2rem; 17 + } 18 + 19 + .pinned-items { 20 + display: flex; 21 + flex-direction: column; 22 + gap: 1rem; 23 + } 24 + 25 + .pinned-items .entry-card, 26 + .pinned-items .feed-entry-card, 27 + .pinned-items .notebook-card { 28 + border-left: 3px solid var(--color-primary) !important; 29 + } 30 + 31 + .pinned-items .feed-entry-card:hover { 32 + border-left: 3px solid var(--color-secondary) !important; 33 + margin-left: 0; 34 + } 35 + 36 + .feed-section { 37 + margin-bottom: 2rem; 38 + } 39 + 40 + .entries-feed { 41 + display: flex; 42 + flex-direction: column; 43 + gap: 1rem; 44 + } 45 + 46 + .loading, 47 + .pinned-item-loading { 48 + color: var(--color-text-muted); 49 + padding: 2rem; 50 + text-align: center; 51 + }
+19
crates/weaver-app/assets/styling/navbar.css
··· 43 43 color: var(--color-text, #666); 44 44 font-weight: 500; 45 45 } 46 + 47 + .nav-tools { 48 + display: flex; 49 + align-items: center; 50 + gap: 1rem; 51 + margin-left: auto; 52 + margin-right: 1rem; 53 + } 54 + 55 + .nav-tool-link { 56 + color: var(--color-text-muted); 57 + text-decoration: none; 58 + font-size: 0.875rem; 59 + transition: color 0.2s ease; 60 + } 61 + 62 + .nav-tool-link:hover { 63 + color: var(--color-primary); 64 + }
+194 -10
crates/weaver-app/src/components/entry.rs
··· 9 9 data::use_handle, 10 10 }; 11 11 use dioxus::prelude::*; 12 - use jacquard::IntoStatic; 13 12 use jacquard::types::aturi::AtUri; 13 + use jacquard::{IntoStatic, types::string::Handle}; 14 14 15 15 pub const ENTRY_CSS: Asset = asset!("/assets/styling/entry.css"); 16 16 ··· 75 75 book_title(), 76 76 title() 77 77 ); 78 - tracing::debug!("[EntryPage] rendering, entry.is_some={}", entry.read().is_some()); 78 + tracing::debug!( 79 + "[EntryPage] rendering, entry.is_some={}", 80 + entry.read().is_some() 81 + ); 79 82 80 83 // Handle blob caching when entry data is available 81 84 // Use read() instead of read_unchecked() for proper reactive tracking ··· 149 152 } 150 153 } 151 154 155 + /// Truncate markdown content for preview (preserves markdown syntax) 156 + /// Takes first few paragraphs up to max_chars, truncating at paragraph boundary 157 + fn truncate_markdown_preview(content: &str, max_chars: usize, max_paragraphs: usize) -> String { 158 + let mut result = String::new(); 159 + let mut char_count = 0; 160 + let mut para_count = 0; 161 + let mut in_code_block = false; 162 + 163 + for line in content.lines() { 164 + // Track code blocks to avoid breaking them 165 + if line.trim().starts_with("```") { 166 + in_code_block = !in_code_block; 167 + // Skip code blocks in preview entirely 168 + if in_code_block { 169 + continue; 170 + } 171 + } 172 + 173 + if in_code_block { 174 + continue; 175 + } 176 + 177 + // Skip headings, images in preview 178 + let trimmed = line.trim(); 179 + if trimmed.starts_with('#') || trimmed.starts_with('!') { 180 + continue; 181 + } 182 + 183 + // Empty line = paragraph boundary 184 + if trimmed.is_empty() { 185 + if !result.is_empty() && !result.ends_with("\n\n") { 186 + para_count += 1; 187 + if para_count >= max_paragraphs || char_count >= max_chars { 188 + break; 189 + } 190 + result.push_str("\n\n"); 191 + } 192 + continue; 193 + } 194 + 195 + // Check if adding this line would exceed limit 196 + if char_count + line.len() > max_chars && !result.is_empty() { 197 + break; 198 + } 199 + 200 + if !result.is_empty() && !result.ends_with('\n') { 201 + result.push('\n'); 202 + } 203 + result.push_str(line); 204 + char_count += line.len(); 205 + } 206 + 207 + result.trim().to_string() 208 + } 209 + 152 210 /// OpenGraph and Twitter Card meta tags for entries 153 211 #[component] 154 212 pub fn EntryOgMeta( ··· 225 283 } else { 226 284 crate::env::WEAVER_APP_HOST.to_string() 227 285 }; 228 - let canonical_url = format!( 229 - "{}/{}/{}/{}", 230 - base, 231 - ident(), 232 - book_title(), 233 - entry_path 234 - ); 286 + let canonical_url = format!("{}/{}/{}/{}", base, ident(), book_title(), entry_path); 235 287 let og_image_url = format!( 236 288 "{}/og/{}/{}/{}.png", 237 289 base, ··· 366 418 None 367 419 }; 368 420 369 - // Render preview from entry content 421 + // Render preview from truncated entry content 370 422 let preview_html = parsed_entry.as_ref().map(|entry| { 371 423 let parser = markdown_weaver::Parser::new(&entry.content); 372 424 let mut html_buf = String::new(); ··· 451 503 if let Some(ref html) = preview_html { 452 504 div { class: "entry-card-preview", dangerous_inner_html: "{html}" } 453 505 } 506 + if let Some(ref tags) = entry_view.tags { 507 + if !tags.is_empty() { 508 + div { class: "entry-card-tags", 509 + for tag in tags.iter() { 510 + span { class: "entry-card-tag", "{tag}" } 511 + } 512 + } 513 + } 514 + } 515 + } 516 + } 517 + } 518 + 519 + /// Card for entries in a feed (e.g., home page) 520 + /// Takes EntryView directly (not BookEntryView) and always shows author info 521 + #[component] 522 + pub fn FeedEntryCard(entry_view: EntryView<'static>, entry: entry::Entry<'static>) -> Element { 523 + use crate::Route; 524 + use jacquard::from_data; 525 + use weaver_api::sh_weaver::actor::ProfileDataViewInner; 526 + 527 + let title = entry_view 528 + .title 529 + .as_ref() 530 + .map(|t| t.as_ref()) 531 + .unwrap_or("Untitled"); 532 + 533 + // Extract DID and rkey from the entry URI 534 + let uri = &entry_view.uri; 535 + let parsed_uri = jacquard::types::aturi::AtUri::new(uri.as_ref()).ok(); 536 + 537 + let ident = parsed_uri 538 + .as_ref() 539 + .map(|u| u.authority().clone().into_static()) 540 + .unwrap_or_else(|| AtIdentifier::Handle(Handle::new_static("invalid.handle").unwrap())); 541 + 542 + let rkey: SmolStr = parsed_uri 543 + .as_ref() 544 + .and_then(|u| u.rkey().map(|r| SmolStr::new(r.0.as_str()))) 545 + .unwrap_or_default(); 546 + 547 + // Format date from record's created_at 548 + let formatted_date = entry.created_at.as_ref().format("%B %d, %Y").to_string(); 549 + 550 + // Get first author 551 + let first_author = entry_view.authors.first(); 552 + 553 + // Render preview from truncated entry content 554 + let preview_html = { 555 + let parser = markdown_weaver::Parser::new(&entry.content); 556 + let mut html_buf = String::new(); 557 + markdown_weaver::html::push_html(&mut html_buf, parser); 558 + html_buf 559 + }; 560 + 561 + rsx! { 562 + div { class: "entry-card feed-entry-card", 563 + // Title 564 + Link { 565 + to: Route::StandaloneEntry { 566 + ident: ident.clone(), 567 + rkey: rkey.clone().into() 568 + }, 569 + class: "entry-card-title-link", 570 + h3 { class: "entry-card-title", "{title}" } 571 + } 572 + 573 + // Byline: author + date 574 + div { class: "entry-card-byline", 575 + if let Some(author) = first_author { 576 + { 577 + match &author.record.inner { 578 + ProfileDataViewInner::ProfileView(profile) => { 579 + let display_name = profile.display_name.as_ref().map(|n| n.as_ref()).unwrap_or("Unknown"); 580 + let handle = profile.handle.clone(); 581 + rsx! { 582 + Link { 583 + to: Route::RepositoryIndex { ident: AtIdentifier::Handle(handle.clone()) }, 584 + class: "entry-card-author", 585 + if let Some(ref avatar_url) = profile.avatar { 586 + Avatar { 587 + AvatarImage { src: avatar_url.as_ref() } 588 + } 589 + } 590 + span { class: "author-name", "{display_name}" } 591 + span { class: "meta-label", "@{handle}" } 592 + } 593 + } 594 + } 595 + ProfileDataViewInner::ProfileViewDetailed(profile) => { 596 + let display_name = profile.display_name.as_ref().map(|n| n.as_ref()).unwrap_or("Unknown"); 597 + let handle = profile.handle.clone(); 598 + rsx! { 599 + Link { 600 + to: Route::RepositoryIndex { ident: AtIdentifier::Handle(handle.clone()) }, 601 + class: "entry-card-author", 602 + if let Some(ref avatar_url) = profile.avatar { 603 + Avatar { 604 + AvatarImage { src: avatar_url.as_ref() } 605 + } 606 + } 607 + span { class: "author-name", "{display_name}" } 608 + span { class: "meta-label", "@{handle}" } 609 + } 610 + } 611 + } 612 + ProfileDataViewInner::TangledProfileView(profile) => { 613 + let handle = profile.handle.clone(); 614 + rsx! { 615 + Link { 616 + to: Route::RepositoryIndex { ident: AtIdentifier::Handle(handle.clone()) }, 617 + class: "entry-card-author", 618 + span { class: "author-name", "@{handle}" } 619 + } 620 + } 621 + } 622 + _ => { 623 + rsx! { 624 + div { class: "entry-card-author", 625 + span { class: "author-name", "Unknown" } 626 + } 627 + } 628 + } 629 + } 630 + } 631 + } 632 + div { class: "entry-card-date", 633 + time { datetime: "{entry.created_at.as_str()}", "{formatted_date}" } 634 + } 635 + } 636 + 637 + div { class: "entry-card-preview", dangerous_inner_html: "{preview_html}" } 454 638 if let Some(ref tags) = entry_view.tags { 455 639 if !tags.is_empty() { 456 640 div { class: "entry-card-tags",
+2 -2
crates/weaver-app/src/components/mod.rs
··· 8 8 mod entry; 9 9 #[allow(unused_imports)] 10 10 pub use entry::{ 11 - ENTRY_CSS, EntryCard, EntryMarkdown, EntryMetadata, EntryOgMeta, EntryPage, NavButton, 12 - extract_preview, 11 + ENTRY_CSS, EntryCard, EntryMarkdown, EntryMetadata, EntryOgMeta, EntryPage, FeedEntryCard, 12 + NavButton, extract_preview, 13 13 }; 14 14 15 15 pub mod identity;
+77 -1
crates/weaver-app/src/data.rs
··· 25 25 use std::sync::Arc; 26 26 use weaver_api::com_atproto::repo::strong_ref::StrongRef; 27 27 use weaver_api::sh_weaver::actor::ProfileDataView; 28 - use weaver_api::sh_weaver::notebook::{BookEntryView, NotebookView, entry::Entry}; 28 + use weaver_api::sh_weaver::notebook::{BookEntryView, EntryView, NotebookView, entry::Entry}; 29 29 // ============================================================================ 30 30 // Wrapper Hooks (feature-gated) 31 31 // ============================================================================ ··· 633 633 .ok() 634 634 .map(|notebooks| { 635 635 notebooks 636 + .iter() 637 + .map(|arc| arc.as_ref().clone()) 638 + .collect::<Vec<_>>() 639 + }) 640 + } 641 + }); 642 + let memo = use_memo(move || res.read().clone().flatten()); 643 + (res, memo) 644 + } 645 + 646 + /// Fetches entries from UFOS with SSR support in fullstack mode 647 + #[cfg(feature = "fullstack-server")] 648 + pub fn use_entries_from_ufos() -> ( 649 + Result<Resource<Option<Vec<(serde_json::Value, serde_json::Value, u64)>>>, RenderError>, 650 + Memo<Option<Vec<(EntryView<'static>, Entry<'static>, u64)>>>, 651 + ) { 652 + let fetcher = use_context::<crate::fetch::Fetcher>(); 653 + let res = use_server_future(move || { 654 + let fetcher = fetcher.clone(); 655 + async move { 656 + match fetcher.fetch_entries_from_ufos().await { 657 + Ok(entries) => { 658 + Some( 659 + entries 660 + .iter() 661 + .filter_map(|arc| { 662 + let (view, entry, time) = arc.as_ref(); 663 + let view_json = serde_json::to_value(view).ok()?; 664 + let entry_json = serde_json::to_value(entry).ok()?; 665 + Some((view_json, entry_json, *time)) 666 + }) 667 + .collect::<Vec<_>>() 668 + ) 669 + } 670 + Err(e) => { 671 + tracing::error!("[use_entries_from_ufos] fetch failed: {:?}", e); 672 + None 673 + } 674 + } 675 + } 676 + }); 677 + let memo = use_memo(use_reactive!(|res| { 678 + let res = res.as_ref().ok()?; 679 + if let Some(Some(values)) = &*res.read() { 680 + let result: Vec<_> = values 681 + .iter() 682 + .filter_map(|(view_json, entry_json, time)| { 683 + let view = jacquard::from_json_value::<EntryView>(view_json.clone()).ok()?; 684 + let entry = jacquard::from_json_value::<Entry>(entry_json.clone()).ok()?; 685 + Some((view, entry, *time)) 686 + }) 687 + .collect(); 688 + Some(result) 689 + } else { 690 + None 691 + } 692 + })); 693 + (res, memo) 694 + } 695 + 696 + /// Fetches entries from UFOS client-side only (no SSR) 697 + #[cfg(not(feature = "fullstack-server"))] 698 + pub fn use_entries_from_ufos() -> ( 699 + Resource<Option<Vec<(EntryView<'static>, Entry<'static>, u64)>>>, 700 + Memo<Option<Vec<(EntryView<'static>, Entry<'static>, u64)>>>, 701 + ) { 702 + let fetcher = use_context::<crate::fetch::Fetcher>(); 703 + let res = use_resource(move || { 704 + let fetcher = fetcher.clone(); 705 + async move { 706 + fetcher 707 + .fetch_entries_from_ufos() 708 + .await 709 + .ok() 710 + .map(|entries| { 711 + entries 636 712 .iter() 637 713 .map(|arc| arc.as_ref().clone()) 638 714 .collect::<Vec<_>>()
+62
crates/weaver-app/src/fetch.rs
··· 480 480 Ok(notebooks) 481 481 } 482 482 483 + /// Fetch entries from UFOS discovery service (reverse chronological) 484 + pub async fn fetch_entries_from_ufos( 485 + &self, 486 + ) -> Result<Vec<Arc<(EntryView<'static>, Entry<'static>, u64)>>> { 487 + use jacquard::{IntoStatic, types::aturi::AtUri, types::ident::AtIdentifier}; 488 + 489 + let url = "https://ufos-api.microcosm.blue/records?collection=sh.weaver.notebook.entry"; 490 + 491 + let response = reqwest::get(url) 492 + .await 493 + .map_err(|e| { 494 + tracing::error!("[fetch_entries_from_ufos] request failed: {:?}", e); 495 + dioxus::CapturedError::from_display(e) 496 + })?; 497 + 498 + let mut records: Vec<UfosRecord> = response 499 + .json() 500 + .await 501 + .map_err(|e| { 502 + tracing::error!("[fetch_entries_from_ufos] json parse failed: {:?}", e); 503 + dioxus::CapturedError::from_display(e) 504 + })?; 505 + 506 + // Sort by time_us descending (reverse chronological) 507 + records.sort_by(|a, b| b.time_us.cmp(&a.time_us)); 508 + 509 + let mut entries = Vec::new(); 510 + let client = self.get_client(); 511 + 512 + for ufos_record in records { 513 + // Parse DID 514 + let did = match Did::new(&ufos_record.did) { 515 + Ok(d) => d.into_static(), 516 + Err(e) => { 517 + tracing::warn!("[fetch_entries_from_ufos] invalid DID {}: {:?}", ufos_record.did, e); 518 + continue; 519 + } 520 + }; 521 + let ident = AtIdentifier::Did(did); 522 + 523 + // Fetch the entry view 524 + match client 525 + .fetch_entry_by_rkey(&ident, &ufos_record.rkey) 526 + .await 527 + { 528 + Ok((entry_view, entry)) => { 529 + entries.push(Arc::new(( 530 + entry_view.into_static(), 531 + entry.into_static(), 532 + ufos_record.time_us, 533 + ))); 534 + } 535 + Err(e) => { 536 + tracing::warn!("[fetch_entries_from_ufos] failed to load entry {}: {:?}", ufos_record.rkey, e); 537 + continue; 538 + } 539 + } 540 + } 541 + 542 + Ok(entries) 543 + } 544 + 483 545 pub async fn fetch_notebooks_for_did( 484 546 &self, 485 547 ident: &AtIdentifier<'_>,
+134 -27
crates/weaver-app/src/views/home.rs
··· 1 - use crate::{Route, components::identity::NotebookCard, data}; 1 + use crate::{ 2 + components::{FeedEntryCard, NotebookCard, css::DefaultNotebookCss}, 3 + data, 4 + }; 2 5 use dioxus::prelude::*; 3 - use jacquard::types::aturi::AtUri; 6 + use jacquard::smol_str::SmolStr; 7 + use jacquard::types::ident::AtIdentifier; 8 + use jacquard::types::string::Did; 9 + 10 + /// Pinned content items - can be notebooks or entries 11 + #[derive(Clone, PartialEq)] 12 + pub enum PinnedItem { 13 + Notebook { 14 + ident: AtIdentifier<'static>, 15 + title: SmolStr, 16 + }, 17 + #[allow(dead_code)] 18 + Entry { 19 + ident: AtIdentifier<'static>, 20 + rkey: SmolStr, 21 + }, 22 + } 23 + 24 + /// Hardcoded pinned items 25 + fn pinned_items() -> Vec<PinnedItem> { 26 + vec![ 27 + // Add pinned items here, e.g.: 28 + // PinnedItem::Notebook { 29 + // ident: AtIdentifier::Did(Did::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap()), 30 + // title: SmolStr::new_static("Weaver"), 31 + // }, 32 + PinnedItem::Entry { 33 + ident: AtIdentifier::Did(Did::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap()), 34 + rkey: SmolStr::new_static("3m4rbphjzt62b"), 35 + }, 36 + ] 37 + } 4 38 5 39 /// OpenGraph and Twitter Card meta tags for the homepage 6 40 #[component] ··· 32 66 } 33 67 34 68 const NOTEBOOK_CARD_CSS: Asset = asset!("/assets/styling/notebook-card.css"); 69 + const ENTRY_CSS: Asset = asset!("/assets/styling/entry.css"); 70 + const ENTRY_CARD_CSS: Asset = asset!("/assets/styling/entry-card.css"); 71 + const HOME_CSS: Asset = asset!("/assets/styling/home.css"); 35 72 36 73 /// The Home page component that will be rendered when the current route is `[Route::Home]` 37 74 #[component] 38 75 pub fn Home() -> Element { 39 - // Fetch notebooks from UFOS with SSR support 40 - let (notebooks_result, notebooks) = data::use_notebooks_from_ufos(); 76 + // Fetch entries from UFOS with SSR support 77 + let (entries_result, entries) = data::use_entries_from_ufos(); 78 + 79 + let pinned = pinned_items(); 80 + let has_pinned = !pinned.is_empty(); 41 81 42 82 #[cfg(feature = "fullstack-server")] 43 - notebooks_result 44 - .as_ref() 45 - .ok() 46 - .map(|r| r.suspend()) 47 - .transpose()?; 83 + let _entries_res = entries_result?; 48 84 49 85 rsx! { 50 86 SiteOgMeta {} 87 + 88 + document::Link { rel: "stylesheet", href: HOME_CSS } 51 89 document::Link { rel: "stylesheet", href: NOTEBOOK_CARD_CSS } 90 + document::Link { rel: "stylesheet", href: ENTRY_CSS } 91 + document::Link { rel: "stylesheet", href: ENTRY_CARD_CSS } 92 + DefaultNotebookCss { } 52 93 div { 53 - class: "record-view-container", 94 + class: "home-container", 54 95 55 - div { class: "notebooks-list", 56 - match &*notebooks.read() { 57 - Some(notebook_list) => rsx! { 58 - for notebook in notebook_list.iter() { 59 - { 60 - let view = &notebook.0; 61 - let entries = &notebook.1; 62 - rsx! { 63 - div { 64 - key: "{view.cid}", 65 - NotebookCard { 66 - notebook: view.clone(), 67 - entry_refs: entries.clone() 68 - } 96 + // Pinned section 97 + if has_pinned { 98 + section { class: "pinned-section", 99 + h2 { class: "section-header", "Featured" } 100 + div { class: "pinned-items", 101 + for item in pinned.into_iter() { 102 + PinnedItemCard { item } 103 + } 104 + } 105 + } 106 + } 107 + 108 + // Main feed 109 + section { class: "feed-section", 110 + h2 { class: "section-header", "Recent" } 111 + div { class: "entries-feed", 112 + match &*entries.read() { 113 + Some(entry_list) => rsx! { 114 + for (entry_view, entry, _time_us) in entry_list.iter() { 115 + div { 116 + key: "{entry_view.cid}", 117 + FeedEntryCard { 118 + entry_view: entry_view.clone(), 119 + entry: entry.clone() 69 120 } 70 121 } 71 122 } 123 + }, 124 + _ => rsx! { 125 + div { class: "loading", "Loading entries..." } 72 126 } 73 - }, 74 - _ => rsx! { 75 - div { "Loading notebooks..." } 76 127 } 77 128 } 78 129 } 79 130 } 131 + } 132 + } 80 133 134 + #[component] 135 + fn PinnedItemCard(item: PinnedItem) -> Element { 136 + match item { 137 + PinnedItem::Notebook { ident, title } => rsx! { 138 + PinnedNotebookCard { ident, title } 139 + }, 140 + PinnedItem::Entry { ident, rkey } => rsx! { 141 + PinnedEntryCard { ident, rkey } 142 + }, 143 + } 144 + } 145 + 146 + #[component] 147 + fn PinnedNotebookCard(ident: AtIdentifier<'static>, title: SmolStr) -> Element { 148 + let ident_memo = use_memo(move || ident.clone()); 149 + let title_memo = use_memo(move || title.clone()); 150 + let (note_res, notebook) = data::use_notebook(ident_memo.into(), title_memo.into()); 151 + 152 + #[cfg(feature = "fullstack-server")] 153 + let _note_res = note_res?; 154 + 155 + match &*notebook.read() { 156 + Some((view, entries)) => rsx! { 157 + NotebookCard { 158 + notebook: view.clone(), 159 + entry_refs: entries.clone() 160 + } 161 + }, 162 + None => rsx! { 163 + div { class: "pinned-item-loading", "Loading notebook..." } 164 + }, 165 + } 166 + } 167 + 168 + #[component] 169 + fn PinnedEntryCard(ident: AtIdentifier<'static>, rkey: SmolStr) -> Element { 170 + let ident_memo = use_memo(move || ident.clone()); 171 + let rkey_memo = use_memo(move || rkey.clone()); 172 + let (entry_res, entry_data) = 173 + data::use_standalone_entry_data(ident_memo.into(), rkey_memo.into()); 174 + 175 + #[cfg(feature = "fullstack-server")] 176 + let _entry_res = entry_res?; 177 + 178 + match &*entry_data.read() { 179 + Some(data) => rsx! { 180 + FeedEntryCard { 181 + entry_view: data.entry_view.clone(), 182 + entry: data.entry.clone() 183 + } 184 + }, 185 + None => rsx! { 186 + div { class: "pinned-item-loading", "Loading entry..." } 187 + }, 81 188 } 82 189 }
+42 -5
crates/weaver-app/src/views/navbar.rs
··· 6 6 use crate::fetch::Fetcher; 7 7 use dioxus::prelude::*; 8 8 use dioxus_primitives::toast::{ToastOptions, use_toast}; 9 + use jacquard::types::ident::AtIdentifier; 9 10 use jacquard::types::string::Did; 10 11 11 12 const NAVBAR_CSS: Asset = asset!("/assets/styling/navbar.css"); ··· 63 64 div { 64 65 id: "navbar", 65 66 nav { class: "breadcrumbs", 66 - Link { 67 - to: Route::Home {}, 68 - class: "breadcrumb", 69 - "Home" 67 + // On home page: show profile link if authenticated, otherwise "Home" 68 + match (&route, &auth_state.read().did) { 69 + (Route::Home {}, Some(did)) => rsx! { 70 + ProfileBreadcrumb { did: did.clone() } 71 + }, 72 + _ => rsx! { 73 + a { 74 + href: "/", 75 + class: "breadcrumb", 76 + "Home" 77 + } 78 + } 70 79 } 71 80 72 81 // Show repository breadcrumb if we're on a repository page 73 - match route { 82 + match &route { 74 83 Route::RepositoryIndex { ident } => { 75 84 let route_handle = route_handle.read().clone(); 76 85 let handle = route_handle.unwrap_or(ident.clone()); ··· 239 248 _ => rsx! {}, 240 249 } 241 250 } 251 + 252 + // Tool links (show on home page) 253 + if matches!(route, Route::Home {}) { 254 + nav { class: "nav-tools", 255 + Link { 256 + to: Route::RecordPage { uri: vec![] }, 257 + class: "nav-tool-link", 258 + "Record Viewer" 259 + } 260 + Link { 261 + to: Route::Editor { entry: None }, 262 + class: "nav-tool-link", 263 + "Editor" 264 + } 265 + } 266 + } 267 + 242 268 if auth_state.read().is_authenticated() { 243 269 if let Some(did) = &auth_state.read().did { 244 270 AuthButton { did: did.clone() } ··· 260 286 } 261 287 262 288 Outlet::<Route> {} 289 + } 290 + } 291 + 292 + #[component] 293 + fn ProfileBreadcrumb(did: Did<'static>) -> Element { 294 + rsx! { 295 + Link { 296 + to: Route::RepositoryIndex { ident: AtIdentifier::Did(did) }, 297 + class: "breadcrumb", 298 + "Profile" 299 + } 263 300 } 264 301 } 265 302