From 21e174ee5790e61717ae411fce08b200dc423ea1 Mon Sep 17 00:00:00 2001 From: Orual Date: Mon, 1 Dec 2025 21:47:26 -0500 Subject: [PATCH] more of an actual home page now --- .../weaver-app/assets/styling/entry-card.css | 62 +++++- crates/weaver-app/assets/styling/home.css | 51 +++++ crates/weaver-app/assets/styling/navbar.css | 19 ++ crates/weaver-app/src/components/entry.rs | 204 +++++++++++++++++- crates/weaver-app/src/components/mod.rs | 4 +- crates/weaver-app/src/data.rs | 78 ++++++- crates/weaver-app/src/fetch.rs | 62 ++++++ crates/weaver-app/src/views/home.rs | 163 +++++++++++--- crates/weaver-app/src/views/navbar.rs | 47 +++- 9 files changed, 642 insertions(+), 48 deletions(-) create mode 100644 crates/weaver-app/assets/styling/home.css diff --git a/crates/weaver-app/assets/styling/entry-card.css b/crates/weaver-app/assets/styling/entry-card.css index 497133a..d72e2ca 100644 --- a/crates/weaver-app/assets/styling/entry-card.css +++ b/crates/weaver-app/assets/styling/entry-card.css @@ -105,22 +105,36 @@ } .entry-card-meta { - gap: 1rem; margin-bottom: 0.5rem; font-size: 0.875rem; color: var(--color-subtle); } +a.entry-card-author, .entry-card-author { - margin-left: auto; display: flex; align-items: center; gap: 0.5rem; + margin-top: 0.25rem; + text-decoration: none; } .entry-card-author .author-name { font-weight: 500; color: var(--color-text); + transition: color 0.2s ease; +} + +.entry-card-author .meta-label { + color: var(--color-muted); + font-size: 0.8rem; + transition: color 0.2s ease; +} + +.entry-card-author:hover .author-name, +.entry-card-author:hover .meta-label, +.feed-entry-card .entry-card-author:hover .meta-label { + color: var(--color-link); } .entry-card-date { @@ -128,6 +142,40 @@ font-size: 0.8rem; } +/* Feed entry card - matches existing entry-card patterns */ +.feed-entry-card { + background: var(--color-surface); + box-shadow: 0 1px 2px color-mix(in srgb, var(--color-text) 6%, transparent); + padding: 1.25rem; + transition: + box-shadow 0.2s ease, + border-color 0.2s ease; +} + +.feed-entry-card:hover { + border-left: 2px solid var(--color-secondary); + margin-left: -1px; +} + +.feed-entry-card:hover .entry-card-title { + color: var(--color-secondary); +} + +.feed-entry-card .entry-card-title { + margin-bottom: 0.5rem; +} + +.feed-entry-card .entry-card-byline { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.feed-entry-card .entry-card-byline .entry-card-date { + margin-left: auto; +} + .entry-card-tags { display: flex; gap: 0.4rem; @@ -197,4 +245,14 @@ box-shadow: none; border: 1px solid var(--color-border); } + + .feed-entry-card { + box-shadow: none; + border-top: 1px solid var(--color-border); + border-right: 1px solid var(--color-border); + border-bottom: 1px solid var(--color-border); + + border-left: 1px solid var(--color-border); + /* Keep border-left as accent */ + } } diff --git a/crates/weaver-app/assets/styling/home.css b/crates/weaver-app/assets/styling/home.css new file mode 100644 index 0000000..faa5841 --- /dev/null +++ b/crates/weaver-app/assets/styling/home.css @@ -0,0 +1,51 @@ +.home-container { + max-width: 1000px; + margin: 0 auto; + padding: 1rem; +} + +.section-header { + font-size: 1.25rem; + font-weight: 600; + color: var(--color-text-muted); + margin-bottom: 1rem; + padding-bottom: 0.5rem; +} + +.pinned-section { + margin-bottom: 2rem; +} + +.pinned-items { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.pinned-items .entry-card, +.pinned-items .feed-entry-card, +.pinned-items .notebook-card { + border-left: 3px solid var(--color-primary) !important; +} + +.pinned-items .feed-entry-card:hover { + border-left: 3px solid var(--color-secondary) !important; + margin-left: 0; +} + +.feed-section { + margin-bottom: 2rem; +} + +.entries-feed { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.loading, +.pinned-item-loading { + color: var(--color-text-muted); + padding: 2rem; + text-align: center; +} diff --git a/crates/weaver-app/assets/styling/navbar.css b/crates/weaver-app/assets/styling/navbar.css index 9d29a1b..5f1503e 100644 --- a/crates/weaver-app/assets/styling/navbar.css +++ b/crates/weaver-app/assets/styling/navbar.css @@ -43,3 +43,22 @@ color: var(--color-text, #666); font-weight: 500; } + +.nav-tools { + display: flex; + align-items: center; + gap: 1rem; + margin-left: auto; + margin-right: 1rem; +} + +.nav-tool-link { + color: var(--color-text-muted); + text-decoration: none; + font-size: 0.875rem; + transition: color 0.2s ease; +} + +.nav-tool-link:hover { + color: var(--color-primary); +} diff --git a/crates/weaver-app/src/components/entry.rs b/crates/weaver-app/src/components/entry.rs index 145b81c..76afe5a 100644 --- a/crates/weaver-app/src/components/entry.rs +++ b/crates/weaver-app/src/components/entry.rs @@ -9,8 +9,8 @@ use crate::{ data::use_handle, }; use dioxus::prelude::*; -use jacquard::IntoStatic; use jacquard::types::aturi::AtUri; +use jacquard::{IntoStatic, types::string::Handle}; pub const ENTRY_CSS: Asset = asset!("/assets/styling/entry.css"); @@ -75,7 +75,10 @@ pub fn EntryPage( book_title(), title() ); - tracing::debug!("[EntryPage] rendering, entry.is_some={}", entry.read().is_some()); + tracing::debug!( + "[EntryPage] rendering, entry.is_some={}", + entry.read().is_some() + ); // Handle blob caching when entry data is available // Use read() instead of read_unchecked() for proper reactive tracking @@ -149,6 +152,61 @@ pub fn extract_preview(content: &str, max_len: usize) -> String { } } +/// Truncate markdown content for preview (preserves markdown syntax) +/// Takes first few paragraphs up to max_chars, truncating at paragraph boundary +fn truncate_markdown_preview(content: &str, max_chars: usize, max_paragraphs: usize) -> String { + let mut result = String::new(); + let mut char_count = 0; + let mut para_count = 0; + let mut in_code_block = false; + + for line in content.lines() { + // Track code blocks to avoid breaking them + if line.trim().starts_with("```") { + in_code_block = !in_code_block; + // Skip code blocks in preview entirely + if in_code_block { + continue; + } + } + + if in_code_block { + continue; + } + + // Skip headings, images in preview + let trimmed = line.trim(); + if trimmed.starts_with('#') || trimmed.starts_with('!') { + continue; + } + + // Empty line = paragraph boundary + if trimmed.is_empty() { + if !result.is_empty() && !result.ends_with("\n\n") { + para_count += 1; + if para_count >= max_paragraphs || char_count >= max_chars { + break; + } + result.push_str("\n\n"); + } + continue; + } + + // Check if adding this line would exceed limit + if char_count + line.len() > max_chars && !result.is_empty() { + break; + } + + if !result.is_empty() && !result.ends_with('\n') { + result.push('\n'); + } + result.push_str(line); + char_count += line.len(); + } + + result.trim().to_string() +} + /// OpenGraph and Twitter Card meta tags for entries #[component] pub fn EntryOgMeta( @@ -225,13 +283,7 @@ fn EntryPageView( } else { crate::env::WEAVER_APP_HOST.to_string() }; - let canonical_url = format!( - "{}/{}/{}/{}", - base, - ident(), - book_title(), - entry_path - ); + let canonical_url = format!("{}/{}/{}/{}", base, ident(), book_title(), entry_path); let og_image_url = format!( "{}/og/{}/{}/{}.png", base, @@ -366,7 +418,7 @@ pub fn EntryCard( None }; - // Render preview from entry content + // Render preview from truncated entry content let preview_html = parsed_entry.as_ref().map(|entry| { let parser = markdown_weaver::Parser::new(&entry.content); let mut html_buf = String::new(); @@ -464,6 +516,138 @@ pub fn EntryCard( } } +/// Card for entries in a feed (e.g., home page) +/// Takes EntryView directly (not BookEntryView) and always shows author info +#[component] +pub fn FeedEntryCard(entry_view: EntryView<'static>, entry: entry::Entry<'static>) -> Element { + use crate::Route; + use jacquard::from_data; + use weaver_api::sh_weaver::actor::ProfileDataViewInner; + + let title = entry_view + .title + .as_ref() + .map(|t| t.as_ref()) + .unwrap_or("Untitled"); + + // Extract DID and rkey from the entry URI + let uri = &entry_view.uri; + let parsed_uri = jacquard::types::aturi::AtUri::new(uri.as_ref()).ok(); + + let ident = parsed_uri + .as_ref() + .map(|u| u.authority().clone().into_static()) + .unwrap_or_else(|| AtIdentifier::Handle(Handle::new_static("invalid.handle").unwrap())); + + let rkey: SmolStr = parsed_uri + .as_ref() + .and_then(|u| u.rkey().map(|r| SmolStr::new(r.0.as_str()))) + .unwrap_or_default(); + + // Format date from record's created_at + let formatted_date = entry.created_at.as_ref().format("%B %d, %Y").to_string(); + + // Get first author + let first_author = entry_view.authors.first(); + + // Render preview from truncated entry content + let preview_html = { + let parser = markdown_weaver::Parser::new(&entry.content); + let mut html_buf = String::new(); + markdown_weaver::html::push_html(&mut html_buf, parser); + html_buf + }; + + rsx! { + div { class: "entry-card feed-entry-card", + // Title + Link { + to: Route::StandaloneEntry { + ident: ident.clone(), + rkey: rkey.clone().into() + }, + class: "entry-card-title-link", + h3 { class: "entry-card-title", "{title}" } + } + + // Byline: author + date + div { class: "entry-card-byline", + if let Some(author) = first_author { + { + match &author.record.inner { + ProfileDataViewInner::ProfileView(profile) => { + let display_name = profile.display_name.as_ref().map(|n| n.as_ref()).unwrap_or("Unknown"); + let handle = profile.handle.clone(); + rsx! { + Link { + to: Route::RepositoryIndex { ident: AtIdentifier::Handle(handle.clone()) }, + class: "entry-card-author", + if let Some(ref avatar_url) = profile.avatar { + Avatar { + AvatarImage { src: avatar_url.as_ref() } + } + } + span { class: "author-name", "{display_name}" } + span { class: "meta-label", "@{handle}" } + } + } + } + ProfileDataViewInner::ProfileViewDetailed(profile) => { + let display_name = profile.display_name.as_ref().map(|n| n.as_ref()).unwrap_or("Unknown"); + let handle = profile.handle.clone(); + rsx! { + Link { + to: Route::RepositoryIndex { ident: AtIdentifier::Handle(handle.clone()) }, + class: "entry-card-author", + if let Some(ref avatar_url) = profile.avatar { + Avatar { + AvatarImage { src: avatar_url.as_ref() } + } + } + span { class: "author-name", "{display_name}" } + span { class: "meta-label", "@{handle}" } + } + } + } + ProfileDataViewInner::TangledProfileView(profile) => { + let handle = profile.handle.clone(); + rsx! { + Link { + to: Route::RepositoryIndex { ident: AtIdentifier::Handle(handle.clone()) }, + class: "entry-card-author", + span { class: "author-name", "@{handle}" } + } + } + } + _ => { + rsx! { + div { class: "entry-card-author", + span { class: "author-name", "Unknown" } + } + } + } + } + } + } + div { class: "entry-card-date", + time { datetime: "{entry.created_at.as_str()}", "{formatted_date}" } + } + } + + div { class: "entry-card-preview", dangerous_inner_html: "{preview_html}" } + if let Some(ref tags) = entry_view.tags { + if !tags.is_empty() { + div { class: "entry-card-tags", + for tag in tags.iter() { + span { class: "entry-card-tag", "{tag}" } + } + } + } + } + } + } +} + /// Metadata header showing title, authors, date, tags #[component] pub fn EntryMetadata( diff --git a/crates/weaver-app/src/components/mod.rs b/crates/weaver-app/src/components/mod.rs index 557df5a..3763b07 100644 --- a/crates/weaver-app/src/components/mod.rs +++ b/crates/weaver-app/src/components/mod.rs @@ -8,8 +8,8 @@ pub use css::NotebookCss; mod entry; #[allow(unused_imports)] pub use entry::{ - ENTRY_CSS, EntryCard, EntryMarkdown, EntryMetadata, EntryOgMeta, EntryPage, NavButton, - extract_preview, + ENTRY_CSS, EntryCard, EntryMarkdown, EntryMetadata, EntryOgMeta, EntryPage, FeedEntryCard, + NavButton, extract_preview, }; pub mod identity; diff --git a/crates/weaver-app/src/data.rs b/crates/weaver-app/src/data.rs index ddffd75..cfaac12 100644 --- a/crates/weaver-app/src/data.rs +++ b/crates/weaver-app/src/data.rs @@ -25,7 +25,7 @@ use jacquard::{ use std::sync::Arc; use weaver_api::com_atproto::repo::strong_ref::StrongRef; use weaver_api::sh_weaver::actor::ProfileDataView; -use weaver_api::sh_weaver::notebook::{BookEntryView, NotebookView, entry::Entry}; +use weaver_api::sh_weaver::notebook::{BookEntryView, EntryView, NotebookView, entry::Entry}; // ============================================================================ // Wrapper Hooks (feature-gated) // ============================================================================ @@ -643,6 +643,82 @@ pub fn use_notebooks_from_ufos() -> ( (res, memo) } +/// Fetches entries from UFOS with SSR support in fullstack mode +#[cfg(feature = "fullstack-server")] +pub fn use_entries_from_ufos() -> ( + Result>>, RenderError>, + Memo, Entry<'static>, u64)>>>, +) { + let fetcher = use_context::(); + let res = use_server_future(move || { + let fetcher = fetcher.clone(); + async move { + match fetcher.fetch_entries_from_ufos().await { + Ok(entries) => { + Some( + entries + .iter() + .filter_map(|arc| { + let (view, entry, time) = arc.as_ref(); + let view_json = serde_json::to_value(view).ok()?; + let entry_json = serde_json::to_value(entry).ok()?; + Some((view_json, entry_json, *time)) + }) + .collect::>() + ) + } + Err(e) => { + tracing::error!("[use_entries_from_ufos] fetch failed: {:?}", e); + None + } + } + } + }); + let memo = use_memo(use_reactive!(|res| { + let res = res.as_ref().ok()?; + if let Some(Some(values)) = &*res.read() { + let result: Vec<_> = values + .iter() + .filter_map(|(view_json, entry_json, time)| { + let view = jacquard::from_json_value::(view_json.clone()).ok()?; + let entry = jacquard::from_json_value::(entry_json.clone()).ok()?; + Some((view, entry, *time)) + }) + .collect(); + Some(result) + } else { + None + } + })); + (res, memo) +} + +/// Fetches entries from UFOS client-side only (no SSR) +#[cfg(not(feature = "fullstack-server"))] +pub fn use_entries_from_ufos() -> ( + Resource, Entry<'static>, u64)>>>, + Memo, Entry<'static>, u64)>>>, +) { + let fetcher = use_context::(); + let res = use_resource(move || { + let fetcher = fetcher.clone(); + async move { + fetcher + .fetch_entries_from_ufos() + .await + .ok() + .map(|entries| { + entries + .iter() + .map(|arc| arc.as_ref().clone()) + .collect::>() + }) + } + }); + let memo = use_memo(move || res.read().clone().flatten()); + (res, memo) +} + /// Fetches notebook metadata with SSR support in fullstack mode #[cfg(feature = "fullstack-server")] pub fn use_notebook( diff --git a/crates/weaver-app/src/fetch.rs b/crates/weaver-app/src/fetch.rs index 4d9e5f5..b69ef45 100644 --- a/crates/weaver-app/src/fetch.rs +++ b/crates/weaver-app/src/fetch.rs @@ -480,6 +480,68 @@ impl Fetcher { Ok(notebooks) } + /// Fetch entries from UFOS discovery service (reverse chronological) + pub async fn fetch_entries_from_ufos( + &self, + ) -> Result, Entry<'static>, u64)>>> { + use jacquard::{IntoStatic, types::aturi::AtUri, types::ident::AtIdentifier}; + + let url = "https://ufos-api.microcosm.blue/records?collection=sh.weaver.notebook.entry"; + + let response = reqwest::get(url) + .await + .map_err(|e| { + tracing::error!("[fetch_entries_from_ufos] request failed: {:?}", e); + dioxus::CapturedError::from_display(e) + })?; + + let mut records: Vec = response + .json() + .await + .map_err(|e| { + tracing::error!("[fetch_entries_from_ufos] json parse failed: {:?}", e); + dioxus::CapturedError::from_display(e) + })?; + + // Sort by time_us descending (reverse chronological) + records.sort_by(|a, b| b.time_us.cmp(&a.time_us)); + + let mut entries = Vec::new(); + let client = self.get_client(); + + for ufos_record in records { + // Parse DID + let did = match Did::new(&ufos_record.did) { + Ok(d) => d.into_static(), + Err(e) => { + tracing::warn!("[fetch_entries_from_ufos] invalid DID {}: {:?}", ufos_record.did, e); + continue; + } + }; + let ident = AtIdentifier::Did(did); + + // Fetch the entry view + match client + .fetch_entry_by_rkey(&ident, &ufos_record.rkey) + .await + { + Ok((entry_view, entry)) => { + entries.push(Arc::new(( + entry_view.into_static(), + entry.into_static(), + ufos_record.time_us, + ))); + } + Err(e) => { + tracing::warn!("[fetch_entries_from_ufos] failed to load entry {}: {:?}", ufos_record.rkey, e); + continue; + } + } + } + + Ok(entries) + } + pub async fn fetch_notebooks_for_did( &self, ident: &AtIdentifier<'_>, diff --git a/crates/weaver-app/src/views/home.rs b/crates/weaver-app/src/views/home.rs index 4a98f49..ff6946f 100644 --- a/crates/weaver-app/src/views/home.rs +++ b/crates/weaver-app/src/views/home.rs @@ -1,6 +1,40 @@ -use crate::{Route, components::identity::NotebookCard, data}; +use crate::{ + components::{FeedEntryCard, NotebookCard, css::DefaultNotebookCss}, + data, +}; use dioxus::prelude::*; -use jacquard::types::aturi::AtUri; +use jacquard::smol_str::SmolStr; +use jacquard::types::ident::AtIdentifier; +use jacquard::types::string::Did; + +/// Pinned content items - can be notebooks or entries +#[derive(Clone, PartialEq)] +pub enum PinnedItem { + Notebook { + ident: AtIdentifier<'static>, + title: SmolStr, + }, + #[allow(dead_code)] + Entry { + ident: AtIdentifier<'static>, + rkey: SmolStr, + }, +} + +/// Hardcoded pinned items +fn pinned_items() -> Vec { + vec![ + // Add pinned items here, e.g.: + // PinnedItem::Notebook { + // ident: AtIdentifier::Did(Did::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap()), + // title: SmolStr::new_static("Weaver"), + // }, + PinnedItem::Entry { + ident: AtIdentifier::Did(Did::new_static("did:plc:yfvwmnlztr4dwkb7hwz55r2g").unwrap()), + rkey: SmolStr::new_static("3m4rbphjzt62b"), + }, + ] +} /// OpenGraph and Twitter Card meta tags for the homepage #[component] @@ -32,51 +66,124 @@ pub fn SiteOgMeta() -> Element { } const NOTEBOOK_CARD_CSS: Asset = asset!("/assets/styling/notebook-card.css"); +const ENTRY_CSS: Asset = asset!("/assets/styling/entry.css"); +const ENTRY_CARD_CSS: Asset = asset!("/assets/styling/entry-card.css"); +const HOME_CSS: Asset = asset!("/assets/styling/home.css"); /// The Home page component that will be rendered when the current route is `[Route::Home]` #[component] pub fn Home() -> Element { - // Fetch notebooks from UFOS with SSR support - let (notebooks_result, notebooks) = data::use_notebooks_from_ufos(); + // Fetch entries from UFOS with SSR support + let (entries_result, entries) = data::use_entries_from_ufos(); + + let pinned = pinned_items(); + let has_pinned = !pinned.is_empty(); #[cfg(feature = "fullstack-server")] - notebooks_result - .as_ref() - .ok() - .map(|r| r.suspend()) - .transpose()?; + let _entries_res = entries_result?; rsx! { SiteOgMeta {} + + document::Link { rel: "stylesheet", href: HOME_CSS } document::Link { rel: "stylesheet", href: NOTEBOOK_CARD_CSS } + document::Link { rel: "stylesheet", href: ENTRY_CSS } + document::Link { rel: "stylesheet", href: ENTRY_CARD_CSS } + DefaultNotebookCss { } div { - class: "record-view-container", - - div { class: "notebooks-list", - match &*notebooks.read() { - Some(notebook_list) => rsx! { - for notebook in notebook_list.iter() { - { - let view = ¬ebook.0; - let entries = ¬ebook.1; - rsx! { - div { - key: "{view.cid}", - NotebookCard { - notebook: view.clone(), - entry_refs: entries.clone() - } + class: "home-container", + + // Pinned section + if has_pinned { + section { class: "pinned-section", + h2 { class: "section-header", "Featured" } + div { class: "pinned-items", + for item in pinned.into_iter() { + PinnedItemCard { item } + } + } + } + } + + // Main feed + section { class: "feed-section", + h2 { class: "section-header", "Recent" } + div { class: "entries-feed", + match &*entries.read() { + Some(entry_list) => rsx! { + for (entry_view, entry, _time_us) in entry_list.iter() { + div { + key: "{entry_view.cid}", + FeedEntryCard { + entry_view: entry_view.clone(), + entry: entry.clone() } } } + }, + _ => rsx! { + div { class: "loading", "Loading entries..." } } - }, - _ => rsx! { - div { "Loading notebooks..." } } } } } + } +} +#[component] +fn PinnedItemCard(item: PinnedItem) -> Element { + match item { + PinnedItem::Notebook { ident, title } => rsx! { + PinnedNotebookCard { ident, title } + }, + PinnedItem::Entry { ident, rkey } => rsx! { + PinnedEntryCard { ident, rkey } + }, + } +} + +#[component] +fn PinnedNotebookCard(ident: AtIdentifier<'static>, title: SmolStr) -> Element { + let ident_memo = use_memo(move || ident.clone()); + let title_memo = use_memo(move || title.clone()); + let (note_res, notebook) = data::use_notebook(ident_memo.into(), title_memo.into()); + + #[cfg(feature = "fullstack-server")] + let _note_res = note_res?; + + match &*notebook.read() { + Some((view, entries)) => rsx! { + NotebookCard { + notebook: view.clone(), + entry_refs: entries.clone() + } + }, + None => rsx! { + div { class: "pinned-item-loading", "Loading notebook..." } + }, + } +} + +#[component] +fn PinnedEntryCard(ident: AtIdentifier<'static>, rkey: SmolStr) -> Element { + let ident_memo = use_memo(move || ident.clone()); + let rkey_memo = use_memo(move || rkey.clone()); + let (entry_res, entry_data) = + data::use_standalone_entry_data(ident_memo.into(), rkey_memo.into()); + + #[cfg(feature = "fullstack-server")] + let _entry_res = entry_res?; + + match &*entry_data.read() { + Some(data) => rsx! { + FeedEntryCard { + entry_view: data.entry_view.clone(), + entry: data.entry.clone() + } + }, + None => rsx! { + div { class: "pinned-item-loading", "Loading entry..." } + }, } } diff --git a/crates/weaver-app/src/views/navbar.rs b/crates/weaver-app/src/views/navbar.rs index 445da4a..8e07642 100644 --- a/crates/weaver-app/src/views/navbar.rs +++ b/crates/weaver-app/src/views/navbar.rs @@ -6,6 +6,7 @@ use crate::data::{use_get_handle, use_load_handle}; use crate::fetch::Fetcher; use dioxus::prelude::*; use dioxus_primitives::toast::{ToastOptions, use_toast}; +use jacquard::types::ident::AtIdentifier; use jacquard::types::string::Did; const NAVBAR_CSS: Asset = asset!("/assets/styling/navbar.css"); @@ -63,14 +64,22 @@ pub fn Navbar() -> Element { div { id: "navbar", nav { class: "breadcrumbs", - Link { - to: Route::Home {}, - class: "breadcrumb", - "Home" + // On home page: show profile link if authenticated, otherwise "Home" + match (&route, &auth_state.read().did) { + (Route::Home {}, Some(did)) => rsx! { + ProfileBreadcrumb { did: did.clone() } + }, + _ => rsx! { + a { + href: "/", + class: "breadcrumb", + "Home" + } + } } // Show repository breadcrumb if we're on a repository page - match route { + match &route { Route::RepositoryIndex { ident } => { let route_handle = route_handle.read().clone(); let handle = route_handle.unwrap_or(ident.clone()); @@ -239,6 +248,23 @@ pub fn Navbar() -> Element { _ => rsx! {}, } } + + // Tool links (show on home page) + if matches!(route, Route::Home {}) { + nav { class: "nav-tools", + Link { + to: Route::RecordPage { uri: vec![] }, + class: "nav-tool-link", + "Record Viewer" + } + Link { + to: Route::Editor { entry: None }, + class: "nav-tool-link", + "Editor" + } + } + } + if auth_state.read().is_authenticated() { if let Some(did) = &auth_state.read().did { AuthButton { did: did.clone() } @@ -263,6 +289,17 @@ pub fn Navbar() -> Element { } } +#[component] +fn ProfileBreadcrumb(did: Did<'static>) -> Element { + rsx! { + Link { + to: Route::RepositoryIndex { ident: AtIdentifier::Did(did) }, + class: "breadcrumb", + "Profile" + } + } +} + #[component] fn AuthButton(did: Did<'static>) -> Element { let auth_handle = use_get_handle(did); -- 2.51.2