From b73b1f86f9c451479c352ea00260154b9a7f8f2d Mon Sep 17 00:00:00 2001 From: Orual Date: Mon, 1 Dec 2025 14:58:34 -0500 Subject: [PATCH] fixed not defaulting to path slug, as well as weird case of trailing punct it title not matching --- Cargo.lock | 1 + .../lexicons/sh_weaver_notebook_defs.json | 8 + crates/weaver-api/src/sh_weaver/notebook.rs | 142 ++++++++++---- crates/weaver-app/Cargo.toml | 2 +- crates/weaver-app/src/auth/mod.rs | 81 ++++---- crates/weaver-app/src/components/entry.rs | 38 +++- crates/weaver-app/src/components/identity.rs | 65 +++++-- crates/weaver-app/src/components/mod.rs | 1 + .../src/components/toast/component.rs | 15 ++ crates/weaver-app/src/components/toast/mod.rs | 2 + .../weaver-app/src/components/toast/style.css | 174 ++++++++++++++++++ crates/weaver-app/src/data.rs | 65 ++++--- crates/weaver-app/src/fetch.rs | 16 +- crates/weaver-app/src/main.rs | 51 ++--- crates/weaver-app/src/views/home.rs | 28 +-- crates/weaver-app/src/views/navbar.rs | 167 +++++++++++++++-- crates/weaver-common/src/agent.rs | 41 ++++- lexicons/notebook/defs.json | 2 + 18 files changed, 687 insertions(+), 212 deletions(-) create mode 100644 crates/weaver-app/src/components/toast/component.rs create mode 100644 crates/weaver-app/src/components/toast/mod.rs create mode 100644 crates/weaver-app/src/components/toast/style.css diff --git a/Cargo.lock b/Cargo.lock index 71df227..277e75c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4786,6 +4786,7 @@ dependencies = [ "jacquard-lexicon", "miette 7.6.0", "mini-moka 0.10.99", + "n0-future", "percent-encoding", "reqwest", "serde", diff --git a/crates/weaver-api/lexicons/sh_weaver_notebook_defs.json b/crates/weaver-api/lexicons/sh_weaver_notebook_defs.json index c38adbb..0caf785 100644 --- a/crates/weaver-api/lexicons/sh_weaver_notebook_defs.json +++ b/crates/weaver-api/lexicons/sh_weaver_notebook_defs.json @@ -101,6 +101,10 @@ "type": "string", "format": "datetime" }, + "path": { + "type": "ref", + "ref": "#path" + }, "record": { "type": "unknown" }, @@ -147,6 +151,10 @@ "type": "string", "format": "datetime" }, + "path": { + "type": "ref", + "ref": "#path" + }, "record": { "type": "unknown" }, diff --git a/crates/weaver-api/src/sh_weaver/notebook.rs b/crates/weaver-api/src/sh_weaver/notebook.rs index 416f640..113b90b 100644 --- a/crates/weaver-api/src/sh_weaver/notebook.rs +++ b/crates/weaver-api/src/sh_weaver/notebook.rs @@ -447,6 +447,13 @@ fn lexicon_doc_sh_weaver_notebook_defs() -> ::jacquard_lexicon::lexicon::Lexicon known_values: None, }), ); + map.insert( + ::jacquard_common::smol_str::SmolStr::new_static("path"), + ::jacquard_lexicon::lexicon::LexObjectProperty::Ref(::jacquard_lexicon::lexicon::LexRef { + description: None, + r#ref: ::jacquard_common::CowStr::new_static("#path"), + }), + ); map.insert( ::jacquard_common::smol_str::SmolStr::new_static("record"), ::jacquard_lexicon::lexicon::LexObjectProperty::Unknown(::jacquard_lexicon::lexicon::LexUnknown { @@ -566,6 +573,13 @@ fn lexicon_doc_sh_weaver_notebook_defs() -> ::jacquard_lexicon::lexicon::Lexicon known_values: None, }), ); + map.insert( + ::jacquard_common::smol_str::SmolStr::new_static("path"), + ::jacquard_lexicon::lexicon::LexObjectProperty::Ref(::jacquard_lexicon::lexicon::LexRef { + description: None, + r#ref: ::jacquard_common::CowStr::new_static("#path"), + }), + ); map.insert( ::jacquard_common::smol_str::SmolStr::new_static("record"), ::jacquard_lexicon::lexicon::LexObjectProperty::Unknown(::jacquard_lexicon::lexicon::LexUnknown { @@ -1139,6 +1153,9 @@ pub struct EntryView<'a> { #[serde(borrow)] pub cid: jacquard_common::types::string::Cid<'a>, pub indexed_at: jacquard_common::types::string::Datetime, + #[serde(skip_serializing_if = "std::option::Option::is_none")] + #[serde(borrow)] + pub path: std::option::Option>, #[serde(borrow)] pub record: jacquard_common::types::value::Data<'a>, #[serde(skip_serializing_if = "std::option::Option::is_none")] @@ -1253,6 +1270,7 @@ pub struct EntryViewBuilder<'a, S: entry_view_state::State> { ::core::option::Option>>, ::core::option::Option>, ::core::option::Option, + ::core::option::Option>, ::core::option::Option>, ::core::option::Option>, ::core::option::Option>, @@ -1274,7 +1292,17 @@ impl<'a> EntryViewBuilder<'a, entry_view_state::Empty> { pub fn new() -> Self { EntryViewBuilder { _phantom_state: ::core::marker::PhantomData, - __unsafe_private_named: (None, None, None, None, None, None, None, None), + __unsafe_private_named: ( + None, + None, + None, + None, + None, + None, + None, + None, + None, + ), _phantom: ::core::marker::PhantomData, } } @@ -1337,6 +1365,25 @@ where } } +impl<'a, S: entry_view_state::State> EntryViewBuilder<'a, S> { + /// Set the `path` field (optional) + pub fn path( + mut self, + value: impl Into>>, + ) -> Self { + self.__unsafe_private_named.3 = value.into(); + self + } + /// Set the `path` field to an Option value (optional) + pub fn maybe_path( + mut self, + value: Option>, + ) -> Self { + self.__unsafe_private_named.3 = value; + self + } +} + impl<'a, S> EntryViewBuilder<'a, S> where S: entry_view_state::State, @@ -1347,7 +1394,7 @@ where mut self, value: impl Into>, ) -> EntryViewBuilder<'a, entry_view_state::SetRecord> { - self.__unsafe_private_named.3 = ::core::option::Option::Some(value.into()); + self.__unsafe_private_named.4 = ::core::option::Option::Some(value.into()); EntryViewBuilder { _phantom_state: ::core::marker::PhantomData, __unsafe_private_named: self.__unsafe_private_named, @@ -1362,7 +1409,7 @@ impl<'a, S: entry_view_state::State> EntryViewBuilder<'a, S> { mut self, value: impl Into>>, ) -> Self { - self.__unsafe_private_named.4 = value.into(); + self.__unsafe_private_named.5 = value.into(); self } /// Set the `renderedView` field to an Option value (optional) @@ -1370,7 +1417,7 @@ impl<'a, S: entry_view_state::State> EntryViewBuilder<'a, S> { mut self, value: Option>, ) -> Self { - self.__unsafe_private_named.4 = value; + self.__unsafe_private_named.5 = value; self } } @@ -1381,7 +1428,7 @@ impl<'a, S: entry_view_state::State> EntryViewBuilder<'a, S> { mut self, value: impl Into>>, ) -> Self { - self.__unsafe_private_named.5 = value.into(); + self.__unsafe_private_named.6 = value.into(); self } /// Set the `tags` field to an Option value (optional) @@ -1389,7 +1436,7 @@ impl<'a, S: entry_view_state::State> EntryViewBuilder<'a, S> { mut self, value: Option>, ) -> Self { - self.__unsafe_private_named.5 = value; + self.__unsafe_private_named.6 = value; self } } @@ -1400,7 +1447,7 @@ impl<'a, S: entry_view_state::State> EntryViewBuilder<'a, S> { mut self, value: impl Into>>, ) -> Self { - self.__unsafe_private_named.6 = value.into(); + self.__unsafe_private_named.7 = value.into(); self } /// Set the `title` field to an Option value (optional) @@ -1408,7 +1455,7 @@ impl<'a, S: entry_view_state::State> EntryViewBuilder<'a, S> { mut self, value: Option>, ) -> Self { - self.__unsafe_private_named.6 = value; + self.__unsafe_private_named.7 = value; self } } @@ -1423,7 +1470,7 @@ where mut self, value: impl Into>, ) -> EntryViewBuilder<'a, entry_view_state::SetUri> { - self.__unsafe_private_named.7 = ::core::option::Option::Some(value.into()); + self.__unsafe_private_named.8 = ::core::option::Option::Some(value.into()); EntryViewBuilder { _phantom_state: ::core::marker::PhantomData, __unsafe_private_named: self.__unsafe_private_named, @@ -1447,11 +1494,12 @@ where authors: self.__unsafe_private_named.0.unwrap(), cid: self.__unsafe_private_named.1.unwrap(), indexed_at: self.__unsafe_private_named.2.unwrap(), - record: self.__unsafe_private_named.3.unwrap(), - rendered_view: self.__unsafe_private_named.4, - tags: self.__unsafe_private_named.5, - title: self.__unsafe_private_named.6, - uri: self.__unsafe_private_named.7.unwrap(), + path: self.__unsafe_private_named.3, + record: self.__unsafe_private_named.4.unwrap(), + rendered_view: self.__unsafe_private_named.5, + tags: self.__unsafe_private_named.6, + title: self.__unsafe_private_named.7, + uri: self.__unsafe_private_named.8.unwrap(), extra_data: Default::default(), } } @@ -1467,11 +1515,12 @@ where authors: self.__unsafe_private_named.0.unwrap(), cid: self.__unsafe_private_named.1.unwrap(), indexed_at: self.__unsafe_private_named.2.unwrap(), - record: self.__unsafe_private_named.3.unwrap(), - rendered_view: self.__unsafe_private_named.4, - tags: self.__unsafe_private_named.5, - title: self.__unsafe_private_named.6, - uri: self.__unsafe_private_named.7.unwrap(), + path: self.__unsafe_private_named.3, + record: self.__unsafe_private_named.4.unwrap(), + rendered_view: self.__unsafe_private_named.5, + tags: self.__unsafe_private_named.6, + title: self.__unsafe_private_named.7, + uri: self.__unsafe_private_named.8.unwrap(), extra_data: Some(extra_data), } } @@ -1511,6 +1560,9 @@ pub struct NotebookView<'a> { #[serde(borrow)] pub cid: jacquard_common::types::string::Cid<'a>, pub indexed_at: jacquard_common::types::string::Datetime, + #[serde(skip_serializing_if = "std::option::Option::is_none")] + #[serde(borrow)] + pub path: std::option::Option>, #[serde(borrow)] pub record: jacquard_common::types::value::Data<'a>, #[serde(skip_serializing_if = "std::option::Option::is_none")] @@ -1622,6 +1674,7 @@ pub struct NotebookViewBuilder<'a, S: notebook_view_state::State> { ::core::option::Option>>, ::core::option::Option>, ::core::option::Option, + ::core::option::Option>, ::core::option::Option>, ::core::option::Option>, ::core::option::Option>, @@ -1642,7 +1695,7 @@ impl<'a> NotebookViewBuilder<'a, notebook_view_state::Empty> { pub fn new() -> Self { NotebookViewBuilder { _phantom_state: ::core::marker::PhantomData, - __unsafe_private_named: (None, None, None, None, None, None, None), + __unsafe_private_named: (None, None, None, None, None, None, None, None), _phantom: ::core::marker::PhantomData, } } @@ -1705,6 +1758,25 @@ where } } +impl<'a, S: notebook_view_state::State> NotebookViewBuilder<'a, S> { + /// Set the `path` field (optional) + pub fn path( + mut self, + value: impl Into>>, + ) -> Self { + self.__unsafe_private_named.3 = value.into(); + self + } + /// Set the `path` field to an Option value (optional) + pub fn maybe_path( + mut self, + value: Option>, + ) -> Self { + self.__unsafe_private_named.3 = value; + self + } +} + impl<'a, S> NotebookViewBuilder<'a, S> where S: notebook_view_state::State, @@ -1715,7 +1787,7 @@ where mut self, value: impl Into>, ) -> NotebookViewBuilder<'a, notebook_view_state::SetRecord> { - self.__unsafe_private_named.3 = ::core::option::Option::Some(value.into()); + self.__unsafe_private_named.4 = ::core::option::Option::Some(value.into()); NotebookViewBuilder { _phantom_state: ::core::marker::PhantomData, __unsafe_private_named: self.__unsafe_private_named, @@ -1730,7 +1802,7 @@ impl<'a, S: notebook_view_state::State> NotebookViewBuilder<'a, S> { mut self, value: impl Into>>, ) -> Self { - self.__unsafe_private_named.4 = value.into(); + self.__unsafe_private_named.5 = value.into(); self } /// Set the `tags` field to an Option value (optional) @@ -1738,7 +1810,7 @@ impl<'a, S: notebook_view_state::State> NotebookViewBuilder<'a, S> { mut self, value: Option>, ) -> Self { - self.__unsafe_private_named.4 = value; + self.__unsafe_private_named.5 = value; self } } @@ -1749,7 +1821,7 @@ impl<'a, S: notebook_view_state::State> NotebookViewBuilder<'a, S> { mut self, value: impl Into>>, ) -> Self { - self.__unsafe_private_named.5 = value.into(); + self.__unsafe_private_named.6 = value.into(); self } /// Set the `title` field to an Option value (optional) @@ -1757,7 +1829,7 @@ impl<'a, S: notebook_view_state::State> NotebookViewBuilder<'a, S> { mut self, value: Option>, ) -> Self { - self.__unsafe_private_named.5 = value; + self.__unsafe_private_named.6 = value; self } } @@ -1772,7 +1844,7 @@ where mut self, value: impl Into>, ) -> NotebookViewBuilder<'a, notebook_view_state::SetUri> { - self.__unsafe_private_named.6 = ::core::option::Option::Some(value.into()); + self.__unsafe_private_named.7 = ::core::option::Option::Some(value.into()); NotebookViewBuilder { _phantom_state: ::core::marker::PhantomData, __unsafe_private_named: self.__unsafe_private_named, @@ -1796,10 +1868,11 @@ where authors: self.__unsafe_private_named.0.unwrap(), cid: self.__unsafe_private_named.1.unwrap(), indexed_at: self.__unsafe_private_named.2.unwrap(), - record: self.__unsafe_private_named.3.unwrap(), - tags: self.__unsafe_private_named.4, - title: self.__unsafe_private_named.5, - uri: self.__unsafe_private_named.6.unwrap(), + path: self.__unsafe_private_named.3, + record: self.__unsafe_private_named.4.unwrap(), + tags: self.__unsafe_private_named.5, + title: self.__unsafe_private_named.6, + uri: self.__unsafe_private_named.7.unwrap(), extra_data: Default::default(), } } @@ -1815,10 +1888,11 @@ where authors: self.__unsafe_private_named.0.unwrap(), cid: self.__unsafe_private_named.1.unwrap(), indexed_at: self.__unsafe_private_named.2.unwrap(), - record: self.__unsafe_private_named.3.unwrap(), - tags: self.__unsafe_private_named.4, - title: self.__unsafe_private_named.5, - uri: self.__unsafe_private_named.6.unwrap(), + path: self.__unsafe_private_named.3, + record: self.__unsafe_private_named.4.unwrap(), + tags: self.__unsafe_private_named.5, + title: self.__unsafe_private_named.6, + uri: self.__unsafe_private_named.7.unwrap(), extra_data: Some(extra_data), } } diff --git a/crates/weaver-app/Cargo.toml b/crates/weaver-app/Cargo.toml index c0e5bcb..aa6625f 100644 --- a/crates/weaver-app/Cargo.toml +++ b/crates/weaver-app/Cargo.toml @@ -11,7 +11,7 @@ fullstack-server = ["dioxus/fullstack"] wasm-split = ["dioxus/wasm-split"] no-app-index = [] -web = ["dioxus/web"] +web = ["dioxus/web", "dioxus-primitives/web"] desktop = ["dioxus/desktop"] mobile = ["dioxus/mobile"] server = [ "dioxus/server", "dep:jacquard-axum", "dep:axum"] diff --git a/crates/weaver-app/src/auth/mod.rs b/crates/weaver-app/src/auth/mod.rs index fbb8c83..3000678 100644 --- a/crates/weaver-app/src/auth/mod.rs +++ b/crates/weaver-app/src/auth/mod.rs @@ -1,5 +1,4 @@ mod storage; -use dioxus::CapturedError; pub use storage::AuthStore; mod state; @@ -10,6 +9,17 @@ use dioxus::prelude::*; #[cfg(all(feature = "fullstack-server", feature = "server"))] use jacquard::oauth::types::OAuthClientMetadata; +/// Result of attempting to restore a session +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RestoreResult { + /// Session was successfully restored + Restored, + /// No saved session was found + NoSession, + /// Session was found but expired/invalid and has been cleared + SessionExpired, +} + #[cfg(all(feature = "fullstack-server", feature = "server"))] #[get("/oauth-client-metadata.json")] pub async fn client_metadata() -> Result> { @@ -25,28 +35,25 @@ pub async fn client_metadata() -> Result> { } #[cfg(not(target_arch = "wasm32"))] -pub async fn restore_session( - _fetcher: Fetcher, - _auth_state: Signal, -) -> Result<(), String> { - Ok(()) +pub async fn restore_session(_fetcher: Fetcher, _auth_state: Signal) -> RestoreResult { + RestoreResult::NoSession } #[cfg(target_arch = "wasm32")] -pub async fn restore_session( - fetcher: Fetcher, - mut auth_state: Signal, -) -> Result<(), CapturedError> { - use dioxus::prelude::*; +pub async fn restore_session(fetcher: Fetcher, mut auth_state: Signal) -> RestoreResult { use gloo_storage::{LocalStorage, Storage}; + use jacquard::oauth::authstore::ClientAuthStore; use jacquard::types::string::Did; + // Look for session keys in localStorage (format: oauth_session_{did}_{session_id}) - let keys = LocalStorage::get_all::()?; - let mut found_session: Option<(String, String)> = None; + let Ok(keys) = LocalStorage::get_all::() else { + return RestoreResult::NoSession; + }; + let Some(keys) = keys.as_object() else { + return RestoreResult::NoSession; + }; - let keys = keys - .as_object() - .ok_or(CapturedError::from_display(format!("{}", keys)))?; + let mut found_session: Option<(String, String)> = None; for key in keys.keys() { if key.starts_with("oauth_session_") { let parts: Vec<&str> = key @@ -61,25 +68,27 @@ pub async fn restore_session( } } - let (did_str, session_id) = - found_session.ok_or(CapturedError::from_display("No saved session found"))?; - let did = Did::new_owned(did_str)?; - - let session = fetcher - .client - .oauth_client - .restore(&did, &session_id) - .await?; + let Some((did_str, session_id)) = found_session else { + return RestoreResult::NoSession; + }; + let Ok(did) = Did::new_owned(did_str) else { + return RestoreResult::NoSession; + }; - // Get DID and handle from session - let (restored_did, session_id) = session.session_info().await; - - // Update auth state - auth_state - .write() - .set_authenticated(restored_did, session_id); - fetcher.upgrade_to_authenticated(session).await; - - tracing::debug!("session restored"); - Ok(()) + match fetcher.client.oauth_client.restore(&did, &session_id).await { + Ok(session) => { + let (restored_did, session_id) = session.session_info().await; + auth_state + .write() + .set_authenticated(restored_did, session_id); + fetcher.upgrade_to_authenticated(session).await; + tracing::debug!("session restored"); + RestoreResult::Restored + } + Err(e) => { + tracing::warn!("Session restore failed, clearing dead session: {e}"); + let _ = AuthStore::new().delete_session(&did, &session_id).await; + RestoreResult::SessionExpired + } + } } diff --git a/crates/weaver-app/src/components/entry.rs b/crates/weaver-app/src/components/entry.rs index 4d1f2ee..219633e 100644 --- a/crates/weaver-app/src/components/entry.rs +++ b/crates/weaver-app/src/components/entry.rs @@ -62,13 +62,24 @@ pub fn EntryPage( #[cfg(feature = "fullstack-server")] use_effect(use_reactive!(|route| { if route != last_route() { + tracing::debug!("[EntryPage] route changed, restarting resource"); entry_res.restart(); last_route.set(route.clone()); } })); + // Debug: log route params and entry state + tracing::debug!( + "[EntryPage] route params: ident={:?}, book_title={:?}, title={:?}", + ident(), + book_title(), + title() + ); + tracing::debug!("[EntryPage] rendering, entry.is_some={}", entry.read().is_some()); + // Handle blob caching when entry data is available - match &*entry.read_unchecked() { + // Use read() instead of read_unchecked() for proper reactive tracking + match &*entry.read() { Some((book_entry_view, entry_record)) => { if let Some(embeds) = &entry_record.embeds { if let Some(_images) = &embeds.images { @@ -202,6 +213,17 @@ pub fn EntryCard( .as_ref() .map(|t| t.as_ref()) .unwrap_or("Untitled"); + + // Get path from view for URL, fallback to title + let entry_path = entry_view + .path + .as_ref() + .map(|p| p.as_ref().to_string()) + .unwrap_or_else(|| title.to_string()); + + // Parse entry record for content preview + let parsed_entry = from_data::(&entry_view.record).ok(); + // Format date let formatted_date = entry_view .indexed_at @@ -229,7 +251,7 @@ pub fn EntryCard( }; // Render preview from entry content - let preview_html = from_data::(&entry_view.record).ok().map(|entry| { + let preview_html = parsed_entry.as_ref().map(|entry| { let parser = markdown_weaver::Parser::new(&entry.content); let mut html_buf = String::new(); markdown_weaver::html::push_html(&mut html_buf, parser); @@ -244,7 +266,7 @@ pub fn EntryCard( to: Route::EntryPage { ident: ident.clone(), book_title: book_title.clone(), - title: title.to_string().into() + title: entry_path.clone().into() }, class: "entry-card-title-link", h3 { class: "entry-card-title", "{title}" } @@ -477,6 +499,14 @@ fn NavButton( .as_ref() .map(|t| t.as_ref()) .unwrap_or("Untitled"); + + // Get path from view for URL, fallback to title + let entry_path = entry + .path + .as_ref() + .map(|p| p.as_ref().to_string()) + .unwrap_or_else(|| entry_title.to_string()); + let arrow = if direction == "prev" { "←" } else { "→" }; rsx! { @@ -484,7 +514,7 @@ fn NavButton( to: Route::EntryPage { ident: ident.clone(), book_title: book_title.clone(), - title: entry_title.to_string().into() + title: entry_path.into() }, class: "nav-button nav-button-{direction}", div { class: "nav-arrow", "{arrow}" } diff --git a/crates/weaver-app/src/components/identity.rs b/crates/weaver-app/src/components/identity.rs index 3178f8c..59fa54b 100644 --- a/crates/weaver-app/src/components/identity.rs +++ b/crates/weaver-app/src/components/identity.rs @@ -89,7 +89,8 @@ pub fn NotebookCard( notebook: NotebookView<'static>, entry_refs: Vec>, ) -> Element { - use jacquard::IntoStatic; + use jacquard::{from_data, IntoStatic}; + use weaver_api::sh_weaver::notebook::book::Book; let fetcher = use_context::(); let auth_state = use_context::>(); @@ -100,6 +101,13 @@ pub fn NotebookCard( .map(|t| t.as_ref()) .unwrap_or("Untitled Notebook"); + // Get notebook path for URLs, fallback to title + let notebook_path = notebook + .path + .as_ref() + .map(|p| p.as_ref().to_string()) + .unwrap_or_else(|| title.to_string()); + // Check ownership for "Add Entry" link let notebook_ident = notebook.uri.authority().clone().into_static(); let is_owner = { @@ -117,7 +125,7 @@ pub fn NotebookCard( let show_authors = notebook.authors.len() > 1; let ident = notebook.uri.authority().clone().into_static(); - let book_title: SmolStr = title.to_string().into(); + let book_title: SmolStr = notebook_path.clone().into(); // Fetch all entries to get first/last let ident_for_fetch = ident.clone(); @@ -139,7 +147,7 @@ pub fn NotebookCard( Link { to: Route::EntryPage { ident: ident.clone(), - book_title: title.to_string().into(), + book_title: notebook_path.clone().into(), title: "".into() // Will redirect to first entry }, class: "notebook-card-header-link", @@ -217,14 +225,23 @@ pub fn NotebookCard( .map(|t| t.as_ref()) .unwrap_or("Untitled"); - let preview_html = from_data::(&entry_view.entry.record).ok().map(|entry| { + // Get path from view, fallback to title + let entry_path = entry_view.entry.path + .as_ref() + .map(|p| p.as_ref().to_string()) + .unwrap_or_else(|| entry_title.to_string()); + + // Parse entry for created_at and preview + let parsed_entry = from_data::(&entry_view.entry.record).ok(); + + let preview_html = parsed_entry.as_ref().map(|entry| { 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 }); - let created_at = from_data::(&entry_view.entry.record).ok() + let created_at = parsed_entry.as_ref() .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string()); let entry_uri = entry_view.entry.uri.clone().into_static(); @@ -236,7 +253,7 @@ pub fn NotebookCard( to: Route::EntryPage { ident: ident.clone(), book_title: book_title.clone(), - title: entry_title.to_string().into() + title: entry_path.clone().into() }, class: "entry-preview-title-link", div { class: "entry-preview-title", "{entry_title}" } @@ -258,7 +275,7 @@ pub fn NotebookCard( to: Route::EntryPage { ident: ident.clone(), book_title: book_title.clone(), - title: entry_title.to_string().into() + title: entry_path.clone().into() }, class: "entry-preview-content-link", div { class: "entry-preview-content", dangerous_inner_html: "{html}" } @@ -278,14 +295,23 @@ pub fn NotebookCard( .map(|t| t.as_ref()) .unwrap_or("Untitled"); - let preview_html = from_data::(&first_entry.entry.record).ok().map(|entry| { + // Get path from view, fallback to title + let entry_path = first_entry.entry.path + .as_ref() + .map(|p| p.as_ref().to_string()) + .unwrap_or_else(|| entry_title.to_string()); + + // Parse entry for created_at and preview + let parsed_entry = from_data::(&first_entry.entry.record).ok(); + + let preview_html = parsed_entry.as_ref().map(|entry| { 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 }); - let created_at = from_data::(&first_entry.entry.record).ok() + let created_at = parsed_entry.as_ref() .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string()); let entry_uri = first_entry.entry.uri.clone().into_static(); @@ -297,7 +323,7 @@ pub fn NotebookCard( to: Route::EntryPage { ident: ident.clone(), book_title: book_title.clone(), - title: entry_title.to_string().into() + title: entry_path.clone().into() }, class: "entry-preview-title-link", div { class: "entry-preview-title", "{entry_title}" } @@ -319,7 +345,7 @@ pub fn NotebookCard( to: Route::EntryPage { ident: ident.clone(), book_title: book_title.clone(), - title: entry_title.to_string().into() + title: entry_path.clone().into() }, class: "entry-preview-content-link", div { class: "entry-preview-content", dangerous_inner_html: "{html}" } @@ -348,14 +374,23 @@ pub fn NotebookCard( .map(|t| t.as_ref()) .unwrap_or("Untitled"); - let preview_html = from_data::(&last_entry.entry.record).ok().map(|entry| { + // Get path from view, fallback to title + let entry_path = last_entry.entry.path + .as_ref() + .map(|p| p.as_ref().to_string()) + .unwrap_or_else(|| entry_title.to_string()); + + // Parse entry for created_at and preview + let parsed_entry = from_data::(&last_entry.entry.record).ok(); + + let preview_html = parsed_entry.as_ref().map(|entry| { 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 }); - let created_at = from_data::(&last_entry.entry.record).ok() + let created_at = parsed_entry.as_ref() .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string()); let entry_uri = last_entry.entry.uri.clone().into_static(); @@ -367,7 +402,7 @@ pub fn NotebookCard( to: Route::EntryPage { ident: ident.clone(), book_title: book_title.clone(), - title: entry_title.to_string().into() + title: entry_path.clone().into() }, class: "entry-preview-title-link", div { class: "entry-preview-title", "{entry_title}" } @@ -389,7 +424,7 @@ pub fn NotebookCard( to: Route::EntryPage { ident: ident.clone(), book_title: book_title.clone(), - title: entry_title.to_string().into() + title: entry_path.clone().into() }, class: "entry-preview-content-link", div { class: "entry-preview-content", dangerous_inner_html: "{html}" } diff --git a/crates/weaver-app/src/components/mod.rs b/crates/weaver-app/src/components/mod.rs index fcbce2c..f812cd1 100644 --- a/crates/weaver-app/src/components/mod.rs +++ b/crates/weaver-app/src/components/mod.rs @@ -134,3 +134,4 @@ pub mod profile_actions; pub use entry_actions::EntryActions; pub use profile_actions::{ProfileActions, ProfileActionsMenubar}; +pub mod toast; diff --git a/crates/weaver-app/src/components/toast/component.rs b/crates/weaver-app/src/components/toast/component.rs new file mode 100644 index 0000000..4b6878f --- /dev/null +++ b/crates/weaver-app/src/components/toast/component.rs @@ -0,0 +1,15 @@ +use dioxus::prelude::*; +use dioxus_primitives::toast::{self, ToastProviderProps}; + +#[component] +pub fn ToastProvider(props: ToastProviderProps) -> Element { + rsx! { + document::Link { rel: "stylesheet", href: asset!("./style.css") } + toast::ToastProvider { + default_duration: props.default_duration, + max_toasts: props.max_toasts, + render_toast: props.render_toast, + {props.children} + } + } +} diff --git a/crates/weaver-app/src/components/toast/mod.rs b/crates/weaver-app/src/components/toast/mod.rs new file mode 100644 index 0000000..2590c01 --- /dev/null +++ b/crates/weaver-app/src/components/toast/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; diff --git a/crates/weaver-app/src/components/toast/style.css b/crates/weaver-app/src/components/toast/style.css new file mode 100644 index 0000000..7a771ca --- /dev/null +++ b/crates/weaver-app/src/components/toast/style.css @@ -0,0 +1,174 @@ +.toast-container { + position: fixed; + z-index: 9999; + right: 20px; + bottom: 20px; + max-width: 350px; +} + +.toast-list { + display: flex; + flex-direction: column-reverse; + padding: 0; + margin: 0; + gap: 0.75rem; +} + +.toast-item { + display: flex; +} + +.toast { + z-index: calc(var(--toast-count) - var(--toast-index)); + display: flex; + overflow: hidden; + width: 18rem; + min-height: 4rem; + height: auto; + box-sizing: border-box; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border: 1px solid var(--color-border); + margin-top: -4rem; + background-color: var(--color-surface); + box-shadow: 0 4px 12px rgb(0 0 0 / 15%); + opacity: calc(1 - var(--toast-hidden)); + transform: scale( + calc(100% - var(--toast-index) * 5%), + calc(100% - var(--toast-index) * 2%) + ); + transition: transform 0.2s ease, margin-top 0.2s ease, opacity 0.2s ease; + + --toast-hidden: calc(min(max(0, var(--toast-index) - 2), 1)); +} + +.toast-container:not(:hover, :focus-within) + .toast[data-toast-even]:not([data-top]) { + animation: slide-up-even 0.2s ease-out; +} + +.toast-container:not(:hover, :focus-within) + .toast[data-toast-odd]:not([data-top]) { + animation: slide-up-odd 0.2s ease-out; +} + +@keyframes slide-up-even { + from { + transform: translateY(0.5rem) + scale( + calc(100% - var(--toast-index) * 5%), + calc(100% - var(--toast-index) * 2%) + ); + } + + to { + transform: translateY(0) + scale( + calc(100% - var(--toast-index) * 5%), + calc(100% - var(--toast-index) * 2%) + ); + } +} + +@keyframes slide-up-odd { + from { + transform: translateY(0.5rem) + scale( + calc(100% - var(--toast-index) * 5%), + calc(100% - var(--toast-index) * 2%) + ); + } + + to { + transform: translateY(0) + scale( + calc(100% - var(--toast-index) * 5%), + calc(100% - var(--toast-index) * 2%) + ); + } +} + +.toast[data-top] { + animation: slide-in 0.2s ease-out; +} + +.toast-container:hover .toast[data-top], +.toast-container:focus-within .toast[data-top] { + animation: slide-in 0 ease-out; +} + +@keyframes slide-in { + from { + opacity: 0; + transform: translateY(100%) + scale( + calc(110% - var(--toast-index) * 5%), + calc(110% - var(--toast-index) * 2%) + ); + } + + to { + opacity: 1; + transform: translateY(0) + scale( + calc(100% - var(--toast-index) * 5%), + calc(100% - var(--toast-index) * 2%) + ); + } +} + +.toast-container:hover .toast, +.toast-container:focus-within .toast { + margin-top: var(--toast-padding); + opacity: 1; + transform: scale(calc(100%)); +} + +.toast[data-type="success"] { + border-left: 4px solid var(--color-success); +} + +.toast[data-type="error"] { + border-left: 4px solid var(--color-error); +} + +.toast[data-type="warning"] { + border-left: 4px solid var(--color-warning); +} + +.toast[data-type="info"] { + border-left: 4px solid var(--color-secondary); +} + +.toast-content { + flex: 1; + margin-right: 8px; +} + +.toast-title { + margin-bottom: 4px; + color: var(--color-emphasis); + font-weight: 600; +} + +.toast-description { + color: var(--color-text); + font-size: 0.875rem; +} + +.toast-close { + align-self: flex-start; + padding: 0; + border: none; + margin: 0; + background: none; + color: var(--color-muted); + cursor: pointer; + font-size: 18px; + line-height: 1; +} + +.toast-close:hover { + color: var(--color-text); +} diff --git a/crates/weaver-app/src/data.rs b/crates/weaver-app/src/data.rs index 186df29..4bd0096 100644 --- a/crates/weaver-app/src/data.rs +++ b/crates/weaver-app/src/data.rs @@ -45,37 +45,47 @@ pub fn use_entry_data( let res = use_server_future(use_reactive!(|(ident, book_title, title)| { let fetcher = fetcher.clone(); async move { - if let Some(entry) = fetcher + let fetch_result = fetcher .get_entry(ident(), book_title(), title()) - .await - .ok() - .flatten() - { - let (_book_entry_view, entry_record) = (&entry.0, &entry.1); - if let Some(embeds) = &entry_record.embeds { - if let Some(images) = &embeds.images { - let ident_val = ident.clone(); - let images = images.clone(); - for image in &images.images { - use jacquard::smol_str::ToSmolStr; - - let cid = image.image.blob().cid(); - cache_blob( - ident_val.to_smolstr(), - cid.to_smolstr(), - image.name.as_ref().map(|n| n.to_smolstr()), - ) - .await - .ok(); + .await; + + match fetch_result { + Ok(Some(entry)) => { + let (_book_entry_view, entry_record) = (&entry.0, &entry.1); + if let Some(embeds) = &entry_record.embeds { + if let Some(images) = &embeds.images { + let ident_val = ident.clone(); + let images = images.clone(); + for image in &images.images { + use jacquard::smol_str::ToSmolStr; + + let cid = image.image.blob().cid(); + cache_blob( + ident_val.to_smolstr(), + cid.to_smolstr(), + image.name.as_ref().map(|n| n.to_smolstr()), + ) + .await + .ok(); + } } } + Some(( + serde_json::to_value(entry.0.clone()).unwrap(), + serde_json::to_value(entry.1.clone()).unwrap(), + )) + } + Ok(None) => None, + Err(e) => { + tracing::error!( + "[use_entry_data] fetch error for {}/{}/{}: {:?}", + ident(), + book_title(), + title(), + e + ); + None } - Some(( - serde_json::to_value(entry.0.clone()).unwrap(), - serde_json::to_value(entry.1.clone()).unwrap(), - )) - } else { - None } } })); @@ -86,7 +96,6 @@ pub fn use_entry_data( let book_entry = from_json_value::(ev.clone()).unwrap(); let entry = from_json_value::(e.clone()).unwrap(); - Some((book_entry, entry)) } else { None diff --git a/crates/weaver-app/src/fetch.rs b/crates/weaver-app/src/fetch.rs index 6d710d2..878dd73 100644 --- a/crates/weaver-app/src/fetch.rs +++ b/crates/weaver-app/src/fetch.rs @@ -66,7 +66,7 @@ impl Client { } impl HttpClient for Client { - type Error = reqwest::Error; + type Error = IdentityError; #[cfg(not(target_arch = "wasm32"))] fn send_http( @@ -77,7 +77,6 @@ impl HttpClient for Client { self.oauth_client.client.send_http(request) } - #[doc = " Send an HTTP request and return the response."] #[cfg(target_arch = "wasm32")] fn send_http( &self, @@ -388,7 +387,7 @@ impl Fetcher { let stored = Arc::new((notebook, entries)); Ok(Some(stored)) } else { - Ok(None) + Err(dioxus::CapturedError::from_display("Notebook not found")) } } @@ -409,10 +408,10 @@ impl Fetcher { let stored = Arc::new(entry); Ok(Some(stored)) } else { - Ok(None) + Err(dioxus::CapturedError::from_display("Entry not found")) } } else { - Ok(None) + Err(dioxus::CapturedError::from_display("Notebook not found")) } } @@ -542,7 +541,7 @@ impl Fetcher { Ok(Some(book_entries)) } else { - Ok(None) + Err(dioxus::CapturedError::from_display("Notebook not found")) } } @@ -863,10 +862,8 @@ impl Fetcher { // } impl HttpClient for Fetcher { - #[doc = " Error type returned by the HTTP client"] - type Error = reqwest::Error; + type Error = IdentityError; - #[doc = " Send an HTTP request and return the response."] #[cfg(not(target_arch = "wasm32"))] fn send_http( &self, @@ -879,7 +876,6 @@ impl HttpClient for Fetcher { } } - #[doc = " Send an HTTP request and return the response."] #[cfg(target_arch = "wasm32")] fn send_http( &self, diff --git a/crates/weaver-app/src/main.rs b/crates/weaver-app/src/main.rs index 109039d..f6ab1bc 100644 --- a/crates/weaver-app/src/main.rs +++ b/crates/weaver-app/src/main.rs @@ -132,7 +132,7 @@ fn main() { let console_level = if cfg!(debug_assertions) { Level::DEBUG } else { - Level::INFO + Level::DEBUG }; let wasm_layer = tracing_wasm::WASMLayer::new( @@ -226,42 +226,16 @@ fn App() -> Element { let auth_state = use_signal(|| AuthState::default()); #[allow(unused)] let auth_state = use_context_provider(|| auth_state); - #[cfg(all( - target_family = "wasm", - target_os = "unknown", - feature = "fullstack-server" - ))] - { - let fetcher = fetcher.clone(); - use_effect(move || { - let fetcher = fetcher.clone(); - use_future(move || { - let fetcher = fetcher.clone(); - async move { - if let Err(e) = auth::restore_session(fetcher, auth_state).await { - tracing::debug!("Session restoration failed: {}", e); - } - } - }); - }); - } - - #[cfg(all( - target_family = "wasm", - target_os = "unknown", - not(feature = "fullstack-server") - ))] - { + #[cfg(all(target_family = "wasm", target_os = "unknown"))] + let restore_result = { let fetcher = fetcher.clone(); - use_future(move || { + use_resource(move || { let fetcher = fetcher.clone(); - async move { - if let Err(e) = auth::restore_session(fetcher, auth_state).await { - tracing::debug!("Session restoration failed: {}", e); - } - } - }); - } + async move { auth::restore_session(fetcher, auth_state).await } + }) + }; + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] + let restore_result: Option = None; #[cfg(all( target_family = "wasm", @@ -278,6 +252,9 @@ fn App() -> Element { }); } + #[cfg(all(target_family = "wasm", target_os = "unknown"))] + use_context_provider(|| restore_result); + rsx! { document::Link { rel: "icon", href: FAVICON } document::Link { rel: "stylesheet", href: MAIN_CSS } @@ -286,7 +263,9 @@ fn App() -> Element { document::Link { rel: "preconnect", href: "https://fonts.gstatic.com" } document::Link { rel: "stylesheet", href: THEME_DEFAULTS_CSS } - Router:: {} + components::toast::ToastProvider { + Router:: {} + } } } diff --git a/crates/weaver-app/src/views/home.rs b/crates/weaver-app/src/views/home.rs index 3ac93cc..4f0cb23 100644 --- a/crates/weaver-app/src/views/home.rs +++ b/crates/weaver-app/src/views/home.rs @@ -9,18 +9,7 @@ const NOTEBOOK_CARD_CSS: Asset = asset!("/assets/styling/notebook-card.css"); pub fn Home() -> Element { // Fetch notebooks from UFOS with SSR support let (notebooks_result, notebooks) = data::use_notebooks_from_ufos(); - let navigator = use_navigator(); - let mut uri_input = use_signal(|| String::new()); - let handle_uri_submit = move || { - let input_uri = uri_input.read().clone(); - if !input_uri.is_empty() { - if let Ok(parsed) = AtUri::new(&input_uri) { - let link = format!("{}/record/{}", crate::env::WEAVER_APP_DOMAIN, parsed); - navigator.push(link); - } - } - }; #[cfg(feature = "fullstack-server")] notebooks_result .as_ref() @@ -32,22 +21,7 @@ pub fn Home() -> Element { document::Link { rel: "stylesheet", href: NOTEBOOK_CARD_CSS } div { class: "record-view-container", - div { class: "record-header", - div { class: "uri-input-section", - input { - r#type: "text", - class: "uri-input", - placeholder: "at://did:plc:.../collection/rkey", - value: "{uri_input}", - oninput: move |evt| uri_input.set(evt.value()), - onkeydown: move |evt| { - if evt.key() == Key::Enter { - handle_uri_submit(); - } - }, - } - } - } + div { class: "notebooks-list", match &*notebooks.read() { Some(notebook_list) => rsx! { diff --git a/crates/weaver-app/src/views/navbar.rs b/crates/weaver-app/src/views/navbar.rs index ba6f828..c61383d 100644 --- a/crates/weaver-app/src/views/navbar.rs +++ b/crates/weaver-app/src/views/navbar.rs @@ -1,10 +1,11 @@ use crate::Route; -use crate::auth::AuthState; +use crate::auth::{AuthState, RestoreResult}; use crate::components::button::{Button, ButtonVariant}; use crate::components::login::LoginModal; use crate::data::{use_get_handle, use_load_handle}; use crate::fetch::Fetcher; use dioxus::prelude::*; +use dioxus_primitives::toast::{use_toast, ToastOptions}; use jacquard::types::string::Did; const NAVBAR_CSS: Asset = asset!("/assets/styling/navbar.css"); @@ -19,11 +20,34 @@ pub fn Navbar() -> Element { let route = use_route::(); tracing::trace!("Route: {:?}", route); - let mut auth_state = use_context::>(); + let auth_state = use_context::>(); + + // Show toast if session expired + #[cfg(all(target_family = "wasm", target_os = "unknown"))] + { + let restore_result = use_context::>(); + let toast = use_toast(); + let mut shown = use_signal(|| false); + + if !shown() && restore_result() == Some(RestoreResult::SessionExpired) { + shown.set(true); + toast.warning( + "Session Expired".to_string(), + ToastOptions::new().description("Please sign in again"), + ); + } + } let (route_handle_res, route_handle) = use_load_handle(match &route { Route::EntryPage { ident, .. } => Some(ident.clone()), Route::RepositoryIndex { ident } => Some(ident.clone()), Route::NotebookIndex { ident, .. } => Some(ident.clone()), + Route::DraftsList { ident } => Some(ident.clone()), + Route::DraftEdit { ident, .. } => Some(ident.clone()), + Route::NewDraft { ident, .. } => Some(ident.clone()), + Route::StandaloneEntry { ident, .. } => Some(ident.clone()), + Route::StandaloneEntryEdit { ident, .. } => Some(ident.clone()), + Route::NotebookEntryByRkey { ident, .. } => Some(ident.clone()), + Route::NotebookEntryEdit { ident, .. } => Some(ident.clone()), _ => None, }); @@ -51,43 +75,152 @@ pub fn Navbar() -> Element { let route_handle = route_handle.read().clone(); let handle = route_handle.unwrap_or(ident.clone()); rsx! { - span { class: "breadcrumb-separator", " > " } - span { class: "breadcrumb breadcrumb-current", "@{handle}" } + span { class:"breadcrumb-separator"," > "} + span { class:"breadcrumb breadcrumb-current","@{handle}"} } }, - Route::NotebookIndex { ident, book_title } => { + Route::NotebookIndex{ ident, book_title } => { let route_handle = route_handle.read().clone(); let handle = route_handle.unwrap_or(ident.clone()); rsx! { - span { class: "breadcrumb-separator", " > " } + span { class:"breadcrumb-separator"," > " } Link { - to: Route::RepositoryIndex { ident: ident.clone() }, - class: "breadcrumb", - "@{handle}" + to: Route::RepositoryIndex { ident: ident.clone() + }, + class: "breadcrumb","@{handle}" } - span { class: "breadcrumb-separator", " > " } - span { class: "breadcrumb breadcrumb-current", "{book_title}" } + span{ class: "breadcrumb-separator"," > "} + span{ class: "breadcrumb breadcrumb-current","{book_title}"} } }, Route::EntryPage { ident, book_title, .. } => { + let route_handle=route_handle.read().clone(); + let handle=route_handle.unwrap_or(ident.clone()); + rsx! { + span { class:"breadcrumb-separator"," > "} + Link { + to: Route::RepositoryIndex { + ident:ident.clone() + }, + class:"breadcrumb","@{handle}" + } + span { class:"breadcrumb-separator"," > "} + Link { + to: Route::NotebookIndex { + ident: ident.clone(), + book_title: book_title.clone() + }, + class: "breadcrumb", + "{book_title}" + } + } + }, + Route::DraftsList { ident } => { + let route_handle = route_handle.read().clone(); + let handle = route_handle.unwrap_or(ident.clone()); + rsx! { + span { class:"breadcrumb-separator"," > "} + span { class:"breadcrumb breadcrumb-current","@{handle}"} + } + }, + Route::DraftEdit { ident, tid } => { + let route_handle = route_handle.read().clone(); + let handle = route_handle.unwrap_or(ident.clone()); + rsx! { + span { class:"breadcrumb-separator"," > "} + span { class:"breadcrumb breadcrumb-current","@{handle}"} + } + }, + Route::NewDraft { ident, notebook } => { + let route_handle = route_handle.read().clone(); + let handle = route_handle.unwrap_or(ident.clone()); + if let Some(notebook) = notebook { + rsx! { + span { class:"breadcrumb-separator"," > "} + Link { + to: Route::RepositoryIndex { + ident:ident.clone() + }, + class:"breadcrumb","@{handle}" + } + span { class:"breadcrumb-separator"," > "} + Link { + to: Route::NotebookIndex { + ident: ident.clone(), + book_title: notebook.clone() + }, + class: "breadcrumb", + "{notebook}" + } + } + } else { + rsx! { + span { class:"breadcrumb-separator"," > "} + span { class:"breadcrumb breadcrumb-current","@{handle}"} + } + } + }, + Route::StandaloneEntry { ident, .. } => { + let route_handle = route_handle.read().clone(); + let handle = route_handle.unwrap_or(ident.clone()); + rsx! { + span { class:"breadcrumb-separator"," > "} + span { class:"breadcrumb breadcrumb-current","@{handle}"} + } + }, + Route::StandaloneEntryEdit { ident, .. } => { let route_handle = route_handle.read().clone(); let handle = route_handle.unwrap_or(ident.clone()); rsx! { - span { class: "breadcrumb-separator", " > " } + span { class:"breadcrumb-separator"," > "} + span { class:"breadcrumb breadcrumb-current","@{handle}"} + } + }, + Route::NotebookEntryByRkey { ident, book_title, .. } => { + let route_handle=route_handle.read().clone(); + let handle=route_handle.unwrap_or(ident.clone()); + rsx! { + span { class:"breadcrumb-separator"," > "} Link { - to: Route::RepositoryIndex { ident: ident.clone() }, + to: Route::RepositoryIndex { + ident:ident.clone() + }, + class:"breadcrumb","@{handle}" + } + span { class:"breadcrumb-separator"," > "} + Link { + to: Route::NotebookIndex { + ident: ident.clone(), + book_title: book_title.clone() + }, class: "breadcrumb", - "@{handle}" + "{book_title}" + } + } + }, + Route::NotebookEntryEdit { ident, book_title, .. } => { + let route_handle=route_handle.read().clone(); + let handle=route_handle.unwrap_or(ident.clone()); + rsx! { + span { class:"breadcrumb-separator"," > "} + Link { + to: Route::RepositoryIndex { + ident:ident.clone() + }, + class:"breadcrumb","@{handle}" } - span { class: "breadcrumb-separator", " > " } + span { class:"breadcrumb-separator"," > "} Link { - to: Route::NotebookIndex { ident: ident.clone(), book_title: book_title.clone() }, + to: Route::NotebookIndex { + ident: ident.clone(), + book_title: book_title.clone() + }, class: "breadcrumb", "{book_title}" } } }, - _ => rsx! {} + _ => rsx! {}, } } if auth_state.read().is_authenticated() { diff --git a/crates/weaver-common/src/agent.rs b/crates/weaver-common/src/agent.rs index 7eef674..b614b13 100644 --- a/crates/weaver-common/src/agent.rs +++ b/crates/weaver-common/src/agent.rs @@ -30,6 +30,31 @@ use crate::{PublishResult, W_TICKER, normalize_title_path}; const CONSTELLATION_URL: &str = "https://constellation.microcosm.blue"; +/// Strip trailing punctuation that URL parsers commonly eat +/// (period, comma, semicolon, colon, exclamation, question mark) +fn strip_trailing_punctuation(s: &str) -> &str { + s.trim_end_matches(['.', ',', ';', ':', '!', '?']) +} + +/// Check if a search term matches a value, with fallback to stripped punctuation +fn title_matches(value: &str, search: &str) -> bool { + // Exact match first + if value == search { + return true; + } + // Try with trailing punctuation stripped from search term + let stripped_search = strip_trailing_punctuation(search); + if stripped_search != search && value == stripped_search { + return true; + } + // Try with trailing punctuation stripped from value (for titles ending in punctuation) + let stripped_value = strip_trailing_punctuation(value); + if stripped_value != value && stripped_value == search { + return true; + } + false +} + /// Extension trait providing weaver-specific multi-step operations on Agent /// /// This trait extends jacquard's Agent with notebook-specific workflows that @@ -347,6 +372,7 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { let title = notebook.value.title.clone(); let tags = notebook.value.tags.clone(); + let path = notebook.value.path.clone(); let mut authors = Vec::new(); use weaver_api::app_bsky::actor::{ @@ -383,6 +409,7 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { .uri(notebook.uri) .indexed_at(jacquard::types::string::Datetime::now()) .maybe_title(title) + .maybe_path(path) .maybe_tags(tags) .authors(authors) .record(to_data(¬ebook.value).map_err(|_| { @@ -411,6 +438,7 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { let entry = self.fetch_record(&entry_uri).await?; let title = entry.value.title.clone(); + let path = entry.value.path.clone(); let tags = entry.value.tags.clone(); Ok(EntryView::new() @@ -424,6 +452,7 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { })?) .maybe_tags(tags) .title(title) + .path(path) .authors(notebook.authors.clone()) .build()) } @@ -450,7 +479,9 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { .await .map_err(|e| AgentError::from(e))?; if let Ok(entry) = resp.parse() { - if entry.value.path == title || entry.value.title == title { + let path_matches = title_matches(entry.value.path.as_ref(), title); + let title_field_matches = title_matches(entry.value.title.as_ref(), title); + if path_matches || title_field_matches { // Build BookEntryView with prev/next let entry_view = self.fetch_entry_view(notebook, entry_ref).await?; @@ -549,13 +580,13 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { )) })?; - // Match on path first, then title + // Match on path first, then title (with trailing punctuation tolerance) let matched_title = if let Some(ref path) = notebook.path - && path.as_ref() == title + && title_matches(path.as_ref(), title) { Some(path.clone()) } else if let Some(ref book_title) = notebook.title - && book_title.as_ref() == title + && title_matches(book_title.as_ref(), title) { Some(book_title.clone()) } else { @@ -564,6 +595,7 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { if let Some(matched) = matched_title { let tags = notebook.tags.clone(); + let path = notebook.path.clone(); let mut authors = Vec::new(); for (index, author) in notebook.authors.iter().enumerate() { @@ -591,6 +623,7 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync { .uri(record.uri) .indexed_at(jacquard::types::string::Datetime::now()) .title(matched) + .maybe_path(path) .maybe_tags(tags) .authors(authors) .record(record.value.clone()) diff --git a/lexicons/notebook/defs.json b/lexicons/notebook/defs.json index 37bee96..c65636a 100644 --- a/lexicons/notebook/defs.json +++ b/lexicons/notebook/defs.json @@ -8,6 +8,7 @@ "properties": { "title": { "type": "ref", "ref": "#title" }, + "path": { "type": "ref", "ref": "#path" }, "tags": { "type": "ref", "ref": "#tags" }, "uri": { "type": "string", "format": "at-uri" }, "cid": { "type": "string", "format": "cid" }, @@ -25,6 +26,7 @@ "properties": { "title": { "type": "ref", "ref": "#title" }, + "path": { "type": "ref", "ref": "#path" }, "tags": { "type": "ref", "ref": "#tags" }, "uri": { "type": "string", "format": "at-uri" }, "cid": { "type": "string", "format": "cid" }, -- knotmirror 0.0.1