fixed not defaulting to path slug, as well as weird case of trailing punct it title not matching

Orual b73b1f86 ef7507ec

+686 -211
+1
Cargo.lock
··· 4786 4786 "jacquard-lexicon", 4787 4787 "miette 7.6.0", 4788 4788 "mini-moka 0.10.99", 4789 + "n0-future", 4789 4790 "percent-encoding", 4790 4791 "reqwest", 4791 4792 "serde",
+8
crates/weaver-api/lexicons/sh_weaver_notebook_defs.json
··· 101 101 "type": "string", 102 102 "format": "datetime" 103 103 }, 104 + "path": { 105 + "type": "ref", 106 + "ref": "#path" 107 + }, 104 108 "record": { 105 109 "type": "unknown" 106 110 }, ··· 146 150 "indexedAt": { 147 151 "type": "string", 148 152 "format": "datetime" 153 + }, 154 + "path": { 155 + "type": "ref", 156 + "ref": "#path" 149 157 }, 150 158 "record": { 151 159 "type": "unknown"
+108 -34
crates/weaver-api/src/sh_weaver/notebook.rs
··· 448 448 }), 449 449 ); 450 450 map.insert( 451 + ::jacquard_common::smol_str::SmolStr::new_static("path"), 452 + ::jacquard_lexicon::lexicon::LexObjectProperty::Ref(::jacquard_lexicon::lexicon::LexRef { 453 + description: None, 454 + r#ref: ::jacquard_common::CowStr::new_static("#path"), 455 + }), 456 + ); 457 + map.insert( 451 458 ::jacquard_common::smol_str::SmolStr::new_static("record"), 452 459 ::jacquard_lexicon::lexicon::LexObjectProperty::Unknown(::jacquard_lexicon::lexicon::LexUnknown { 453 460 description: None, ··· 564 571 r#enum: None, 565 572 r#const: None, 566 573 known_values: None, 574 + }), 575 + ); 576 + map.insert( 577 + ::jacquard_common::smol_str::SmolStr::new_static("path"), 578 + ::jacquard_lexicon::lexicon::LexObjectProperty::Ref(::jacquard_lexicon::lexicon::LexRef { 579 + description: None, 580 + r#ref: ::jacquard_common::CowStr::new_static("#path"), 567 581 }), 568 582 ); 569 583 map.insert( ··· 1139 1153 #[serde(borrow)] 1140 1154 pub cid: jacquard_common::types::string::Cid<'a>, 1141 1155 pub indexed_at: jacquard_common::types::string::Datetime, 1156 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 1157 + #[serde(borrow)] 1158 + pub path: std::option::Option<crate::sh_weaver::notebook::Path<'a>>, 1142 1159 #[serde(borrow)] 1143 1160 pub record: jacquard_common::types::value::Data<'a>, 1144 1161 #[serde(skip_serializing_if = "std::option::Option::is_none")] ··· 1253 1270 ::core::option::Option<Vec<crate::sh_weaver::notebook::AuthorListView<'a>>>, 1254 1271 ::core::option::Option<jacquard_common::types::string::Cid<'a>>, 1255 1272 ::core::option::Option<jacquard_common::types::string::Datetime>, 1273 + ::core::option::Option<crate::sh_weaver::notebook::Path<'a>>, 1256 1274 ::core::option::Option<jacquard_common::types::value::Data<'a>>, 1257 1275 ::core::option::Option<crate::sh_weaver::notebook::RenderedView<'a>>, 1258 1276 ::core::option::Option<crate::sh_weaver::notebook::Tags<'a>>, ··· 1274 1292 pub fn new() -> Self { 1275 1293 EntryViewBuilder { 1276 1294 _phantom_state: ::core::marker::PhantomData, 1277 - __unsafe_private_named: (None, None, None, None, None, None, None, None), 1295 + __unsafe_private_named: ( 1296 + None, 1297 + None, 1298 + None, 1299 + None, 1300 + None, 1301 + None, 1302 + None, 1303 + None, 1304 + None, 1305 + ), 1278 1306 _phantom: ::core::marker::PhantomData, 1279 1307 } 1280 1308 } ··· 1337 1365 } 1338 1366 } 1339 1367 1368 + impl<'a, S: entry_view_state::State> EntryViewBuilder<'a, S> { 1369 + /// Set the `path` field (optional) 1370 + pub fn path( 1371 + mut self, 1372 + value: impl Into<Option<crate::sh_weaver::notebook::Path<'a>>>, 1373 + ) -> Self { 1374 + self.__unsafe_private_named.3 = value.into(); 1375 + self 1376 + } 1377 + /// Set the `path` field to an Option value (optional) 1378 + pub fn maybe_path( 1379 + mut self, 1380 + value: Option<crate::sh_weaver::notebook::Path<'a>>, 1381 + ) -> Self { 1382 + self.__unsafe_private_named.3 = value; 1383 + self 1384 + } 1385 + } 1386 + 1340 1387 impl<'a, S> EntryViewBuilder<'a, S> 1341 1388 where 1342 1389 S: entry_view_state::State, ··· 1347 1394 mut self, 1348 1395 value: impl Into<jacquard_common::types::value::Data<'a>>, 1349 1396 ) -> EntryViewBuilder<'a, entry_view_state::SetRecord<S>> { 1350 - self.__unsafe_private_named.3 = ::core::option::Option::Some(value.into()); 1397 + self.__unsafe_private_named.4 = ::core::option::Option::Some(value.into()); 1351 1398 EntryViewBuilder { 1352 1399 _phantom_state: ::core::marker::PhantomData, 1353 1400 __unsafe_private_named: self.__unsafe_private_named, ··· 1362 1409 mut self, 1363 1410 value: impl Into<Option<crate::sh_weaver::notebook::RenderedView<'a>>>, 1364 1411 ) -> Self { 1365 - self.__unsafe_private_named.4 = value.into(); 1412 + self.__unsafe_private_named.5 = value.into(); 1366 1413 self 1367 1414 } 1368 1415 /// Set the `renderedView` field to an Option value (optional) ··· 1370 1417 mut self, 1371 1418 value: Option<crate::sh_weaver::notebook::RenderedView<'a>>, 1372 1419 ) -> Self { 1373 - self.__unsafe_private_named.4 = value; 1420 + self.__unsafe_private_named.5 = value; 1374 1421 self 1375 1422 } 1376 1423 } ··· 1381 1428 mut self, 1382 1429 value: impl Into<Option<crate::sh_weaver::notebook::Tags<'a>>>, 1383 1430 ) -> Self { 1384 - self.__unsafe_private_named.5 = value.into(); 1431 + self.__unsafe_private_named.6 = value.into(); 1385 1432 self 1386 1433 } 1387 1434 /// Set the `tags` field to an Option value (optional) ··· 1389 1436 mut self, 1390 1437 value: Option<crate::sh_weaver::notebook::Tags<'a>>, 1391 1438 ) -> Self { 1392 - self.__unsafe_private_named.5 = value; 1439 + self.__unsafe_private_named.6 = value; 1393 1440 self 1394 1441 } 1395 1442 } ··· 1400 1447 mut self, 1401 1448 value: impl Into<Option<crate::sh_weaver::notebook::Title<'a>>>, 1402 1449 ) -> Self { 1403 - self.__unsafe_private_named.6 = value.into(); 1450 + self.__unsafe_private_named.7 = value.into(); 1404 1451 self 1405 1452 } 1406 1453 /// Set the `title` field to an Option value (optional) ··· 1408 1455 mut self, 1409 1456 value: Option<crate::sh_weaver::notebook::Title<'a>>, 1410 1457 ) -> Self { 1411 - self.__unsafe_private_named.6 = value; 1458 + self.__unsafe_private_named.7 = value; 1412 1459 self 1413 1460 } 1414 1461 } ··· 1423 1470 mut self, 1424 1471 value: impl Into<jacquard_common::types::string::AtUri<'a>>, 1425 1472 ) -> EntryViewBuilder<'a, entry_view_state::SetUri<S>> { 1426 - self.__unsafe_private_named.7 = ::core::option::Option::Some(value.into()); 1473 + self.__unsafe_private_named.8 = ::core::option::Option::Some(value.into()); 1427 1474 EntryViewBuilder { 1428 1475 _phantom_state: ::core::marker::PhantomData, 1429 1476 __unsafe_private_named: self.__unsafe_private_named, ··· 1447 1494 authors: self.__unsafe_private_named.0.unwrap(), 1448 1495 cid: self.__unsafe_private_named.1.unwrap(), 1449 1496 indexed_at: self.__unsafe_private_named.2.unwrap(), 1450 - record: self.__unsafe_private_named.3.unwrap(), 1451 - rendered_view: self.__unsafe_private_named.4, 1452 - tags: self.__unsafe_private_named.5, 1453 - title: self.__unsafe_private_named.6, 1454 - uri: self.__unsafe_private_named.7.unwrap(), 1497 + path: self.__unsafe_private_named.3, 1498 + record: self.__unsafe_private_named.4.unwrap(), 1499 + rendered_view: self.__unsafe_private_named.5, 1500 + tags: self.__unsafe_private_named.6, 1501 + title: self.__unsafe_private_named.7, 1502 + uri: self.__unsafe_private_named.8.unwrap(), 1455 1503 extra_data: Default::default(), 1456 1504 } 1457 1505 } ··· 1467 1515 authors: self.__unsafe_private_named.0.unwrap(), 1468 1516 cid: self.__unsafe_private_named.1.unwrap(), 1469 1517 indexed_at: self.__unsafe_private_named.2.unwrap(), 1470 - record: self.__unsafe_private_named.3.unwrap(), 1471 - rendered_view: self.__unsafe_private_named.4, 1472 - tags: self.__unsafe_private_named.5, 1473 - title: self.__unsafe_private_named.6, 1474 - uri: self.__unsafe_private_named.7.unwrap(), 1518 + path: self.__unsafe_private_named.3, 1519 + record: self.__unsafe_private_named.4.unwrap(), 1520 + rendered_view: self.__unsafe_private_named.5, 1521 + tags: self.__unsafe_private_named.6, 1522 + title: self.__unsafe_private_named.7, 1523 + uri: self.__unsafe_private_named.8.unwrap(), 1475 1524 extra_data: Some(extra_data), 1476 1525 } 1477 1526 } ··· 1511 1560 #[serde(borrow)] 1512 1561 pub cid: jacquard_common::types::string::Cid<'a>, 1513 1562 pub indexed_at: jacquard_common::types::string::Datetime, 1563 + #[serde(skip_serializing_if = "std::option::Option::is_none")] 1564 + #[serde(borrow)] 1565 + pub path: std::option::Option<crate::sh_weaver::notebook::Path<'a>>, 1514 1566 #[serde(borrow)] 1515 1567 pub record: jacquard_common::types::value::Data<'a>, 1516 1568 #[serde(skip_serializing_if = "std::option::Option::is_none")] ··· 1622 1674 ::core::option::Option<Vec<crate::sh_weaver::notebook::AuthorListView<'a>>>, 1623 1675 ::core::option::Option<jacquard_common::types::string::Cid<'a>>, 1624 1676 ::core::option::Option<jacquard_common::types::string::Datetime>, 1677 + ::core::option::Option<crate::sh_weaver::notebook::Path<'a>>, 1625 1678 ::core::option::Option<jacquard_common::types::value::Data<'a>>, 1626 1679 ::core::option::Option<crate::sh_weaver::notebook::Tags<'a>>, 1627 1680 ::core::option::Option<crate::sh_weaver::notebook::Title<'a>>, ··· 1642 1695 pub fn new() -> Self { 1643 1696 NotebookViewBuilder { 1644 1697 _phantom_state: ::core::marker::PhantomData, 1645 - __unsafe_private_named: (None, None, None, None, None, None, None), 1698 + __unsafe_private_named: (None, None, None, None, None, None, None, None), 1646 1699 _phantom: ::core::marker::PhantomData, 1647 1700 } 1648 1701 } ··· 1705 1758 } 1706 1759 } 1707 1760 1761 + impl<'a, S: notebook_view_state::State> NotebookViewBuilder<'a, S> { 1762 + /// Set the `path` field (optional) 1763 + pub fn path( 1764 + mut self, 1765 + value: impl Into<Option<crate::sh_weaver::notebook::Path<'a>>>, 1766 + ) -> Self { 1767 + self.__unsafe_private_named.3 = value.into(); 1768 + self 1769 + } 1770 + /// Set the `path` field to an Option value (optional) 1771 + pub fn maybe_path( 1772 + mut self, 1773 + value: Option<crate::sh_weaver::notebook::Path<'a>>, 1774 + ) -> Self { 1775 + self.__unsafe_private_named.3 = value; 1776 + self 1777 + } 1778 + } 1779 + 1708 1780 impl<'a, S> NotebookViewBuilder<'a, S> 1709 1781 where 1710 1782 S: notebook_view_state::State, ··· 1715 1787 mut self, 1716 1788 value: impl Into<jacquard_common::types::value::Data<'a>>, 1717 1789 ) -> NotebookViewBuilder<'a, notebook_view_state::SetRecord<S>> { 1718 - self.__unsafe_private_named.3 = ::core::option::Option::Some(value.into()); 1790 + self.__unsafe_private_named.4 = ::core::option::Option::Some(value.into()); 1719 1791 NotebookViewBuilder { 1720 1792 _phantom_state: ::core::marker::PhantomData, 1721 1793 __unsafe_private_named: self.__unsafe_private_named, ··· 1730 1802 mut self, 1731 1803 value: impl Into<Option<crate::sh_weaver::notebook::Tags<'a>>>, 1732 1804 ) -> Self { 1733 - self.__unsafe_private_named.4 = value.into(); 1805 + self.__unsafe_private_named.5 = value.into(); 1734 1806 self 1735 1807 } 1736 1808 /// Set the `tags` field to an Option value (optional) ··· 1738 1810 mut self, 1739 1811 value: Option<crate::sh_weaver::notebook::Tags<'a>>, 1740 1812 ) -> Self { 1741 - self.__unsafe_private_named.4 = value; 1813 + self.__unsafe_private_named.5 = value; 1742 1814 self 1743 1815 } 1744 1816 } ··· 1749 1821 mut self, 1750 1822 value: impl Into<Option<crate::sh_weaver::notebook::Title<'a>>>, 1751 1823 ) -> Self { 1752 - self.__unsafe_private_named.5 = value.into(); 1824 + self.__unsafe_private_named.6 = value.into(); 1753 1825 self 1754 1826 } 1755 1827 /// Set the `title` field to an Option value (optional) ··· 1757 1829 mut self, 1758 1830 value: Option<crate::sh_weaver::notebook::Title<'a>>, 1759 1831 ) -> Self { 1760 - self.__unsafe_private_named.5 = value; 1832 + self.__unsafe_private_named.6 = value; 1761 1833 self 1762 1834 } 1763 1835 } ··· 1772 1844 mut self, 1773 1845 value: impl Into<jacquard_common::types::string::AtUri<'a>>, 1774 1846 ) -> NotebookViewBuilder<'a, notebook_view_state::SetUri<S>> { 1775 - self.__unsafe_private_named.6 = ::core::option::Option::Some(value.into()); 1847 + self.__unsafe_private_named.7 = ::core::option::Option::Some(value.into()); 1776 1848 NotebookViewBuilder { 1777 1849 _phantom_state: ::core::marker::PhantomData, 1778 1850 __unsafe_private_named: self.__unsafe_private_named, ··· 1796 1868 authors: self.__unsafe_private_named.0.unwrap(), 1797 1869 cid: self.__unsafe_private_named.1.unwrap(), 1798 1870 indexed_at: self.__unsafe_private_named.2.unwrap(), 1799 - record: self.__unsafe_private_named.3.unwrap(), 1800 - tags: self.__unsafe_private_named.4, 1801 - title: self.__unsafe_private_named.5, 1802 - uri: self.__unsafe_private_named.6.unwrap(), 1871 + path: self.__unsafe_private_named.3, 1872 + record: self.__unsafe_private_named.4.unwrap(), 1873 + tags: self.__unsafe_private_named.5, 1874 + title: self.__unsafe_private_named.6, 1875 + uri: self.__unsafe_private_named.7.unwrap(), 1803 1876 extra_data: Default::default(), 1804 1877 } 1805 1878 } ··· 1815 1888 authors: self.__unsafe_private_named.0.unwrap(), 1816 1889 cid: self.__unsafe_private_named.1.unwrap(), 1817 1890 indexed_at: self.__unsafe_private_named.2.unwrap(), 1818 - record: self.__unsafe_private_named.3.unwrap(), 1819 - tags: self.__unsafe_private_named.4, 1820 - title: self.__unsafe_private_named.5, 1821 - uri: self.__unsafe_private_named.6.unwrap(), 1891 + path: self.__unsafe_private_named.3, 1892 + record: self.__unsafe_private_named.4.unwrap(), 1893 + tags: self.__unsafe_private_named.5, 1894 + title: self.__unsafe_private_named.6, 1895 + uri: self.__unsafe_private_named.7.unwrap(), 1822 1896 extra_data: Some(extra_data), 1823 1897 } 1824 1898 }
+1 -1
crates/weaver-app/Cargo.toml
··· 11 11 wasm-split = ["dioxus/wasm-split"] 12 12 no-app-index = [] 13 13 14 - web = ["dioxus/web"] 14 + web = ["dioxus/web", "dioxus-primitives/web"] 15 15 desktop = ["dioxus/desktop"] 16 16 mobile = ["dioxus/mobile"] 17 17 server = [ "dioxus/server", "dep:jacquard-axum", "dep:axum"]
+45 -36
crates/weaver-app/src/auth/mod.rs
··· 1 1 mod storage; 2 - use dioxus::CapturedError; 3 2 pub use storage::AuthStore; 4 3 5 4 mod state; ··· 10 9 #[cfg(all(feature = "fullstack-server", feature = "server"))] 11 10 use jacquard::oauth::types::OAuthClientMetadata; 12 11 12 + /// Result of attempting to restore a session 13 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 14 + pub enum RestoreResult { 15 + /// Session was successfully restored 16 + Restored, 17 + /// No saved session was found 18 + NoSession, 19 + /// Session was found but expired/invalid and has been cleared 20 + SessionExpired, 21 + } 22 + 13 23 #[cfg(all(feature = "fullstack-server", feature = "server"))] 14 24 #[get("/oauth-client-metadata.json")] 15 25 pub async fn client_metadata() -> Result<axum::Json<serde_json::Value>> { ··· 25 35 } 26 36 27 37 #[cfg(not(target_arch = "wasm32"))] 28 - pub async fn restore_session( 29 - _fetcher: Fetcher, 30 - _auth_state: Signal<AuthState>, 31 - ) -> Result<(), String> { 32 - Ok(()) 38 + pub async fn restore_session(_fetcher: Fetcher, _auth_state: Signal<AuthState>) -> RestoreResult { 39 + RestoreResult::NoSession 33 40 } 34 41 35 42 #[cfg(target_arch = "wasm32")] 36 - pub async fn restore_session( 37 - fetcher: Fetcher, 38 - mut auth_state: Signal<AuthState>, 39 - ) -> Result<(), CapturedError> { 40 - use dioxus::prelude::*; 43 + pub async fn restore_session(fetcher: Fetcher, mut auth_state: Signal<AuthState>) -> RestoreResult { 41 44 use gloo_storage::{LocalStorage, Storage}; 45 + use jacquard::oauth::authstore::ClientAuthStore; 42 46 use jacquard::types::string::Did; 47 + 43 48 // Look for session keys in localStorage (format: oauth_session_{did}_{session_id}) 44 - let keys = LocalStorage::get_all::<serde_json::Value>()?; 45 - let mut found_session: Option<(String, String)> = None; 49 + let Ok(keys) = LocalStorage::get_all::<serde_json::Value>() else { 50 + return RestoreResult::NoSession; 51 + }; 52 + let Some(keys) = keys.as_object() else { 53 + return RestoreResult::NoSession; 54 + }; 46 55 47 - let keys = keys 48 - .as_object() 49 - .ok_or(CapturedError::from_display(format!("{}", keys)))?; 56 + let mut found_session: Option<(String, String)> = None; 50 57 for key in keys.keys() { 51 58 if key.starts_with("oauth_session_") { 52 59 let parts: Vec<&str> = key ··· 61 68 } 62 69 } 63 70 64 - let (did_str, session_id) = 65 - found_session.ok_or(CapturedError::from_display("No saved session found"))?; 66 - let did = Did::new_owned(did_str)?; 67 - 68 - let session = fetcher 69 - .client 70 - .oauth_client 71 - .restore(&did, &session_id) 72 - .await?; 73 - 74 - // Get DID and handle from session 75 - let (restored_did, session_id) = session.session_info().await; 76 - 77 - // Update auth state 78 - auth_state 79 - .write() 80 - .set_authenticated(restored_did, session_id); 81 - fetcher.upgrade_to_authenticated(session).await; 71 + let Some((did_str, session_id)) = found_session else { 72 + return RestoreResult::NoSession; 73 + }; 74 + let Ok(did) = Did::new_owned(did_str) else { 75 + return RestoreResult::NoSession; 76 + }; 82 77 83 - tracing::debug!("session restored"); 84 - Ok(()) 78 + match fetcher.client.oauth_client.restore(&did, &session_id).await { 79 + Ok(session) => { 80 + let (restored_did, session_id) = session.session_info().await; 81 + auth_state 82 + .write() 83 + .set_authenticated(restored_did, session_id); 84 + fetcher.upgrade_to_authenticated(session).await; 85 + tracing::debug!("session restored"); 86 + RestoreResult::Restored 87 + } 88 + Err(e) => { 89 + tracing::warn!("Session restore failed, clearing dead session: {e}"); 90 + let _ = AuthStore::new().delete_session(&did, &session_id).await; 91 + RestoreResult::SessionExpired 92 + } 93 + } 85 94 }
+34 -4
crates/weaver-app/src/components/entry.rs
··· 62 62 #[cfg(feature = "fullstack-server")] 63 63 use_effect(use_reactive!(|route| { 64 64 if route != last_route() { 65 + tracing::debug!("[EntryPage] route changed, restarting resource"); 65 66 entry_res.restart(); 66 67 last_route.set(route.clone()); 67 68 } 68 69 })); 69 70 71 + // Debug: log route params and entry state 72 + tracing::debug!( 73 + "[EntryPage] route params: ident={:?}, book_title={:?}, title={:?}", 74 + ident(), 75 + book_title(), 76 + title() 77 + ); 78 + tracing::debug!("[EntryPage] rendering, entry.is_some={}", entry.read().is_some()); 79 + 70 80 // Handle blob caching when entry data is available 71 - match &*entry.read_unchecked() { 81 + // Use read() instead of read_unchecked() for proper reactive tracking 82 + match &*entry.read() { 72 83 Some((book_entry_view, entry_record)) => { 73 84 if let Some(embeds) = &entry_record.embeds { 74 85 if let Some(_images) = &embeds.images { ··· 202 213 .as_ref() 203 214 .map(|t| t.as_ref()) 204 215 .unwrap_or("Untitled"); 216 + 217 + // Get path from view for URL, fallback to title 218 + let entry_path = entry_view 219 + .path 220 + .as_ref() 221 + .map(|p| p.as_ref().to_string()) 222 + .unwrap_or_else(|| title.to_string()); 223 + 224 + // Parse entry record for content preview 225 + let parsed_entry = from_data::<Entry>(&entry_view.record).ok(); 226 + 205 227 // Format date 206 228 let formatted_date = entry_view 207 229 .indexed_at ··· 229 251 }; 230 252 231 253 // Render preview from entry content 232 - let preview_html = from_data::<Entry>(&entry_view.record).ok().map(|entry| { 254 + let preview_html = parsed_entry.as_ref().map(|entry| { 233 255 let parser = markdown_weaver::Parser::new(&entry.content); 234 256 let mut html_buf = String::new(); 235 257 markdown_weaver::html::push_html(&mut html_buf, parser); ··· 244 266 to: Route::EntryPage { 245 267 ident: ident.clone(), 246 268 book_title: book_title.clone(), 247 - title: title.to_string().into() 269 + title: entry_path.clone().into() 248 270 }, 249 271 class: "entry-card-title-link", 250 272 h3 { class: "entry-card-title", "{title}" } ··· 477 499 .as_ref() 478 500 .map(|t| t.as_ref()) 479 501 .unwrap_or("Untitled"); 502 + 503 + // Get path from view for URL, fallback to title 504 + let entry_path = entry 505 + .path 506 + .as_ref() 507 + .map(|p| p.as_ref().to_string()) 508 + .unwrap_or_else(|| entry_title.to_string()); 509 + 480 510 let arrow = if direction == "prev" { "←" } else { "→" }; 481 511 482 512 rsx! { ··· 484 514 to: Route::EntryPage { 485 515 ident: ident.clone(), 486 516 book_title: book_title.clone(), 487 - title: entry_title.to_string().into() 517 + title: entry_path.into() 488 518 }, 489 519 class: "nav-button nav-button-{direction}", 490 520 div { class: "nav-arrow", "{arrow}" }
+50 -15
crates/weaver-app/src/components/identity.rs
··· 89 89 notebook: NotebookView<'static>, 90 90 entry_refs: Vec<StrongRef<'static>>, 91 91 ) -> Element { 92 - use jacquard::IntoStatic; 92 + use jacquard::{from_data, IntoStatic}; 93 + use weaver_api::sh_weaver::notebook::book::Book; 93 94 94 95 let fetcher = use_context::<fetch::Fetcher>(); 95 96 let auth_state = use_context::<Signal<AuthState>>(); ··· 100 101 .map(|t| t.as_ref()) 101 102 .unwrap_or("Untitled Notebook"); 102 103 104 + // Get notebook path for URLs, fallback to title 105 + let notebook_path = notebook 106 + .path 107 + .as_ref() 108 + .map(|p| p.as_ref().to_string()) 109 + .unwrap_or_else(|| title.to_string()); 110 + 103 111 // Check ownership for "Add Entry" link 104 112 let notebook_ident = notebook.uri.authority().clone().into_static(); 105 113 let is_owner = { ··· 117 125 let show_authors = notebook.authors.len() > 1; 118 126 119 127 let ident = notebook.uri.authority().clone().into_static(); 120 - let book_title: SmolStr = title.to_string().into(); 128 + let book_title: SmolStr = notebook_path.clone().into(); 121 129 122 130 // Fetch all entries to get first/last 123 131 let ident_for_fetch = ident.clone(); ··· 139 147 Link { 140 148 to: Route::EntryPage { 141 149 ident: ident.clone(), 142 - book_title: title.to_string().into(), 150 + book_title: notebook_path.clone().into(), 143 151 title: "".into() // Will redirect to first entry 144 152 }, 145 153 class: "notebook-card-header-link", ··· 217 225 .map(|t| t.as_ref()) 218 226 .unwrap_or("Untitled"); 219 227 220 - let preview_html = from_data::<Entry>(&entry_view.entry.record).ok().map(|entry| { 228 + // Get path from view, fallback to title 229 + let entry_path = entry_view.entry.path 230 + .as_ref() 231 + .map(|p| p.as_ref().to_string()) 232 + .unwrap_or_else(|| entry_title.to_string()); 233 + 234 + // Parse entry for created_at and preview 235 + let parsed_entry = from_data::<Entry>(&entry_view.entry.record).ok(); 236 + 237 + let preview_html = parsed_entry.as_ref().map(|entry| { 221 238 let parser = markdown_weaver::Parser::new(&entry.content); 222 239 let mut html_buf = String::new(); 223 240 markdown_weaver::html::push_html(&mut html_buf, parser); 224 241 html_buf 225 242 }); 226 243 227 - let created_at = from_data::<Entry>(&entry_view.entry.record).ok() 244 + let created_at = parsed_entry.as_ref() 228 245 .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string()); 229 246 230 247 let entry_uri = entry_view.entry.uri.clone().into_static(); ··· 236 253 to: Route::EntryPage { 237 254 ident: ident.clone(), 238 255 book_title: book_title.clone(), 239 - title: entry_title.to_string().into() 256 + title: entry_path.clone().into() 240 257 }, 241 258 class: "entry-preview-title-link", 242 259 div { class: "entry-preview-title", "{entry_title}" } ··· 258 275 to: Route::EntryPage { 259 276 ident: ident.clone(), 260 277 book_title: book_title.clone(), 261 - title: entry_title.to_string().into() 278 + title: entry_path.clone().into() 262 279 }, 263 280 class: "entry-preview-content-link", 264 281 div { class: "entry-preview-content", dangerous_inner_html: "{html}" } ··· 278 295 .map(|t| t.as_ref()) 279 296 .unwrap_or("Untitled"); 280 297 281 - let preview_html = from_data::<Entry>(&first_entry.entry.record).ok().map(|entry| { 298 + // Get path from view, fallback to title 299 + let entry_path = first_entry.entry.path 300 + .as_ref() 301 + .map(|p| p.as_ref().to_string()) 302 + .unwrap_or_else(|| entry_title.to_string()); 303 + 304 + // Parse entry for created_at and preview 305 + let parsed_entry = from_data::<Entry>(&first_entry.entry.record).ok(); 306 + 307 + let preview_html = parsed_entry.as_ref().map(|entry| { 282 308 let parser = markdown_weaver::Parser::new(&entry.content); 283 309 let mut html_buf = String::new(); 284 310 markdown_weaver::html::push_html(&mut html_buf, parser); 285 311 html_buf 286 312 }); 287 313 288 - let created_at = from_data::<Entry>(&first_entry.entry.record).ok() 314 + let created_at = parsed_entry.as_ref() 289 315 .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string()); 290 316 291 317 let entry_uri = first_entry.entry.uri.clone().into_static(); ··· 297 323 to: Route::EntryPage { 298 324 ident: ident.clone(), 299 325 book_title: book_title.clone(), 300 - title: entry_title.to_string().into() 326 + title: entry_path.clone().into() 301 327 }, 302 328 class: "entry-preview-title-link", 303 329 div { class: "entry-preview-title", "{entry_title}" } ··· 319 345 to: Route::EntryPage { 320 346 ident: ident.clone(), 321 347 book_title: book_title.clone(), 322 - title: entry_title.to_string().into() 348 + title: entry_path.clone().into() 323 349 }, 324 350 class: "entry-preview-content-link", 325 351 div { class: "entry-preview-content", dangerous_inner_html: "{html}" } ··· 348 374 .map(|t| t.as_ref()) 349 375 .unwrap_or("Untitled"); 350 376 351 - let preview_html = from_data::<Entry>(&last_entry.entry.record).ok().map(|entry| { 377 + // Get path from view, fallback to title 378 + let entry_path = last_entry.entry.path 379 + .as_ref() 380 + .map(|p| p.as_ref().to_string()) 381 + .unwrap_or_else(|| entry_title.to_string()); 382 + 383 + // Parse entry for created_at and preview 384 + let parsed_entry = from_data::<Entry>(&last_entry.entry.record).ok(); 385 + 386 + let preview_html = parsed_entry.as_ref().map(|entry| { 352 387 let parser = markdown_weaver::Parser::new(&entry.content); 353 388 let mut html_buf = String::new(); 354 389 markdown_weaver::html::push_html(&mut html_buf, parser); 355 390 html_buf 356 391 }); 357 392 358 - let created_at = from_data::<Entry>(&last_entry.entry.record).ok() 393 + let created_at = parsed_entry.as_ref() 359 394 .map(|entry| entry.created_at.as_ref().format("%B %d, %Y").to_string()); 360 395 361 396 let entry_uri = last_entry.entry.uri.clone().into_static(); ··· 367 402 to: Route::EntryPage { 368 403 ident: ident.clone(), 369 404 book_title: book_title.clone(), 370 - title: entry_title.to_string().into() 405 + title: entry_path.clone().into() 371 406 }, 372 407 class: "entry-preview-title-link", 373 408 div { class: "entry-preview-title", "{entry_title}" } ··· 389 424 to: Route::EntryPage { 390 425 ident: ident.clone(), 391 426 book_title: book_title.clone(), 392 - title: entry_title.to_string().into() 427 + title: entry_path.clone().into() 393 428 }, 394 429 class: "entry-preview-content-link", 395 430 div { class: "entry-preview-content", dangerous_inner_html: "{html}" }
+1
crates/weaver-app/src/components/mod.rs
··· 134 134 135 135 pub use entry_actions::EntryActions; 136 136 pub use profile_actions::{ProfileActions, ProfileActionsMenubar}; 137 + pub mod toast;
+15
crates/weaver-app/src/components/toast/component.rs
··· 1 + use dioxus::prelude::*; 2 + use dioxus_primitives::toast::{self, ToastProviderProps}; 3 + 4 + #[component] 5 + pub fn ToastProvider(props: ToastProviderProps) -> Element { 6 + rsx! { 7 + document::Link { rel: "stylesheet", href: asset!("./style.css") } 8 + toast::ToastProvider { 9 + default_duration: props.default_duration, 10 + max_toasts: props.max_toasts, 11 + render_toast: props.render_toast, 12 + {props.children} 13 + } 14 + } 15 + }
+2
crates/weaver-app/src/components/toast/mod.rs
··· 1 + mod component; 2 + pub use component::*;
+174
crates/weaver-app/src/components/toast/style.css
··· 1 + .toast-container { 2 + position: fixed; 3 + z-index: 9999; 4 + right: 20px; 5 + bottom: 20px; 6 + max-width: 350px; 7 + } 8 + 9 + .toast-list { 10 + display: flex; 11 + flex-direction: column-reverse; 12 + padding: 0; 13 + margin: 0; 14 + gap: 0.75rem; 15 + } 16 + 17 + .toast-item { 18 + display: flex; 19 + } 20 + 21 + .toast { 22 + z-index: calc(var(--toast-count) - var(--toast-index)); 23 + display: flex; 24 + overflow: hidden; 25 + width: 18rem; 26 + min-height: 4rem; 27 + height: auto; 28 + box-sizing: border-box; 29 + align-items: center; 30 + justify-content: space-between; 31 + padding: 12px 16px; 32 + border: 1px solid var(--color-border); 33 + margin-top: -4rem; 34 + background-color: var(--color-surface); 35 + box-shadow: 0 4px 12px rgb(0 0 0 / 15%); 36 + opacity: calc(1 - var(--toast-hidden)); 37 + transform: scale( 38 + calc(100% - var(--toast-index) * 5%), 39 + calc(100% - var(--toast-index) * 2%) 40 + ); 41 + transition: transform 0.2s ease, margin-top 0.2s ease, opacity 0.2s ease; 42 + 43 + --toast-hidden: calc(min(max(0, var(--toast-index) - 2), 1)); 44 + } 45 + 46 + .toast-container:not(:hover, :focus-within) 47 + .toast[data-toast-even]:not([data-top]) { 48 + animation: slide-up-even 0.2s ease-out; 49 + } 50 + 51 + .toast-container:not(:hover, :focus-within) 52 + .toast[data-toast-odd]:not([data-top]) { 53 + animation: slide-up-odd 0.2s ease-out; 54 + } 55 + 56 + @keyframes slide-up-even { 57 + from { 58 + transform: translateY(0.5rem) 59 + scale( 60 + calc(100% - var(--toast-index) * 5%), 61 + calc(100% - var(--toast-index) * 2%) 62 + ); 63 + } 64 + 65 + to { 66 + transform: translateY(0) 67 + scale( 68 + calc(100% - var(--toast-index) * 5%), 69 + calc(100% - var(--toast-index) * 2%) 70 + ); 71 + } 72 + } 73 + 74 + @keyframes slide-up-odd { 75 + from { 76 + transform: translateY(0.5rem) 77 + scale( 78 + calc(100% - var(--toast-index) * 5%), 79 + calc(100% - var(--toast-index) * 2%) 80 + ); 81 + } 82 + 83 + to { 84 + transform: translateY(0) 85 + scale( 86 + calc(100% - var(--toast-index) * 5%), 87 + calc(100% - var(--toast-index) * 2%) 88 + ); 89 + } 90 + } 91 + 92 + .toast[data-top] { 93 + animation: slide-in 0.2s ease-out; 94 + } 95 + 96 + .toast-container:hover .toast[data-top], 97 + .toast-container:focus-within .toast[data-top] { 98 + animation: slide-in 0 ease-out; 99 + } 100 + 101 + @keyframes slide-in { 102 + from { 103 + opacity: 0; 104 + transform: translateY(100%) 105 + scale( 106 + calc(110% - var(--toast-index) * 5%), 107 + calc(110% - var(--toast-index) * 2%) 108 + ); 109 + } 110 + 111 + to { 112 + opacity: 1; 113 + transform: translateY(0) 114 + scale( 115 + calc(100% - var(--toast-index) * 5%), 116 + calc(100% - var(--toast-index) * 2%) 117 + ); 118 + } 119 + } 120 + 121 + .toast-container:hover .toast, 122 + .toast-container:focus-within .toast { 123 + margin-top: var(--toast-padding); 124 + opacity: 1; 125 + transform: scale(calc(100%)); 126 + } 127 + 128 + .toast[data-type="success"] { 129 + border-left: 4px solid var(--color-success); 130 + } 131 + 132 + .toast[data-type="error"] { 133 + border-left: 4px solid var(--color-error); 134 + } 135 + 136 + .toast[data-type="warning"] { 137 + border-left: 4px solid var(--color-warning); 138 + } 139 + 140 + .toast[data-type="info"] { 141 + border-left: 4px solid var(--color-secondary); 142 + } 143 + 144 + .toast-content { 145 + flex: 1; 146 + margin-right: 8px; 147 + } 148 + 149 + .toast-title { 150 + margin-bottom: 4px; 151 + color: var(--color-emphasis); 152 + font-weight: 600; 153 + } 154 + 155 + .toast-description { 156 + color: var(--color-text); 157 + font-size: 0.875rem; 158 + } 159 + 160 + .toast-close { 161 + align-self: flex-start; 162 + padding: 0; 163 + border: none; 164 + margin: 0; 165 + background: none; 166 + color: var(--color-muted); 167 + cursor: pointer; 168 + font-size: 18px; 169 + line-height: 1; 170 + } 171 + 172 + .toast-close:hover { 173 + color: var(--color-text); 174 + }
+36 -27
crates/weaver-app/src/data.rs
··· 45 45 let res = use_server_future(use_reactive!(|(ident, book_title, title)| { 46 46 let fetcher = fetcher.clone(); 47 47 async move { 48 - if let Some(entry) = fetcher 48 + let fetch_result = fetcher 49 49 .get_entry(ident(), book_title(), title()) 50 - .await 51 - .ok() 52 - .flatten() 53 - { 54 - let (_book_entry_view, entry_record) = (&entry.0, &entry.1); 55 - if let Some(embeds) = &entry_record.embeds { 56 - if let Some(images) = &embeds.images { 57 - let ident_val = ident.clone(); 58 - let images = images.clone(); 59 - for image in &images.images { 60 - use jacquard::smol_str::ToSmolStr; 50 + .await; 61 51 62 - let cid = image.image.blob().cid(); 63 - cache_blob( 64 - ident_val.to_smolstr(), 65 - cid.to_smolstr(), 66 - image.name.as_ref().map(|n| n.to_smolstr()), 67 - ) 68 - .await 69 - .ok(); 52 + match fetch_result { 53 + Ok(Some(entry)) => { 54 + let (_book_entry_view, entry_record) = (&entry.0, &entry.1); 55 + if let Some(embeds) = &entry_record.embeds { 56 + if let Some(images) = &embeds.images { 57 + let ident_val = ident.clone(); 58 + let images = images.clone(); 59 + for image in &images.images { 60 + use jacquard::smol_str::ToSmolStr; 61 + 62 + let cid = image.image.blob().cid(); 63 + cache_blob( 64 + ident_val.to_smolstr(), 65 + cid.to_smolstr(), 66 + image.name.as_ref().map(|n| n.to_smolstr()), 67 + ) 68 + .await 69 + .ok(); 70 + } 70 71 } 71 72 } 73 + Some(( 74 + serde_json::to_value(entry.0.clone()).unwrap(), 75 + serde_json::to_value(entry.1.clone()).unwrap(), 76 + )) 72 77 } 73 - Some(( 74 - serde_json::to_value(entry.0.clone()).unwrap(), 75 - serde_json::to_value(entry.1.clone()).unwrap(), 76 - )) 77 - } else { 78 - None 78 + Ok(None) => None, 79 + Err(e) => { 80 + tracing::error!( 81 + "[use_entry_data] fetch error for {}/{}/{}: {:?}", 82 + ident(), 83 + book_title(), 84 + title(), 85 + e 86 + ); 87 + None 88 + } 79 89 } 80 90 } 81 91 })); ··· 86 96 87 97 let book_entry = from_json_value::<BookEntryView>(ev.clone()).unwrap(); 88 98 let entry = from_json_value::<Entry>(e.clone()).unwrap(); 89 - 90 99 Some((book_entry, entry)) 91 100 } else { 92 101 None
+6 -10
crates/weaver-app/src/fetch.rs
··· 66 66 } 67 67 68 68 impl HttpClient for Client { 69 - type Error = reqwest::Error; 69 + type Error = IdentityError; 70 70 71 71 #[cfg(not(target_arch = "wasm32"))] 72 72 fn send_http( ··· 77 77 self.oauth_client.client.send_http(request) 78 78 } 79 79 80 - #[doc = " Send an HTTP request and return the response."] 81 80 #[cfg(target_arch = "wasm32")] 82 81 fn send_http( 83 82 &self, ··· 388 387 let stored = Arc::new((notebook, entries)); 389 388 Ok(Some(stored)) 390 389 } else { 391 - Ok(None) 390 + Err(dioxus::CapturedError::from_display("Notebook not found")) 392 391 } 393 392 } 394 393 ··· 409 408 let stored = Arc::new(entry); 410 409 Ok(Some(stored)) 411 410 } else { 412 - Ok(None) 411 + Err(dioxus::CapturedError::from_display("Entry not found")) 413 412 } 414 413 } else { 415 - Ok(None) 414 + Err(dioxus::CapturedError::from_display("Notebook not found")) 416 415 } 417 416 } 418 417 ··· 542 541 543 542 Ok(Some(book_entries)) 544 543 } else { 545 - Ok(None) 544 + Err(dioxus::CapturedError::from_display("Notebook not found")) 546 545 } 547 546 } 548 547 ··· 863 862 // } 864 863 865 864 impl HttpClient for Fetcher { 866 - #[doc = " Error type returned by the HTTP client"] 867 - type Error = reqwest::Error; 865 + type Error = IdentityError; 868 866 869 - #[doc = " Send an HTTP request and return the response."] 870 867 #[cfg(not(target_arch = "wasm32"))] 871 868 fn send_http( 872 869 &self, ··· 879 876 } 880 877 } 881 878 882 - #[doc = " Send an HTTP request and return the response."] 883 879 #[cfg(target_arch = "wasm32")] 884 880 fn send_http( 885 881 &self,
+15 -36
crates/weaver-app/src/main.rs
··· 132 132 let console_level = if cfg!(debug_assertions) { 133 133 Level::DEBUG 134 134 } else { 135 - Level::INFO 135 + Level::DEBUG 136 136 }; 137 137 138 138 let wasm_layer = tracing_wasm::WASMLayer::new( ··· 226 226 let auth_state = use_signal(|| AuthState::default()); 227 227 #[allow(unused)] 228 228 let auth_state = use_context_provider(|| auth_state); 229 - #[cfg(all( 230 - target_family = "wasm", 231 - target_os = "unknown", 232 - feature = "fullstack-server" 233 - ))] 234 - { 229 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 230 + let restore_result = { 235 231 let fetcher = fetcher.clone(); 236 - use_effect(move || { 232 + use_resource(move || { 237 233 let fetcher = fetcher.clone(); 238 - use_future(move || { 239 - let fetcher = fetcher.clone(); 240 - async move { 241 - if let Err(e) = auth::restore_session(fetcher, auth_state).await { 242 - tracing::debug!("Session restoration failed: {}", e); 243 - } 244 - } 245 - }); 246 - }); 247 - } 248 - 249 - #[cfg(all( 250 - target_family = "wasm", 251 - target_os = "unknown", 252 - not(feature = "fullstack-server") 253 - ))] 254 - { 255 - let fetcher = fetcher.clone(); 256 - use_future(move || { 257 - let fetcher = fetcher.clone(); 258 - async move { 259 - if let Err(e) = auth::restore_session(fetcher, auth_state).await { 260 - tracing::debug!("Session restoration failed: {}", e); 261 - } 262 - } 263 - }); 264 - } 234 + async move { auth::restore_session(fetcher, auth_state).await } 235 + }) 236 + }; 237 + #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 238 + let restore_result: Option<auth::RestoreResult> = None; 265 239 266 240 #[cfg(all( 267 241 target_family = "wasm", ··· 278 252 }); 279 253 } 280 254 255 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 256 + use_context_provider(|| restore_result); 257 + 281 258 rsx! { 282 259 document::Link { rel: "icon", href: FAVICON } 283 260 document::Link { rel: "stylesheet", href: MAIN_CSS } ··· 286 263 document::Link { rel: "preconnect", href: "https://fonts.gstatic.com" } 287 264 288 265 document::Link { rel: "stylesheet", href: THEME_DEFAULTS_CSS } 289 - Router::<Route> {} 266 + components::toast::ToastProvider { 267 + Router::<Route> {} 268 + } 290 269 } 291 270 } 292 271
+1 -27
crates/weaver-app/src/views/home.rs
··· 9 9 pub fn Home() -> Element { 10 10 // Fetch notebooks from UFOS with SSR support 11 11 let (notebooks_result, notebooks) = data::use_notebooks_from_ufos(); 12 - let navigator = use_navigator(); 13 - let mut uri_input = use_signal(|| String::new()); 14 12 15 - let handle_uri_submit = move || { 16 - let input_uri = uri_input.read().clone(); 17 - if !input_uri.is_empty() { 18 - if let Ok(parsed) = AtUri::new(&input_uri) { 19 - let link = format!("{}/record/{}", crate::env::WEAVER_APP_DOMAIN, parsed); 20 - navigator.push(link); 21 - } 22 - } 23 - }; 24 13 #[cfg(feature = "fullstack-server")] 25 14 notebooks_result 26 15 .as_ref() ··· 32 21 document::Link { rel: "stylesheet", href: NOTEBOOK_CARD_CSS } 33 22 div { 34 23 class: "record-view-container", 35 - div { class: "record-header", 36 - div { class: "uri-input-section", 37 - input { 38 - r#type: "text", 39 - class: "uri-input", 40 - placeholder: "at://did:plc:.../collection/rkey", 41 - value: "{uri_input}", 42 - oninput: move |evt| uri_input.set(evt.value()), 43 - onkeydown: move |evt| { 44 - if evt.key() == Key::Enter { 45 - handle_uri_submit(); 46 - } 47 - }, 48 - } 49 - } 50 - } 24 + 51 25 div { class: "notebooks-list", 52 26 match &*notebooks.read() { 53 27 Some(notebook_list) => rsx! {
+150 -17
crates/weaver-app/src/views/navbar.rs
··· 1 1 use crate::Route; 2 - use crate::auth::AuthState; 2 + use crate::auth::{AuthState, RestoreResult}; 3 3 use crate::components::button::{Button, ButtonVariant}; 4 4 use crate::components::login::LoginModal; 5 5 use crate::data::{use_get_handle, use_load_handle}; 6 6 use crate::fetch::Fetcher; 7 7 use dioxus::prelude::*; 8 + use dioxus_primitives::toast::{use_toast, ToastOptions}; 8 9 use jacquard::types::string::Did; 9 10 10 11 const NAVBAR_CSS: Asset = asset!("/assets/styling/navbar.css"); ··· 19 20 let route = use_route::<Route>(); 20 21 tracing::trace!("Route: {:?}", route); 21 22 22 - let mut auth_state = use_context::<Signal<crate::auth::AuthState>>(); 23 + let auth_state = use_context::<Signal<crate::auth::AuthState>>(); 24 + 25 + // Show toast if session expired 26 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 27 + { 28 + let restore_result = use_context::<Resource<RestoreResult>>(); 29 + let toast = use_toast(); 30 + let mut shown = use_signal(|| false); 31 + 32 + if !shown() && restore_result() == Some(RestoreResult::SessionExpired) { 33 + shown.set(true); 34 + toast.warning( 35 + "Session Expired".to_string(), 36 + ToastOptions::new().description("Please sign in again"), 37 + ); 38 + } 39 + } 23 40 let (route_handle_res, route_handle) = use_load_handle(match &route { 24 41 Route::EntryPage { ident, .. } => Some(ident.clone()), 25 42 Route::RepositoryIndex { ident } => Some(ident.clone()), 26 43 Route::NotebookIndex { ident, .. } => Some(ident.clone()), 44 + Route::DraftsList { ident } => Some(ident.clone()), 45 + Route::DraftEdit { ident, .. } => Some(ident.clone()), 46 + Route::NewDraft { ident, .. } => Some(ident.clone()), 47 + Route::StandaloneEntry { ident, .. } => Some(ident.clone()), 48 + Route::StandaloneEntryEdit { ident, .. } => Some(ident.clone()), 49 + Route::NotebookEntryByRkey { ident, .. } => Some(ident.clone()), 50 + Route::NotebookEntryEdit { ident, .. } => Some(ident.clone()), 27 51 _ => None, 28 52 }); 29 53 ··· 51 75 let route_handle = route_handle.read().clone(); 52 76 let handle = route_handle.unwrap_or(ident.clone()); 53 77 rsx! { 54 - span { class: "breadcrumb-separator", " > " } 55 - span { class: "breadcrumb breadcrumb-current", "@{handle}" } 78 + span { class:"breadcrumb-separator"," > "} 79 + span { class:"breadcrumb breadcrumb-current","@{handle}"} 56 80 } 57 81 }, 58 - Route::NotebookIndex { ident, book_title } => { 82 + Route::NotebookIndex{ ident, book_title } => { 59 83 let route_handle = route_handle.read().clone(); 60 84 let handle = route_handle.unwrap_or(ident.clone()); 61 85 rsx! { 62 - span { class: "breadcrumb-separator", " > " } 86 + span { class:"breadcrumb-separator"," > " } 63 87 Link { 64 - to: Route::RepositoryIndex { ident: ident.clone() }, 88 + to: Route::RepositoryIndex { ident: ident.clone() 89 + }, 90 + class: "breadcrumb","@{handle}" 91 + } 92 + span{ class: "breadcrumb-separator"," > "} 93 + span{ class: "breadcrumb breadcrumb-current","{book_title}"} 94 + } 95 + }, 96 + Route::EntryPage { ident, book_title, .. } => { 97 + let route_handle=route_handle.read().clone(); 98 + let handle=route_handle.unwrap_or(ident.clone()); 99 + rsx! { 100 + span { class:"breadcrumb-separator"," > "} 101 + Link { 102 + to: Route::RepositoryIndex { 103 + ident:ident.clone() 104 + }, 105 + class:"breadcrumb","@{handle}" 106 + } 107 + span { class:"breadcrumb-separator"," > "} 108 + Link { 109 + to: Route::NotebookIndex { 110 + ident: ident.clone(), 111 + book_title: book_title.clone() 112 + }, 65 113 class: "breadcrumb", 66 - "@{handle}" 114 + "{book_title}" 115 + } 116 + } 117 + }, 118 + Route::DraftsList { ident } => { 119 + let route_handle = route_handle.read().clone(); 120 + let handle = route_handle.unwrap_or(ident.clone()); 121 + rsx! { 122 + span { class:"breadcrumb-separator"," > "} 123 + span { class:"breadcrumb breadcrumb-current","@{handle}"} 124 + } 125 + }, 126 + Route::DraftEdit { ident, tid } => { 127 + let route_handle = route_handle.read().clone(); 128 + let handle = route_handle.unwrap_or(ident.clone()); 129 + rsx! { 130 + span { class:"breadcrumb-separator"," > "} 131 + span { class:"breadcrumb breadcrumb-current","@{handle}"} 132 + } 133 + }, 134 + Route::NewDraft { ident, notebook } => { 135 + let route_handle = route_handle.read().clone(); 136 + let handle = route_handle.unwrap_or(ident.clone()); 137 + if let Some(notebook) = notebook { 138 + rsx! { 139 + span { class:"breadcrumb-separator"," > "} 140 + Link { 141 + to: Route::RepositoryIndex { 142 + ident:ident.clone() 143 + }, 144 + class:"breadcrumb","@{handle}" 145 + } 146 + span { class:"breadcrumb-separator"," > "} 147 + Link { 148 + to: Route::NotebookIndex { 149 + ident: ident.clone(), 150 + book_title: notebook.clone() 151 + }, 152 + class: "breadcrumb", 153 + "{notebook}" 154 + } 67 155 } 68 - span { class: "breadcrumb-separator", " > " } 69 - span { class: "breadcrumb breadcrumb-current", "{book_title}" } 156 + } else { 157 + rsx! { 158 + span { class:"breadcrumb-separator"," > "} 159 + span { class:"breadcrumb breadcrumb-current","@{handle}"} 160 + } 161 + } 162 + }, 163 + Route::StandaloneEntry { ident, .. } => { 164 + let route_handle = route_handle.read().clone(); 165 + let handle = route_handle.unwrap_or(ident.clone()); 166 + rsx! { 167 + span { class:"breadcrumb-separator"," > "} 168 + span { class:"breadcrumb breadcrumb-current","@{handle}"} 70 169 } 71 170 }, 72 - Route::EntryPage { ident, book_title, .. } => { 171 + Route::StandaloneEntryEdit { ident, .. } => { 73 172 let route_handle = route_handle.read().clone(); 74 173 let handle = route_handle.unwrap_or(ident.clone()); 75 174 rsx! { 76 - span { class: "breadcrumb-separator", " > " } 175 + span { class:"breadcrumb-separator"," > "} 176 + span { class:"breadcrumb breadcrumb-current","@{handle}"} 177 + } 178 + }, 179 + Route::NotebookEntryByRkey { ident, book_title, .. } => { 180 + let route_handle=route_handle.read().clone(); 181 + let handle=route_handle.unwrap_or(ident.clone()); 182 + rsx! { 183 + span { class:"breadcrumb-separator"," > "} 184 + Link { 185 + to: Route::RepositoryIndex { 186 + ident:ident.clone() 187 + }, 188 + class:"breadcrumb","@{handle}" 189 + } 190 + span { class:"breadcrumb-separator"," > "} 77 191 Link { 78 - to: Route::RepositoryIndex { ident: ident.clone() }, 192 + to: Route::NotebookIndex { 193 + ident: ident.clone(), 194 + book_title: book_title.clone() 195 + }, 79 196 class: "breadcrumb", 80 - "@{handle}" 197 + "{book_title}" 198 + } 199 + } 200 + }, 201 + Route::NotebookEntryEdit { ident, book_title, .. } => { 202 + let route_handle=route_handle.read().clone(); 203 + let handle=route_handle.unwrap_or(ident.clone()); 204 + rsx! { 205 + span { class:"breadcrumb-separator"," > "} 206 + Link { 207 + to: Route::RepositoryIndex { 208 + ident:ident.clone() 209 + }, 210 + class:"breadcrumb","@{handle}" 81 211 } 82 - span { class: "breadcrumb-separator", " > " } 212 + span { class:"breadcrumb-separator"," > "} 83 213 Link { 84 - to: Route::NotebookIndex { ident: ident.clone(), book_title: book_title.clone() }, 214 + to: Route::NotebookIndex { 215 + ident: ident.clone(), 216 + book_title: book_title.clone() 217 + }, 85 218 class: "breadcrumb", 86 219 "{book_title}" 87 220 } 88 221 } 89 222 }, 90 - _ => rsx! {} 223 + _ => rsx! {}, 91 224 } 92 225 } 93 226 if auth_state.read().is_authenticated() {
+37 -4
crates/weaver-common/src/agent.rs
··· 30 30 31 31 const CONSTELLATION_URL: &str = "https://constellation.microcosm.blue"; 32 32 33 + /// Strip trailing punctuation that URL parsers commonly eat 34 + /// (period, comma, semicolon, colon, exclamation, question mark) 35 + fn strip_trailing_punctuation(s: &str) -> &str { 36 + s.trim_end_matches(['.', ',', ';', ':', '!', '?']) 37 + } 38 + 39 + /// Check if a search term matches a value, with fallback to stripped punctuation 40 + fn title_matches(value: &str, search: &str) -> bool { 41 + // Exact match first 42 + if value == search { 43 + return true; 44 + } 45 + // Try with trailing punctuation stripped from search term 46 + let stripped_search = strip_trailing_punctuation(search); 47 + if stripped_search != search && value == stripped_search { 48 + return true; 49 + } 50 + // Try with trailing punctuation stripped from value (for titles ending in punctuation) 51 + let stripped_value = strip_trailing_punctuation(value); 52 + if stripped_value != value && stripped_value == search { 53 + return true; 54 + } 55 + false 56 + } 57 + 33 58 /// Extension trait providing weaver-specific multi-step operations on Agent 34 59 /// 35 60 /// This trait extends jacquard's Agent with notebook-specific workflows that ··· 347 372 348 373 let title = notebook.value.title.clone(); 349 374 let tags = notebook.value.tags.clone(); 375 + let path = notebook.value.path.clone(); 350 376 351 377 let mut authors = Vec::new(); 352 378 use weaver_api::app_bsky::actor::{ ··· 383 409 .uri(notebook.uri) 384 410 .indexed_at(jacquard::types::string::Datetime::now()) 385 411 .maybe_title(title) 412 + .maybe_path(path) 386 413 .maybe_tags(tags) 387 414 .authors(authors) 388 415 .record(to_data(&notebook.value).map_err(|_| { ··· 411 438 let entry = self.fetch_record(&entry_uri).await?; 412 439 413 440 let title = entry.value.title.clone(); 441 + let path = entry.value.path.clone(); 414 442 let tags = entry.value.tags.clone(); 415 443 416 444 Ok(EntryView::new() ··· 424 452 })?) 425 453 .maybe_tags(tags) 426 454 .title(title) 455 + .path(path) 427 456 .authors(notebook.authors.clone()) 428 457 .build()) 429 458 } ··· 450 479 .await 451 480 .map_err(|e| AgentError::from(e))?; 452 481 if let Ok(entry) = resp.parse() { 453 - if entry.value.path == title || entry.value.title == title { 482 + let path_matches = title_matches(entry.value.path.as_ref(), title); 483 + let title_field_matches = title_matches(entry.value.title.as_ref(), title); 484 + if path_matches || title_field_matches { 454 485 // Build BookEntryView with prev/next 455 486 let entry_view = self.fetch_entry_view(notebook, entry_ref).await?; 456 487 ··· 549 580 )) 550 581 })?; 551 582 552 - // Match on path first, then title 583 + // Match on path first, then title (with trailing punctuation tolerance) 553 584 let matched_title = if let Some(ref path) = notebook.path 554 - && path.as_ref() == title 585 + && title_matches(path.as_ref(), title) 555 586 { 556 587 Some(path.clone()) 557 588 } else if let Some(ref book_title) = notebook.title 558 - && book_title.as_ref() == title 589 + && title_matches(book_title.as_ref(), title) 559 590 { 560 591 Some(book_title.clone()) 561 592 } else { ··· 564 595 565 596 if let Some(matched) = matched_title { 566 597 let tags = notebook.tags.clone(); 598 + let path = notebook.path.clone(); 567 599 568 600 let mut authors = Vec::new(); 569 601 for (index, author) in notebook.authors.iter().enumerate() { ··· 591 623 .uri(record.uri) 592 624 .indexed_at(jacquard::types::string::Datetime::now()) 593 625 .title(matched) 626 + .maybe_path(path) 594 627 .maybe_tags(tags) 595 628 .authors(authors) 596 629 .record(record.value.clone())
+2
lexicons/notebook/defs.json
··· 8 8 9 9 "properties": { 10 10 "title": { "type": "ref", "ref": "#title" }, 11 + "path": { "type": "ref", "ref": "#path" }, 11 12 "tags": { "type": "ref", "ref": "#tags" }, 12 13 "uri": { "type": "string", "format": "at-uri" }, 13 14 "cid": { "type": "string", "format": "cid" }, ··· 25 26 26 27 "properties": { 27 28 "title": { "type": "ref", "ref": "#title" }, 29 + "path": { "type": "ref", "ref": "#path" }, 28 30 "tags": { "type": "ref", "ref": "#tags" }, 29 31 "uri": { "type": "string", "format": "at-uri" }, 30 32 "cid": { "type": "string", "format": "cid" },